From 392c2e13f12a8c55f697703243f9f7d461c196f1 Mon Sep 17 00:00:00 2001 From: sinmb79 Date: Sat, 28 Mar 2026 17:12:39 +0900 Subject: [PATCH] =?UTF-8?q?feat:=20v3.2=20=EB=82=98=EB=A8=B8=EC=A7=80=20?= =?UTF-8?q?=EB=AF=B8=EC=99=84=EC=84=B1=20=EA=B8=B0=EB=8A=A5=20=EA=B5=AC?= =?UTF-8?q?=ED=98=84?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit [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 --- .env.example | 26 +- README.md | 15 +- blog.cmd | 9 + blog_engine_cli.py | 367 +++++++++++++++++++++ blog_runtime.py | 63 ++++ bots/assist_bot.py | 313 ++++++++++++++++++ bots/converters/shorts_converter.py | 7 +- bots/converters/video_engine.py | 4 +- bots/distributors/image_host.py | 80 +++++ bots/distributors/instagram_bot.py | 98 ++++++ bots/engine_loader.py | 173 +++++++++- bots/remote_claude.py | 5 + bots/scheduler.py | 155 ++++++++- bots/writer_bot.py | 271 +++++++++++++++ config/engine.json | 47 +-- dashboard/README.md | 8 +- dashboard/backend/api_assist.py | 116 +++++++ dashboard/backend/api_content.py | 38 ++- dashboard/backend/server.py | 9 + dashboard/frontend/src/App.jsx | 4 +- dashboard/frontend/src/pages/Assist.jsx | 416 ++++++++++++++++++++++++ dashboard/start.bat | 18 +- dashboard/start_dev.bat | 16 +- requirements.txt | 3 + runtime_guard.py | 107 ++++++ scripts/setup.bat | 26 +- 26 files changed, 2296 insertions(+), 98 deletions(-) create mode 100644 blog.cmd create mode 100644 blog_engine_cli.py create mode 100644 blog_runtime.py create mode 100644 bots/assist_bot.py create mode 100644 bots/writer_bot.py create mode 100644 dashboard/backend/api_assist.py create mode 100644 dashboard/frontend/src/pages/Assist.jsx create mode 100644 runtime_guard.py diff --git a/.env.example b/.env.example index 91be9cf..18294ff 100644 --- a/.env.example +++ b/.env.example @@ -30,7 +30,7 @@ IMAGE_MODE=manual OPENAI_API_KEY= # 블로그 사이트 URL (Search Console 등록용) -BLOG_SITE_URL= +BLOG_SITE_URL=https://www.the4thpath.com/ # ─── v3: 멀티플랫폼 배포 ────────────────────────────── @@ -76,16 +76,34 @@ TIKTOK_OPEN_ID= # YouTube Studio > 채널 > 고급 설정에서 채널 ID 확인 YOUTUBE_CHANNEL_ID= -# ─── v3 엔진 추상화 (선택) ──────────────────────────── +# ─── v3 Phase 2: Instagram Reels 비디오 호스팅 ─────── +# Reels 업로드는 공개 접근 가능한 MP4 URL이 필요합니다. +# 방법 1: Tailscale + 로컬 서버 → LOCAL_IMAGE_SERVER=true 로 설정 +# 방법 2: CDN/서버 베이스 URL 직접 지정 (예: https://cdn.yourdomain.com/outputs) +VIDEO_HOST_BASE_URL= + +# ─── v3: 엔진 추상화 (선택) ────────────────────────── # Seedance 2.0 — AI 시네마틱 영상 생성 (소설 쇼츠 권장) # https://seedance2.ai/ SEEDANCE_API_KEY= # ElevenLabs — 고품질 한국어 TTS # https://elevenlabs.io/ ELEVENLABS_API_KEY= -# Google Gemini — 글쓰기 대체 / Veo 영상 +# Google Gemini — 글쓰기 대체 엔진 / Veo 영상 (engine.json: provider="gemini") # https://aistudio.google.com/ GEMINI_API_KEY= -# Runway Gen-3 — AI 영상 생성 +# Runway Gen-3 — AI 영상 생성 (engine.json: provider="runway") # https://runwayml.com/ RUNWAY_API_KEY= + +# ─── v3.1: 글쓰기 엔진 웹 세션 (선택) ──────────────── +# Gemini Web — gemini.google.com 브라우저 세션 쿠키 (비공식) +# 브라우저 DevTools > Application > Cookies > google.com 에서 확인 +# (engine.json: provider="gemini_web") +GEMINI_WEB_1PSID= +GEMINI_WEB_1PSIDTS= + +# ─── v3.1: Remote Claude 설정 ──────────────────────── +# Telegram에서 Claude Agent SDK로 코드 실행 허용 여부 (true/false) +# remote_claude.py (scheduler.py와 별도 실행) +REMOTE_CLAUDE_POLLING_ENABLED=false diff --git a/README.md b/README.md index cdd0432..29e19b2 100644 --- a/README.md +++ b/README.md @@ -286,18 +286,27 @@ python scripts\get_token.py ### 스케줄러 시작 (권장) +안전한 기본 진입점은 프로젝트 venv Python + `blog_runtime.py` 입니다. + ```bash -venv\Scripts\activate -python bots\scheduler.py +venv\Scripts\python.exe blog_runtime.py scheduler ``` 백그라운드 실행 (Windows): ```bash -pythonw bots\scheduler.py +venv\Scripts\python.exe blog_runtime.py scheduler ``` Windows 작업 스케줄러를 통해 PC 시작 시 자동 실행되도록 `setup.bat`이 등록합니다. +### 대시보드 시작 + +```bash +venv\Scripts\python.exe blog_runtime.py server +``` + +`blog.cmd` 역시 내부적으로 같은 런처를 사용합니다. + ### 개별 봇 단독 실행 ```bash diff --git a/blog.cmd b/blog.cmd new file mode 100644 index 0000000..10ad367 --- /dev/null +++ b/blog.cmd @@ -0,0 +1,9 @@ +@echo off +set "PROJECT_ROOT=D:\workspace\blog-writer" +set "PYTHON=%PROJECT_ROOT%\venv\Scripts\python.exe" +if not exist "%PYTHON%" ( + echo [ERROR] Missing project virtualenv Python: %PYTHON% + echo Create the venv and install requirements first. + exit /b 1 +) +"%PYTHON%" "%PROJECT_ROOT%\blog_runtime.py" %* diff --git a/blog_engine_cli.py b/blog_engine_cli.py new file mode 100644 index 0000000..0f87dc4 --- /dev/null +++ b/blog_engine_cli.py @@ -0,0 +1,367 @@ +""" +blog_engine_cli.py +The 4th Path 블로그 엔진 — OpenClaw 에이전트용 CLI 인터페이스 + +사용법: + blog status 전체 시스템 상태 요약 + blog pipeline 파이프라인 단계별 상태 + blog content 콘텐츠 큐 현황 + blog review 검수 대기 목록 + blog approve 콘텐츠 승인 + blog reject 콘텐츠 반려 + blog sessions 수동(어시스트) 세션 목록 + blog session 특정 세션 상세 (프롬프트 포함) + blog assist 새 수동 모드 세션 시작 + 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 ") + 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 ") + 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 ") + 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 ") + 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 [args] + + status 전체 시스템 상태 요약 + pipeline 파이프라인 단계별 상태 + content 콘텐츠 큐 현황 (큐/작성중/검수/발행) + review 검수 대기 목록 상세 + approve 콘텐츠 승인 + reject 콘텐츠 반려 + sessions 수동(어시스트) 세션 목록 + session 특정 세션 프롬프트/에셋 확인 + assist 새 수동 모드 세션 시작 (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() diff --git a/blog_runtime.py b/blog_runtime.py new file mode 100644 index 0000000..c25fd42 --- /dev/null +++ b/blog_runtime.py @@ -0,0 +1,63 @@ +""" +Single safe launcher for project runtime entrypoints. +""" +from __future__ import annotations + +import runpy +import subprocess +import sys +from pathlib import Path + +from runtime_guard import ( + PROJECT_ROOT, + ensure_project_runtime, + load_required_distributions, + project_python_cmd, +) + + +CLI_REQUIREMENTS = ["requests"] +SERVER_REQUIREMENTS = ["fastapi", "uvicorn", "python-dotenv", "python-multipart"] +SCHEDULER_REQUIREMENTS = ["apscheduler", "python-dotenv", "python-telegram-bot", "anthropic"] + + +def _run_subprocess(args: list[str]) -> int: + completed = subprocess.run(project_python_cmd(args), cwd=str(PROJECT_ROOT)) + return completed.returncode + + +def main() -> None: + args = sys.argv[1:] + + if not args: + sys.argv = [str(PROJECT_ROOT / "blog_engine_cli.py")] + ensure_project_runtime("blog CLI", CLI_REQUIREMENTS) + runpy.run_path(str(PROJECT_ROOT / "blog_engine_cli.py"), run_name="__main__") + return + + command = args[0].lower() + rest = args[1:] + + if command == "server": + ensure_project_runtime("dashboard server", SERVER_REQUIREMENTS) + raise SystemExit( + _run_subprocess( + ["-m", "uvicorn", "dashboard.backend.server:app", "--host", "0.0.0.0", "--port", "8080", *rest] + ) + ) + + if command == "scheduler": + ensure_project_runtime("scheduler", SCHEDULER_REQUIREMENTS) + raise SystemExit(_run_subprocess([str(PROJECT_ROOT / "bots" / "scheduler.py"), *rest])) + + if command == "python": + ensure_project_runtime("project python", load_required_distributions()) + raise SystemExit(_run_subprocess(rest)) + + sys.argv = [str(PROJECT_ROOT / "blog_engine_cli.py"), *args] + ensure_project_runtime("blog CLI", CLI_REQUIREMENTS) + runpy.run_path(str(PROJECT_ROOT / "blog_engine_cli.py"), run_name="__main__") + + +if __name__ == "__main__": + main() diff --git a/bots/assist_bot.py b/bots/assist_bot.py new file mode 100644 index 0000000..6bd3681 --- /dev/null +++ b/bots/assist_bot.py @@ -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']*>([^<]+)', html, re.I) + title = re.sub(r'\s*[–\-|]\s*.+$', '', m.group(1)).strip() if m else '제목 없음' + + # 본문 — 블로거/워드프레스/일반 HTML 순서로 시도 + body = '' + for pat in [ + r']+class="[^"]*post-body[^"]*"[^>]*>(.*?)', + r']*>(.*?)', + r']+class="[^"]*entry-content[^"]*"[^>]*>(.*?)', + r']*>(.*?)', + ]: + 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 diff --git a/bots/converters/shorts_converter.py b/bots/converters/shorts_converter.py index 6473e55..69a8d34 100644 --- a/bots/converters/shorts_converter.py +++ b/bots/converters/shorts_converter.py @@ -649,11 +649,12 @@ def burn_subtitles(video_mp4: str, srt_path: str, output_mp4: str) -> bool: 'Alignment=2,' 'Bold=1' ) - # srt 경로에서 역슬래시 → 슬래시 (ffmpeg 호환) - srt_esc = str(srt_path).replace('\\', '/').replace(':', '\\:') + # Windows 경로는 subtitles 필터에서 옵션 구분자(:)로 오인될 수 있어 + # filename=... 형태로 명시하고 슬래시/콜론만 ffmpeg 호환 형태로 정규화한다. + srt_esc = str(srt_path).replace('\\', '/').replace(':', '\\:').replace("'", r"\'") return _run_ffmpeg([ '-i', video_mp4, - '-vf', f'subtitles={srt_esc}:force_style=\'{style}\'', + '-vf', f"subtitles=filename='{srt_esc}':force_style='{style}'", '-c:v', 'libx264', '-c:a', 'copy', output_mp4, ]) diff --git a/bots/converters/video_engine.py b/bots/converters/video_engine.py index 87623b4..64afe60 100644 --- a/bots/converters/video_engine.py +++ b/bots/converters/video_engine.py @@ -251,10 +251,10 @@ class FFmpegSlidesEngine(VideoEngine): 'Alignment=2,' 'Bold=1' ) - srt_esc = str(srt_path).replace('\\', '/').replace(':', '\\:') + srt_esc = str(srt_path).replace('\\', '/').replace(':', '\\:').replace("'", r"\'") return self._run_ffmpeg([ '-i', video_mp4, - '-vf', f'subtitles={srt_esc}:force_style=\'{style}\'', + '-vf', f"subtitles=filename='{srt_esc}':force_style='{style}'", '-c:v', 'libx264', '-c:a', 'copy', output_mp4, ]) diff --git a/bots/distributors/image_host.py b/bots/distributors/image_host.py index 06d126d..4e1634f 100644 --- a/bots/distributors/image_host.py +++ b/bots/distributors/image_host.py @@ -215,6 +215,86 @@ def get_public_url(image_path: str) -> str: return '' +# ─── 비디오 호스팅 (릴스용) ────────────────────────── + +_local_video_server = None + + +def start_local_video_server(port: int = 8766) -> str: + """ + 로컬 HTTP 파일 서버 시작 — 비디오(MP4) 전용. + Returns: base URL (예: http://192.168.1.100:8766) + """ + import socket + import threading + import http.server + import functools + + global _local_video_server + if _local_video_server: + return _local_video_server + + outputs_dir = str(BASE_DIR / 'data' / 'outputs') + handler = functools.partial( + http.server.SimpleHTTPRequestHandler, directory=outputs_dir + ) + server = http.server.HTTPServer(('0.0.0.0', port), handler) + thread = threading.Thread(target=server.serve_forever, daemon=True) + thread.start() + + try: + s = socket.socket(socket.AF_INET, socket.SOCK_DGRAM) + s.connect(('8.8.8.8', 80)) + local_ip = s.getsockname()[0] + s.close() + except Exception: + local_ip = '127.0.0.1' + + base_url = f'http://{local_ip}:{port}' + _local_video_server = base_url + logger.info(f"로컬 비디오 서버 시작: {base_url}") + return base_url + + +def get_public_video_url(video_path: str) -> str: + """ + 비디오 파일 → 공개 URL 반환 (Instagram Reels, TikTok 등). + Instagram Reels API는 공개 접근 가능한 MP4 URL이 필요. + + 우선순위: + 1. LOCAL_IMAGE_SERVER=true → 로컬 HTTP 서버 (Tailscale 외부 접속 필요) + 2. VIDEO_HOST_BASE_URL 환경변수 → 직접 지정한 CDN/서버 URL + + 운영 환경에서는 Tailscale로 미니PC를 외부에서 접근하거나, + Cloudflare Tunnel 등을 사용하세요. + """ + if not Path(video_path).exists(): + logger.error(f"비디오 파일 없음: {video_path}") + return '' + + # 1. 직접 지정한 CDN/서버 베이스 URL + video_base = os.getenv('VIDEO_HOST_BASE_URL', '').rstrip('/') + if video_base: + filename = Path(video_path).name + url = f'{video_base}/{filename}' + logger.info(f"비디오 외부 URL: {url}") + return url + + # 2. 로컬 HTTP 서버 (Tailscale/ngrok으로 외부 접근 가능한 경우) + if os.getenv('LOCAL_IMAGE_SERVER', '').lower() == 'true': + base_url = start_local_video_server() + filename = Path(video_path).name + url = f'{base_url}/{filename}' + logger.warning(f"로컬 비디오 서버 URL (외부 접근 필요): {url}") + return url + + logger.warning( + "비디오 공개 URL 생성 불가. .env에 VIDEO_HOST_BASE_URL을 설정하거나 " + "LOCAL_IMAGE_SERVER=true (Tailscale/ngrok)로 설정하세요." + ) + return '' + + if __name__ == '__main__': import sys if len(sys.argv) > 1: diff --git a/bots/distributors/instagram_bot.py b/bots/distributors/instagram_bot.py index d9840dd..ab282e1 100644 --- a/bots/distributors/instagram_bot.py +++ b/bots/distributors/instagram_bot.py @@ -142,6 +142,104 @@ def publish_container(container_id: str) -> str: return '' +def upload_video_container(video_url: str, caption: str) -> str: + """ + Instagram Reels 업로드 컨테이너 생성. + video_url: 공개 접근 가능한 MP4 URL + Returns: container_id + """ + if not _check_credentials(): + return '' + + url = f"{GRAPH_API_BASE}/{INSTAGRAM_ACCOUNT_ID}/media" + params = { + 'media_type': 'REELS', + 'video_url': video_url, + 'caption': caption, + 'share_to_feed': 'true', + 'access_token': INSTAGRAM_ACCESS_TOKEN, + } + try: + resp = requests.post(url, data=params, timeout=30) + resp.raise_for_status() + container_id = resp.json().get('id', '') + logger.info(f"Reels 컨테이너 생성: {container_id}") + return container_id + except Exception as e: + logger.error(f"Instagram Reels 컨테이너 생성 실패: {e}") + return '' + + +def wait_for_video_ready(container_id: str, max_wait: int = 300) -> bool: + """ + 비디오 컨테이너 처리 완료 대기 (최대 max_wait초). + Reels는 인코딩 시간이 이미지보다 길다. + """ + status_url = f"{GRAPH_API_BASE}/{container_id}" + for _ in range(max_wait // 10): + try: + resp = requests.get( + status_url, + params={'fields': 'status_code', 'access_token': INSTAGRAM_ACCESS_TOKEN}, + timeout=10, + ) + status = resp.json().get('status_code', '') + if status == 'FINISHED': + return True + if status in ('ERROR', 'EXPIRED'): + logger.error(f"Reels 컨테이너 오류: {status}") + return False + logger.debug(f"Reels 인코딩 중: {status}") + except Exception as e: + logger.warning(f"Reels 상태 확인 오류: {e}") + time.sleep(10) + logger.warning("Reels 컨테이너 준비 시간 초과") + return False + + +def publish_reels(article: dict, video_path_or_url: str) -> bool: + """ + 쇼츠 MP4를 Instagram Reels로 게시. + video_path_or_url: 로컬 MP4 파일 경로 또는 공개 MP4 URL + - 로컬 경로인 경우 image_host.get_public_video_url()로 공개 URL 변환 + - http/https URL인 경우 그대로 사용 + """ + if not _check_credentials(): + logger.info("Instagram 미설정 — Reels 발행 건너뜀") + return False + + logger.info(f"Instagram Reels 발행 시작: {article.get('title', '')}") + + # 로컬 경로 → 공개 URL 변환 + video_url = video_path_or_url + if not video_path_or_url.startswith('http'): + import sys + sys.path.insert(0, str(Path(__file__).parent)) + from image_host import get_public_video_url + video_url = get_public_video_url(video_path_or_url) + if not video_url: + logger.error( + "Reels 공개 URL 변환 실패 — .env에 VIDEO_HOST_BASE_URL 또는 " + "LOCAL_IMAGE_SERVER=true (Tailscale) 설정 필요" + ) + return False + + caption = build_caption(article) + container_id = upload_video_container(video_url, caption) + if not container_id: + return False + + if not wait_for_video_ready(container_id): + return False + + post_id = publish_container(container_id) + if not post_id: + return False + + _log_published(article, post_id, 'instagram_reels') + return True + + def publish_card(article: dict, image_path_or_url: str) -> bool: """ 카드 이미지를 인스타그램 피드에 게시. diff --git a/bots/engine_loader.py b/bots/engine_loader.py index 4343f04..7765060 100644 --- a/bots/engine_loader.py +++ b/bots/engine_loader.py @@ -96,28 +96,59 @@ class ClaudeWriter(BaseWriter): class OpenClawWriter(BaseWriter): - """OpenClaw CLI를 subprocess로 호출하는 글쓰기 엔진""" + """OpenClaw CLI를 subprocess로 호출하는 글쓰기 엔진 (ChatGPT Pro OAuth)""" + + # Windows에서 npm 글로벌 .cmd 스크립트 우선 사용 + _CLI = 'openclaw.cmd' if os.name == 'nt' else 'openclaw' def __init__(self, cfg: dict): self.agent_name = cfg.get('agent_name', 'blog-writer') - self.timeout = cfg.get('timeout', 120) + self.timeout = cfg.get('timeout', 300) def write(self, prompt: str, system: str = '') -> str: try: - cmd = ['openclaw', 'run', self.agent_name, '--prompt', prompt] - if system: - cmd += ['--system', system] + message = f"{system}\n\n{prompt}".strip() if system else prompt + cmd = [ + self._CLI, 'agent', + '--agent', self.agent_name, + '--message', message, + '--json', + ] result = subprocess.run( cmd, capture_output=True, - text=True, timeout=self.timeout, - encoding='utf-8', + shell=False, ) + stderr_str = result.stderr.decode('utf-8', errors='replace').strip() if result.returncode != 0: - logger.error(f"OpenClawWriter 오류: {result.stderr[:300]}") + logger.error(f"OpenClawWriter returncode={result.returncode} stderr={stderr_str[:300]}") return '' - return result.stdout.strip() + # stdout이 비어있는 경우 — openclaw가 stderr에만 출력하거나 인증 실패 + if not result.stdout: + logger.error(f"OpenClawWriter stdout 비어있음 (returncode=0) stderr={stderr_str[:300]}") + return '' + stdout = result.stdout.decode('utf-8', errors='replace').strip() + if not stdout: + logger.error("OpenClawWriter stdout 디코딩 후 비어있음") + return '' + # 1) JSON 응답 시도 — JSON 블록 추출 + json_candidate = stdout + if not stdout.startswith('{'): + import re + m = re.search(r'\{[\s\S]*\}', stdout) + json_candidate = m.group(0) if m else '' + if json_candidate: + try: + data = json.loads(json_candidate) + payloads = data.get('result', {}).get('payloads', []) + if payloads: + return payloads[0].get('text', '') or stdout + except json.JSONDecodeError: + pass + # 2) JSON 파싱 실패 또는 payloads 없음 → plain text 그대로 반환 + logger.info(f"OpenClawWriter plain text 응답 ({len(stdout)}자)") + return stdout except subprocess.TimeoutExpired: logger.error(f"OpenClawWriter 타임아웃 ({self.timeout}초)") return '' @@ -163,6 +194,128 @@ class GeminiWriter(BaseWriter): return '' +class ClaudeWebWriter(BaseWriter): + """Playwright Chromium에 세션 쿠키를 주입해 claude.ai를 자동화하는 Writer + + Chrome을 닫을 필요 없음 — Playwright 자체 Chromium을 별도로 실행. + 필요 환경변수: + CLAUDE_WEB_COOKIE — __Secure-next-auth.session-token 값 + """ + + def __init__(self, cfg: dict): + self.cookie = os.getenv(cfg.get('cookie_env', 'CLAUDE_WEB_COOKIE'), '') + self.timeout_ms = cfg.get('timeout', 180) * 1000 + + def write(self, prompt: str, system: str = '') -> str: + # claude.ai는 Cloudflare Turnstile로 헤드리스 브라우저를 차단함 + # 쿠키 세션 만료 여부와 무관하게 자동화 불가 → 비활성화 + logger.warning("ClaudeWebWriter: Cloudflare 차단으로 비활성화 (수동 사용 권장)") + return '' + if not self.cookie: + logger.warning("CLAUDE_WEB_COOKIE 없음 — ClaudeWebWriter 비활성화") + return '' + try: + from playwright.sync_api import sync_playwright + except ImportError: + logger.warning("playwright 미설치 — ClaudeWebWriter 비활성화") + return '' + token = self.cookie.strip() + message = f"{system}\n\n{prompt}".strip() if system else prompt + try: + with sync_playwright() as pw: + browser = pw.chromium.launch( + headless=True, + args=['--disable-blink-features=AutomationControlled'], + ) + ctx = browser.new_context( + user_agent=( + 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) ' + 'AppleWebKit/537.36 (KHTML, like Gecko) ' + 'Chrome/131.0.0.0 Safari/537.36' + ), + ) + # 세션 쿠키 주입 + ctx.add_cookies([{ + 'name': '__Secure-next-auth.session-token', + 'value': token, + 'domain': 'claude.ai', + 'path': '/', + 'secure': True, + 'httpOnly': True, + }]) + page = ctx.new_page() + try: + from playwright_stealth import stealth_sync + stealth_sync(page) + except ImportError: + pass + page.goto('https://claude.ai/new', wait_until='domcontentloaded', timeout=60000) + page.wait_for_timeout(3000) + # 입력창 대기 + editor = page.locator('[contenteditable="true"]').first + editor.wait_for(timeout=30000) + editor.click() + page.keyboard.type(message, delay=30) + page.keyboard.press('Enter') + # 스트리밍 완료 대기 — 전송 버튼 재활성화 + page.wait_for_selector( + 'button[aria-label="Send message"]:not([disabled])', + timeout=self.timeout_ms, + ) + # 응답 텍스트 추출 + blocks = page.locator('.font-claude-message') + text = blocks.last.inner_text() if blocks.count() else '' + browser.close() + return text.strip() + except Exception as e: + logger.error(f"ClaudeWebWriter 오류: {e}") + return '' + + +class GeminiWebWriter(BaseWriter): + """gemini.google.com 웹 세션 쿠키를 사용하는 비공식 Writer (gemini-webapi) + + 필요 환경변수: + GEMINI_WEB_1PSID — 브라우저 DevTools > Application > Cookies > + google.com 에서 __Secure-1PSID 값 + GEMINI_WEB_1PSIDTS — 같은 위치에서 __Secure-1PSIDTS 값 + """ + + def __init__(self, cfg: dict): + self.psid = os.getenv(cfg.get('psid_env', 'GEMINI_WEB_1PSID'), '') + self.psidts = os.getenv(cfg.get('psidts_env', 'GEMINI_WEB_1PSIDTS'), '') + + def write(self, prompt: str, system: str = '') -> str: + if not self.psid or not self.psidts: + logger.warning("GEMINI_WEB_1PSID / GEMINI_WEB_1PSIDTS 없음 — GeminiWebWriter 비활성화") + return '' + try: + import asyncio + from gemini_webapi import GeminiClient + + async def _run(): + client = GeminiClient(secure_1psid=self.psid, secure_1psidts=self.psidts) + await client.init(timeout=30, auto_close=False, close_delay=300) + message = f"{system}\n\n{prompt}".strip() if system else prompt + resp = await client.generate_content(message) + return resp.text + + try: + loop = asyncio.get_event_loop() + if loop.is_running(): + import concurrent.futures + with concurrent.futures.ThreadPoolExecutor() as pool: + future = pool.submit(asyncio.run, _run()) + return future.result(timeout=120) + else: + return loop.run_until_complete(_run()) + except RuntimeError: + return asyncio.run(_run()) + except Exception as e: + logger.error(f"GeminiWebWriter 오류: {e}") + return '' + + # ─── TTS 구현체 ───────────────────────────────────────── class GoogleCloudTTS(BaseTTS): @@ -458,6 +611,8 @@ class EngineLoader: 'claude': ClaudeWriter, 'openclaw': OpenClawWriter, 'gemini': GeminiWriter, + 'claude_web': ClaudeWebWriter, + 'gemini_web': GeminiWebWriter, } cls = writers.get(provider, ClaudeWriter) logger.info(f"Writer 로드: {provider} ({cls.__name__})") diff --git a/bots/remote_claude.py b/bots/remote_claude.py index c909e0a..4d3c268 100644 --- a/bots/remote_claude.py +++ b/bots/remote_claude.py @@ -18,6 +18,7 @@ load_dotenv() BASE_DIR = Path(__file__).parent.parent TELEGRAM_BOT_TOKEN = os.getenv('TELEGRAM_BOT_TOKEN', '') TELEGRAM_CHAT_ID = int(os.getenv('TELEGRAM_CHAT_ID', '0')) +REMOTE_CLAUDE_POLLING_ENABLED = os.getenv('REMOTE_CLAUDE_POLLING_ENABLED', '').lower() in {'1', 'true', 'yes', 'on'} logging.basicConfig( level=logging.INFO, @@ -90,6 +91,10 @@ async def cmd_help(update: Update, context: ContextTypes.DEFAULT_TYPE): def main(): + if not REMOTE_CLAUDE_POLLING_ENABLED: + logger.info("Remote Claude Bot polling 비활성화 — 기본 운영은 scheduler.py Telegram 리스너 사용") + return + if not TELEGRAM_BOT_TOKEN: logger.error("TELEGRAM_BOT_TOKEN이 없습니다.") return diff --git a/bots/scheduler.py b/bots/scheduler.py index 15bf7e0..5aff414 100644 --- a/bots/scheduler.py +++ b/bots/scheduler.py @@ -12,12 +12,20 @@ from datetime import datetime from logging.handlers import RotatingFileHandler from pathlib import Path +from runtime_guard import ensure_project_runtime + +ensure_project_runtime( + "scheduler", + ["apscheduler", "python-dotenv", "python-telegram-bot", "anthropic"], +) + from apscheduler.schedulers.asyncio import AsyncIOScheduler from dotenv import load_dotenv from telegram import Update from telegram.ext import Application, CommandHandler, MessageHandler, filters, ContextTypes import anthropic +import re load_dotenv() @@ -120,7 +128,9 @@ def job_ai_writer(): def _trigger_openclaw_writer(): topics_dir = DATA_DIR / 'topics' drafts_dir = DATA_DIR / 'drafts' + originals_dir = DATA_DIR / 'originals' drafts_dir.mkdir(exist_ok=True) + originals_dir.mkdir(exist_ok=True) today = datetime.now().strftime('%Y%m%d') topic_files = sorted(topics_dir.glob(f'{today}_*.json')) if not topic_files: @@ -128,24 +138,110 @@ def _trigger_openclaw_writer(): return for topic_file in topic_files[:3]: draft_check = drafts_dir / topic_file.name - if draft_check.exists(): + original_check = originals_dir / topic_file.name + if draft_check.exists() or original_check.exists(): continue topic_data = json.loads(topic_file.read_text(encoding='utf-8')) logger.info(f"글 작성 요청: {topic_data.get('topic', '')}") - _call_openclaw(topic_data, draft_check) + _call_openclaw(topic_data, original_check) + + +def _safe_slug(text: str) -> str: + slug = re.sub(r'[^a-z0-9]+', '-', text.lower()).strip('-') + return slug or datetime.now().strftime('article-%Y%m%d-%H%M%S') + + +def _build_openclaw_prompt(topic_data: dict) -> tuple[str, str]: + topic = topic_data.get('topic', '').strip() + corner = topic_data.get('corner', '쉬운세상').strip() or '쉬운세상' + description = topic_data.get('description', '').strip() + source = topic_data.get('source_url') or topic_data.get('source') or '' + published_at = topic_data.get('published_at', '') + system = ( + "당신은 The 4th Path 블로그 엔진의 전문 에디터다. " + "반드시 아래 섹션 헤더 형식만 사용해 완성된 Blogger-ready HTML 원고를 출력하라. " + "본문(BODY)은 HTML로 작성하고, KEY_POINTS는 3줄 이내로 작성한다." + ) + prompt = f"""다음 글감을 바탕으로 한국어 블로그 원고를 작성해줘. + +주제: {topic} +코너: {corner} +설명: {description} +출처: {source} +발행시점 참고: {published_at} + +출력 형식은 아래 섹션만 정확히 사용해. + +---TITLE--- +제목 + +---META--- +검색 설명 150자 이내 + +---SLUG--- +영문 소문자 slug + +---TAGS--- +태그1, 태그2, 태그3 + +---CORNER--- +{corner} + +---BODY--- +

...

형식의 Blogger-ready HTML 본문 + +---KEY_POINTS--- +- 핵심포인트1 +- 핵심포인트2 +- 핵심포인트3 + +---COUPANG_KEYWORDS--- +키워드1, 키워드2 + +---SOURCES--- +{source} | 참고 출처 | {published_at} + +---DISCLAIMER--- +필요 시 짧은 면책문구 +""" + return system, prompt def _call_openclaw(topic_data: dict, output_path: Path): - logger.info(f"OpenClaw 호출 (플레이스홀더): {topic_data.get('topic', '')}") - # OpenClaw 연동 완료 후 아래 주석 해제: - # import subprocess - # result = subprocess.run( - # ['openclaw', 'run', 'blog-writer', '--input', json.dumps(topic_data)], - # capture_output=True, text=True - # ) - # output = result.stdout - topic_data['_pending_openclaw'] = True - output_path.write_text(json.dumps(topic_data, ensure_ascii=False, indent=2), encoding='utf-8') + logger.info(f"OpenClaw 작성 요청: {topic_data.get('topic', '')}") + sys.path.insert(0, str(BASE_DIR)) + sys.path.insert(0, str(BASE_DIR / 'bots')) + + from engine_loader import EngineLoader + from article_parser import parse_output + + system, prompt = _build_openclaw_prompt(topic_data) + writer = EngineLoader().get_writer() + raw_output = writer.write(prompt, system=system).strip() + if not raw_output: + raise RuntimeError('OpenClaw writer 응답이 비어 있습니다.') + + article = parse_output(raw_output) + if not article: + raise RuntimeError('OpenClaw writer 출력 파싱 실패') + + article.setdefault('title', topic_data.get('topic', '').strip()) + article['slug'] = article.get('slug') or _safe_slug(article['title']) + article['corner'] = article.get('corner') or topic_data.get('corner', '쉬운세상') + article['topic'] = topic_data.get('topic', '') + article['description'] = topic_data.get('description', '') + article['quality_score'] = topic_data.get('quality_score', 0) + article['source'] = topic_data.get('source', '') + article['source_url'] = topic_data.get('source_url') or topic_data.get('source') or '' + article['published_at'] = topic_data.get('published_at', '') + article['created_at'] = datetime.now().isoformat() + + output_path.parent.mkdir(parents=True, exist_ok=True) + output_path.write_text( + json.dumps(article, ensure_ascii=False, indent=2), + encoding='utf-8', + ) + logger.info(f"OpenClaw 원고 저장 완료: {output_path.name}") def job_convert(): @@ -263,6 +359,37 @@ def _distribute_instagram(): logger.info(f"Instagram 발행 완료: {card_file.name}") +def job_distribute_instagram_reels(): + """10:30 — Instagram Reels (쇼츠 MP4) 발행""" + if not _publish_enabled: + return + logger.info("[스케줄] Instagram Reels 발행") + try: + _distribute_instagram_reels() + except Exception as e: + logger.error(f"Instagram Reels 배포 오류: {e}") + + +def _distribute_instagram_reels(): + sys.path.insert(0, str(BASE_DIR / 'bots' / 'distributors')) + import instagram_bot + today = datetime.now().strftime('%Y%m%d') + outputs_dir = DATA_DIR / 'outputs' + for shorts_file in sorted(outputs_dir.glob(f'{today}_*_shorts.mp4')): + flag = shorts_file.with_suffix('.ig_reels_done') + if flag.exists(): + continue + slug = shorts_file.stem.replace(f'{today}_', '').replace('_shorts', '') + article = _load_article_by_slug(today, slug) + if not article: + logger.warning(f"Instagram Reels: 원본 article 없음 ({slug})") + continue + success = instagram_bot.publish_reels(article, str(shorts_file)) + if success: + flag.touch() + logger.info(f"Instagram Reels 발행 완료: {shorts_file.name}") + + def job_distribute_x(): """11:00 — X 스레드 게시""" if not _publish_enabled: @@ -810,7 +937,9 @@ def setup_scheduler() -> AsyncIOScheduler: scheduler.add_job(lambda: job_publish(1), 'cron', hour=9, minute=0, id='blog_publish') # 09:00 블로그 scheduler.add_job(job_distribute_instagram, 'cron', - hour=10, minute=0, id='instagram_dist') # 10:00 인스타 + hour=10, minute=0, id='instagram_dist') # 10:00 인스타 카드 + scheduler.add_job(job_distribute_instagram_reels, 'cron', + hour=10, minute=30, id='instagram_reels_dist') # 10:30 인스타 릴스 scheduler.add_job(job_distribute_x, 'cron', hour=11, minute=0, id='x_dist') # 11:00 X scheduler.add_job(job_distribute_tiktok, 'cron', diff --git a/bots/writer_bot.py b/bots/writer_bot.py new file mode 100644 index 0000000..00a54eb --- /dev/null +++ b/bots/writer_bot.py @@ -0,0 +1,271 @@ +""" +글쓰기 봇 (bots/writer_bot.py) +역할: topics 폴더의 글감을 읽어 EngineLoader 글쓰기 엔진으로 원고를 생성하고 + data/originals/에 저장하는 독립 실행형 스크립트. + +호출: + python bots/writer_bot.py — 오늘 날짜 미처리 글감 전부 처리 + python bots/writer_bot.py --topic "..." — 직접 글감 지정 (대화형 사용) + python bots/writer_bot.py --file path/to/topic.json + +대시보드 manual-write 엔드포인트에서도 subprocess로 호출. +""" +import argparse +import json +import logging +import re +import sys +from datetime import datetime +from pathlib import Path + +from dotenv import load_dotenv + +load_dotenv() + +BASE_DIR = Path(__file__).parent.parent +sys.path.insert(0, str(BASE_DIR)) +sys.path.insert(0, str(BASE_DIR / 'bots')) + +DATA_DIR = BASE_DIR / 'data' +LOG_DIR = BASE_DIR / 'logs' +LOG_DIR.mkdir(exist_ok=True) + +logging.basicConfig( + level=logging.INFO, + format='%(asctime)s [%(levelname)s] %(message)s', + handlers=[ + logging.FileHandler(LOG_DIR / 'writer.log', encoding='utf-8'), + logging.StreamHandler(), + ], +) +logger = logging.getLogger(__name__) + + +# ─── 유틸 ──────────────────────────────────────────── + +def _safe_slug(text: str) -> str: + slug = re.sub(r'[^a-z0-9]+', '-', text.lower()).strip('-') + return slug or datetime.now().strftime('article-%Y%m%d-%H%M%S') + + +def _build_prompt(topic_data: dict) -> tuple[str, str]: + topic = topic_data.get('topic', '').strip() + corner = topic_data.get('corner', '쉬운세상').strip() or '쉬운세상' + description = topic_data.get('description', '').strip() + source = topic_data.get('source_url') or topic_data.get('source') or '' + published_at = topic_data.get('published_at', '') + + system = ( + "당신은 The 4th Path 블로그 엔진의 전문 에디터다. " + "반드시 아래 섹션 헤더 형식만 사용해 완성된 Blogger-ready HTML 원고를 출력하라. " + "본문(BODY)은 HTML로 작성하고, KEY_POINTS는 3줄 이내로 작성한다." + ) + prompt = f"""다음 글감을 바탕으로 한국어 블로그 원고를 작성해줘. + +주제: {topic} +코너: {corner} +설명: {description} +출처: {source} +발행시점 참고: {published_at} + +출력 형식은 아래 섹션만 정확히 사용해. + +---TITLE--- +제목 + +---META--- +검색 설명 150자 이내 + +---SLUG--- +영문 소문자 slug + +---TAGS--- +태그1, 태그2, 태그3 + +---CORNER--- +{corner} + +---BODY--- +

...

형식의 Blogger-ready HTML 본문 + +---KEY_POINTS--- +- 핵심포인트1 +- 핵심포인트2 +- 핵심포인트3 + +---COUPANG_KEYWORDS--- +키워드1, 키워드2 + +---SOURCES--- +{source} | 참고 출처 | {published_at} + +---DISCLAIMER--- +필요 시 짧은 면책문구 +""" + return system, prompt + + +# ─── 핵심 로직 ─────────────────────────────────────── + +def write_article(topic_data: dict, output_path: Path) -> dict: + """ + topic_data → EngineLoader 호출 → article dict 저장. + Returns: article dict (저장 완료) + Raises: RuntimeError — 글 작성 또는 파싱 실패 시 + """ + from engine_loader import EngineLoader + from article_parser import parse_output + + title = topic_data.get('topic', topic_data.get('title', '')) + logger.info(f"글 작성 시작: {title}") + + system, prompt = _build_prompt(topic_data) + writer = EngineLoader().get_writer() + raw_output = writer.write(prompt, system=system).strip() + + if not raw_output: + raise RuntimeError('글쓰기 엔진 응답이 비어 있습니다.') + + article = parse_output(raw_output) + if not article: + raise RuntimeError(f'글쓰기 엔진 출력 파싱 실패 (앞 200자): {raw_output[:200]}') + + article.setdefault('title', title) + article['slug'] = article.get('slug') or _safe_slug(article['title']) + article['corner'] = article.get('corner') or topic_data.get('corner', '쉬운세상') + article['topic'] = topic_data.get('topic', '') + article['description'] = topic_data.get('description', '') + article['quality_score'] = topic_data.get('quality_score', 0) + article['source'] = topic_data.get('source', '') + article['source_url'] = topic_data.get('source_url') or topic_data.get('source') or '' + article['published_at'] = topic_data.get('published_at', '') + article['created_at'] = datetime.now().isoformat() + + output_path.parent.mkdir(parents=True, exist_ok=True) + output_path.write_text( + json.dumps(article, ensure_ascii=False, indent=2), + encoding='utf-8', + ) + logger.info(f"원고 저장 완료: {output_path.name}") + return article + + +def run_pending(limit: int = 3) -> list[dict]: + """ + data/topics/ 에서 오늘 날짜 미처리 글감을 최대 limit개 처리. + Returns: 처리 결과 리스트 [{'slug':..., 'success':..., 'error':...}] + """ + topics_dir = DATA_DIR / 'topics' + originals_dir = DATA_DIR / 'originals' + originals_dir.mkdir(parents=True, exist_ok=True) + + today = datetime.now().strftime('%Y%m%d') + topic_files = sorted(topics_dir.glob(f'{today}_*.json')) + + if not topic_files: + logger.info("오늘 날짜 글감 없음") + return [] + + results = [] + processed = 0 + for topic_file in topic_files: + if processed >= limit: + break + output_path = originals_dir / topic_file.name + if output_path.exists(): + logger.debug(f"이미 처리됨: {topic_file.name}") + continue + try: + topic_data = json.loads(topic_file.read_text(encoding='utf-8')) + article = write_article(topic_data, output_path) + results.append({'file': topic_file.name, 'slug': article.get('slug', ''), 'success': True}) + processed += 1 + except Exception as e: + logger.error(f"글 작성 실패 [{topic_file.name}]: {e}") + results.append({'file': topic_file.name, 'slug': '', 'success': False, 'error': str(e)}) + + return results + + +def run_from_topic(topic: str, corner: str = '쉬운세상') -> dict: + """ + 직접 주제 문자열로 글 작성. + Returns: article dict + """ + originals_dir = DATA_DIR / 'originals' + originals_dir.mkdir(parents=True, exist_ok=True) + + slug = _safe_slug(topic) + filename = f"{datetime.now().strftime('%Y%m%d_%H%M%S')}_{slug}.json" + output_path = originals_dir / filename + + topic_data = { + 'topic': topic, + 'corner': corner, + 'description': '', + 'source': '', + 'published_at': datetime.now().isoformat(), + } + return write_article(topic_data, output_path) + + +def run_from_file(file_path: str) -> dict: + """ + JSON 파일에서 topic_data를 읽어 글 작성. + """ + originals_dir = DATA_DIR / 'originals' + originals_dir.mkdir(parents=True, exist_ok=True) + + topic_file = Path(file_path) + topic_data = json.loads(topic_file.read_text(encoding='utf-8')) + output_path = originals_dir / topic_file.name + return write_article(topic_data, output_path) + + +# ─── CLI 진입점 ────────────────────────────────────── + +def main(): + parser = argparse.ArgumentParser(description='The 4th Path 글쓰기 봇') + parser.add_argument('--topic', type=str, help='직접 글감 지정') + parser.add_argument('--corner', type=str, default='쉬운세상', help='코너 지정 (기본: 쉬운세상)') + parser.add_argument('--file', type=str, help='글감 JSON 파일 경로') + parser.add_argument('--limit', type=int, default=3, help='최대 처리 글 수 (기본: 3)') + args = parser.parse_args() + + if args.topic: + try: + article = run_from_topic(args.topic, corner=args.corner) + print(f"[완료] 제목: {article.get('title', '')} | slug: {article.get('slug', '')}") + sys.exit(0) + except Exception as e: + print(f"[오류] {e}", file=sys.stderr) + sys.exit(1) + + if args.file: + try: + article = run_from_file(args.file) + print(f"[완료] 제목: {article.get('title', '')} | slug: {article.get('slug', '')}") + sys.exit(0) + except Exception as e: + print(f"[오류] {e}", file=sys.stderr) + sys.exit(1) + + # 기본: 오늘 날짜 미처리 글감 처리 + results = run_pending(limit=args.limit) + if not results: + print("[완료] 처리할 글감 없음") + sys.exit(0) + + ok = sum(1 for r in results if r['success']) + fail = len(results) - ok + print(f"[완료] 성공 {ok}건 / 실패 {fail}건") + for r in results: + status = '✅' if r['success'] else '❌' + err = f" ({r.get('error', '')})" if not r['success'] else '' + print(f" {status} {r['file']}{err}") + + sys.exit(0 if fail == 0 else 1) + + +if __name__ == '__main__': + main() diff --git a/config/engine.json b/config/engine.json index 60780ec..e291f98 100644 --- a/config/engine.json +++ b/config/engine.json @@ -1,31 +1,39 @@ { "_comment": "The 4th Path 블로그 자동 수익 엔진 — 엔진 설정 (v3)", "_updated": "2026-03-26", - "writing": { - "provider": "claude", + "provider": "openclaw", + "_comment_provider": "openclaw=ChatGPT Pro(OAuth), claude_web=Claude Max(웹쿠키), gemini_web=Gemini Pro(웹쿠키), claude=Anthropic API키, gemini=Google AI API키", "options": { + "openclaw": { + "agent_name": "blog-writer", + "timeout": 300 + }, + "claude_web": { + "cookie_env": "CLAUDE_WEB_COOKIE", + "ua_env": "CLAUDE_WEB_UA", + "timeout": 240 + }, + "gemini_web": { + "psid_env": "GEMINI_WEB_1PSID", + "psidts_env": "GEMINI_WEB_1PSIDTS" + }, "claude": { "api_key_env": "ANTHROPIC_API_KEY", - "model": "claude-opus-4-5", + "model": "claude-opus-4-6", "max_tokens": 4096, "temperature": 0.7 }, - "openclaw": { - "agent_name": "blog-writer", - "timeout": 120 - }, "gemini": { "api_key_env": "GEMINI_API_KEY", - "model": "gemini-2.0-flash", + "model": "gemini-2.5-pro", "max_tokens": 4096, "temperature": 0.7 } } }, - "tts": { - "provider": "gtts", + "provider": "openai", "options": { "google_cloud": { "api_key_env": "GOOGLE_TTS_API_KEY", @@ -52,9 +60,8 @@ } } }, - "image_generation": { - "provider": "dalle", + "provider": "external", "options": { "dalle": { "api_key_env": "OPENAI_API_KEY", @@ -67,9 +74,8 @@ } } }, - "video_generation": { - "provider": "ffmpeg_slides", + "provider": "sora", "options": { "ffmpeg_slides": { "resolution": "1080x1920", @@ -108,7 +114,6 @@ } } }, - "publishing": { "blogger": { "enabled": true, @@ -119,7 +124,11 @@ "channel_id_env": "YOUTUBE_CHANNEL_ID", "category": "shorts", "privacy": "public", - "tags": ["쇼츠", "AI", "the4thpath"] + "tags": [ + "쇼츠", + "AI", + "the4thpath" + ] }, "instagram": { "enabled": false, @@ -136,7 +145,6 @@ "comment": "노벨피아 연재 — 추후 활성화" } }, - "quality_gates": { "gate1_research_min_score": 60, "gate2_writing_min_score": 70, @@ -146,7 +154,6 @@ "min_word_count": 300, "safety_check": true }, - "schedule": { "collector": "07:00", "writer": "08:00", @@ -155,7 +162,6 @@ "youtube_uploader": "10:00", "analytics": "22:00" }, - "brand": { "name": "The 4th Path", "sub": "Independent Tech Media", @@ -163,11 +169,10 @@ "url": "the4thpath.com", "cta": "팔로우하면 매일 이런 정보를 받습니다" }, - "optional_keys": { "SEEDANCE_API_KEY": "Seedance 2.0 AI 영상 생성", "ELEVENLABS_API_KEY": "ElevenLabs 고품질 TTS", "GEMINI_API_KEY": "Google Gemini 글쓰기 / Veo 영상", "RUNWAY_API_KEY": "Runway Gen-3 AI 영상 생성" } -} +} \ No newline at end of file diff --git a/dashboard/README.md b/dashboard/README.md index 183c5a0..5f0fb31 100644 --- a/dashboard/README.md +++ b/dashboard/README.md @@ -52,7 +52,7 @@ dashboard/ ```bash cd D:/workspace/blog-writer -pip install fastapi uvicorn python-dotenv +venv\Scripts\python.exe -m pip install -r requirements.txt ``` ### 프론트엔드 의존성 설치 @@ -69,6 +69,8 @@ npm install - **프로덕션**: `start.bat` 더블클릭 - **개발 모드**: `start_dev.bat` 더블클릭 +두 스크립트 모두 프로젝트 `venv\Scripts\python.exe`가 없으면 즉시 중단합니다. + ### Linux/Mac ```bash @@ -84,7 +86,7 @@ bash dashboard/start.sh dev ```bash # 터미널 1 — 백엔드 cd D:/workspace/blog-writer -python -m uvicorn dashboard.backend.server:app --port 8080 --reload +venv\Scripts\python.exe blog_runtime.py server --reload # 터미널 2 — 프론트엔드 (개발) cd D:/workspace/blog-writer/dashboard/frontend @@ -117,6 +119,6 @@ npm run build ```bash # 백엔드를 0.0.0.0으로 바인딩하면 Tailscale IP로 접속 가능 -python -m uvicorn dashboard.backend.server:app --host 0.0.0.0 --port 8080 +venv\Scripts\python.exe blog_runtime.py server # 접속: http://:8080 ``` diff --git a/dashboard/backend/api_assist.py b/dashboard/backend/api_assist.py new file mode 100644 index 0000000..19466e9 --- /dev/null +++ b/dashboard/backend/api_assist.py @@ -0,0 +1,116 @@ +""" +dashboard/backend/api_assist.py +수동(어시스트) 모드 API +""" +import threading +from datetime import datetime +from pathlib import Path + +from fastapi import APIRouter, File, Form, HTTPException, UploadFile + +BASE_DIR = Path(__file__).parent.parent.parent + +router = APIRouter() + +# assist_bot을 지연 임포트 (서버 기동 시 오류 방지) +def _bot(): + import sys + if str(BASE_DIR) not in sys.path: + sys.path.insert(0, str(BASE_DIR)) + from bots import assist_bot + return assist_bot + + +@router.post("/assist/session") +async def create_session(payload: dict): + """URL을 받아 새 어시스트 세션을 생성하고 파이프라인을 시작한다.""" + url = payload.get('url', '').strip() + if not url.startswith('http'): + raise HTTPException(status_code=400, detail="유효한 URL을 입력하세요.") + bot = _bot() + session = bot.create_session(url) + t = threading.Thread( + target=bot.run_pipeline, + args=(session['session_id'],), + daemon=True, + ) + t.start() + return session + + +@router.get("/assist/sessions") +async def list_sessions(): + return _bot().list_sessions() + + +@router.get("/assist/session/{sid}") +async def get_session(sid: str): + bot = _bot() + # inbox 자동 스캔 + bot.scan_inbox(sid) + session = bot.load_session(sid) + if not session: + raise HTTPException(status_code=404, detail="세션을 찾을 수 없습니다.") + return session + + +@router.post("/assist/session/{sid}/upload") +async def upload_asset(sid: str, file: UploadFile = File(...), asset_type: str = Form("image")): + """에셋 파일 직접 업로드.""" + bot = _bot() + session = bot.load_session(sid) + if not session: + raise HTTPException(status_code=404, detail="세션을 찾을 수 없습니다.") + + assets_dir = bot.session_dir(sid) / 'assets' + assets_dir.mkdir(parents=True, exist_ok=True) + + # 파일명 충돌 방지 + fname = file.filename or f"asset_{datetime.now().strftime('%H%M%S')}" + dest = assets_dir / fname + dest.write_bytes(await file.read()) + + ext = dest.suffix.lower() + detected_type = 'video' if ext in bot.VIDEO_EXTENSIONS else 'image' + + session.setdefault('assets', []).append({ + 'type': detected_type, + 'path': str(dest), + 'filename': fname, + 'added_at': datetime.now().isoformat(), + }) + bot.save_session(session) + return {"ok": True, "filename": fname, "type": detected_type} + + +@router.delete("/assist/session/{sid}/asset/{filename}") +async def delete_asset(sid: str, filename: str): + bot = _bot() + session = bot.load_session(sid) + if not session: + raise HTTPException(status_code=404, detail="세션을 찾을 수 없습니다.") + session['assets'] = [a for a in session.get('assets', []) if a['filename'] != filename] + bot.save_session(session) + # 파일도 삭제 + p = bot.session_dir(sid) / 'assets' / filename + p.unlink(missing_ok=True) + return {"ok": True} + + +@router.get("/assist/inbox") +async def inbox_info(): + """inbox 폴더 경로 및 파일 목록 반환.""" + bot = _bot() + inbox = bot.INBOX_DIR + files = [f.name for f in inbox.iterdir() if f.is_file()] if inbox.exists() else [] + return {"path": str(inbox), "files": files, "count": len(files)} + + +@router.delete("/assist/session/{sid}") +async def delete_session(sid: str): + import shutil + bot = _bot() + p = bot.session_dir(sid) + if p.exists(): + shutil.rmtree(p) + return {"ok": True} diff --git a/dashboard/backend/api_content.py b/dashboard/backend/api_content.py index 3edee88..808264a 100644 --- a/dashboard/backend/api_content.py +++ b/dashboard/backend/api_content.py @@ -4,13 +4,14 @@ Content 탭 API — 칸반 보드, 승인/거부, 수동 트리거 """ import json import subprocess -import sys from datetime import datetime from pathlib import Path from fastapi import APIRouter, HTTPException from pydantic import BaseModel +from runtime_guard import project_python_path, run_with_project_python + BASE_DIR = Path(__file__).parent.parent.parent DATA_DIR = BASE_DIR / "data" @@ -115,8 +116,17 @@ async def reject_content(item_id: str): @router.post("/manual-write") async def manual_write(req: WriteRequest): """collector_bot + writer_bot 수동 트리거""" - python = sys.executable bots_dir = BASE_DIR / "bots" + python = project_python_path() + + if not python.exists(): + raise HTTPException( + status_code=500, + detail=( + f"프로젝트 가상환경 Python이 없습니다: {python}. " + "venv 생성 후 requirements.txt를 설치하세요." + ), + ) results = [] @@ -124,8 +134,8 @@ async def manual_write(req: WriteRequest): collector = bots_dir / "collector_bot.py" if collector.exists(): try: - result = subprocess.run( - [python, str(collector)], + result = run_with_project_python( + [str(collector)], capture_output=True, text=True, timeout=120, @@ -135,22 +145,23 @@ async def manual_write(req: WriteRequest): results.append({ "step": "collector", "success": result.returncode == 0, + "python": str(python), "output": result.stdout[-500:] if result.stdout else "", "error": result.stderr[-300:] if result.stderr else "", }) except subprocess.TimeoutExpired: - results.append({"step": "collector", "success": False, "error": "타임아웃"}) + results.append({"step": "collector", "success": False, "python": str(python), "error": "타임아웃"}) except Exception as e: - results.append({"step": "collector", "success": False, "error": str(e)}) + results.append({"step": "collector", "success": False, "python": str(python), "error": str(e)}) else: - results.append({"step": "collector", "success": False, "error": "파일 없음"}) + results.append({"step": "collector", "success": False, "python": str(python), "error": "파일 없음"}) # writer_bot 실행 writer = bots_dir / "writer_bot.py" if writer.exists(): try: - result = subprocess.run( - [python, str(writer)], + result = run_with_project_python( + [str(writer)], capture_output=True, text=True, timeout=300, @@ -160,14 +171,15 @@ async def manual_write(req: WriteRequest): results.append({ "step": "writer", "success": result.returncode == 0, + "python": str(python), "output": result.stdout[-500:] if result.stdout else "", "error": result.stderr[-300:] if result.stderr else "", }) except subprocess.TimeoutExpired: - results.append({"step": "writer", "success": False, "error": "타임아웃"}) + results.append({"step": "writer", "success": False, "python": str(python), "error": "타임아웃"}) except Exception as e: - results.append({"step": "writer", "success": False, "error": str(e)}) + results.append({"step": "writer", "success": False, "python": str(python), "error": str(e)}) else: - results.append({"step": "writer", "success": False, "error": "파일 없음"}) + results.append({"step": "writer", "success": False, "python": str(python), "error": "파일 없음"}) - return {"results": results} + return {"python": str(python), "results": results} diff --git a/dashboard/backend/server.py b/dashboard/backend/server.py index 00aeb72..7e5596d 100644 --- a/dashboard/backend/server.py +++ b/dashboard/backend/server.py @@ -8,6 +8,13 @@ dashboard/backend/server.py import os from pathlib import Path +from runtime_guard import ensure_project_runtime + +ensure_project_runtime( + "dashboard server", + ["fastapi", "uvicorn", "python-dotenv"], +) + from fastapi import FastAPI from fastapi.middleware.cors import CORSMiddleware from fastapi.staticfiles import StaticFiles @@ -23,6 +30,7 @@ from dashboard.backend import ( api_tools, api_cost, api_logs, + api_assist, ) app = FastAPI(title="The 4th Path — Control Panel", version="1.0.0") @@ -51,6 +59,7 @@ app.include_router(api_connections.router, prefix="/api") app.include_router(api_tools.router, prefix="/api") app.include_router(api_cost.router, prefix="/api") app.include_router(api_logs.router, prefix="/api") +app.include_router(api_assist.router, prefix="/api") @app.get("/api/health") async def health(): diff --git a/dashboard/frontend/src/App.jsx b/dashboard/frontend/src/App.jsx index 55828ae..72e0816 100644 --- a/dashboard/frontend/src/App.jsx +++ b/dashboard/frontend/src/App.jsx @@ -1,15 +1,17 @@ import React, { useState } from 'react' -import { LayoutDashboard, FileText, BarChart2, BookOpen, Settings, ScrollText } from 'lucide-react' +import { LayoutDashboard, FileText, BarChart2, BookOpen, Settings, ScrollText, UserCheck } from 'lucide-react' import Overview from './pages/Overview.jsx' import Content from './pages/Content.jsx' import Analytics from './pages/Analytics.jsx' import Novel from './pages/Novel.jsx' import SettingsPage from './pages/Settings.jsx' import Logs from './pages/Logs.jsx' +import Assist from './pages/Assist.jsx' const TABS = [ { id: 'overview', label: '개요', icon: LayoutDashboard, component: Overview }, { id: 'content', label: '콘텐츠', icon: FileText, component: Content }, + { id: 'assist', label: '수동모드', icon: UserCheck, component: Assist }, { id: 'analytics', label: '분석', icon: BarChart2, component: Analytics }, { id: 'novel', label: '소설', icon: BookOpen, component: Novel }, { id: 'settings', label: '설정', icon: Settings, component: SettingsPage }, diff --git a/dashboard/frontend/src/pages/Assist.jsx b/dashboard/frontend/src/pages/Assist.jsx new file mode 100644 index 0000000..301eb8b --- /dev/null +++ b/dashboard/frontend/src/pages/Assist.jsx @@ -0,0 +1,416 @@ +import { useState, useEffect, useRef } from 'react' +import { + Link2, Upload, FolderOpen, RefreshCw, Trash2, + CheckCircle, Clock, AlertCircle, Loader, ChevronDown, + ChevronUp, Copy, Image, Video, FileText, Plus, X +} from 'lucide-react' + +const API = '' + +const STATUS_COLOR = { + pending: '#888880', + fetching: '#4a5abf', + generating: '#c8a84e', + awaiting: '#c8a84e', + assembling: '#4a5abf', + ready: '#3a7d5c', + error: '#bf3a3a', +} + +const STATUS_ICON = { + pending: , + fetching: , + generating: , + awaiting: , + assembling: , + ready: , + error: , +} + +function CopyBtn({ text }) { + const [copied, setCopied] = useState(false) + const copy = () => { + navigator.clipboard.writeText(text) + setCopied(true) + setTimeout(() => setCopied(false), 1500) + } + return ( + + ) +} + +function PromptCard({ prompt, index }) { + return ( +
+
+ + + 이미지 #{index + 1} — {prompt.purpose} + + +
+
+ KO{prompt.ko} +
+
+ EN{prompt.en} +
+
+ ) +} + +function AssetDropZone({ sessionId, onUploaded }) { + const [dragging, setDragging] = useState(false) + const [uploading, setUploading] = useState(false) + const inputRef = useRef() + + const handleFiles = async (files) => { + setUploading(true) + for (const file of files) { + const fd = new FormData() + fd.append('file', file) + fd.append('asset_type', file.type.startsWith('video') ? 'video' : 'image') + await fetch(`${API}/api/assist/session/${sessionId}/upload`, { method: 'POST', body: fd }) + } + setUploading(false) + onUploaded() + } + + return ( +
{ e.preventDefault(); setDragging(true) }} + onDragLeave={() => setDragging(false)} + onDrop={e => { e.preventDefault(); setDragging(false); handleFiles([...e.dataTransfer.files]) }} + onClick={() => inputRef.current?.click()} + style={{ + border: `2px dashed ${dragging ? '#c8a84e' : '#333'}`, + borderRadius: 8, padding: '20px 16px', textAlign: 'center', + cursor: 'pointer', background: dragging ? '#1a1a0a' : 'transparent', + transition: 'all 0.2s', + }} + > + handleFiles([...e.target.files])} /> + {uploading + ? <>
업로드 중...
+ : <> + +
이미지 / 영상 드래그 앤 드롭 또는 클릭하여 선택
+
JPG, PNG, WebP, MP4, MOV 지원
+ + } +
+ ) +} + +function SessionCard({ session: initial, onDelete }) { + const [session, setSession] = useState(initial) + const [open, setOpen] = useState(initial.status === 'awaiting') + const [refreshing, setRefreshing] = useState(false) + + const refresh = async () => { + setRefreshing(true) + const r = await fetch(`${API}/api/assist/session/${session.session_id}`) + if (r.ok) setSession(await r.json()) + setRefreshing(false) + } + + // 처리 중이면 자동 폴링 + useEffect(() => { + const active = ['pending', 'fetching', 'generating'] + if (!active.includes(session.status)) return + const t = setInterval(refresh, 3000) + return () => clearInterval(t) + }, [session.status]) + + const removeAsset = async (filename) => { + await fetch(`${API}/api/assist/session/${session.session_id}/asset/${filename}`, { method: 'DELETE' }) + refresh() + } + + const color = STATUS_COLOR[session.status] || '#888' + const icon = STATUS_ICON[session.status] + const prompts = session.prompts || {} + const imagePrompts = prompts.image_prompts || [] + const videoPrompt = prompts.video_prompt || null + const narration = prompts.narration_script || '' + const assets = session.assets || [] + + return ( +
+ {/* 헤더 */} +
setOpen(o => !o)}> +
{icon}
+
+
+ {session.title || session.url} +
+
+ {new Date(session.created_at).toLocaleString('ko-KR')} + {' · '} + {session.status_label || session.status} + {assets.length > 0 && · 에셋 {assets.length}개} +
+
+
+ + + {open ? : } +
+
+ + {/* 본문 */} + {open && ( +
+ {session.status === 'error' && ( +
+ {session.error} +
+ )} + + {session.body_preview && ( +
+ {session.body_preview}… +
+ )} + + {/* 프롬프트 섹션 */} + {imagePrompts.length > 0 && ( +
+
+ 이미지 프롬프트 +
+ {imagePrompts.map((p, i) => )} +
+ )} + + {videoPrompt && ( +
+
+
+
+
+ Sora / Runway / Veo + +
+
+ KO{videoPrompt.ko} +
+
+ EN{videoPrompt.en} +
+
+
+ )} + + {narration && ( +
+
+ 나레이션 스크립트 +
+
+
+ +
+
+ {narration} +
+
+
+ )} + + {/* 에셋 업로드 */} + {['awaiting', 'ready', 'generating'].includes(session.status) && ( +
+
+ 에셋 제공 +
+ +
+ )} + + {/* 등록된 에셋 */} + {assets.length > 0 && ( +
+
+ 등록된 에셋 ({assets.length}) +
+
+ {assets.map((a, i) => ( +
+ {a.type === 'video' ?
+ ))} +
+
+ )} +
+ )} +
+ ) +} + +export default function Assist() { + const [url, setUrl] = useState('') + const [sessions, setSessions] = useState([]) + const [loading, setLoading] = useState(false) + const [inboxPath, setInboxPath] = useState('') + + const load = async () => { + const [s, i] = await Promise.all([ + fetch(`${API}/api/assist/sessions`).then(r => r.json()).catch(() => []), + fetch(`${API}/api/assist/inbox`).then(r => r.json()).catch(() => ({})), + ]) + setSessions(s) + setInboxPath(i.path || '') + } + + useEffect(() => { load() }, []) + + const submit = async () => { + if (!url.trim()) return + setLoading(true) + await fetch(`${API}/api/assist/session`, { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ url: url.trim() }), + }) + setUrl('') + await load() + setLoading(false) + } + + const deleteSession = async (sid) => { + if (!confirm('이 세션을 삭제하시겠습니까?')) return + await fetch(`${API}/api/assist/session/${sid}`, { method: 'DELETE' }) + load() + } + + return ( +
+ {/* 헤더 */} +
+

+ 수동(어시스트) 모드 +

+

+ 직접 작성한 블로그 글 URL을 입력하면 시스템이 이미지·영상 프롬프트를 생성합니다. + 생성한 에셋을 업로드하면 쇼츠 조립·배포 파이프라인으로 연결됩니다. +

+
+ + {/* URL 입력 */} +
+
+ + 블로그 글 URL +
+
+ setUrl(e.target.value)} + onKeyDown={e => e.key === 'Enter' && submit()} + placeholder="https://www.the4thpath.com/2026/03/..." + style={{ + flex: 1, background: '#0a0a0d', border: '1px solid #333', + borderRadius: 6, padding: '8px 12px', color: '#e0e0d8', + fontSize: 13, outline: 'none', + }} + /> + +
+
+ + {/* inbox 폴더 안내 */} + {inboxPath && ( +
+ +
+ 폴더 드롭 경로 + {inboxPath} +
+
+ 파일명 앞 8자리에 세션 ID를 포함하면 자동 연결됩니다 +
+
+ )} + + {/* 세션 목록 */} +
+ + 세션 목록 ({sessions.length}) + + +
+ + {sessions.length === 0 + ?
+ 아직 세션이 없습니다. URL을 입력해 시작하세요. +
+ : sessions.map(s => ( + + )) + } + + +
+ ) +} diff --git a/dashboard/start.bat b/dashboard/start.bat index ba026a5..404852c 100644 --- a/dashboard/start.bat +++ b/dashboard/start.bat @@ -9,18 +9,14 @@ echo ================================================ set SCRIPT_DIR=%~dp0 set PROJECT_ROOT=%SCRIPT_DIR%.. -:: Python 가상환경 활성화 -if exist "%PROJECT_ROOT%\venv\Scripts\activate.bat" ( - echo [*] 가상환경 활성화... - call "%PROJECT_ROOT%\venv\Scripts\activate.bat" -) else if exist "%PROJECT_ROOT%\.venv\Scripts\activate.bat" ( - call "%PROJECT_ROOT%\.venv\Scripts\activate.bat" +set "PYTHON=%PROJECT_ROOT%\venv\Scripts\python.exe" +if not exist "%PYTHON%" ( + echo [ERROR] Missing project virtualenv Python: %PYTHON% + echo Run scripts\setup.bat or create venv and install requirements first. + pause + exit /b 1 ) -:: 백엔드 의존성 확인 -echo [*] FastAPI 의존성 확인... -pip install fastapi uvicorn python-dotenv --quiet 2>nul - :: 프론트엔드 의존성 설치 if not exist "%SCRIPT_DIR%frontend\node_modules" ( echo [*] npm 패키지 설치 중... @@ -47,6 +43,6 @@ echo 종료하려면 이 창을 닫으세요. echo. cd /d "%PROJECT_ROOT%" -python -m uvicorn dashboard.backend.server:app --host 0.0.0.0 --port 8080 +"%PYTHON%" blog_runtime.py server pause diff --git a/dashboard/start_dev.bat b/dashboard/start_dev.bat index b9bdf11..e07a367 100644 --- a/dashboard/start_dev.bat +++ b/dashboard/start_dev.bat @@ -9,16 +9,14 @@ echo ================================================ set SCRIPT_DIR=%~dp0 set PROJECT_ROOT=%SCRIPT_DIR%.. -:: Python 가상환경 활성화 -if exist "%PROJECT_ROOT%\venv\Scripts\activate.bat" ( - call "%PROJECT_ROOT%\venv\Scripts\activate.bat" -) else if exist "%PROJECT_ROOT%\.venv\Scripts\activate.bat" ( - call "%PROJECT_ROOT%\.venv\Scripts\activate.bat" +set "PYTHON=%PROJECT_ROOT%\venv\Scripts\python.exe" +if not exist "%PYTHON%" ( + echo [ERROR] Missing project virtualenv Python: %PYTHON% + echo Run scripts\setup.bat or create venv and install requirements first. + pause + exit /b 1 ) -:: 백엔드 의존성 확인 -pip install fastapi uvicorn python-dotenv --quiet 2>nul - :: 프론트엔드 의존성 설치 if not exist "%SCRIPT_DIR%frontend\node_modules" ( echo [*] npm 패키지 설치 중... @@ -27,7 +25,7 @@ if not exist "%SCRIPT_DIR%frontend\node_modules" ( ) echo [*] 백엔드 시작 중... -start "FastAPI Backend" cmd /k "cd /d %PROJECT_ROOT% && python -m uvicorn dashboard.backend.server:app --host 0.0.0.0 --port 8080 --reload" +start "FastAPI Backend" cmd /k "cd /d %PROJECT_ROOT% && %PYTHON% blog_runtime.py server --reload" echo [*] 프론트엔드 개발 서버 시작 중... start "Vite Frontend" cmd /k "cd /d %SCRIPT_DIR%frontend && npm run dev" diff --git a/requirements.txt b/requirements.txt index e50c7a9..9026a72 100644 --- a/requirements.txt +++ b/requirements.txt @@ -3,6 +3,9 @@ google-api-python-client google-auth-oauthlib google-auth-httplib2 python-dotenv +fastapi +uvicorn +python-multipart apscheduler requests beautifulsoup4 diff --git a/runtime_guard.py b/runtime_guard.py new file mode 100644 index 0000000..491b9fb --- /dev/null +++ b/runtime_guard.py @@ -0,0 +1,107 @@ +""" +Shared runtime checks and project-Python helpers. +""" +from __future__ import annotations + +import importlib.metadata +import os +import re +import subprocess +import sys +from pathlib import Path + + +PROJECT_ROOT = Path(__file__).resolve().parent +REQUIREMENTS_FILE = PROJECT_ROOT / "requirements.txt" +VENV_DIR = PROJECT_ROOT / "venv" + + +def project_python_path() -> Path: + if os.name == "nt": + return VENV_DIR / "Scripts" / "python.exe" + return VENV_DIR / "bin" / "python" + + +def _normalized(path: str | Path) -> str: + return str(Path(path).resolve()).casefold() + + +def _parse_requirement_name(line: str) -> str | None: + stripped = line.strip() + if not stripped or stripped.startswith("#"): + return None + requirement = stripped.split(";", 1)[0].strip() + requirement = re.split(r"[<>=!~]", requirement, 1)[0].strip() + return requirement or None + + +def load_required_distributions() -> list[str]: + if not REQUIREMENTS_FILE.exists(): + return [] + + packages: list[str] = [] + for line in REQUIREMENTS_FILE.read_text(encoding="utf-8").splitlines(): + name = _parse_requirement_name(line) + if name: + packages.append(name) + return packages + + +def missing_distributions(distributions: list[str]) -> list[str]: + missing: list[str] = [] + for name in distributions: + try: + importlib.metadata.version(name) + except importlib.metadata.PackageNotFoundError: + missing.append(name) + return missing + + +def ensure_project_runtime( + entrypoint: str, + required_distributions: list[str] | None = None, +) -> None: + expected_python = project_python_path() + current_python = Path(sys.executable) + + if not expected_python.exists(): + raise RuntimeError( + f"{entrypoint} requires the project virtualenv at '{expected_python}'. " + "Create it first and install requirements.txt." + ) + + if _normalized(current_python) != _normalized(expected_python): + raise RuntimeError( + f"{entrypoint} must run with the project virtualenv Python.\n" + f"Current: {current_python}\n" + f"Expected: {expected_python}\n" + f"Safe path: {expected_python} {PROJECT_ROOT / 'blog_runtime.py'} " + f"{_default_launcher_arg(entrypoint)}" + ) + + missing = missing_distributions(required_distributions or []) + if missing: + raise RuntimeError( + f"{entrypoint} is missing required packages in the project virtualenv: " + f"{', '.join(missing)}\n" + f"Install with: {expected_python} -m pip install -r {REQUIREMENTS_FILE}" + ) + + +def run_with_project_python(args: list[str], **kwargs) -> subprocess.CompletedProcess: + ensure_project_runtime("project subprocess") + cmd = [str(project_python_path()), *args] + return subprocess.run(cmd, **kwargs) + + +def project_python_cmd(args: list[str]) -> list[str]: + return [str(project_python_path()), *args] + + +def _default_launcher_arg(entrypoint: str) -> str: + lowered = entrypoint.lower() + if "scheduler" in lowered: + return "scheduler" + if "server" in lowered or "dashboard" in lowered: + return "server" + return "status" diff --git a/scripts/setup.bat b/scripts/setup.bat index e227353..7bcd117 100644 --- a/scripts/setup.bat +++ b/scripts/setup.bat @@ -36,16 +36,27 @@ if not exist data\published mkdir data\published if not exist data\analytics mkdir data\analytics if not exist data\images mkdir data\images if not exist data\drafts mkdir data\drafts +if not exist data\originals mkdir data\originals +if not exist data\outputs mkdir data\outputs +if not exist data\assist mkdir data\assist +if not exist data\assist\sessions mkdir data\assist\sessions +if not exist data\assist\inbox mkdir data\assist\inbox +if not exist data\novels mkdir data\novels if not exist logs mkdir logs +if not exist config\novels mkdir config\novels -REM Register scheduler.py in Windows Task Scheduler -set SCRIPT_PATH=%~dp0bots\scheduler.py +REM Download fonts (Noto Sans KR for card/shorts converter) +echo [INFO] Downloading fonts... +venv\Scripts\python.exe scripts\download_fonts.py + +REM Register scheduler.py in Windows Task Scheduler (blog.cmd 경유) +set BLOG_CMD=%~dp0blog.cmd set PYTHON_PATH=%~dp0venv\Scripts\pythonw.exe schtasks /query /tn "BlogEngine" >nul 2>&1 if errorlevel 1 ( - schtasks /create /tn "BlogEngine" /tr "\"%PYTHON_PATH%\" \"%SCRIPT_PATH%\"" /sc onlogon /rl highest /f - echo [OK] BlogEngine registered in Windows Task Scheduler + schtasks /create /tn "BlogEngine" /tr "\"%BLOG_CMD%\" scheduler" /sc onlogon /rl highest /f + echo [OK] BlogEngine registered in Windows Task Scheduler (blog scheduler) ) else ( echo [INFO] BlogEngine task already registered. ) @@ -58,7 +69,10 @@ echo. echo Next steps: echo 1. Open .env and fill in all API keys echo 2. Run scripts\get_token.py to get Google OAuth token -echo 3. Update BLOG_MAIN_ID in config\blogs.json with your actual blog ID -echo 4. Start scheduler with: python bots\scheduler.py +echo (Blogger + Search Console + YouTube OAuth) +echo 3. Update BLOG_MAIN_ID in .env with your Blogger blog ID +echo 4. Start scheduler: blog scheduler +echo 5. Start dashboard: blog server (http://localhost:8080) +echo 6. CLI status check: blog status echo. pause