Files
blog-writer/bots/shorts/asset_resolver.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

329 lines
12 KiB
Python

"""
bots/shorts/asset_resolver.py
역할: 각 파이프라인 단계에서 사용할 에셋 소스를 결정하고
resolution_manifest.json을 생성.
Semi-auto 우선순위:
input/{scripts,images,videos,audio}/{article_id}* 파일 체크
→ 있으면 user_provided, 없으면 auto
캐릭터 결정:
article.corner → shorts_config corner_character_map → character type
→ character assets 경로 결정
출력:
resolution_manifest.json (메모리 dict로 반환, 필요시 저장)
"""
import json
import logging
import re
import shutil
from pathlib import Path
from typing import Optional
logger = logging.getLogger(__name__)
BASE_DIR = Path(__file__).parent.parent.parent
EXPRESSION_MOOD_MAP = {
'dramatic': ['surprised', 'thinking', 'determined'],
'upbeat': ['curious', 'explaining', 'smiling'],
'mysterious': ['curious', 'thinking', 'smiling'],
'calm': ['explaining','thinking', 'smiling'],
}
SEGMENT_EXPRESSION = {
'hook': 0, # index into mood expression list
'body': 1,
'closer': 2,
}
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 _normalize_id(article_id: str) -> str:
"""slug/article_id → 파일명 접두사 비교용 문자열."""
return re.sub(r'[^a-z0-9_\-]', '', article_id.lower())
# ─── Input 폴더 스캔 ─────────────────────────────────────────
def _scan_input(article_id: str, cfg: dict) -> dict:
"""
input/ 폴더에서 article_id와 매칭되는 사용자 제공 파일 탐색.
Returns: {script: path|None, images: [path], videos: [path], audio: path|None}
"""
input_dirs = cfg.get('input_dirs', {})
norm_id = _normalize_id(article_id)
found = {'script': None, 'images': [], 'videos': [], 'audio': None}
# scripts
scripts_dir = BASE_DIR / input_dirs.get('scripts', 'input/scripts/')
if scripts_dir.exists():
for f in scripts_dir.glob('*.json'):
if _normalize_id(f.stem).startswith(norm_id) or f.stem == article_id:
found['script'] = str(f)
break
if not found['script']:
# FIFO 소비: 가장 오래된 파일
files = sorted(scripts_dir.glob('*.json'))
if files:
found['script'] = str(files[0])
# images
images_dir = BASE_DIR / input_dirs.get('images', 'input/images/')
if images_dir.exists():
matched = [f for f in sorted(images_dir.glob('*.png'))
if _normalize_id(f.stem).startswith(norm_id)]
matched += [f for f in sorted(images_dir.glob('*.jpg'))
if _normalize_id(f.stem).startswith(norm_id)]
if matched:
found['images'] = [str(f) for f in matched]
else:
# FIFO: 매칭 없으면 순서대로 소비
all_imgs = sorted((images_dir.glob('*.png'))) + sorted(images_dir.glob('*.jpg'))
if all_imgs:
found['images'] = [str(f) for f in all_imgs[:5]]
# videos
videos_dir = BASE_DIR / input_dirs.get('videos', 'input/videos/')
if videos_dir.exists():
matched = [f for f in sorted(videos_dir.glob('*.mp4'))
if _normalize_id(f.stem).startswith(norm_id)]
if matched:
found['videos'] = [str(f) for f in matched]
else:
all_vids = sorted(videos_dir.glob('*.mp4'))
if all_vids:
found['videos'] = [str(f) for f in all_vids[:5]]
# audio
audio_dir = BASE_DIR / input_dirs.get('audio', 'input/audio/')
if audio_dir.exists():
for ext in ('*.wav', '*.mp3'):
for f in sorted(audio_dir.glob(ext)):
if _normalize_id(f.stem).startswith(norm_id) or f.stem == article_id:
found['audio'] = str(f)
break
if found['audio']:
break
if not found['audio']:
# FIFO
for ext in ('*.wav', '*.mp3'):
files = sorted(audio_dir.glob(ext))
if files:
found['audio'] = str(files[0])
break
return found
def _move_to_processed(paths: list[str]) -> None:
"""처리 완료 파일을 input/_processed/ 로 이동."""
if not paths:
return
processed = BASE_DIR / 'input' / '_processed'
processed.mkdir(parents=True, exist_ok=True)
for p in paths:
src = Path(p)
if src.exists():
dst = processed / src.name
try:
shutil.move(str(src), str(dst))
logger.debug(f'처리 완료 이동: {src.name} → input/_processed/')
except Exception as e:
logger.warning(f'파일 이동 실패 ({src.name}): {e}')
# ─── 캐릭터 결정 ──────────────────────────────────────────────
def _resolve_character(article: dict, cfg: dict) -> dict:
"""
article.corner → character type → assets 경로.
Returns: {type, name, display_name, default_pose, poses_dir, expressions_dir, backgrounds_dir, ...}
"""
corner = article.get('corner', '')
corner_map = cfg.get('assets', {}).get('corner_character_map', {})
char_type = corner_map.get(corner, 'tech_blog')
characters = cfg.get('assets', {}).get('characters', {})
char_cfg = characters.get(char_type, characters.get('tech_blog', {}))
return {
'type': char_type,
'name': char_cfg.get('name', 'bao'),
'display_name': char_cfg.get('display_name', '바오'),
'default_pose': str(BASE_DIR / char_cfg.get('default_pose', '')),
'poses_dir': str(BASE_DIR / char_cfg.get('poses_dir', '')),
'expressions_dir': str(BASE_DIR / char_cfg.get('expressions_dir', '')),
'backgrounds_dir': str(BASE_DIR / char_cfg.get('backgrounds_dir', '')),
'scarves_dir': str(BASE_DIR / char_cfg.get('scarves_dir', '')) if 'scarves_dir' in char_cfg else None,
}
def _pick_pose(char_info: dict, mood: str) -> str:
"""mood 기반 포즈 선택 (poses_dir 내 파일)."""
poses_dir = Path(char_info['poses_dir'])
if not poses_dir.exists():
return char_info['default_pose']
pose_files = sorted(poses_dir.glob('*.png'))
if not pose_files:
return char_info['default_pose']
mood_pose_map = {
'dramatic': 'pose_explaining',
'upbeat': 'pose_waving',
'mysterious': 'pose_thinking',
'calm': 'pose_sitting',
}
preferred = mood_pose_map.get(mood, '')
for pf in pose_files:
if preferred and preferred in pf.stem:
return str(pf)
return str(pose_files[0])
def _pick_expressions(char_info: dict, mood: str) -> list[str]:
"""훅/본문/클로저 각각 표정 파일 경로 선택."""
expr_dir = Path(char_info['expressions_dir'])
if not expr_dir.exists():
return [char_info['default_pose']] * 3
expr_files = {f.stem: str(f) for f in expr_dir.glob('*.png')}
if not expr_files:
return [char_info['default_pose']] * 3
mood_exprs = EXPRESSION_MOOD_MAP.get(mood, ['curious', 'explaining', 'smiling'])
result = []
for expr_name in mood_exprs:
# 완전 일치 또는 접두사 일치
match = next((v for k, v in expr_files.items() if expr_name in k), None)
if not match:
match = list(expr_files.values())[0]
result.append(match)
return result
def _pick_background(char_info: dict) -> str:
"""캐릭터 타입에 맞는 배경 파일 선택 (첫 번째 파일)."""
bg_dir = Path(char_info['backgrounds_dir'])
if not bg_dir.exists():
return ''
bg_files = sorted(bg_dir.glob('*.png')) + sorted(bg_dir.glob('*.jpg'))
return str(bg_files[0]) if bg_files else ''
# ─── 메인 엔트리포인트 ────────────────────────────────────────
def resolve(
article: dict,
script: Optional[dict] = None,
cfg: Optional[dict] = None,
commit_processed: bool = False,
) -> dict:
"""
에셋 소스 결정 → resolution manifest 생성.
Args:
article: article dict (slug, corner 등)
script: 이미 추출된 스크립트 (mood 결정용)
cfg: shorts_config.json dict
commit_processed: True이면 사용된 input/ 파일을 _processed/로 이동
Returns:
manifest dict:
{
script_source: "auto" | "user_provided",
visual_source: "auto" | "user_provided" | "mixed",
audio_source: "auto" | "user_provided",
character: {type, name, display_name, default_pose, poses_dir, ...},
pose: "path/to/pose.png",
expressions: ["path/to/expr1.png", ...], # [hook, body, closer]
background: "path/to/bg.png",
user_script_path: str | None,
user_clips: [str, ...], # mp4 경로
user_images: [str, ...], # png/jpg 경로
user_audio: str | None,
}
"""
if cfg is None:
cfg = _load_config()
article_id = article.get('slug', article.get('article_id', 'unknown'))
mood = (script or {}).get('mood', 'upbeat')
production_mode = cfg.get('production_mode', 'auto')
manifest = {
'article_id': article_id,
'production_mode': production_mode,
'script_source': 'auto',
'visual_source': 'auto',
'audio_source': 'auto',
'user_script_path': None,
'user_clips': [],
'user_images': [],
'user_audio': None,
}
# Semi-auto: input/ 폴더 스캔
if production_mode == 'semi_auto':
found = _scan_input(article_id, cfg)
if found['script']:
manifest['script_source'] = 'user_provided'
manifest['user_script_path'] = found['script']
if found['videos']:
manifest['visual_source'] = 'user_provided'
manifest['user_clips'] = found['videos']
elif found['images']:
manifest['visual_source'] = 'user_provided'
manifest['user_images'] = found['images']
if manifest['user_clips'] and manifest['user_images']:
manifest['visual_source'] = 'mixed'
if found['audio']:
manifest['audio_source'] = 'user_provided'
manifest['user_audio'] = found['audio']
logger.info(
f'에셋 결정 (semi_auto): '
f'script={manifest["script_source"]}, '
f'visual={manifest["visual_source"]}, '
f'audio={manifest["audio_source"]}'
)
else:
logger.info('에셋 결정 (auto): 모든 에셋 자동 생성')
# 캐릭터 결정
char_info = _resolve_character(article, cfg)
pose = _pick_pose(char_info, mood)
expressions = _pick_expressions(char_info, mood)
background = _pick_background(char_info)
manifest['character'] = char_info
manifest['pose'] = pose
manifest['expressions'] = expressions
manifest['background'] = background
# 처리된 input/ 파일 이동
if commit_processed and production_mode == 'semi_auto':
to_move = []
if manifest['user_script_path']:
to_move.append(manifest['user_script_path'])
to_move.extend(manifest['user_clips'])
to_move.extend(manifest['user_images'])
if manifest['user_audio']:
to_move.append(manifest['user_audio'])
_move_to_processed(to_move)
return manifest