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:
0
bots/distributors/__init__.py
Normal file
0
bots/distributors/__init__.py
Normal file
224
bots/distributors/image_host.py
Normal file
224
bots/distributors/image_host.py
Normal 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 <이미지경로>")
|
||||
207
bots/distributors/instagram_bot.py
Normal file
207
bots/distributors/instagram_bot.py
Normal 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))
|
||||
214
bots/distributors/tiktok_bot.py
Normal file
214
bots/distributors/tiktok_bot.py
Normal 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
149
bots/distributors/x_bot.py
Normal 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")
|
||||
186
bots/distributors/youtube_bot.py
Normal file
186
bots/distributors/youtube_bot.py
Normal 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)
|
||||
Reference in New Issue
Block a user