[Instagram Reels] Phase 2 완성 - instagram_bot.py: publish_reels() 추가 (MP4 → Reels API) - upload_video_container(), wait_for_video_ready() 구현 - 로컬 경로 → 공개 URL 자동 변환 (image_host.get_public_video_url()) - scheduler.py: job_distribute_instagram_reels() 추가 (10:30) - image_host.py: get_public_video_url() + 로컬 비디오 서버 추가 - VIDEO_HOST_BASE_URL 환경변수 지원 (Tailscale/CDN) [writer_bot.py] 신규 — 독립 실행형 글쓰기 봇 - api_content.py manual-write 엔드포인트에서 subprocess 호출 가능 - run_pending(): 오늘 날짜 미처리 글감 자동 처리 - run_from_topic(): 직접 주제 지정 - run_from_file(): JSON 파일 지정 - CLI: python bots/writer_bot.py [--topic "..." | --file path.json | --limit N] [보조 시스템 신규] v3.1 CLI + Assist 모드 - blog.cmd: venv Python 경유 Windows 런처 - blog_runtime.py + runtime_guard.py: 실행 진입점 + venv 검증 - blog_engine_cli.py: 대시보드 API 기반 CLI (blog status, blog review 등) - bots/assist_bot.py: URL 기반 수동 어시스트 파이프라인 - dashboard/backend/api_assist.py + frontend/Assist.jsx: 수동모드 탭 [engine_loader.py] v3.1 개선 - OpenClawWriter: --json 플래그 + payloads 파싱 + plain text 폴백 - ClaudeWebWriter: Playwright 쿠키 세션 (Cloudflare 차단으로 현재 비활성) - GeminiWebWriter: gemini-webapi 비공식 클라이언트 [scheduler.py] v3.1 개선 - _call_openclaw(): 플레이스홀더 → EngineLoader 실제 호출 - _build_openclaw_prompt(): 구조화된 HTML 원고 프롬프트 - data/originals/: 원본 article JSON 저장 경로 추가 [설정/환경] 정비 - .env.example: SEEDANCE/ELEVENLABS/GEMINI/RUNWAY 복원 + VIDEO_HOST_BASE_URL, GEMINI_WEB_* , REMOTE_CLAUDE_POLLING_ENABLED 추가 - scripts/setup.bat: data/originals, outputs, assist, novels, config/novels 디렉토리 생성 + 폰트 다운로드 + blog.cmd 기반 Task Scheduler 등록 - requirements.txt: fastapi, uvicorn, python-multipart 추가 Co-Authored-By: Claude Sonnet 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
|