Files
blog-writer/bots/converters/thread_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

153 lines
5.0 KiB
Python

"""
X 스레드 변환봇 (converters/thread_converter.py)
역할: 원본 마크다운 → X(트위터) 스레드 JSON (LAYER 2)
- TITLE + KEY_POINTS → 280자 트윗 3-5개로 분할
- 첫 트윗: 흥미 유발 + 코너 해시태그
- 중간 트윗: 핵심 포인트
- 마지막 트윗: 블로그 링크 + CTA
출력: data/outputs/{date}_{slug}_thread.json
"""
import json
import logging
import textwrap
from datetime import datetime
from pathlib import Path
BASE_DIR = Path(__file__).parent.parent.parent
LOG_DIR = BASE_DIR / 'logs'
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'
TWEET_MAX = 280
CORNER_HASHTAGS = {
'쉬운세상': '#쉬운세상 #AI활용 #디지털라이프',
'숨은보물': '#숨은보물 #AI도구 #생산성',
'바이브리포트': '#바이브리포트 #트렌드 #AI시대',
'팩트체크': '#팩트체크 #AI뉴스',
'한컷': '#한컷 #AI만평',
}
BRAND_TAG = '#The4thPath'
def _split_to_tweet(text: str, max_len: int = TWEET_MAX) -> list[str]:
"""텍스트를 280자 단위로 자연스럽게 분할"""
if len(text) <= max_len:
return [text]
tweets = []
sentences = text.replace('. ', '.\n').replace('다. ', '다.\n').split('\n')
current = ''
for sentence in sentences:
sentence = sentence.strip()
if not sentence:
continue
test = (current + ' ' + sentence).strip() if current else sentence
if len(test) <= max_len:
current = test
else:
if current:
tweets.append(current)
# 문장 자체가 너무 길면 강제 분할
if len(sentence) > max_len:
chunks = textwrap.wrap(sentence, max_len - 5)
tweets.extend(chunks[:-1])
current = chunks[-1] if chunks else ''
else:
current = sentence
if current:
tweets.append(current)
return tweets or [text[:max_len]]
def convert(article: dict, blog_url: str = '', save_file: bool = True) -> list[dict]:
"""
article dict → X 스레드 트윗 리스트.
각 트윗: {'order': int, 'text': str, 'char_count': int}
"""
title = article.get('title', '')
corner = article.get('corner', '')
key_points = article.get('key_points', [])
tags = article.get('tags', [])
slug = article.get('slug', 'article')
logger.info(f"스레드 변환 시작: {title}")
hashtags = CORNER_HASHTAGS.get(corner, '')
tag_str = ' '.join(f'#{t}' for t in tags[:3] if t)
if tag_str:
hashtags = hashtags + ' ' + tag_str
tweets = []
# 트윗 1: 흥미 유발 + 제목 + 코너 해시태그
intro_text = f"👀 {title}\n\n{hashtags} {BRAND_TAG}"
if len(intro_text) <= TWEET_MAX:
tweets.append(intro_text)
else:
short_title = textwrap.shorten(title, width=100, placeholder='...')
tweets.append(f"👀 {short_title}\n\n{hashtags}")
# 트윗 2-4: 핵심 포인트
for i, point in enumerate(key_points[:3], 1):
bullets = ['', '', '']
bullet = bullets[i - 1] if i <= 3 else f'{i}.'
tweet_text = f"{bullet} {point}"
if len(tweet_text) <= TWEET_MAX:
tweets.append(tweet_text)
else:
split_tweets = _split_to_tweet(tweet_text)
tweets.extend(split_tweets)
# 마지막 트윗: CTA + 블로그 링크
post_url = blog_url or f"{BLOG_BASE_URL}/{slug}"
cta_text = f"전체 내용 보기 👇\n{post_url}\n\n{BRAND_TAG}"
tweets.append(cta_text)
result = [
{'order': i + 1, 'text': t, 'char_count': len(t)}
for i, t in enumerate(tweets)
]
if save_file:
date_str = datetime.now().strftime('%Y%m%d')
filename = f"{date_str}_{slug}_thread.json"
output_path = OUTPUT_DIR / filename
output_path.write_text(
json.dumps(result, ensure_ascii=False, indent=2), encoding='utf-8'
)
logger.info(f"스레드 저장: {output_path} ({len(result)}개 트윗)")
logger.info("스레드 변환 완료")
return result
if __name__ == '__main__':
sample = {
'title': 'ChatGPT 처음 쓰는 사람을 위한 완전 가이드',
'slug': 'chatgpt-guide',
'corner': '쉬운세상',
'tags': ['ChatGPT', 'AI', '가이드'],
'key_points': [
'무료로 바로 시작할 수 있다 — chat.openai.com 접속',
'GPT-4는 유료지만 GPT-3.5도 일반 용도엔 충분하다',
'프롬프트의 질이 답변의 질을 결정한다',
],
}
threads = convert(sample)
for t in threads:
print(f"[{t['order']}] ({t['char_count']}자) {t['text']}")
print()