feat: v3 멀티플랫폼 자동화 엔진 — 변환/배포 엔진 + 쇼츠 + README
## 변환 엔진 (bots/converters/) - blog_converter: HTML 자동감지 + Schema.org JSON-LD + AdSense 플레이스홀더 - card_converter: Pillow 1080×1080 인스타그램 카드 이미지 - thread_converter: X 스레드 280자 자동 분할 - newsletter_converter: 주간 HTML 뉴스레터 - shorts_converter: TTS + ffmpeg 뉴스앵커 쇼츠 영상 (1080×1920) ## 배포 엔진 (bots/distributors/) - image_host: ImgBB 업로드 / 로컬 HTTP 서버 - instagram_bot: Instagram Graph API (컨테이너 → 폴링 → 발행) - x_bot: X API v2 OAuth1 스레드 게시 - tiktok_bot: TikTok Content Posting API v2 청크 업로드 - youtube_bot: YouTube Data API v3 재개가능 업로드 ## 기타 - article_parser: KEY_POINTS 파싱 추가 (SNS/TTS용 핵심 3줄) - publisher_bot: HTML 본문 직접 발행 지원 - scheduler: 시차 배포 스케줄 + Telegram 변환/배포 명령 추가 - remote_claude: Claude Agent SDK Telegram 연동 - templates/shorts_template.json: 코너별 색상/TTS/트랜지션 설정 - scripts/download_fonts.py: NotoSansKR / 맑은고딕 자동 설치 - .gitignore: .claude/, 기획문서, 생성 미디어 파일 추가 - .env.example: 플레이스홀더 텍스트 (실제 값 없음) - README: v3 아키텍처 전체 문서화 (설치/API키/상세설명/FAQ) - requirements.txt: openai, pydub 추가 Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
@@ -0,0 +1,172 @@
|
||||
"""
|
||||
블로그 변환봇 (converters/blog_converter.py)
|
||||
역할: 원본 마크다운 → 블로그 HTML 변환 (LAYER 2)
|
||||
- 마크다운 → HTML (목차, 테이블, 코드블록)
|
||||
- AdSense 플레이스홀더 삽입
|
||||
- Schema.org Article JSON-LD
|
||||
- 쿠팡 링크봇 호출
|
||||
출력: data/outputs/{date}_{slug}_blog.html
|
||||
"""
|
||||
import json
|
||||
import logging
|
||||
import sys
|
||||
from datetime import datetime, timezone
|
||||
from pathlib import Path
|
||||
|
||||
import markdown
|
||||
from bs4 import BeautifulSoup
|
||||
|
||||
BASE_DIR = Path(__file__).parent.parent.parent
|
||||
sys.path.insert(0, str(BASE_DIR / 'bots'))
|
||||
|
||||
LOG_DIR = BASE_DIR / 'logs'
|
||||
LOG_DIR.mkdir(exist_ok=True)
|
||||
OUTPUT_DIR = BASE_DIR / 'data' / 'outputs'
|
||||
OUTPUT_DIR.mkdir(exist_ok=True)
|
||||
|
||||
logging.basicConfig(
|
||||
level=logging.INFO,
|
||||
format='%(asctime)s [%(levelname)s] %(message)s',
|
||||
handlers=[
|
||||
logging.FileHandler(LOG_DIR / 'converter.log', encoding='utf-8'),
|
||||
logging.StreamHandler(),
|
||||
]
|
||||
)
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
BLOG_BASE_URL = 'https://the4thpath.com'
|
||||
|
||||
|
||||
def markdown_to_html(md_text: str) -> tuple[str, str]:
|
||||
"""마크다운 → HTML (목차 포함)"""
|
||||
md = markdown.Markdown(
|
||||
extensions=['toc', 'tables', 'fenced_code', 'attr_list'],
|
||||
extension_configs={
|
||||
'toc': {'title': '목차', 'toc_depth': '2-3'}
|
||||
}
|
||||
)
|
||||
html = md.convert(md_text)
|
||||
toc = md.toc
|
||||
return html, toc
|
||||
|
||||
|
||||
def insert_adsense_placeholders(html: str) -> str:
|
||||
"""두 번째 H2 뒤 + 결론 H2 앞에 AdSense 슬롯 삽입"""
|
||||
AD_SLOT_1 = '\n<!-- AD_SLOT_1 -->\n'
|
||||
AD_SLOT_2 = '\n<!-- AD_SLOT_2 -->\n'
|
||||
soup = BeautifulSoup(html, 'lxml')
|
||||
h2_tags = soup.find_all('h2')
|
||||
|
||||
if len(h2_tags) >= 2:
|
||||
ad_tag = BeautifulSoup(AD_SLOT_1, 'html.parser')
|
||||
h2_tags[1].insert_after(ad_tag)
|
||||
|
||||
for h2 in soup.find_all('h2'):
|
||||
if any(kw in h2.get_text() for kw in ['결론', '마무리', '정리', '요약', 'conclusion']):
|
||||
ad_tag2 = BeautifulSoup(AD_SLOT_2, 'html.parser')
|
||||
h2.insert_before(ad_tag2)
|
||||
break
|
||||
|
||||
return str(soup)
|
||||
|
||||
|
||||
def build_json_ld(article: dict, post_url: str = '') -> str:
|
||||
"""Schema.org Article JSON-LD"""
|
||||
schema = {
|
||||
"@context": "https://schema.org",
|
||||
"@type": "Article",
|
||||
"headline": article.get('title', ''),
|
||||
"description": article.get('meta', ''),
|
||||
"datePublished": datetime.now(timezone.utc).isoformat(),
|
||||
"dateModified": datetime.now(timezone.utc).isoformat(),
|
||||
"author": {"@type": "Person", "name": "테크인사이더"},
|
||||
"publisher": {
|
||||
"@type": "Organization",
|
||||
"name": "The 4th Path",
|
||||
"logo": {"@type": "ImageObject", "url": f"{BLOG_BASE_URL}/logo.png"}
|
||||
},
|
||||
"mainEntityOfPage": {"@type": "WebPage", "@id": post_url or BLOG_BASE_URL},
|
||||
}
|
||||
return f'<script type="application/ld+json">\n{json.dumps(schema, ensure_ascii=False, indent=2)}\n</script>'
|
||||
|
||||
|
||||
def build_full_html(article: dict, body_html: str, toc_html: str,
|
||||
post_url: str = '') -> str:
|
||||
"""JSON-LD + 목차 + 본문 + 면책 조합"""
|
||||
json_ld = build_json_ld(article, post_url)
|
||||
disclaimer = article.get('disclaimer', '')
|
||||
parts = [json_ld]
|
||||
if toc_html:
|
||||
parts.append(f'<div class="toc-wrapper">{toc_html}</div>')
|
||||
parts.append(body_html)
|
||||
if disclaimer:
|
||||
parts.append(f'<hr/><p class="disclaimer"><small>{disclaimer}</small></p>')
|
||||
return '\n'.join(parts)
|
||||
|
||||
|
||||
def _is_html_body(body: str) -> bool:
|
||||
"""AI가 이미 HTML을 출력했는지 감지 (마크다운 변환 건너뜀)"""
|
||||
stripped = body.lstrip()
|
||||
return stripped.startswith('<') and any(
|
||||
tag in stripped[:200].lower()
|
||||
for tag in ['<style', '<div', '<h1', '<section', '<article', '<p>']
|
||||
)
|
||||
|
||||
|
||||
def convert(article: dict, save_file: bool = True) -> str:
|
||||
"""
|
||||
article dict → 블로그 HTML 문자열 반환.
|
||||
save_file=True이면 data/outputs/에 저장.
|
||||
BODY가 이미 HTML이면 마크다운 변환을 건너뜀.
|
||||
"""
|
||||
logger.info(f"블로그 변환 시작: {article.get('title', '')}")
|
||||
|
||||
body = article.get('body', '')
|
||||
|
||||
if _is_html_body(body):
|
||||
# AI가 Blogger-ready HTML을 직접 출력한 경우 — 변환 없이 그대로 사용
|
||||
logger.info("HTML 본문 감지 — 마크다운 변환 건너뜀")
|
||||
body_html = body
|
||||
toc_html = ''
|
||||
else:
|
||||
# 레거시 마크다운 → HTML 변환
|
||||
body_html, toc_html = markdown_to_html(body)
|
||||
|
||||
# AdSense 삽입
|
||||
body_html = insert_adsense_placeholders(body_html)
|
||||
|
||||
# 쿠팡 링크 삽입
|
||||
try:
|
||||
import linker_bot
|
||||
body_html = linker_bot.process(article, body_html)
|
||||
except Exception as e:
|
||||
logger.warning(f"쿠팡 링크 삽입 실패 (건너뜀): {e}")
|
||||
|
||||
# 최종 HTML
|
||||
html = build_full_html(article, body_html, toc_html)
|
||||
|
||||
if save_file:
|
||||
slug = article.get('slug', 'article')
|
||||
date_str = datetime.now().strftime('%Y%m%d')
|
||||
filename = f"{date_str}_{slug}_blog.html"
|
||||
output_path = OUTPUT_DIR / filename
|
||||
output_path.write_text(html, encoding='utf-8')
|
||||
logger.info(f"저장: {output_path}")
|
||||
|
||||
logger.info("블로그 변환 완료")
|
||||
return html
|
||||
|
||||
|
||||
if __name__ == '__main__':
|
||||
sample = {
|
||||
'title': '테스트 글',
|
||||
'meta': '테스트 설명',
|
||||
'slug': 'test-article',
|
||||
'corner': '쉬운세상',
|
||||
'body': '## 소개\n\n본문입니다.\n\n## 결론\n\n마무리입니다.',
|
||||
'coupang_keywords': [],
|
||||
'disclaimer': '',
|
||||
'key_points': ['포인트 1', '포인트 2', '포인트 3'],
|
||||
}
|
||||
html = convert(sample)
|
||||
print(html[:500])
|
||||
Reference in New Issue
Block a user