Files
blog-writer/bots/shorts/youtube_uploader.py
JOUNGWOOK KWON fb5e6ddbdf feat: YouTube Shorts 파이프라인 완성 및 HJW TV 업로드 연동
- youtube_uploader.py: YOUTUBE_REFRESH_TOKEN/CLIENT_ID/CLIENT_SECRET 환경변수 폴백 추가
  (token.json 없는 Docker 환경에서 브랜드 계정 인증 가능)
- shorts_config.json: corners_eligible를 실제 블로그 코너명으로 수정
- caption_renderer.py: render_captions() 반환값 누락 수정
- get_token.py: web→installed 타입 변환, port 8080 고정, prompt=consent 추가
- get_youtube_token.py: YouTube 전용 OAuth 토큰 발급 스크립트 (별도 클라이언트)
- CLAUDE.md: 프로젝트 개요, 배포 방법, 핵심 파일, YouTube 채널 정보 추가
- publisher_bot.py: 이미지 분산 배치, SEO 검증, 버그 수정
- scheduler.py: 알림 강화, atomic write, 중복 방지, hot reload 개선

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-04-06 09:27:48 +09:00

277 lines
9.5 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 서비스 객체 생성 (token.json 우선, env fallback)."""
from google.oauth2.credentials import Credentials
from google.auth.transport.requests import Request
from googleapiclient.discovery import build
creds = None
# 1) token.json 파일 우선
if TOKEN_PATH.exists():
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,
)
# 2) .env의 YOUTUBE_REFRESH_TOKEN으로 직접 생성 (Docker 환경 대응)
if not creds:
refresh_token = os.environ.get('YOUTUBE_REFRESH_TOKEN', '') or os.environ.get('GOOGLE_REFRESH_TOKEN', '')
client_id = os.environ.get('YOUTUBE_CLIENT_ID', '') or os.environ.get('GOOGLE_CLIENT_ID', '')
client_secret = os.environ.get('YOUTUBE_CLIENT_SECRET', '') or os.environ.get('GOOGLE_CLIENT_SECRET', '')
if not all([refresh_token, client_id, client_secret]):
raise RuntimeError(
'OAuth 인증 정보 없음: token.json 또는 '
'YOUTUBE_REFRESH_TOKEN/YOUTUBE_CLIENT_ID/YOUTUBE_CLIENT_SECRET 환경변수 필요'
)
creds = Credentials(
token=None,
refresh_token=refresh_token,
token_uri='https://oauth2.googleapis.com/token',
client_id=client_id,
client_secret=client_secret,
)
logger.info('token.json 없음 — YOUTUBE_REFRESH_TOKEN 환경변수로 인증')
# 토큰 갱신
if creds.expired or not creds.token:
if creds.refresh_token:
creds.refresh(Request())
# token.json이 있으면 갱신된 토큰 저장
if TOKEN_PATH.exists():
creds_data = json.loads(TOKEN_PATH.read_text(encoding='utf-8'))
creds_data['token'] = creds.token
TOKEN_PATH.write_text(json.dumps(creds_data, indent=2), encoding='utf-8')
else:
raise RuntimeError('refresh_token 없음 — 재인증 필요')
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}')