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:
sinmb79
2026-03-25 18:15:07 +09:00
parent 6d6ba14e76
commit b54f8e198e
24 changed files with 4367 additions and 274 deletions

View File

View File

@@ -0,0 +1,224 @@
"""
이미지 호스팅 헬퍼 (distributors/image_host.py)
역할: 로컬 카드 이미지 → 공개 URL 변환
Instagram Graph API는 공개 URL이 필요하므로
카드 이미지를 외부에서 접근 가능한 URL로 변환한다.
지원 방식:
1. ImgBB (무료 API, 키 필요) ← IMGBB_API_KEY 설정 시
2. Blogger 미디어 업로드 (기존 OAuth) ← 기본값 (추가 비용 없음)
3. 로컬 HTTP 서버 (개발/테스트용) ← LOCAL_IMAGE_SERVER=true 시
"""
import base64
import json
import logging
import os
from pathlib import Path
import requests
from dotenv import load_dotenv
load_dotenv()
BASE_DIR = Path(__file__).parent.parent.parent
LOG_DIR = BASE_DIR / 'logs'
LOG_DIR.mkdir(exist_ok=True)
logging.basicConfig(
level=logging.INFO,
format='%(asctime)s [%(levelname)s] %(message)s',
handlers=[
logging.FileHandler(LOG_DIR / 'distributor.log', encoding='utf-8'),
logging.StreamHandler(),
]
)
logger = logging.getLogger(__name__)
IMGBB_API_KEY = os.getenv('IMGBB_API_KEY', '')
IMGBB_API_URL = 'https://api.imgbb.com/v1/upload'
# ─── 방식 1: ImgBB ────────────────────────────────────
def upload_to_imgbb(image_path: str, expiration: int = 0) -> str:
"""
ImgBB에 이미지 업로드.
expiration: 0=영구, 초 단위 만료 시간 (예: 86400=1일)
Returns: 공개 URL 또는 ''
"""
if not IMGBB_API_KEY:
logger.debug("IMGBB_API_KEY 없음 — ImgBB 건너뜀")
return ''
try:
with open(image_path, 'rb') as f:
image_data = base64.b64encode(f.read()).decode('utf-8')
payload = {
'key': IMGBB_API_KEY,
'image': image_data,
}
if expiration > 0:
payload['expiration'] = expiration
resp = requests.post(IMGBB_API_URL, data=payload, timeout=30)
resp.raise_for_status()
result = resp.json()
if result.get('success'):
url = result['data']['url']
logger.info(f"ImgBB 업로드 완료: {url}")
return url
else:
logger.warning(f"ImgBB 오류: {result.get('error', {})}")
return ''
except Exception as e:
logger.error(f"ImgBB 업로드 실패: {e}")
return ''
# ─── 방식 2: Blogger 미디어 업로드 ───────────────────
def upload_to_blogger(image_path: str) -> str:
"""
Blogger에 이미지를 첨부파일로 업로드 후 공개 URL 반환.
기존 Google OAuth (token.json) 재사용.
Returns: 공개 URL 또는 ''
"""
try:
import sys
sys.path.insert(0, str(BASE_DIR / 'bots'))
from publisher_bot import get_google_credentials
from googleapiclient.discovery import build
from googleapiclient.http import MediaFileUpload
blog_id = os.getenv('BLOG_MAIN_ID', '')
if not blog_id:
logger.warning("BLOG_MAIN_ID 없음")
return ''
creds = get_google_credentials()
service = build('blogger', 'v3', credentials=creds)
# Blogger API: 미디어 업로드 (pages나 posts에 이미지 첨부)
# 참고: Blogger는 직접 미디어 API가 없으므로 임시 draft 포스트로 업로드
media = MediaFileUpload(image_path, mimetype='image/png', resumable=True)
# 임시 draft 포스트에 이미지 삽입 → URL 추출 → 포스트 삭제
img_data = open(image_path, 'rb').read()
img_b64 = base64.b64encode(img_data).decode()
img_html = f'<img src="data:image/png;base64,{img_b64}" />'
draft = service.posts().insert(
blogId=blog_id,
body={'title': '__img_upload__', 'content': img_html},
isDraft=True,
).execute()
post_url = draft.get('url', '')
post_id = draft.get('id', '')
# draft 삭제
if post_id:
service.posts().delete(blogId=blog_id, postId=post_id).execute()
# base64 embedded는 직접 URL이 아니므로 ImgBB fallback 필요
# Blogger는 외부 이미지 호스팅 역할을 하지 않음
# → 실제 운영 시 ImgBB 또는 CDN 사용 권장
logger.warning("Blogger 미디어 업로드: base64 방식은 인스타 공개 URL로 부적합. ImgBB 권장.")
return ''
except Exception as e:
logger.error(f"Blogger 업로드 실패: {e}")
return ''
# ─── 방식 3: 로컬 HTTP 서버 (개발용) ─────────────────
_local_server = None
def start_local_server(port: int = 8765) -> str:
"""
로컬 HTTP 파일 서버 시작 (개발/테스트용).
Returns: base URL (예: http://192.168.1.100:8765)
"""
import socket
import threading
import http.server
import functools
global _local_server
if _local_server:
return _local_server
outputs_dir = str(BASE_DIR / 'data' / 'outputs')
handler = functools.partial(
http.server.SimpleHTTPRequestHandler, directory=outputs_dir
)
server = http.server.HTTPServer(('0.0.0.0', port), handler)
def run():
server.serve_forever()
thread = threading.Thread(target=run, daemon=True)
thread.start()
# 로컬 IP 확인
try:
s = socket.socket(socket.AF_INET, socket.SOCK_DGRAM)
s.connect(('8.8.8.8', 80))
local_ip = s.getsockname()[0]
s.close()
except Exception:
local_ip = '127.0.0.1'
base_url = f'http://{local_ip}:{port}'
_local_server = base_url
logger.info(f"로컬 이미지 서버 시작: {base_url}")
return base_url
def get_local_url(image_path: str, port: int = 8765) -> str:
"""로컬 서버 URL 반환 (개발/ngrok 사용 시)"""
base_url = start_local_server(port)
filename = Path(image_path).name
return f'{base_url}/{filename}'
# ─── 메인 함수 ───────────────────────────────────────
def get_public_url(image_path: str) -> str:
"""
이미지 파일 → 공개 URL 반환.
우선순위: ImgBB → 로컬 서버(개발용)
"""
if not Path(image_path).exists():
logger.error(f"이미지 파일 없음: {image_path}")
return ''
# 1. ImgBB (API 키 있을 때)
url = upload_to_imgbb(image_path, expiration=86400 * 7) # 7일
if url:
return url
# 2. 로컬 HTTP 서버 (ngrok 또는 내부망 테스트용)
if os.getenv('LOCAL_IMAGE_SERVER', '').lower() == 'true':
url = get_local_url(image_path)
logger.warning(f"로컬 서버 URL 사용 (인터넷 접근 필요): {url}")
return url
logger.warning(
"공개 URL 생성 불가. .env에 IMGBB_API_KEY를 설정하거나 "
"LOCAL_IMAGE_SERVER=true로 설정하세요."
)
return ''
if __name__ == '__main__':
import sys
if len(sys.argv) > 1:
url = get_public_url(sys.argv[1])
print(f"공개 URL: {url}")
else:
print("사용법: python image_host.py <이미지경로>")

