## 변환 엔진 (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>
153 lines
5.0 KiB
Python
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()
|