feat: v3.2 나머지 미완성 기능 구현

[Instagram Reels] Phase 2 완성
- instagram_bot.py: publish_reels() 추가 (MP4 → Reels API)
  - upload_video_container(), wait_for_video_ready() 구현
  - 로컬 경로 → 공개 URL 자동 변환 (image_host.get_public_video_url())
- scheduler.py: job_distribute_instagram_reels() 추가 (10:30)
- image_host.py: get_public_video_url() + 로컬 비디오 서버 추가
  - VIDEO_HOST_BASE_URL 환경변수 지원 (Tailscale/CDN)

[writer_bot.py] 신규 — 독립 실행형 글쓰기 봇
- api_content.py manual-write 엔드포인트에서 subprocess 호출 가능
- run_pending(): 오늘 날짜 미처리 글감 자동 처리
- run_from_topic(): 직접 주제 지정
- run_from_file(): JSON 파일 지정
- CLI: python bots/writer_bot.py [--topic "..." | --file path.json | --limit N]

[보조 시스템 신규] v3.1 CLI + Assist 모드
- blog.cmd: venv Python 경유 Windows 런처
- blog_runtime.py + runtime_guard.py: 실행 진입점 + venv 검증
- blog_engine_cli.py: 대시보드 API 기반 CLI (blog status, blog review 등)
- bots/assist_bot.py: URL 기반 수동 어시스트 파이프라인
- dashboard/backend/api_assist.py + frontend/Assist.jsx: 수동모드 탭

[engine_loader.py] v3.1 개선
- OpenClawWriter: --json 플래그 + payloads 파싱 + plain text 폴백
- ClaudeWebWriter: Playwright 쿠키 세션 (Cloudflare 차단으로 현재 비활성)
- GeminiWebWriter: gemini-webapi 비공식 클라이언트

[scheduler.py] v3.1 개선
- _call_openclaw(): 플레이스홀더 → EngineLoader 실제 호출
- _build_openclaw_prompt(): 구조화된 HTML 원고 프롬프트
- data/originals/: 원본 article JSON 저장 경로 추가

[설정/환경] 정비
- .env.example: SEEDANCE/ELEVENLABS/GEMINI/RUNWAY 복원
  + VIDEO_HOST_BASE_URL, GEMINI_WEB_* , REMOTE_CLAUDE_POLLING_ENABLED 추가
- scripts/setup.bat: data/originals, outputs, assist, novels, config/novels
  디렉토리 생성 + 폰트 다운로드 + blog.cmd 기반 Task Scheduler 등록
- requirements.txt: fastapi, uvicorn, python-multipart 추가

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
sinmb79
2026-03-28 17:12:39 +09:00
parent 213f57b52d
commit 392c2e13f1
26 changed files with 2296 additions and 98 deletions

View File

@@ -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

View File

@@ -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

9
blog.cmd Normal file
View File

@@ -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" %*

367
blog_engine_cli.py Normal file
View File

@@ -0,0 +1,367 @@
"""
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()

63
blog_runtime.py Normal file
View File

@@ -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()

313
bots/assist_bot.py Normal file
View File

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

View File

@@ -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,
])

View File

@@ -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,
])

View File

@@ -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:

View File

@@ -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:
"""
카드 이미지를 인스타그램 피드에 게시.

View File

@@ -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__})")

View File

@@ -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

View File

@@ -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---
<h2>...</h2> 형식의 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',

271
bots/writer_bot.py Normal file
View File

@@ -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---
<h2>...</h2> 형식의 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()

View File

@@ -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 영상 생성"
}
}
}

View File

