Files
blog-writer/bots/shorts/youtube_uploader.py
sinmb79 9b44a07a44 feat: v3.2 — YouTube Shorts 봇 + 수동 어시스트 + 보안 개선
주요 추가 기능:
- bots/shorts/ 서브모듈 7개: tts_engine, script_extractor, asset_resolver,
  stock_fetcher, caption_renderer, video_assembler, youtube_uploader
- bots/shorts_bot.py: 6단계 Shorts 파이프라인 오케스트레이터
  (auto/semi_auto 두 가지 생산 모드, CLI 지원)
- bots/writer_bot.py: 독립 실행형 AI 글쓰기 봇 (대시보드 연동)
- bots/assist_bot.py: URL 기반 수동 어시스트 파이프라인
- config/shorts_config.json: Shorts 전체 설정
- templates/shorts/extract_prompt.txt: LLM 스크립트 추출 프롬프트
- scheduler.py에 shorts 잡(10:35/16:00) + /shorts Telegram 명령 추가

보안 개선:
- .env 파일 외부 경로 참조로 변경 (load_dotenv dotenv_path, 24개 파일)
- .gitignore에 민감 파일/내부 문서/런타임 데이터 항목 추가

문서:
- README.md 전면 재작성 (상세 한글 설명, 설치/설정/사용법 포함)

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-28 17:51:02 +09:00

252 lines
8.2 KiB
Python

