[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>
368 lines
12 KiB
Python
368 lines
12 KiB
Python
"""
|
|
blog_engine_cli.py
|
|
The 4th Path 블로그 엔진 — OpenClaw 에이전트용 CLI 인터페이스
|
|
|
|
사용법:
|
|
blog status 전체 시스템 상태 요약
|
|
blog pipeline 파이프라인 단계별 상태
|
|
blog content 콘텐츠 큐 현황
|
|
blog review 검수 대기 목록
|
|
blog approve <id> 콘텐츠 승인
|
|
blog reject <id> 콘텐츠 반려
|
|
blog sessions 수동(어시스트) 세션 목록
|
|
blog session <id> 특정 세션 상세 (프롬프트 포함)
|
|
blog assist <url> 새 수동 모드 세션 시작
|
|
blog logs [n] 최근 로그 (기본 15줄)
|
|
blog analytics 오늘 분석 데이터
|
|
"""
|
|
import sys
|
|
import json
|
|
import textwrap
|
|
|
|
from runtime_guard import ensure_project_runtime
|
|
|
|
ensure_project_runtime("blog CLI", ["requests"])
|
|
|
|
import requests
|
|
|
|
API = 'http://localhost:8080/api'
|
|
TIMEOUT = 10
|
|
|
|
|
|
def _get(path: str) -> dict | list:
|
|
r = requests.get(f"{API}{path}", timeout=TIMEOUT)
|
|
r.raise_for_status()
|
|
return r.json()
|
|
|
|
|
|
def _post(path: str, data: dict = None) -> dict:
|
|
r = requests.post(f"{API}{path}", json=data or {}, timeout=TIMEOUT)
|
|
r.raise_for_status()
|
|
return r.json()
|
|
|
|
|
|
def _put(path: str, data: dict = None) -> dict:
|
|
r = requests.put(f"{API}{path}", json=data or {}, timeout=TIMEOUT)
|
|
r.raise_for_status()
|
|
return r.json()
|
|
|
|
|
|
def _sep(char='─', width=60):
|
|
print(char * width)
|
|
|
|
|
|
def cmd_status(_args):
|
|
"""전체 시스템 상태 요약"""
|
|
try:
|
|
ov = _get('/overview')
|
|
pip = _get('/pipeline')
|
|
except Exception as e:
|
|
print(f"[오류] 대시보드 연결 실패: {e}")
|
|
print(" → 대시보드가 실행 중인지 확인하세요: python -m uvicorn dashboard.backend.server:app --port 8080")
|
|
return
|
|
|
|
kpi = ov.get('kpi', {})
|
|
_sep('═')
|
|
print(" The 4th Path 블로그 엔진 — 상태")
|
|
_sep('═')
|
|
print(f" 오늘 발행: {kpi.get('today', 0)}편")
|
|
print(f" 이번 주: {kpi.get('this_week', 0)}편")
|
|
print(f" 총 누적: {kpi.get('total', 0)}편")
|
|
rev = kpi.get('revenue', {})
|
|
print(f" 수익 상태: {rev.get('status', '-')}")
|
|
_sep()
|
|
print(" 파이프라인:")
|
|
status_icon = {'done': '✅', 'running': '🔄', 'waiting': '⏳', 'error': '❌'}
|
|
for step in pip.get('steps', []):
|
|
icon = status_icon.get(step.get('status', ''), '○')
|
|
print(f" {icon} {step.get('label', step.get('id', ''))} — {step.get('last_run', '-')}")
|
|
_sep()
|
|
|
|
# 수동모드 세션 요약
|
|
try:
|
|
sessions = _get('/assist/sessions')
|
|
awaiting = [s for s in sessions if s.get('status') == 'awaiting']
|
|
if awaiting:
|
|
print(f" ⏳ 수동모드 에셋 대기 중: {len(awaiting)}개 세션")
|
|
for s in awaiting[:3]:
|
|
print(f" - {s['session_id']} : {s.get('title', s.get('url',''))[:40]}")
|
|
_sep()
|
|
except Exception:
|
|
pass
|
|
|
|
|
|
def cmd_pipeline(_args):
|
|
"""파이프라인 단계별 상태"""
|
|
pip = _get('/pipeline')
|
|
status_icon = {'done': '✅', 'running': '🔄', 'waiting': '⏳', 'error': '❌'}
|
|
_sep()
|
|
print(" 파이프라인 상태")
|
|
_sep()
|
|
for step in pip.get('steps', []):
|
|
icon = status_icon.get(step.get('status', ''), '○')
|
|
print(f" {icon} {step.get('label', step.get('id', ''))}")
|
|
if step.get('last_run'):
|
|
print(f" 마지막 실행: {step['last_run']}")
|
|
if step.get('error'):
|
|
print(f" 오류: {step['error']}")
|
|
_sep()
|
|
|
|
|
|
def cmd_content(_args):
|
|
"""콘텐츠 큐 현황"""
|
|
data = _get('/content')
|
|
_sep()
|
|
print(" 콘텐츠 큐 현황")
|
|
_sep()
|
|
cols = [
|
|
('queue', '📥 글감 큐'),
|
|
('writing', '✍️ 작성 중'),
|
|
('review', '🔍 검수 대기'),
|
|
('published','📤 발행 완료'),
|
|
]
|
|
for key, label in cols:
|
|
items = data.get(key, [])
|
|
print(f" {label}: {len(items)}개")
|
|
for item in items[:3]:
|
|
score = item.get('quality_score', item.get('score', ''))
|
|
score_str = f" [점수:{score}]" if score else ''
|
|
print(f" - {item.get('title', '제목 없음')[:45]}{score_str}")
|
|
if len(items) > 3:
|
|
print(f" ... 외 {len(items)-3}개")
|
|
_sep()
|
|
|
|
|
|
def cmd_review(_args):
|
|
"""검수 대기 목록 상세"""
|
|
data = _get('/content')
|
|
items = data.get('review', [])
|
|
if not items:
|
|
print(" 검수 대기 콘텐츠가 없습니다.")
|
|
return
|
|
_sep()
|
|
print(f" 검수 대기 — {len(items)}개")
|
|
_sep()
|
|
for item in items:
|
|
print(f" ID: {item.get('id', '-')}")
|
|
print(f" 제목: {item.get('title', '-')}")
|
|
print(f" 코너: {item.get('corner', '-')} 점수: {item.get('quality_score', '-')}")
|
|
if item.get('summary'):
|
|
wrapped = textwrap.fill(item['summary'][:200], width=55, initial_indent=' ', subsequent_indent=' ')
|
|
print(f" 요약:\n{wrapped}")
|
|
print(f" → 승인: blog approve {item.get('id')} | 반려: blog reject {item.get('id')}")
|
|
_sep('-')
|
|
|
|
|
|
def cmd_approve(args):
|
|
"""콘텐츠 승인"""
|
|
if not args:
|
|
print("사용법: blog approve <id>")
|
|
return
|
|
cid = args[0]
|
|
result = _post(f'/content/{cid}/approve')
|
|
if result.get('ok') or result.get('status') == 'approved':
|
|
print(f"✅ 승인 완료: {cid}")
|
|
else:
|
|
print(f"오류: {result}")
|
|
|
|
|
|
def cmd_reject(args):
|
|
"""콘텐츠 반려"""
|
|
if not args:
|
|
print("사용법: blog reject <id>")
|
|
return
|
|
cid = args[0]
|
|
result = _post(f'/content/{cid}/reject')
|
|
if result.get('ok') or result.get('status') == 'rejected':
|
|
print(f"🚫 반려 완료: {cid}")
|
|
else:
|
|
print(f"오류: {result}")
|
|
|
|
|
|
def cmd_sessions(_args):
|
|
"""수동(어시스트) 세션 목록"""
|
|
sessions = _get('/assist/sessions')
|
|
if not sessions:
|
|
print(" 수동 모드 세션이 없습니다.")
|
|
return
|
|
status_icon = {
|
|
'pending': '⏳', 'fetching': '🔄', 'generating': '🔄',
|
|
'awaiting': '📤', 'assembling': '🔄', 'ready': '✅', 'error': '❌'
|
|
}
|
|
_sep()
|
|
print(f" 수동(어시스트) 세션 목록 — {len(sessions)}개")
|
|
_sep()
|
|
for s in sessions:
|
|
icon = status_icon.get(s['status'], '○')
|
|
title = s.get('title') or s.get('url', '')
|
|
assets = len(s.get('assets', []))
|
|
print(f" {icon} {s['session_id']}")
|
|
print(f" 제목: {title[:50]}")
|
|
print(f" 상태: {s.get('status_label', s['status'])} 에셋: {assets}개")
|
|
if s['status'] == 'awaiting':
|
|
print(f" → 상세 프롬프트: blog session {s['session_id']}")
|
|
_sep('-', 40)
|
|
|
|
|
|
def cmd_session(args):
|
|
"""특정 세션 상세 정보 및 프롬프트"""
|
|
if not args:
|
|
print("사용법: blog session <session_id>")
|
|
return
|
|
sid = args[0]
|
|
s = _get(f'/assist/session/{sid}')
|
|
_sep()
|
|
print(f" 세션: {sid}")
|
|
print(f" 제목: {s.get('title', '-')}")
|
|
print(f" URL: {s.get('url', '-')}")
|
|
print(f" 상태: {s.get('status_label', s.get('status', '-'))}")
|
|
_sep()
|
|
|
|
prompts = s.get('prompts', {})
|
|
|
|
imgs = prompts.get('image_prompts', [])
|
|
if imgs:
|
|
print(" 📸 이미지 프롬프트:")
|
|
for p in imgs:
|
|
print(f" [{p.get('purpose','')}]")
|
|
print(f" KO: {p.get('ko','')}")
|
|
print(f" EN: {p.get('en','')}")
|
|
_sep('-', 40)
|
|
|
|
vid = prompts.get('video_prompt', {})
|
|
if vid:
|
|
print(" 🎬 영상 프롬프트 (Sora/Runway):")
|
|
print(f" KO: {vid.get('ko','')}")
|
|
print(f" EN: {vid.get('en','')}")
|
|
_sep('-', 40)
|
|
|
|
narr = prompts.get('narration_script', '')
|
|
if narr:
|
|
print(" 🎙 나레이션 스크립트:")
|
|
for line in textwrap.wrap(narr, 55):
|
|
print(f" {line}")
|
|
_sep('-', 40)
|
|
|
|
assets = s.get('assets', [])
|
|
if assets:
|
|
print(f" 📁 등록된 에셋 ({len(assets)}개):")
|
|
for a in assets:
|
|
print(f" [{a['type']}] {a['filename']}")
|
|
else:
|
|
print(" ⏳ 에셋 없음 — 이미지/영상 생성 후 대시보드에서 업로드하세요")
|
|
_sep()
|
|
|
|
|
|
def cmd_assist(args):
|
|
"""새 수동 모드 세션 시작"""
|
|
if not args:
|
|
print("사용법: blog assist <url>")
|
|
return
|
|
url = args[0]
|
|
result = _post('/assist/session', {'url': url})
|
|
sid = result.get('session_id', '')
|
|
print(f"✅ 세션 생성: {sid}")
|
|
print(f" URL: {url}")
|
|
print(f" 상태: {result.get('status_label', result.get('status', '-'))}")
|
|
print(f" 프롬프트 생성 중... 잠시 후 'blog session {sid}' 로 확인하세요.")
|
|
|
|
|
|
def cmd_logs(args):
|
|
"""최근 로그"""
|
|
limit = int(args[0]) if args else 15
|
|
data = _get(f'/logs?limit={limit}')
|
|
logs = data if isinstance(data, list) else data.get('logs', [])
|
|
level_icon = {'ERROR': '❌', 'WARNING': '⚠️', 'INFO': '•', 'DEBUG': '·'}
|
|
_sep()
|
|
print(f" 최근 로그 (최대 {limit}줄)")
|
|
_sep()
|
|
for log in logs[-limit:]:
|
|
lvl = log.get('level', 'INFO')
|
|
icon = level_icon.get(lvl, '•')
|
|
ts = log.get('time', log.get('timestamp', ''))[:16]
|
|
mod = log.get('module', '')
|
|
msg = log.get('message', '')
|
|
print(f" {icon} [{ts}] {mod}: {msg[:70]}")
|
|
_sep()
|
|
|
|
|
|
def cmd_analytics(_args):
|
|
"""오늘 분석 데이터"""
|
|
data = _get('/analytics')
|
|
_sep()
|
|
print(" 분석 데이터")
|
|
_sep()
|
|
print(f" 방문자: {data.get('visitors', 0):,}")
|
|
print(f" 페이지뷰: {data.get('pageviews', 0):,}")
|
|
print(f" 평균 체류: {data.get('avg_duration', '-')}")
|
|
print(f" CTR: {data.get('ctr', '-')}")
|
|
|
|
top = data.get('top_posts', [])
|
|
if top:
|
|
_sep('-', 40)
|
|
print(" 🏆 인기 글 TOP 5:")
|
|
for i, post in enumerate(top[:5], 1):
|
|
print(f" {i}. {post.get('title','')[:45]} ({post.get('views',0)}뷰)")
|
|
_sep()
|
|
|
|
|
|
COMMANDS = {
|
|
'status': cmd_status,
|
|
'pipeline': cmd_pipeline,
|
|
'content': cmd_content,
|
|
'review': cmd_review,
|
|
'approve': cmd_approve,
|
|
'reject': cmd_reject,
|
|
'sessions': cmd_sessions,
|
|
'session': cmd_session,
|
|
'assist': cmd_assist,
|
|
'logs': cmd_logs,
|
|
'analytics': cmd_analytics,
|
|
}
|
|
|
|
HELP = """
|
|
blog <command> [args]
|
|
|
|
status 전체 시스템 상태 요약
|
|
pipeline 파이프라인 단계별 상태
|
|
content 콘텐츠 큐 현황 (큐/작성중/검수/발행)
|
|
review 검수 대기 목록 상세
|
|
approve <id> 콘텐츠 승인
|
|
reject <id> 콘텐츠 반려
|
|
sessions 수동(어시스트) 세션 목록
|
|
session <id> 특정 세션 프롬프트/에셋 확인
|
|
assist <url> 새 수동 모드 세션 시작 (URL 입력)
|
|
logs [n] 최근 로그 (기본 15줄)
|
|
analytics 분석 데이터
|
|
"""
|
|
|
|
|
|
def main():
|
|
args = sys.argv[1:]
|
|
if not args or args[0] in ('-h', '--help', 'help'):
|
|
print(HELP)
|
|
return
|
|
|
|
cmd = args[0].lower()
|
|
rest = args[1:]
|
|
|
|
if cmd not in COMMANDS:
|
|
print(f"알 수 없는 명령: {cmd}")
|
|
print(HELP)
|
|
sys.exit(1)
|
|
|
|
try:
|
|
COMMANDS[cmd](rest)
|
|
except requests.exceptions.ConnectionError:
|
|
print("[오류] 대시보드에 연결할 수 없습니다.")
|
|
print(" → 대시보드를 먼저 시작하세요:")
|
|
print(" cd D:\\workspace\\blog-writer")
|
|
print(" python -m uvicorn dashboard.backend.server:app --port 8080")
|
|
except Exception as e:
|
|
print(f"[오류] {e}")
|
|
sys.exit(1)
|
|
|
|
|
|
if __name__ == '__main__':
|
|
main()
|