@@ -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://<tailscale-ip>:8080
```

View File

@@ -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}

View File

@@ -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}

View File

@@ -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():

View File

@@ -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 },

View File

@@ -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: <Clock size={14} />,
fetching: <Loader size={14} className="spin" />,
generating: <Loader size={14} className="spin" />,
awaiting: <Upload size={14} />,
assembling: <Loader size={14} className="spin" />,
ready: <CheckCircle size={14} />,
error: <AlertCircle size={14} />,
}
function CopyBtn({ text }) {
const [copied, setCopied] = useState(false)
const copy = () => {
navigator.clipboard.writeText(text)
setCopied(true)
setTimeout(() => setCopied(false), 1500)
}
return (
<button onClick={copy} style={{
background: 'none', border: '1px solid #333', borderRadius: 4,
color: copied ? '#3a7d5c' : '#888', cursor: 'pointer',
padding: '2px 8px', fontSize: 11, display: 'flex', alignItems: 'center', gap: 4
}}>
<Copy size={11} /> {copied ? '복사됨' : '복사'}
</button>
)
}
function PromptCard({ prompt, index }) {
return (
<div style={{
background: '#0f0f14', border: '1px solid #2a2a32',
borderRadius: 8, padding: 12, marginBottom: 8
}}>
<div style={{ display: 'flex', justifyContent: 'space-between', marginBottom: 6 }}>
<span style={{ fontSize: 11, color: '#c8a84e', fontWeight: 600 }}>
<Image size={11} style={{ display: 'inline', marginRight: 4 }} />
이미지 #{index + 1} {prompt.purpose}
</span>
<CopyBtn text={prompt.en} />
</div>
<div style={{ fontSize: 12, color: '#b0b0a8', marginBottom: 4 }}>
<span style={{ color: '#666', marginRight: 6 }}>KO</span>{prompt.ko}
</div>
<div style={{ fontSize: 12, color: '#e0e0d8' }}>
<span style={{ color: '#666', marginRight: 6 }}>EN</span>{prompt.en}
</div>
</div>
)
}
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 (
<div
onDragOver={e => { 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',
}}
>
<input ref={inputRef} type="file" multiple accept="image/*,video/*"
style={{ display: 'none' }}
onChange={e => handleFiles([...e.target.files])} />
{uploading
? <><Loader size={20} style={{ color: '#c8a84e', marginBottom: 6 }} /><div style={{ color: '#888', fontSize: 12 }}>업로드 ...</div></>
: <>
<Upload size={20} style={{ color: '#555', marginBottom: 6 }} />
<div style={{ color: '#888', fontSize: 12 }}>이미지 / 영상 드래그 드롭 또는 클릭하여 선택</div>
<div style={{ color: '#555', fontSize: 11, marginTop: 4 }}>JPG, PNG, WebP, MP4, MOV 지원</div>
</>
}
</div>
)
}
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 (
<div style={{
background: '#111116', border: '1px solid #222228',
borderRadius: 10, marginBottom: 12, overflow: 'hidden'
}}>
{/* 헤더 */}
<div style={{
display: 'flex', alignItems: 'center', gap: 10,
padding: '12px 16px', cursor: 'pointer',
}} onClick={() => setOpen(o => !o)}>
<div style={{ color, display: 'flex', alignItems: 'center' }}>{icon}</div>
<div style={{ flex: 1, minWidth: 0 }}>
<div style={{ fontSize: 13, fontWeight: 600, color: '#e0e0d8', overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap' }}>
{session.title || session.url}
</div>
<div style={{ fontSize: 11, color: '#555', marginTop: 2 }}>
{new Date(session.created_at).toLocaleString('ko-KR')}
{' · '}
<span style={{ color }}>{session.status_label || session.status}</span>
{assets.length > 0 && <span style={{ color: '#3a7d5c' }}> · 에셋 {assets.length}</span>}
</div>
</div>
<div style={{ display: 'flex', gap: 6, alignItems: 'center' }}>
<button onClick={e => { e.stopPropagation(); refresh() }}
style={{ background: 'none', border: 'none', color: '#555', cursor: 'pointer', padding: 4 }}>
<RefreshCw size={13} className={refreshing ? 'spin' : ''} />
</button>
<button onClick={e => { e.stopPropagation(); onDelete(session.session_id) }}
style={{ background: 'none', border: 'none', color: '#555', cursor: 'pointer', padding: 4 }}>
<Trash2 size={13} />
</button>
{open ? <ChevronUp size={14} color="#555" /> : <ChevronDown size={14} color="#555" />}
</div>
</div>
{/* 본문 */}
{open && (
<div style={{ borderTop: '1px solid #1a1a20', padding: 16 }}>
{session.status === 'error' && (
<div style={{ background: '#1a0808', border: '1px solid #bf3a3a', borderRadius: 6, padding: 10, marginBottom: 12, fontSize: 12, color: '#bf3a3a' }}>
{session.error}
</div>
)}
{session.body_preview && (
<div style={{ fontSize: 12, color: '#666', marginBottom: 14, lineHeight: 1.6 }}>
{session.body_preview}
</div>
)}
{/* 프롬프트 섹션 */}
{imagePrompts.length > 0 && (
<div style={{ marginBottom: 16 }}>
<div style={{ fontSize: 12, color: '#888', fontWeight: 600, marginBottom: 8, display: 'flex', alignItems: 'center', gap: 6 }}>
<Image size={13} /> 이미지 프롬프트
</div>
{imagePrompts.map((p, i) => <PromptCard key={i} prompt={p} index={i} />)}
</div>
)}
{videoPrompt && (
<div style={{ marginBottom: 16 }}>
<div style={{ fontSize: 12, color: '#888', fontWeight: 600, marginBottom: 8, display: 'flex', alignItems: 'center', gap: 6 }}>
<Video size={13} /> 영상 프롬프트
</div>
<div style={{ background: '#0f0f14', border: '1px solid #2a2a32', borderRadius: 8, padding: 12 }}>
<div style={{ display: 'flex', justifyContent: 'space-between', marginBottom: 6 }}>
<span style={{ fontSize: 11, color: '#4a5abf' }}>Sora / Runway / Veo</span>
<CopyBtn text={videoPrompt.en} />
</div>
<div style={{ fontSize: 12, color: '#b0b0a8', marginBottom: 4 }}>
<span style={{ color: '#666', marginRight: 6 }}>KO</span>{videoPrompt.ko}
</div>
<div style={{ fontSize: 12, color: '#e0e0d8' }}>
<span style={{ color: '#666', marginRight: 6 }}>EN</span>{videoPrompt.en}
</div>
</div>
</div>
)}
{narration && (
<div style={{ marginBottom: 16 }}>
<div style={{ fontSize: 12, color: '#888', fontWeight: 600, marginBottom: 8, display: 'flex', alignItems: 'center', gap: 6 }}>
<FileText size={13} /> 나레이션 스크립트
</div>
<div style={{ background: '#0f0f14', border: '1px solid #2a2a32', borderRadius: 8, padding: 12 }}>
<div style={{ display: 'flex', justifyContent: 'flex-end', marginBottom: 6 }}>
<CopyBtn text={narration} />
</div>
<div style={{ fontSize: 12, color: '#e0e0d8', lineHeight: 1.7, whiteSpace: 'pre-wrap' }}>
{narration}
</div>
</div>
</div>
)}
{/* 에셋 업로드 */}
{['awaiting', 'ready', 'generating'].includes(session.status) && (
<div style={{ marginBottom: 16 }}>
<div style={{ fontSize: 12, color: '#888', fontWeight: 600, marginBottom: 8, display: 'flex', alignItems: 'center', gap: 6 }}>
<Upload size={13} /> 에셋 제공
</div>
<AssetDropZone sessionId={session.session_id} onUploaded={refresh} />
</div>
)}
{/* 등록된 에셋 */}
{assets.length > 0 && (
<div>
<div style={{ fontSize: 12, color: '#888', fontWeight: 600, marginBottom: 8 }}>
등록된 에셋 ({assets.length})
</div>
<div style={{ display: 'flex', flexWrap: 'wrap', gap: 8 }}>
{assets.map((a, i) => (
<div key={i} style={{
background: '#0f0f14', border: '1px solid #2a2a32',
borderRadius: 6, padding: '6px 10px', fontSize: 11,
display: 'flex', alignItems: 'center', gap: 6
}}>
{a.type === 'video' ? <Video size={11} color="#4a5abf" /> : <Image size={11} color="#3a7d5c" />}
<span style={{ color: '#b0b0a8', maxWidth: 140, overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap' }}>
{a.filename}
</span>
<button onClick={() => removeAsset(a.filename)}
style={{ background: 'none', border: 'none', color: '#555', cursor: 'pointer', padding: 0 }}>
<X size={11} />
</button>
</div>
))}
</div>
</div>
)}
</div>
)}
</div>
)
}
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 (
<div style={{ padding: '24px 28px', maxWidth: 820, margin: '0 auto' }}>
{/* 헤더 */}
<div style={{ marginBottom: 24 }}>
<h2 style={{ fontSize: 20, fontWeight: 700, color: '#e0e0d8', margin: 0 }}>
수동(어시스트) 모드
</h2>
<p style={{ fontSize: 13, color: '#666', marginTop: 6 }}>
직접 작성한 블로그 URL을 입력하면 시스템이 이미지·영상 프롬프트를 생성합니다.
생성한 에셋을 업로드하면 쇼츠 조립·배포 파이프라인으로 연결됩니다.
</p>
</div>
{/* URL 입력 */}
<div style={{
background: '#111116', border: '1px solid #222228',
borderRadius: 10, padding: 16, marginBottom: 16
}}>
<div style={{ fontSize: 12, color: '#888', marginBottom: 8, fontWeight: 600 }}>
<Link2 size={12} style={{ display: 'inline', marginRight: 6 }} />
블로그 URL
</div>
<div style={{ display: 'flex', gap: 8 }}>
<input
value={url}
onChange={e => 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',
}}
/>
<button
onClick={submit}
disabled={loading || !url.trim()}
style={{
background: loading ? '#333' : '#c8a84e',
color: loading ? '#888' : '#0a0a0d',
border: 'none', borderRadius: 6, padding: '8px 16px',
fontWeight: 700, fontSize: 13, cursor: loading ? 'default' : 'pointer',
display: 'flex', alignItems: 'center', gap: 6,
}}
>
{loading ? <><Loader size={13} className="spin" /> 분석 </> : <><Plus size={13} /> 분석 시작</>}
</button>
</div>
</div>
{/* inbox 폴더 안내 */}
{inboxPath && (
<div style={{
background: '#0d0d10', border: '1px solid #1e2a1e',
borderRadius: 8, padding: '10px 14px', marginBottom: 20,
display: 'flex', alignItems: 'center', gap: 10
}}>
<FolderOpen size={14} color="#3a7d5c" />
<div>
<span style={{ fontSize: 11, color: '#3a7d5c', fontWeight: 600 }}>폴더 드롭 경로</span>
<span style={{ fontSize: 11, color: '#666', marginLeft: 8 }}>{inboxPath}</span>
</div>
<div style={{ fontSize: 11, color: '#555', marginLeft: 'auto' }}>
파일명 8자리에 세션 ID를 포함하면 자동 연결됩니다
</div>
</div>
)}
{/* 세션 목록 */}
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center', marginBottom: 12 }}>
<span style={{ fontSize: 13, color: '#888', fontWeight: 600 }}>
세션 목록 ({sessions.length})
</span>
<button onClick={load} style={{ background: 'none', border: 'none', color: '#555', cursor: 'pointer', display: 'flex', alignItems: 'center', gap: 4, fontSize: 12 }}>
<RefreshCw size={12} /> 새로고침
</button>
</div>
{sessions.length === 0
? <div style={{ textAlign: 'center', color: '#444', padding: '40px 0', fontSize: 13 }}>
아직 세션이 없습니다. URL을 입력해 시작하세요.
</div>
: sessions.map(s => (
<SessionCard key={s.session_id} session={s} onDelete={deleteSession} />
))
}
<style>{`
.spin { animation: spin 1s linear infinite; }
@keyframes spin { from { transform: rotate(0deg); } to { transform: rotate(360deg); } }
`}</style>
</div>
)
}

View File

@@ -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

View File

@@ -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"

View File

@@ -3,6 +3,9 @@ google-api-python-client
google-auth-oauthlib
google-auth-httplib2
python-dotenv
fastapi
uvicorn
python-multipart
apscheduler
requests
beautifulsoup4

107
runtime_guard.py Normal file
View File

@@ -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"

View File

@@ -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