Files
blog-writer/bots/converters/blog_converter.py
sinmb79 b54f8e198e 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>
2026-03-25 18:15:07 +09:00

173 lines
5.5 KiB
Python

"""
블로그 변환봇 (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])