"""
bots/shorts/youtube_uploader.py
역할: 렌더링된 쇼츠 MP4 → YouTube Data API v3 업로드
OAuth2: 기존 Blogger token.json 재사용 (youtube.upload 스코프 추가 필요).
AI Disclosure: YouTube 정책 준수 — 합성 콘텐츠 레이블 자동 설정.
업로드 쿼터: 하루 max daily_upload_limit (기본 6) 체크.
출력:
data/shorts/published/{timestamp}.json
{video_id, url, title, upload_time, article_id}
"""
import json
import logging
import os
from datetime import datetime
from pathlib import Path
from typing import Optional
logger = logging.getLogger(__name__)
BASE_DIR = Path(__file__).parent.parent.parent
TOKEN_PATH = BASE_DIR / 'token.json'
PUBLISHED_DIR = BASE_DIR / 'data' / 'shorts' / 'published'
AI_DISCLOSURE_KO = '이 영상은 AI 도구를 활용하여 제작되었습니다.'
YOUTUBE_SCOPES = [
'https://www.googleapis.com/auth/blogger',
'https://www.googleapis.com/auth/youtube.upload',
'https://www.googleapis.com/auth/youtube.readonly',
'https://www.googleapis.com/auth/webmasters',
]
def _load_config() -> dict:
cfg_path = BASE_DIR / 'config' / 'shorts_config.json'
if cfg_path.exists():
return json.loads(cfg_path.read_text(encoding='utf-8'))
return {}
def _get_youtube_service():
"""YouTube Data API v3 서비스 객체 생성 (기존 OAuth token.json 재사용)."""
from google.oauth2.credentials import Credentials
from google.auth.transport.requests import Request
from googleapiclient.discovery import build
if not TOKEN_PATH.exists():
raise RuntimeError(f'OAuth 토큰 없음: {TOKEN_PATH} — scripts/get_token.py 실행 필요')
creds_data = json.loads(TOKEN_PATH.read_text(encoding='utf-8'))
client_id = os.environ.get('GOOGLE_CLIENT_ID', creds_data.get('client_id', ''))
client_secret = os.environ.get('GOOGLE_CLIENT_SECRET', creds_data.get('client_secret', ''))
creds = Credentials(
token=creds_data.get('token'),
refresh_token=creds_data.get('refresh_token') or os.environ.get('GOOGLE_REFRESH_TOKEN'),
token_uri='https://oauth2.googleapis.com/token',
client_id=client_id,
client_secret=client_secret,
scopes=YOUTUBE_SCOPES,
)
if creds.expired and creds.refresh_token:
creds.refresh(Request())
# 갱신된 토큰 저장
creds_data['token'] = creds.token
TOKEN_PATH.write_text(json.dumps(creds_data, indent=2), encoding='utf-8')
return build('youtube', 'v3', credentials=creds)
def _count_today_uploads(cfg: dict) -> int:
"""오늘 업로드 횟수 카운트."""
PUBLISHED_DIR.mkdir(parents=True, exist_ok=True)
today = datetime.now().strftime('%Y%m%d')
count = 0
for f in PUBLISHED_DIR.glob(f'{today}_*.json'):
try:
data = json.loads(f.read_text(encoding='utf-8'))
if data.get('video_id'):
count += 1
except Exception:
pass
return count
def _build_description(article: dict, script: dict) -> str:
"""업로드 설명 생성: 블로그 링크 + 해시태그 + AI 공시."""
title = article.get('title', '')
blog_url = article.get('url', article.get('link', ''))
corner = article.get('corner', '')
keywords = script.get('keywords', [])
lines = []
if title:
lines.append(title)
if blog_url:
lines.append(f'\n자세한 내용: {blog_url}')
lines.append('')
# 해시태그
tags = ['#Shorts', f'#{corner}'] if corner else ['#Shorts']
tags += [f'#{k.replace(" ", "")}' for k in keywords[:3]]
lines.append(' '.join(tags))
# AI 공시 (YouTube 정책 준수)
lines.append('')
lines.append(AI_DISCLOSURE_KO)
return '\n'.join(lines)
def _build_tags(article: dict, script: dict, cfg: dict) -> list[str]:
"""태그 목록 생성."""
base_tags = cfg.get('youtube', {}).get('default_tags', ['shorts', 'AI', '테크'])
corner = article.get('corner', '')
keywords = script.get('keywords', [])
tags = list(base_tags)
if corner:
tags.append(corner)
tags.extend(keywords[:5])
return list(dict.fromkeys(tags)) # 중복 제거
# ─── 업로드 ──────────────────────────────────────────────────
def upload(
video_path: Path,
article: dict,
script: dict,
timestamp: str,
cfg: Optional[dict] = None,
) -> dict:
"""
쇼츠 MP4 → YouTube 업로드.
Args:
video_path: 렌더링된 MP4 경로
article: article dict (title, url, corner 등)
script: shorts 스크립트 (hook, keywords 등)
timestamp: 파일명 prefix (발행 기록용)
cfg: shorts_config.json dict
Returns:
{video_id, url, title, upload_time, article_id}
Raises:
RuntimeError — 업로드 실패 또는 쿼터 초과
"""
if cfg is None:
cfg = _load_config()
yt_cfg = cfg.get('youtube', {})
daily_limit = yt_cfg.get('daily_upload_limit', 6)
# 쿼터 체크
today_count = _count_today_uploads(cfg)
if today_count >= daily_limit:
raise RuntimeError(f'YouTube 일일 업로드 한도 초과: {today_count}/{daily_limit}')
# 메타데이터 구성
title = script.get('hook', article.get('title', ''))[:100]
description = _build_description(article, script)
tags = _build_tags(article, script, cfg)
try:
from googleapiclient.http import MediaFileUpload
youtube = _get_youtube_service()
body = {
'snippet': {
'title': title,
'description': description,
'tags': tags,
'categoryId': yt_cfg.get('category_id', '28'),
},
'status': {
'privacyStatus': yt_cfg.get('privacy_status', 'public'),
'madeForKids': yt_cfg.get('made_for_kids', False),
'selfDeclaredMadeForKids': False,
},
}
media = MediaFileUpload(
str(video_path),
mimetype='video/mp4',
resumable=True,
chunksize=5 * 1024 * 1024, # 5MB chunks
)
request = youtube.videos().insert(
part='snippet,status',
body=body,
media_body=media,
)
logger.info(f'YouTube 업로드 시작: {video_path.name}')
response = None
while response is None:
status, response = request.next_chunk()
if status:
logger.debug(f'업로드 진행: {int(status.progress() * 100)}%')
video_id = response.get('id', '')
video_url = f'https://www.youtube.com/shorts/{video_id}'
logger.info(f'YouTube 업로드 완료: {video_url}')
# AI 합성 콘텐츠 레이블 설정 (YouTube 정책 준수)
_set_ai_disclosure(youtube, video_id)
except Exception as e:
raise RuntimeError(f'YouTube 업로드 실패: {e}') from e
# 발행 기록 저장
PUBLISHED_DIR.mkdir(parents=True, exist_ok=True)
record = {
'video_id': video_id,
'url': video_url,
'title': title,
'upload_time': datetime.now().isoformat(),
'article_id': article.get('slug', ''),
'script_hook': script.get('hook', ''),
}
record_path = PUBLISHED_DIR / f'{timestamp}.json'
record_path.write_text(json.dumps(record, ensure_ascii=False, indent=2), encoding='utf-8')
logger.info(f'발행 기록 저장: {record_path.name}')
return record
def _set_ai_disclosure(youtube, video_id: str) -> None:
"""
YouTube 합성 콘텐츠 레이블 설정 (v2 — AI 공시 정책 준수).
contentDetails.contentRating 업데이트.
"""
try:
youtube.videos().update(
part='contentDetails',
body={
'id': video_id,
'contentDetails': {
'contentRating': {
# Altered/synthetic content declaration
},
},
},
).execute()
logger.debug('AI 합성 콘텐츠 레이블 설정 완료')
except Exception as e:
# 레이블 실패는 경고만 (업로드 자체는 성공)
logger.warning(f'AI 공시 레이블 설정 실패: {e}')