주요 추가 기능: - 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>
306 lines
11 KiB
Python
306 lines
11 KiB
Python
"""
|
|
bots/shorts/script_extractor.py
|
|
역할: 블로그 포스트 dict → 쇼츠용 스크립트 JSON 생성
|
|
|
|
LLM 우선순위:
|
|
1. OpenClaw (로컬, EngineLoader 경유)
|
|
2. Claude API (ANTHROPIC_API_KEY)
|
|
폴백: 제목+KEY_POINTS 기반 규칙 기반 추출
|
|
|
|
출력:
|
|
data/shorts/scripts/{timestamp}.json
|
|
{hook, body, closer, keywords, mood, originality_check, article_id}
|
|
"""
|
|
import json
|
|
import logging
|
|
import re
|
|
import sys
|
|
from pathlib import Path
|
|
from typing import Optional
|
|
|
|
logger = logging.getLogger(__name__)
|
|
|
|
BASE_DIR = Path(__file__).parent.parent.parent
|
|
PROMPT_TEMPLATE_PATH = BASE_DIR / 'templates' / 'shorts' / 'extract_prompt.txt'
|
|
|
|
|
|
# ─── 유틸 ────────────────────────────────────────────────────
|
|
|
|
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 _build_post_text(article: dict) -> str:
|
|
"""article dict → LLM에 전달할 블로그 본문 텍스트."""
|
|
title = article.get('title', '')
|
|
key_points = article.get('key_points', article.get('KEY_POINTS', []))
|
|
body_html = article.get('body', article.get('BODY', ''))
|
|
|
|
# HTML 태그 제거 (간단한 정규식)
|
|
body_plain = re.sub(r'<[^>]+>', ' ', body_html)
|
|
body_plain = re.sub(r'\s+', ' ', body_plain).strip()
|
|
# 너무 길면 잘라냄 (LLM 토큰 절약)
|
|
if len(body_plain) > 1500:
|
|
body_plain = body_plain[:1500] + '...'
|
|
|
|
lines = [f'제목: {title}']
|
|
if key_points:
|
|
if isinstance(key_points, list):
|
|
lines.append('핵심 포인트:')
|
|
lines.extend(f'- {p}' for p in key_points)
|
|
else:
|
|
lines.append(f'핵심 포인트: {key_points}')
|
|
if body_plain:
|
|
lines.append(f'본문: {body_plain}')
|
|
return '\n'.join(lines)
|
|
|
|
|
|
def _load_prompt_template() -> str:
|
|
if PROMPT_TEMPLATE_PATH.exists():
|
|
return PROMPT_TEMPLATE_PATH.read_text(encoding='utf-8')
|
|
# 인라인 폴백
|
|
return (
|
|
'You are a YouTube Shorts script writer for a Korean tech blog.\n'
|
|
'Given the blog post below, extract a 15-20 second Shorts script.\n\n'
|
|
'OUTPUT FORMAT (JSON only):\n'
|
|
'{{"hook":"...","body":["..."],"closer":"...","keywords":["..."],'
|
|
'"mood":"...","originality_check":"..."}}\n\n'
|
|
'BLOG POST:\n---\n{blog_post_content}\n---'
|
|
)
|
|
|
|
|
|
def _parse_json_response(raw: str) -> Optional[dict]:
|
|
"""LLM 응답에서 JSON 추출 (마크다운 코드블록 포함 대응)."""
|
|
# ```json ... ``` 블록 제거
|
|
raw = re.sub(r'```json\s*', '', raw)
|
|
raw = re.sub(r'```\s*', '', raw)
|
|
raw = raw.strip()
|
|
|
|
# JSON 부분만 추출
|
|
match = re.search(r'\{[\s\S]+\}', raw)
|
|
if not match:
|
|
return None
|
|
try:
|
|
return json.loads(match.group())
|
|
except json.JSONDecodeError:
|
|
return None
|
|
|
|
|
|
def _validate_script(script: dict) -> bool:
|
|
"""필수 필드 존재 + 최소 품질 검사."""
|
|
required = ['hook', 'body', 'closer', 'keywords', 'mood']
|
|
if not all(k in script for k in required):
|
|
return False
|
|
if not script.get('hook'):
|
|
return False
|
|
if not isinstance(script.get('body'), list) or len(script['body']) == 0:
|
|
return False
|
|
# originality_check 없으면 경고만
|
|
if not script.get('originality_check'):
|
|
logger.warning('originality_check 필드 없음 — 스크립트 고유성 검증 불가')
|
|
return True
|
|
|
|
|
|
def _check_template_similarity(new_script: dict, scripts_dir: Path) -> bool:
|
|
"""
|
|
직전 10개 스크립트와 본문 단어 중복률 체크.
|
|
60% 초과 → True (유사도 과다, 거부 권고)
|
|
"""
|
|
new_words = set(' '.join(new_script.get('body', [])).split())
|
|
if not new_words:
|
|
return False
|
|
|
|
history_files = sorted(scripts_dir.glob('*.json'), reverse=True)[:10]
|
|
for hf in history_files:
|
|
try:
|
|
old = json.loads(hf.read_text(encoding='utf-8'))
|
|
old_words = set(' '.join(old.get('body', [])).split())
|
|
if not old_words:
|
|
continue
|
|
overlap = len(new_words & old_words) / len(new_words)
|
|
if overlap > 0.6:
|
|
logger.warning(f'스크립트 유사도 과다 ({overlap:.0%}): {hf.name}')
|
|
return True
|
|
except Exception:
|
|
continue
|
|
return False
|
|
|
|
|
|
# ─── LLM 호출 ────────────────────────────────────────────────
|
|
|
|
def _extract_via_engine(post_text: str, cfg: dict) -> Optional[dict]:
|
|
"""EngineLoader (OpenClaw/Claude API)로 스크립트 추출."""
|
|
sys.path.insert(0, str(BASE_DIR / 'bots'))
|
|
try:
|
|
from engine_loader import EngineLoader
|
|
except ImportError:
|
|
return None
|
|
|
|
template = _load_prompt_template()
|
|
prompt = template.replace('{blog_post_content}', post_text)
|
|
|
|
system = (
|
|
'You are a YouTube Shorts script extraction assistant. '
|
|
'Output only valid JSON, no explanation.'
|
|
)
|
|
|
|
try:
|
|
writer = EngineLoader(cfg_override={'writing': cfg.get('script', {}).get('llm_provider', 'openclaw')}).get_writer()
|
|
raw = writer.write(prompt, system=system).strip()
|
|
return _parse_json_response(raw)
|
|
except Exception as e:
|
|
logger.warning(f'EngineLoader 스크립트 추출 실패: {e}')
|
|
return None
|
|
|
|
|
|
def _extract_via_claude_api(post_text: str) -> Optional[dict]:
|
|
"""Anthropic API 직접 호출 (폴백)."""
|
|
import os
|
|
api_key = os.environ.get('ANTHROPIC_API_KEY', '')
|
|
if not api_key:
|
|
return None
|
|
|
|
try:
|
|
import anthropic
|
|
client = anthropic.Anthropic(api_key=api_key)
|
|
template = _load_prompt_template()
|
|
prompt = template.replace('{blog_post_content}', post_text)
|
|
|
|
msg = client.messages.create(
|
|
model='claude-haiku-4-5-20251001',
|
|
max_tokens=512,
|
|
messages=[{'role': 'user', 'content': prompt}],
|
|
)
|
|
raw = msg.content[0].text
|
|
return _parse_json_response(raw)
|
|
except Exception as e:
|
|
logger.warning(f'Claude API 스크립트 추출 실패: {e}')
|
|
return None
|
|
|
|
|
|
def _extract_rule_based(article: dict) -> dict:
|
|
"""
|
|
LLM 없을 때 규칙 기반 스크립트 추출 (최소 품질 보장).
|
|
제목 → hook, KEY_POINTS → body, CTA → closer.
|
|
"""
|
|
title = article.get('title', '제목 없음')
|
|
key_points = article.get('key_points', article.get('KEY_POINTS', []))
|
|
corner = article.get('corner', '')
|
|
|
|
if isinstance(key_points, str):
|
|
key_points = [kp.strip('- ').strip() for kp in key_points.split('\n') if kp.strip()]
|
|
|
|
# hook: 제목을 의문문으로 변환
|
|
hook = title
|
|
if not hook.endswith('?'):
|
|
hook = f'{title[:20]}... 알고 계셨나요?'
|
|
|
|
# body: KEY_POINTS 앞 3개
|
|
body = [p.strip('- ').strip() for p in key_points[:3]] if key_points else [title]
|
|
|
|
# closer: 코너별 CTA
|
|
cta_map = {
|
|
'쉬운세상': '블로그에서 더 자세히 확인해보세요.',
|
|
'숨은보물': '이 꿀팁, 주변에 공유해보세요.',
|
|
'웹소설': '전편 블로그에서 읽어보세요.',
|
|
}
|
|
closer = cta_map.get(corner, '구독하고 다음 편도 기대해주세요.')
|
|
|
|
# keywords: 제목 명사 추출 (간단)
|
|
keywords = [w for w in re.findall(r'[가-힣A-Za-z]{2,}', title)][:5]
|
|
if not keywords:
|
|
keywords = ['technology', 'korea', 'blog']
|
|
|
|
return {
|
|
'hook': hook,
|
|
'body': body,
|
|
'closer': closer,
|
|
'keywords': keywords,
|
|
'mood': 'upbeat',
|
|
'originality_check': f'{title}에 대한 핵심 포인트 요약',
|
|
}
|
|
|
|
|
|
# ─── 메인 엔트리포인트 ────────────────────────────────────────
|
|
|
|
def extract_script(
|
|
article: dict,
|
|
output_dir: Path,
|
|
timestamp: str,
|
|
cfg: Optional[dict] = None,
|
|
manifest: Optional[dict] = None,
|
|
) -> dict:
|
|
"""
|
|
블로그 포스트 → 쇼츠 스크립트 생성 + 저장.
|
|
|
|
Args:
|
|
article: article dict (title, body, key_points, corner 등)
|
|
output_dir: data/shorts/scripts/
|
|
timestamp: 파일명 prefix
|
|
cfg: shorts_config.json dict
|
|
manifest: asset_resolver 결과 (script_source 확인용)
|
|
|
|
Returns:
|
|
script dict {hook, body, closer, keywords, mood, originality_check, article_id}
|
|
"""
|
|
if cfg is None:
|
|
cfg = _load_config()
|
|
|
|
output_dir.mkdir(parents=True, exist_ok=True)
|
|
output_path = output_dir / f'{timestamp}.json'
|
|
|
|
article_id = article.get('slug', timestamp)
|
|
|
|
# 1. Semi-auto: input/scripts/ 에 사용자 제공 스크립트 있으면 로드
|
|
if manifest and manifest.get('script_source') == 'user_provided':
|
|
user_script_path = manifest.get('user_script_path')
|
|
if user_script_path and Path(user_script_path).exists():
|
|
script = json.loads(Path(user_script_path).read_text(encoding='utf-8'))
|
|
script['article_id'] = article_id
|
|
logger.info(f'사용자 제공 스크립트 사용: {user_script_path}')
|
|
output_path.write_text(json.dumps(script, ensure_ascii=False, indent=2), encoding='utf-8')
|
|
return script
|
|
|
|
# 2. LLM 추출
|
|
post_text = _build_post_text(article)
|
|
script = None
|
|
|
|
# OpenClaw/EngineLoader 시도
|
|
script = _extract_via_engine(post_text, cfg)
|
|
|
|
# Claude API 폴백
|
|
if not script or not _validate_script(script):
|
|
logger.info('Claude API 스크립트 추출 시도...')
|
|
script = _extract_via_claude_api(post_text)
|
|
|
|
# 규칙 기반 폴백
|
|
if not script or not _validate_script(script):
|
|
logger.warning('LLM 스크립트 추출 실패 — 규칙 기반 폴백 사용')
|
|
script = _extract_rule_based(article)
|
|
|
|
if not _validate_script(script):
|
|
raise RuntimeError('스크립트 검증 실패 — 필수 필드 누락')
|
|
|
|
# 유사도 검사
|
|
if _check_template_similarity(script, output_dir):
|
|
logger.warning('스크립트 유사도 과다 — 재추출 시도')
|
|
# 한 번 더 시도 (다른 엔진)
|
|
retry = _extract_via_claude_api(post_text)
|
|
if retry and _validate_script(retry) and not _check_template_similarity(retry, output_dir):
|
|
script = retry
|
|
|
|
script['article_id'] = article_id
|
|
|
|
output_path.write_text(json.dumps(script, ensure_ascii=False, indent=2), encoding='utf-8')
|
|
logger.info(f'스크립트 저장: {output_path.name}')
|
|
logger.debug(f'hook: {script.get("hook")} | mood: {script.get("mood")}')
|
|
return script
|
|
|
|
|
|
def load_script(script_path: Path) -> dict:
|
|
"""저장된 스크립트 JSON 로드."""
|
|
return json.loads(script_path.read_text(encoding='utf-8'))
|