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>
This commit is contained in:
+74
-3
@@ -464,6 +464,19 @@ def fetch_featured_image(article: dict) -> str:
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
# 5) Unsplash 무료 이미지 검색 (API 키 불필요 — source 파라미터로 크레딧 자동 표시)
|
||||
title = article.get('title', '')
|
||||
unsplash_query = title[:50] if title else (search_keywords[0] if search_keywords else '')
|
||||
if unsplash_query:
|
||||
try:
|
||||
unsplash_url = f'https://source.unsplash.com/800x450/?{_quote(unsplash_query)}'
|
||||
resp = requests.head(unsplash_url, timeout=8, allow_redirects=True)
|
||||
if resp.status_code == 200 and 'images.unsplash.com' in resp.url:
|
||||
logger.info(f"Unsplash 이미지 사용: {unsplash_query[:30]} → {resp.url[:60]}")
|
||||
return resp.url
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
return ''
|
||||
|
||||
|
||||
@@ -512,6 +525,7 @@ def build_full_html(article: dict, body_html: str, toc_html: str) -> str:
|
||||
f'width="100%" style="max-height:420px;object-fit:cover;border-radius:8px;'
|
||||
f'margin-bottom:1.2em;" />'
|
||||
)
|
||||
n_imgs = len(img_tags)
|
||||
if n_imgs == 1:
|
||||
# 1장: 본문 최상단에 배치
|
||||
body_html = img_tags[0] + '\n' + body_html
|
||||
@@ -525,11 +539,17 @@ def build_full_html(article: dict, body_html: str, toc_html: str) -> str:
|
||||
blocks = block_pattern.split(body_html)
|
||||
boundary_indices = [i for i in range(1, len(blocks), 2)]
|
||||
if len(boundary_indices) >= n_imgs + 1:
|
||||
spacing = len(boundary_indices) // (n_imgs + 1)
|
||||
insert_positions = [spacing * (k + 1) for k in range(n_imgs)]
|
||||
# 균등 분산: spacing=0 방지를 위해 비율 기반 계산
|
||||
insert_positions = [
|
||||
int(len(boundary_indices) * (k + 1) / (n_imgs + 1))
|
||||
for k in range(n_imgs)
|
||||
]
|
||||
# 중복 위치 제거 (spacing이 너무 좁을 때)
|
||||
insert_positions = sorted(set(insert_positions))
|
||||
for img_idx, pos in enumerate(reversed(insert_positions)):
|
||||
bi = boundary_indices[min(pos, len(boundary_indices) - 1)]
|
||||
blocks.insert(bi, '\n' + img_tags[n_imgs - 1 - img_idx] + '\n')
|
||||
img_tag_idx = min(len(img_tags) - 1, len(insert_positions) - 1 - img_idx)
|
||||
blocks.insert(bi, '\n' + img_tags[img_tag_idx] + '\n')
|
||||
body_html = ''.join(blocks)
|
||||
else:
|
||||
body_html = '\n'.join(img_tags) + '\n' + body_html
|
||||
@@ -715,6 +735,46 @@ def load_pending_review_file(filepath: str) -> dict:
|
||||
return json.load(f)
|
||||
|
||||
|
||||
def validate_seo(article: dict) -> list[str]:
|
||||
"""#10 발행 전 SEO 기본 요건 검증 — 경고 목록 반환"""
|
||||
warnings = []
|
||||
title = article.get('title', '') or ''
|
||||
meta = article.get('meta', '') or ''
|
||||
body = article.get('body', '') or ''
|
||||
tags = article.get('tags', []) or []
|
||||
if isinstance(tags, str):
|
||||
tags = [t.strip() for t in tags.split(',') if t.strip()]
|
||||
|
||||
# 제목 길이 (30~70자 권장)
|
||||
if len(title) < 15:
|
||||
warnings.append(f"제목이 너무 짧음 ({len(title)}자, 최소 15자 권장)")
|
||||
elif len(title) > 80:
|
||||
warnings.append(f"제목이 너무 김 ({len(title)}자, 80자 이내 권장)")
|
||||
|
||||
# 메타 설명 (50~160자 권장)
|
||||
if len(meta) < 30:
|
||||
warnings.append(f"메타 설명이 너무 짧음 ({len(meta)}자, 최소 30자 권장)")
|
||||
elif len(meta) > 160:
|
||||
warnings.append(f"메타 설명이 너무 김 ({len(meta)}자, 160자 이내 권장)")
|
||||
|
||||
# H2 태그 (최소 2개 권장)
|
||||
h2_count = body.lower().count('<h2')
|
||||
if h2_count < 2:
|
||||
warnings.append(f"H2 소제목이 부족 ({h2_count}개, 최소 2개 권장)")
|
||||
|
||||
# 태그 (최소 3개 권장)
|
||||
if len(tags) < 3:
|
||||
warnings.append(f"태그가 부족 ({len(tags)}개, 최소 3개 권장)")
|
||||
|
||||
# 본문 길이 (최소 500자)
|
||||
import re as _re
|
||||
text_only = _re.sub(r'<[^>]+>', '', body)
|
||||
if len(text_only) < 500:
|
||||
warnings.append(f"본문이 너무 짧음 ({len(text_only)}자, 최소 500자 권장)")
|
||||
|
||||
return warnings
|
||||
|
||||
|
||||
# ─── 메인 발행 함수 ──────────────────────────────────
|
||||
|
||||
def publish(article: dict) -> bool:
|
||||
@@ -727,6 +787,12 @@ def publish(article: dict) -> bool:
|
||||
Returns: True(발행 성공) / False(수동 검토 대기)
|
||||
"""
|
||||
logger.info(f"발행 시도: {article.get('title', '')}")
|
||||
|
||||
# #10 SEO 검증
|
||||
seo_warnings = validate_seo(article)
|
||||
if seo_warnings:
|
||||
logger.warning(f"SEO 경고: {'; '.join(seo_warnings)}")
|
||||
|
||||
safety_cfg = load_config('safety_keywords.json')
|
||||
|
||||
# 안전장치 검사
|
||||
@@ -792,6 +858,11 @@ def approve_pending(filepath: str) -> bool:
|
||||
article.pop('pending_reason', None)
|
||||
article.pop('created_at', None)
|
||||
|
||||
# #10 SEO 검증
|
||||
seo_warnings = validate_seo(article)
|
||||
if seo_warnings:
|
||||
logger.warning(f"SEO 경고 (승인 발행): {'; '.join(seo_warnings)}")
|
||||
|
||||
# 안전장치 우회하여 강제 발행
|
||||
body_html, toc_html = markdown_to_html(article.get('body', ''))
|
||||
body_html = insert_adsense_placeholders(body_html)
|
||||
|
||||
Reference in New Issue
Block a user