Files
blog-writer/bots/converters/blog_converter.py
JOUNGWOOK KWON e1fb6c954a fix: 목차를 이미지 뒤에 배치 + TOC 링크 없으면 숨김
- 목차가 대표이미지 위에 나오던 문제 수정 (이미지 → 목차 → 본문 순서)
- TOC에 실제 <a> 링크가 없으면 "목차" 제목만 나오는 현상 방지

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-30 18:59:26 +09:00

181 lines
6.0 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]
# 목차: h2가 3개 이상이고 TOC에 실제 링크가 있을 때만 표시
h2_count = body_html.lower().count('<h2')
toc_has_links = toc_html and '<a ' in toc_html and h2_count >= 3
if toc_has_links:
import re as _re
m = _re.match(r'(<img\s[^>]*/>)\s*', body_html)
if m:
body_html = m.group(0) + f'<div class="toc-wrapper">{toc_html}</div>\n' + body_html[m.end():]
else:
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])