- upstream sinmb79/blog-writer v3.2.1 코드 베이스 적용 - config_resolver, CLI, writer_bot, shorts pipeline 등 신규 기능 포함 - load_dotenv Windows 경로 → Docker 호환 load_dotenv() 변경 (25개 파일) - runtime_guard.py Docker 환경 bypass 추가 - config/blogs.json: eli-ai 블로그 정체성 (8개 카테고리) - config/sources.json: 38개 RSS 소스 유지 - config/engine.json: writing provider → gemini (2.5-flash) - config/safety_keywords.json: 모든 글 수동 승인 (score 101) - bots/scheduler.py: 시스템 프롬프트 eli 블로그 기준으로 업데이트 - bots/publisher_bot.py: .env refresh token OAuth 폴백 로직 추가 - requirements.txt: google-generativeai, groq 활성화 - Dockerfile + docker-compose.yml: NAS Docker 배포 설정 - CLAUDE.md: 프로젝트 메타데이터 Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
215 lines
6.8 KiB
Python
215 lines
6.8 KiB
Python
"""
|
|
틱톡 배포봇 (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))
|