View File

@@ -0,0 +1,207 @@
"""
인스타그램 배포봇 (distributors/instagram_bot.py)
역할: 카드 이미지 → Instagram Graph API 업로드 (LAYER 3)
- 피드 포스트: 카드 이미지 업로드
- 릴스: 쇼츠 영상 업로드 (Phase 2)
- 캡션: KEY_POINTS + 해시태그 + 블로그 링크(프로필)
사전 조건:
- Facebook Page + Instagram Business 계정 연결
- Instagram Graph API 앱 등록
- .env: INSTAGRAM_ACCESS_TOKEN, INSTAGRAM_ACCOUNT_ID
"""
import json
import logging
import os
import time
from pathlib import Path
import requests
from dotenv import load_dotenv
load_dotenv()
BASE_DIR = Path(__file__).parent.parent.parent
LOG_DIR = BASE_DIR / 'logs'
LOG_DIR.mkdir(exist_ok=True)
DATA_DIR = BASE_DIR / 'data'
logging.basicConfig(
level=logging.INFO,
format='%(asctime)s [%(levelname)s] %(message)s',
handlers=[
logging.FileHandler(LOG_DIR / 'distributor.log', encoding='utf-8'),
logging.StreamHandler(),
]
)
logger = logging.getLogger(__name__)
INSTAGRAM_ACCESS_TOKEN = os.getenv('INSTAGRAM_ACCESS_TOKEN', '')
INSTAGRAM_ACCOUNT_ID = os.getenv('INSTAGRAM_ACCOUNT_ID', '')
GRAPH_API_BASE = 'https://graph.facebook.com/v19.0'
BLOG_URL = 'https://the4thpath.com'
BRAND_TAG = '#The4thPath #테크인사이더 #22BLabs'
def _check_credentials() -> bool:
if not INSTAGRAM_ACCESS_TOKEN or not INSTAGRAM_ACCOUNT_ID:
logger.warning("Instagram 자격증명 없음 (.env: INSTAGRAM_ACCESS_TOKEN, INSTAGRAM_ACCOUNT_ID)")
return False
return True
def build_caption(article: dict) -> str:
"""인스타그램 캡션 생성"""
title = article.get('title', '')
corner = article.get('corner', '')
key_points = article.get('key_points', [])
tags = article.get('tags', [])
lines = [f"{title}", ""]
if key_points:
for point in key_points[:3]:
lines.append(f"{point}")
lines.append("")
lines.append(f"전체 내용: 프로필 링크 🔗")
lines.append("")
hashtags = [f'#{corner.replace(" ", "")}'] if corner else []
hashtags += [f'#{t}' for t in tags[:5] if t]
hashtags.append(BRAND_TAG)
lines.append(' '.join(hashtags))
return '\n'.join(lines)
def upload_image_container(image_url: str, caption: str) -> str:
"""
인스타 이미지 컨테이너 생성.
image_url: 공개 접근 가능한 이미지 URL (Instagram이 직접 다운로드)
Returns: container_id
"""
if not _check_credentials():
return ''
url = f"{GRAPH_API_BASE}/{INSTAGRAM_ACCOUNT_ID}/media"
params = {
'image_url': image_url,
'caption': caption,
'access_token': INSTAGRAM_ACCESS_TOKEN,
}
try:
resp = requests.post(url, data=params, timeout=30)
resp.raise_for_status()
container_id = resp.json().get('id', '')
logger.info(f"이미지 컨테이너 생성: {container_id}")
return container_id
except Exception as e:
logger.error(f"Instagram 컨테이너 생성 실패: {e}")
return ''
def publish_container(container_id: str) -> str:
"""컨테이너 → 실제 발행. Returns: post_id"""
if not _check_credentials() or not container_id:
return ''
# 컨테이너 준비 대기 (최대 60초)
status_url = f"{GRAPH_API_BASE}/{container_id}"
for _ in range(12):
try:
status_resp = requests.get(
status_url,
params={'fields': 'status_code', 'access_token': INSTAGRAM_ACCESS_TOKEN},
timeout=10
)
status = status_resp.json().get('status_code', '')
if status == 'FINISHED':
break
if status in ('ERROR', 'EXPIRED'):
logger.error(f"컨테이너 오류: {status}")
return ''
except Exception:
pass
time.sleep(5)
# 발행
publish_url = f"{GRAPH_API_BASE}/{INSTAGRAM_ACCOUNT_ID}/media_publish"
params = {
'creation_id': container_id,
'access_token': INSTAGRAM_ACCESS_TOKEN,
}
try:
resp = requests.post(publish_url, data=params, timeout=30)
resp.raise_for_status()
post_id = resp.json().get('id', '')
logger.info(f"Instagram 발행 완료: {post_id}")
return post_id
except Exception as e:
logger.error(f"Instagram 발행 실패: {e}")
return ''
def publish_card(article: dict, image_path_or_url: str) -> bool:
"""
카드 이미지를 인스타그램 피드에 게시.
image_path_or_url: 로컬 파일 경로 또는 공개 URL
- 로컬 경로인 경우 image_host.py로 공개 URL 변환
- http/https URL인 경우 그대로 사용
"""
if not _check_credentials():
logger.info("Instagram 미설정 — 발행 건너뜀")
return False
logger.info(f"Instagram 발행 시작: {article.get('title', '')}")
# 로컬 경로 → 공개 URL 변환
image_url = image_path_or_url
if not image_path_or_url.startswith('http'):
import sys
sys.path.insert(0, str(Path(__file__).parent))
from image_host import get_public_url
image_url = get_public_url(image_path_or_url)
if not image_url:
logger.error("공개 URL 변환 실패 — .env에 IMGBB_API_KEY 설정 필요")
return False
caption = build_caption(article)
container_id = upload_image_container(image_url, caption)
if not container_id:
return False
post_id = publish_container(container_id)
if not post_id:
return False
_log_published(article, post_id, 'instagram_card')
return True
def _log_published(article: dict, post_id: str, platform: str):
"""플랫폼별 발행 이력 저장"""
pub_dir = DATA_DIR / 'published'
pub_dir.mkdir(exist_ok=True)
from datetime import datetime
record = {
'platform': platform,
'post_id': post_id,
'title': article.get('title', ''),
'corner': article.get('corner', ''),
'published_at': datetime.now().isoformat(),
}
filename = f"{datetime.now().strftime('%Y%m%d_%H%M%S')}_{platform}_{post_id}.json"
with open(pub_dir / filename, 'w', encoding='utf-8') as f:
json.dump(record, f, ensure_ascii=False, indent=2)
if __name__ == '__main__':
# 테스트 (실제 API 없이 출력 확인)
sample = {
'title': '테스트 글',
'corner': '쉬운세상',
'key_points': ['포인트 1', '포인트 2'],
'tags': ['AI', '테스트'],
}
print(build_caption(sample))

