- upstream sinmb79/blog-writer v3.2.1 코드 베이스 적용 - config_resolver, CLI, writer_bot, shorts pipeline 등 신규 기능 포함 - load_dotenv Windows 경로 → Docker 호환 load_dotenv() 변경 (25개 파일) - runtime_guard.py Docker 환경 bypass 추가 - config/blogs.json: eli-ai 블로그 정체성 (8개 카테고리) - config/sources.json: 38개 RSS 소스 유지 - config/engine.json: writing provider → gemini (2.5-flash) - config/safety_keywords.json: 모든 글 수동 승인 (score 101) - bots/scheduler.py: 시스템 프롬프트 eli 블로그 기준으로 업데이트 - bots/publisher_bot.py: .env refresh token OAuth 폴백 로직 추가 - requirements.txt: google-generativeai, groq 활성화 - Dockerfile + docker-compose.yml: NAS Docker 배포 설정 - CLAUDE.md: 프로젝트 메타데이터 Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
314 lines
11 KiB
Python
314 lines
11 KiB
Python
"""
|
||
bots/assist_bot.py
|
||
수동(어시스트) 모드 파이프라인
|
||
|
||
흐름:
|
||
1. 사용자 → URL 제공
|
||
2. 시스템 → URL 파싱 → 콘텐츠 추출
|
||
3. 시스템 → OpenClaw로 이미지/영상/나레이션 프롬프트 생성
|
||
4. 사용자 → 웹 AI로 에셋 생성 후 제공 (대시보드 업로드 or inbox 폴더 드롭)
|
||
5. 시스템 → 에셋 검증 → 배포 파이프라인 연결
|
||
"""
|
||
import json
|
||
import logging
|
||
import os
|
||
import re
|
||
import shutil
|
||
import subprocess
|
||
import uuid
|
||
from datetime import datetime
|
||
from pathlib import Path
|
||
from typing import Optional
|
||
|
||
import requests
|
||
from dotenv import load_dotenv
|
||
|
||
load_dotenv()
|
||
|
||
BASE_DIR = Path(__file__).parent.parent
|
||
ASSIST_DIR = BASE_DIR / 'data' / 'assist'
|
||
SESSIONS_DIR = ASSIST_DIR / 'sessions'
|
||
INBOX_DIR = ASSIST_DIR / 'inbox'
|
||
LOG_DIR = BASE_DIR / 'logs'
|
||
|
||
for _d in [SESSIONS_DIR, INBOX_DIR, LOG_DIR]:
|
||
_d.mkdir(parents=True, exist_ok=True)
|
||
|
||
logger = logging.getLogger(__name__)
|
||
if not logger.handlers:
|
||
_h = logging.FileHandler(LOG_DIR / 'assist_bot.log', encoding='utf-8')
|
||
_h.setFormatter(logging.Formatter('%(asctime)s [%(levelname)s] %(message)s'))
|
||
logger.addHandler(_h)
|
||
logger.addHandler(logging.StreamHandler())
|
||
logger.setLevel(logging.INFO)
|
||
|
||
_CLI = 'openclaw.cmd' if os.name == 'nt' else 'openclaw'
|
||
|
||
|
||
# ─── 상태 상수 ───────────────────────────────────────────
|
||
|
||
class S:
|
||
PENDING = 'pending' # URL 접수됨
|
||
FETCHING = 'fetching' # URL 수집 중
|
||
GENERATING = 'generating' # 프롬프트 생성 중
|
||
AWAITING = 'awaiting' # 에셋 대기 중
|
||
ASSEMBLING = 'assembling' # 조립 중
|
||
READY = 'ready' # 배포 준비 완료
|
||
ERROR = 'error' # 오류
|
||
|
||
STATUS_LABEL = {
|
||
S.PENDING: '접수됨',
|
||
S.FETCHING: 'URL 수집 중',
|
||
S.GENERATING: '프롬프트 생성 중',
|
||
S.AWAITING: '에셋 대기 중',
|
||
S.ASSEMBLING: '조립 중',
|
||
S.READY: '배포 준비 완료',
|
||
S.ERROR: '오류',
|
||
}
|
||
|
||
IMAGE_EXTENSIONS = {'.jpg', '.jpeg', '.png', '.webp', '.gif'}
|
||
VIDEO_EXTENSIONS = {'.mp4', '.mov', '.avi', '.webm'}
|
||
|
||
|
||
# ─── 세션 I/O ────────────────────────────────────────────
|
||
|
||
def session_dir(sid: str) -> Path:
|
||
return SESSIONS_DIR / sid
|
||
|
||
def meta_path(sid: str) -> Path:
|
||
return session_dir(sid) / 'meta.json'
|
||
|
||
def load_session(sid: str) -> Optional[dict]:
|
||
p = meta_path(sid)
|
||
return json.loads(p.read_text(encoding='utf-8')) if p.exists() else None
|
||
|
||
def save_session(data: dict) -> None:
|
||
sid = data['session_id']
|
||
session_dir(sid).mkdir(parents=True, exist_ok=True)
|
||
meta_path(sid).write_text(
|
||
json.dumps(data, ensure_ascii=False, indent=2), encoding='utf-8'
|
||
)
|
||
|
||
def list_sessions() -> list:
|
||
result = []
|
||
if not SESSIONS_DIR.exists():
|
||
return result
|
||
for d in sorted(SESSIONS_DIR.iterdir(), reverse=True):
|
||
if d.is_dir():
|
||
p = meta_path(d.name)
|
||
if p.exists():
|
||
try:
|
||
result.append(json.loads(p.read_text(encoding='utf-8')))
|
||
except Exception:
|
||
pass
|
||
return result
|
||
|
||
|
||
# ─── URL 파싱 ────────────────────────────────────────────
|
||
|
||
def fetch_article(url: str) -> dict:
|
||
"""URL에서 제목과 본문을 추출한다."""
|
||
headers = {'User-Agent': 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36'}
|
||
resp = requests.get(url, headers=headers, timeout=20)
|
||
resp.raise_for_status()
|
||
html = resp.text
|
||
|
||
# 제목
|
||
m = re.search(r'<title[^>]*>([^<]+)</title>', html, re.I)
|
||
title = re.sub(r'\s*[–\-|]\s*.+$', '', m.group(1)).strip() if m else '제목 없음'
|
||
|
||
# 본문 — 블로거/워드프레스/일반 HTML 순서로 시도
|
||
body = ''
|
||
for pat in [
|
||
r'<div[^>]+class="[^"]*post-body[^"]*"[^>]*>(.*?)</div\s*>',
|
||
r'<article[^>]*>(.*?)</article>',
|
||
r'<div[^>]+class="[^"]*entry-content[^"]*"[^>]*>(.*?)</div\s*>',
|
||
r'<main[^>]*>(.*?)</main>',
|
||
]:
|
||
mm = re.search(pat, html, re.DOTALL | re.I)
|
||
if mm:
|
||
body = mm.group(1)
|
||
break
|
||
if not body:
|
||
body = html
|
||
|
||
body = re.sub(r'<[^>]+>', ' ', body)
|
||
body = re.sub(r'\s+', ' ', body).strip()[:4000]
|
||
return {'title': title, 'body': body, 'url': url}
|
||
|
||
|
||
# ─── 프롬프트 생성 ───────────────────────────────────────
|
||
|
||
def _prompt_request(title: str, body: str) -> str:
|
||
return f"""아래 블로그 글을 읽고, 유튜브 쇼츠(60초 이내) 영상을 만들기 위한 프롬프트를 생성해줘.
|
||
|
||
제목: {title}
|
||
본문:
|
||
{body[:2000]}
|
||
|
||
반드시 아래 JSON 형식만 출력하고 다른 설명은 하지 마.
|
||
|
||
{{
|
||
"image_prompts": [
|
||
{{
|
||
"purpose": "썸네일",
|
||
"ko": "썸네일용 이미지 설명 (한국어)",
|
||
"en": "Thumbnail image prompt for Midjourney/DALL-E, photorealistic, cinematic"
|
||
}},
|
||
{{
|
||
"purpose": "배경1",
|
||
"ko": "영상 배경 이미지 설명 (한국어)",
|
||
"en": "Background image prompt, widescreen 16:9"
|
||
}},
|
||
{{
|
||
"purpose": "배경2",
|
||
"ko": "영상 배경 이미지 설명2 (한국어)",
|
||
"en": "Second background image prompt, widescreen 16:9"
|
||
}}
|
||
],
|
||
"video_prompt": {{
|
||
"ko": "AI 영상 생성용 설명 (한국어)",
|
||
"en": "Short video generation prompt for Sora/Runway, cinematic, 10 seconds"
|
||
}},
|
||
"narration_script": "쇼츠 나레이션 스크립트 (30-60초 분량, 한국어, 자막 스타일)"
|
||
}}"""
|
||
|
||
|
||
def generate_prompts(title: str, body: str) -> dict:
|
||
"""OpenClaw blog-writer 에이전트로 프롬프트를 생성한다."""
|
||
try:
|
||
result = subprocess.run(
|
||
[_CLI, 'agent', '--agent', 'blog-writer',
|
||
'--message', _prompt_request(title, body), '--json'],
|
||
capture_output=True, timeout=180,
|
||
)
|
||
stderr_str = result.stderr.decode('utf-8', errors='replace').strip()
|
||
if result.returncode != 0:
|
||
logger.error(f"OpenClaw returncode={result.returncode} stderr={stderr_str[:200]}")
|
||
elif not result.stdout:
|
||
logger.error(f"OpenClaw stdout 비어있음 stderr={stderr_str[:200]}")
|
||
else:
|
||
stdout = result.stdout.decode('utf-8', errors='replace').strip()
|
||
data = json.loads(stdout)
|
||
raw = data.get('result', {}).get('payloads', [{}])[0].get('text', '')
|
||
m = re.search(r'\{[\s\S]*\}', raw)
|
||
if m:
|
||
return json.loads(m.group())
|
||
logger.warning(f"프롬프트 JSON 없음, raw={raw[:100]}")
|
||
except Exception as e:
|
||
logger.error(f"프롬프트 생성 오류: {e}")
|
||
|
||
# 폴백
|
||
return {
|
||
"image_prompts": [
|
||
{"purpose": "썸네일", "ko": f"{title} 썸네일", "en": f"Thumbnail: {title}, cinematic, photorealistic"},
|
||
{"purpose": "배경1", "ko": "추상적 기술 배경", "en": "Abstract technology background, dark blue, 16:9"},
|
||
{"purpose": "배경2", "ko": "미니멀 배경", "en": "Clean minimal background, soft light, 16:9"},
|
||
],
|
||
"video_prompt": {
|
||
"ko": f"{title}에 관한 역동적인 정보 전달 영상",
|
||
"en": f"Cinematic explainer about {title}, 10 seconds, dynamic cuts",
|
||
},
|
||
"narration_script": f"{title}에 대해 알아봅시다. {body[:200]}",
|
||
}
|
||
|
||
|
||
# ─── 파이프라인 ──────────────────────────────────────────
|
||
|
||
def create_session(url: str) -> dict:
|
||
sid = datetime.now().strftime('%Y%m%d_%H%M%S') + '_' + uuid.uuid4().hex[:6]
|
||
session = {
|
||
'session_id': sid,
|
||
'url': url,
|
||
'status': S.PENDING,
|
||
'status_label': STATUS_LABEL[S.PENDING],
|
||
'created_at': datetime.now().isoformat(),
|
||
'title': '',
|
||
'body_preview': '',
|
||
'prompts': {},
|
||
'assets': [],
|
||
'error': '',
|
||
}
|
||
save_session(session)
|
||
logger.info(f"[어시스트] 새 세션: {sid} — {url}")
|
||
return session
|
||
|
||
|
||
def run_pipeline(sid: str) -> None:
|
||
"""백그라운드 파이프라인 실행."""
|
||
session = load_session(sid)
|
||
if not session:
|
||
return
|
||
|
||
def _update(status: str, **kwargs):
|
||
session['status'] = status
|
||
session['status_label'] = STATUS_LABEL[status]
|
||
session.update(kwargs)
|
||
save_session(session)
|
||
|
||
# 1. URL 수집
|
||
_update(S.FETCHING)
|
||
try:
|
||
article = fetch_article(session['url'])
|
||
_update(S.FETCHING,
|
||
title=article['title'],
|
||
body_preview=article['body'][:300],
|
||
_full_body=article['body'])
|
||
logger.info(f"[어시스트] URL 파싱 완료: {article['title']}")
|
||
except Exception as e:
|
||
_update(S.ERROR, error=f"URL 수집 실패: {e}")
|
||
logger.error(f"[어시스트] {sid} URL 오류: {e}")
|
||
return
|
||
|
||
# 2. 프롬프트 생성
|
||
_update(S.GENERATING)
|
||
try:
|
||
prompts = generate_prompts(session['title'], session.get('_full_body', ''))
|
||
_update(S.AWAITING, prompts=prompts)
|
||
logger.info(f"[어시스트] 프롬프트 준비 완료: {sid}")
|
||
except Exception as e:
|
||
_update(S.ERROR, error=f"프롬프트 생성 실패: {e}")
|
||
return
|
||
|
||
# 3. 에셋 대기 (사용자 업로드 or inbox 드롭 대기)
|
||
logger.info(f"[어시스트] 에셋 대기 중: {sid}")
|
||
|
||
|
||
# ─── 에셋 관리 ───────────────────────────────────────────
|
||
|
||
def link_asset(sid: str, file_path: str) -> bool:
|
||
"""파일을 세션 assets/ 에 복사하고 메타에 등록한다."""
|
||
session = load_session(sid)
|
||
if not session:
|
||
return False
|
||
p = Path(file_path)
|
||
ext = p.suffix.lower()
|
||
asset_type = 'video' if ext in VIDEO_EXTENSIONS else 'image'
|
||
|
||
assets_dir = session_dir(sid) / 'assets'
|
||
assets_dir.mkdir(exist_ok=True)
|
||
dest = assets_dir / p.name
|
||
shutil.copy2(file_path, dest)
|
||
|
||
session.setdefault('assets', []).append({
|
||
'type': asset_type,
|
||
'path': str(dest),
|
||
'filename': p.name,
|
||
'added_at': datetime.now().isoformat(),
|
||
})
|
||
save_session(session)
|
||
logger.info(f"[어시스트] 에셋 등록: {sid} ← {p.name} ({asset_type})")
|
||
return True
|
||
|
||
|
||
def scan_inbox(sid: str) -> list:
|
||
"""inbox 폴더에서 세션 ID 앞 8자리 접두사 파일을 자동 연결한다."""
|
||
found = []
|
||
prefix = sid[:8]
|
||
for f in INBOX_DIR.iterdir():
|
||
if f.is_file() and (f.name.startswith(prefix) or prefix in f.name):
|
||
if link_asset(sid, str(f)):
|
||
f.unlink(missing_ok=True)
|
||
found.append(f.name)
|
||
return found
|