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:
152
bots/converters/thread_converter.py
Normal file
152
bots/converters/thread_converter.py
Normal file
@@ -0,0 +1,152 @@
|
||||
"""
|
||||
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()
|
||||
Reference in New Issue
Block a user