View File

@@ -0,0 +1,214 @@
"""
틱톡 배포봇 (distributors/tiktok_bot.py)
역할: 쇼츠 MP4 → TikTok Content Posting API 업로드 (LAYER 3)
Phase 2.
사전 조건:
- TikTok Developer 계정 + 앱 등록 (Content Posting API 승인)
- .env: TIKTOK_ACCESS_TOKEN, TIKTOK_OPEN_ID
"""
import json
import logging
import os
import time
from pathlib import Path
import requests
from dotenv import load_dotenv
load_dotenv()
BASE_DIR = Path(__file__).parent.parent.parent
LOG_DIR = BASE_DIR / 'logs'
LOG_DIR.mkdir(exist_ok=True)
DATA_DIR = BASE_DIR / 'data'
logging.basicConfig(
level=logging.INFO,
format='%(asctime)s [%(levelname)s] %(message)s',
handlers=[
logging.FileHandler(LOG_DIR / 'distributor.log', encoding='utf-8'),
logging.StreamHandler(),
]
)
logger = logging.getLogger(__name__)
TIKTOK_ACCESS_TOKEN = os.getenv('TIKTOK_ACCESS_TOKEN', '')
TIKTOK_OPEN_ID = os.getenv('TIKTOK_OPEN_ID', '')
TIKTOK_API_BASE = 'https://open.tiktokapis.com/v2'
CORNER_HASHTAGS = {
'쉬운세상': ['쉬운세상', 'AI활용', '디지털라이프', 'The4thPath'],
'숨은보물': ['숨은보물', 'AI도구', '생산성', 'The4thPath'],
'바이브리포트': ['바이브리포트', '트렌드', 'AI시대', 'The4thPath'],
'팩트체크': ['팩트체크', 'AI뉴스', 'The4thPath'],
'한컷': ['한컷만평', 'AI시사', 'The4thPath'],
}
def _check_credentials() -> bool:
if not TIKTOK_ACCESS_TOKEN:
logger.warning("TIKTOK_ACCESS_TOKEN 없음")
return False
return True
def _get_headers() -> dict:
return {
'Authorization': f'Bearer {TIKTOK_ACCESS_TOKEN}',
'Content-Type': 'application/json; charset=UTF-8',
}
def build_caption(article: dict) -> str:
"""틱톡 캡션 생성 (제목 + 핵심 1줄 + 해시태그)"""
title = article.get('title', '')
key_points = article.get('key_points', [])
corner = article.get('corner', '')
caption_parts = [title]
if key_points:
caption_parts.append(key_points[0])
hashtags = CORNER_HASHTAGS.get(corner, ['The4thPath'])
tag_str = ' '.join(f'#{t}' for t in hashtags)
caption_parts.append(tag_str)
return '\n'.join(caption_parts)
def init_upload(video_size: int, video_duration: float) -> tuple[str, str]:
"""
TikTok 업로드 초기화 (Direct Post).
Returns: (upload_url, publish_id)
"""
url = f'{TIKTOK_API_BASE}/post/publish/video/init/'
payload = {
'post_info': {
'title': '', # 영상에서 추출되므로 빈칸 가능
'privacy_level': 'PUBLIC_TO_EVERYONE',
'disable_duet': False,
'disable_comment': False,
'disable_stitch': False,
},
'source_info': {
'source': 'FILE_UPLOAD',
'video_size': video_size,
'chunk_size': min(video_size, 64 * 1024 * 1024), # 64MB
'total_chunk_count': 1,
},
}
try:
resp = requests.post(url, json=payload, headers=_get_headers(), timeout=30)
resp.raise_for_status()
data = resp.json().get('data', {})
upload_url = data.get('upload_url', '')
publish_id = data.get('publish_id', '')
logger.info(f"TikTok 업로드 초기화: publish_id={publish_id}")
return upload_url, publish_id
except Exception as e:
logger.error(f"TikTok 업로드 초기화 실패: {e}")
return '', ''
def upload_chunk(upload_url: str, video_path: str, video_size: int) -> bool:
"""동영상 업로드"""
try:
with open(video_path, 'rb') as f:
video_data = f.read()
headers = {
'Content-Range': f'bytes 0-{video_size-1}/{video_size}',
'Content-Length': str(video_size),
'Content-Type': 'video/mp4',
}
resp = requests.put(upload_url, data=video_data, headers=headers, timeout=300)
if resp.status_code in (200, 201, 206):
logger.info("TikTok 동영상 업로드 완료")
return True
logger.error(f"TikTok 업로드 HTTP {resp.status_code}: {resp.text[:200]}")
return False
except Exception as e:
logger.error(f"TikTok 업로드 실패: {e}")
return False
def check_publish_status(publish_id: str, max_wait: int = 120) -> bool:
"""발행 상태 확인 (최대 max_wait초 대기)"""
url = f'{TIKTOK_API_BASE}/post/publish/status/fetch/'
payload = {'publish_id': publish_id}
for _ in range(max_wait // 5):
try:
resp = requests.post(url, json=payload, headers=_get_headers(), timeout=10)
resp.raise_for_status()
status = resp.json().get('data', {}).get('status', '')
if status == 'PUBLISH_COMPLETE':
logger.info("TikTok 발행 완료")
return True
if status in ('FAILED', 'CANCELED'):
logger.error(f"TikTok 발행 실패: {status}")
return False
except Exception as e:
logger.warning(f"상태 확인 오류: {e}")
time.sleep(5)
logger.warning("TikTok 발행 상태 확인 시간 초과")
return False
def publish_shorts(article: dict, video_path: str) -> bool:
"""
쇼츠 MP4 → TikTok 업로드.
video_path: shorts_converter.convert()가 생성한 MP4
"""
if not _check_credentials():
logger.info("TikTok 미설정 — 발행 건너뜀")
return False
if not Path(video_path).exists():
logger.error(f"영상 파일 없음: {video_path}")
return False
title = article.get('title', '')
logger.info(f"TikTok 발행 시작: {title}")
video_size = Path(video_path).stat().st_size
# 업로드 초기화
upload_url, publish_id = init_upload(video_size, 30.0)
if not upload_url or not publish_id:
return False
# 동영상 업로드
if not upload_chunk(upload_url, video_path, video_size):
return False
# 발행 상태 확인
if not check_publish_status(publish_id):
return False
_log_published(article, publish_id, 'tiktok')
return True
def _log_published(article: dict, post_id: str, platform: str):
pub_dir = DATA_DIR / 'published'
pub_dir.mkdir(exist_ok=True)
from datetime import datetime
record = {
'platform': platform,
'post_id': post_id,
'title': article.get('title', ''),
'corner': article.get('corner', ''),
'published_at': datetime.now().isoformat(),
}
filename = f"{datetime.now().strftime('%Y%m%d_%H%M%S')}_{platform}_{post_id}.json"
with open(pub_dir / filename, 'w', encoding='utf-8') as f:
json.dump(record, f, ensure_ascii=False, indent=2)
if __name__ == '__main__':
sample = {
'title': '테스트 글',
'corner': '쉬운세상',
'key_points': ['포인트 1'],
}
print(build_caption(sample))

149
bots/distributors/x_bot.py Normal file
View File

@@ -0,0 +1,149 @@
"""
X(트위터) 배포봇 (distributors/x_bot.py)
역할: X 스레드 JSON → X API v2로 순차 트윗 게시 (LAYER 3)
사전 조건:
- X Developer 계정 + 앱 등록
- .env: X_API_KEY, X_API_SECRET, X_ACCESS_TOKEN, X_ACCESS_SECRET
"""
import json
import logging
import os
import time
from pathlib import Path
import requests
from dotenv import load_dotenv
from requests_oauthlib import OAuth1
load_dotenv()
BASE_DIR = Path(__file__).parent.parent.parent
LOG_DIR = BASE_DIR / 'logs'
LOG_DIR.mkdir(exist_ok=True)
DATA_DIR = BASE_DIR / 'data'
logging.basicConfig(
level=logging.INFO,
format='%(asctime)s [%(levelname)s] %(message)s',
handlers=[
logging.FileHandler(LOG_DIR / 'distributor.log', encoding='utf-8'),
logging.StreamHandler(),
]
)
logger = logging.getLogger(__name__)
X_API_KEY = os.getenv('X_API_KEY', '')
X_API_SECRET = os.getenv('X_API_SECRET', '')
X_ACCESS_TOKEN = os.getenv('X_ACCESS_TOKEN', '')
X_ACCESS_SECRET = os.getenv('X_ACCESS_SECRET', '')
X_API_V2 = 'https://api.twitter.com/2/tweets'
def _check_credentials() -> bool:
if not all([X_API_KEY, X_API_SECRET, X_ACCESS_TOKEN, X_ACCESS_SECRET]):
logger.warning("X API 자격증명 없음 (.env: X_API_KEY, X_API_SECRET, X_ACCESS_TOKEN, X_ACCESS_SECRET)")
return False
return True
def _get_auth() -> OAuth1:
return OAuth1(X_API_KEY, X_API_SECRET, X_ACCESS_TOKEN, X_ACCESS_SECRET)
def post_tweet(text: str, reply_to_id: str = '') -> str:
"""
단일 트윗 게시.
reply_to_id: 스레드 연결용 이전 트윗 ID
Returns: 트윗 ID
"""
if not _check_credentials():
return ''
payload = {'text': text}
if reply_to_id:
payload['reply'] = {'in_reply_to_tweet_id': reply_to_id}
try:
auth = _get_auth()
resp = requests.post(X_API_V2, json=payload, auth=auth, timeout=15)
resp.raise_for_status()
tweet_id = resp.json().get('data', {}).get('id', '')
logger.info(f"트윗 게시: {tweet_id} ({len(text)}자)")
return tweet_id
except Exception as e:
logger.error(f"트윗 게시 실패: {e}")
return ''
def publish_thread(article: dict, thread_data: list[dict]) -> bool:
"""
스레드 JSON → 순차 트윗 게시.
thread_data: thread_converter.convert() 반환값
"""
if not _check_credentials():
logger.info("X API 미설정 — 발행 건너뜀")
return False
title = article.get('title', '')
logger.info(f"X 스레드 발행 시작: {title} ({len(thread_data)}개 트윗)")
prev_id = ''
tweet_ids = []
for tweet in sorted(thread_data, key=lambda x: x['order']):
text = tweet['text']
tweet_id = post_tweet(text, prev_id)
if not tweet_id:
logger.error(f"스레드 중단: {tweet['order']}번 트윗 실패")
return False
tweet_ids.append(tweet_id)
prev_id = tweet_id
time.sleep(1) # rate limit 방지
logger.info(f"X 스레드 발행 완료: {len(tweet_ids)}")
_log_published(article, tweet_ids[0] if tweet_ids else '', 'x_thread')
return True
def publish_thread_from_file(article: dict, thread_file: str) -> bool:
"""파일에서 스레드 데이터 로드 후 게시"""
try:
data = json.loads(Path(thread_file).read_text(encoding='utf-8'))
return publish_thread(article, data)
except Exception as e:
logger.error(f"스레드 파일 로드 실패: {e}")
return False
def _log_published(article: dict, post_id: str, platform: str):
pub_dir = DATA_DIR / 'published'
pub_dir.mkdir(exist_ok=True)
from datetime import datetime
record = {
'platform': platform,
'post_id': post_id,
'title': article.get('title', ''),
'corner': article.get('corner', ''),
'published_at': datetime.now().isoformat(),
}
filename = f"{datetime.now().strftime('%Y%m%d_%H%M%S')}_{platform}_{post_id}.json"
with open(pub_dir / filename, 'w', encoding='utf-8') as f:
json.dump(record, f, ensure_ascii=False, indent=2)
if __name__ == '__main__':
import sys
sys.path.insert(0, str(BASE_DIR / 'bots' / 'converters'))
import thread_converter
sample = {
'title': 'ChatGPT 처음 쓰는 사람을 위한 완전 가이드',
'slug': 'chatgpt-guide',
'corner': '쉬운세상',
'tags': ['ChatGPT', 'AI'],
'key_points': ['무료로 바로 시작', 'GPT-3.5로도 충분', '프롬프트가 핵심'],
}
threads = thread_converter.convert(sample, save_file=False)
for t in threads:
print(f"[{t['order']}] {t['text']}\n")

View File

@@ -0,0 +1,186 @@
"""
유튜브 배포봇 (distributors/youtube_bot.py)
역할: 쇼츠 MP4 → YouTube Data API v3 업로드 (LAYER 3)
Phase 2.
사전 조건:
- Google Cloud에서 YouTube Data API v3 활성화 (기존 프로젝트에 추가)
- .env: YOUTUBE_CHANNEL_ID (기존 Google OAuth token.json 재사용)
"""
import json
import logging
import os
from pathlib import Path
from dotenv import load_dotenv
load_dotenv()
BASE_DIR = Path(__file__).parent.parent.parent
LOG_DIR = BASE_DIR / 'logs'
LOG_DIR.mkdir(exist_ok=True)
DATA_DIR = BASE_DIR / 'data'
TOKEN_PATH = BASE_DIR / 'token.json'
logging.basicConfig(
level=logging.INFO,
format='%(asctime)s [%(levelname)s] %(message)s',
handlers=[
logging.FileHandler(LOG_DIR / 'distributor.log', encoding='utf-8'),
logging.StreamHandler(),
]
)
logger = logging.getLogger(__name__)
YOUTUBE_CHANNEL_ID = os.getenv('YOUTUBE_CHANNEL_ID', '')
YOUTUBE_SCOPES = [
'https://www.googleapis.com/auth/youtube.upload',
'https://www.googleapis.com/auth/youtube',
]
CORNER_TAGS = {
'쉬운세상': ['AI활용', '디지털라이프', '쉬운세상', 'The4thPath', 'AI가이드'],
'숨은보물': ['숨은보물', 'AI도구', '생산성', 'The4thPath', 'AI툴'],
'바이브리포트': ['트렌드', 'AI시대', '바이브리포트', 'The4thPath'],
'팩트체크': ['팩트체크', 'AI뉴스', 'The4thPath'],
'한컷': ['한컷만평', 'AI시사', 'The4thPath'],
}
def _get_credentials():
"""기존 Google OAuth token.json 재사용"""
try:
from google.oauth2.credentials import Credentials
from google.auth.transport.requests import Request
if not TOKEN_PATH.exists():
raise RuntimeError("token.json 없음. scripts/get_token.py 먼저 실행")
creds = Credentials.from_authorized_user_file(str(TOKEN_PATH), YOUTUBE_SCOPES)
if not creds or not creds.valid:
if creds and creds.expired and creds.refresh_token:
creds.refresh(Request())
TOKEN_PATH.write_text(creds.to_json())
return creds
except Exception as e:
logger.error(f"YouTube 인증 실패: {e}")
return None
def build_video_metadata(article: dict) -> dict:
"""유튜브 업로드용 메타데이터 구성"""
title = article.get('title', '')
meta = article.get('meta', '')
corner = article.get('corner', '')
key_points = article.get('key_points', [])
slug = article.get('slug', '')
# 쇼츠는 #Shorts 태그 필수
description_parts = [meta, '']
if key_points:
for point in key_points[:3]:
description_parts.append(f'{point}')
description_parts.append('')
description_parts.append('the4thpath.com')
description_parts.append('#Shorts')
tags = CORNER_TAGS.get(corner, ['The4thPath']) + ['Shorts', 'AI']
return {
'snippet': {
'title': f'{title} #Shorts',
'description': '\n'.join(description_parts),
'tags': tags,
'categoryId': '28', # Science & Technology
},
'status': {
'privacyStatus': 'public',
'selfDeclaredMadeForKids': False,
},
}
def publish_shorts(article: dict, video_path: str) -> bool:
"""
쇼츠 MP4 → YouTube 업로드.
video_path: shorts_converter.convert()가 생성한 MP4
"""
if not Path(video_path).exists():
logger.error(f"영상 파일 없음: {video_path}")
return False
logger.info(f"YouTube 쇼츠 발행 시작: {article.get('title', '')}")
creds = _get_credentials()
if not creds:
return False
try:
from googleapiclient.discovery import build
from googleapiclient.http import MediaFileUpload
service = build('youtube', 'v3', credentials=creds)
metadata = build_video_metadata(article)
media = MediaFileUpload(
video_path,
mimetype='video/mp4',
resumable=True,
chunksize=5 * 1024 * 1024, # 5MB chunks
)
request = service.videos().insert(
part='snippet,status',
body=metadata,
media_body=media,
)
response = None
while response is None:
status, response = request.next_chunk()
if status:
pct = int(status.progress() * 100)
logger.info(f"업로드 진행: {pct}%")
video_id = response.get('id', '')
video_url = f'https://www.youtube.com/shorts/{video_id}'
logger.info(f"YouTube 쇼츠 발행 완료: {video_url}")
_log_published(article, video_id, 'youtube_shorts', video_url)
return True
except Exception as e:
logger.error(f"YouTube 업로드 실패: {e}")
return False
def _log_published(article: dict, post_id: str, platform: str, url: str = ''):
pub_dir = DATA_DIR / 'published'
pub_dir.mkdir(exist_ok=True)
from datetime import datetime
record = {
'platform': platform,
'post_id': post_id,
'url': url,
'title': article.get('title', ''),
'corner': article.get('corner', ''),
'published_at': datetime.now().isoformat(),
}
filename = f"{datetime.now().strftime('%Y%m%d_%H%M%S')}_{platform}_{post_id}.json"
with open(pub_dir / filename, 'w', encoding='utf-8') as f:
json.dump(record, f, ensure_ascii=False, indent=2)
if __name__ == '__main__':
sample = {
'title': 'ChatGPT 처음 쓰는 사람을 위한 완전 가이드',
'meta': 'ChatGPT를 처음 쓰는 분을 위한 단계별 가이드',
'slug': 'chatgpt-guide',
'corner': '쉬운세상',
'key_points': ['무료로 바로 시작', 'GPT-3.5로도 충분', '프롬프트가 핵심'],
}
meta = build_video_metadata(sample)
import pprint
pprint.pprint(meta)