feat: v3.2 나머지 미완성 기능 구현
[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>
This commit is contained in:
@@ -0,0 +1,313 @@
|
||||
"""
|
||||
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
|
||||
Reference in New Issue
Block a user