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:
26
.env.example
26
.env.example
@@ -30,7 +30,7 @@ IMAGE_MODE=manual
|
|||||||
OPENAI_API_KEY=
|
OPENAI_API_KEY=
|
||||||
|
|
||||||
# 블로그 사이트 URL (Search Console 등록용)
|
# 블로그 사이트 URL (Search Console 등록용)
|
||||||
BLOG_SITE_URL=
|
BLOG_SITE_URL=https://www.the4thpath.com/
|
||||||
|
|
||||||
# ─── v3: 멀티플랫폼 배포 ──────────────────────────────
|
# ─── v3: 멀티플랫폼 배포 ──────────────────────────────
|
||||||
|
|
||||||
@@ -76,16 +76,34 @@ TIKTOK_OPEN_ID=
|
|||||||
# YouTube Studio > 채널 > 고급 설정에서 채널 ID 확인
|
# YouTube Studio > 채널 > 고급 설정에서 채널 ID 확인
|
||||||
YOUTUBE_CHANNEL_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 시네마틱 영상 생성 (소설 쇼츠 권장)
|
# Seedance 2.0 — AI 시네마틱 영상 생성 (소설 쇼츠 권장)
|
||||||
# https://seedance2.ai/
|
# https://seedance2.ai/
|
||||||
SEEDANCE_API_KEY=
|
SEEDANCE_API_KEY=
|
||||||
# ElevenLabs — 고품질 한국어 TTS
|
# ElevenLabs — 고품질 한국어 TTS
|
||||||
# https://elevenlabs.io/
|
# https://elevenlabs.io/
|
||||||
ELEVENLABS_API_KEY=
|
ELEVENLABS_API_KEY=
|
||||||
# Google Gemini — 글쓰기 대체 / Veo 영상
|
# Google Gemini — 글쓰기 대체 엔진 / Veo 영상 (engine.json: provider="gemini")
|
||||||
# https://aistudio.google.com/
|
# https://aistudio.google.com/
|
||||||
GEMINI_API_KEY=
|
GEMINI_API_KEY=
|
||||||
# Runway Gen-3 — AI 영상 생성
|
# Runway Gen-3 — AI 영상 생성 (engine.json: provider="runway")
|
||||||
# https://runwayml.com/
|
# https://runwayml.com/
|
||||||
RUNWAY_API_KEY=
|
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
|
||||||
|
|||||||
15
README.md
15
README.md
@@ -286,18 +286,27 @@ python scripts\get_token.py
|
|||||||
|
|
||||||
### 스케줄러 시작 (권장)
|
### 스케줄러 시작 (권장)
|
||||||
|
|
||||||
|
안전한 기본 진입점은 프로젝트 venv Python + `blog_runtime.py` 입니다.
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
venv\Scripts\activate
|
venv\Scripts\python.exe blog_runtime.py scheduler
|
||||||
python bots\scheduler.py
|
|
||||||
```
|
```
|
||||||
|
|
||||||
백그라운드 실행 (Windows):
|
백그라운드 실행 (Windows):
|
||||||
```bash
|
```bash
|
||||||
pythonw bots\scheduler.py
|
venv\Scripts\python.exe blog_runtime.py scheduler
|
||||||
```
|
```
|
||||||
|
|
||||||
Windows 작업 스케줄러를 통해 PC 시작 시 자동 실행되도록 `setup.bat`이 등록합니다.
|
Windows 작업 스케줄러를 통해 PC 시작 시 자동 실행되도록 `setup.bat`이 등록합니다.
|
||||||
|
|
||||||
|
### 대시보드 시작
|
||||||
|
|
||||||
|
```bash
|
||||||
|
venv\Scripts\python.exe blog_runtime.py server
|
||||||
|
```
|
||||||
|
|
||||||
|
`blog.cmd` 역시 내부적으로 같은 런처를 사용합니다.
|
||||||
|
|
||||||
### 개별 봇 단독 실행
|
### 개별 봇 단독 실행
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
|
|||||||
9
blog.cmd
Normal file
9
blog.cmd
Normal 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
367
blog_engine_cli.py
Normal 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
63
blog_runtime.py
Normal 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
313
bots/assist_bot.py
Normal 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
|
||||||
@@ -649,11 +649,12 @@ def burn_subtitles(video_mp4: str, srt_path: str, output_mp4: str) -> bool:
|
|||||||
'Alignment=2,'
|
'Alignment=2,'
|
||||||
'Bold=1'
|
'Bold=1'
|
||||||
)
|
)
|
||||||
# srt 경로에서 역슬래시 → 슬래시 (ffmpeg 호환)
|
# Windows 경로는 subtitles 필터에서 옵션 구분자(:)로 오인될 수 있어
|
||||||
srt_esc = str(srt_path).replace('\\', '/').replace(':', '\\:')
|
# filename=... 형태로 명시하고 슬래시/콜론만 ffmpeg 호환 형태로 정규화한다.
|
||||||
|
srt_esc = str(srt_path).replace('\\', '/').replace(':', '\\:').replace("'", r"\'")
|
||||||
return _run_ffmpeg([
|
return _run_ffmpeg([
|
||||||
'-i', video_mp4,
|
'-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',
|
'-c:v', 'libx264', '-c:a', 'copy',
|
||||||
output_mp4,
|
output_mp4,
|
||||||
])
|
])
|
||||||
|
|||||||
@@ -251,10 +251,10 @@ class FFmpegSlidesEngine(VideoEngine):
|
|||||||
'Alignment=2,'
|
'Alignment=2,'
|
||||||
'Bold=1'
|
'Bold=1'
|
||||||
)
|
)
|
||||||
srt_esc = str(srt_path).replace('\\', '/').replace(':', '\\:')
|
srt_esc = str(srt_path).replace('\\', '/').replace(':', '\\:').replace("'", r"\'")
|
||||||
return self._run_ffmpeg([
|
return self._run_ffmpeg([
|
||||||
'-i', video_mp4,
|
'-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',
|
'-c:v', 'libx264', '-c:a', 'copy',
|
||||||
output_mp4,
|
output_mp4,
|
||||||
])
|
])
|
||||||
|
|||||||
@@ -215,6 +215,86 @@ def get_public_url(image_path: str) -> str:
|
|||||||
return ''
|
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__':
|
if __name__ == '__main__':
|
||||||
import sys
|
import sys
|
||||||
if len(sys.argv) > 1:
|
if len(sys.argv) > 1:
|
||||||
|
|||||||
@@ -142,6 +142,104 @@ def publish_container(container_id: str) -> str:
|
|||||||
return ''
|
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:
|
def publish_card(article: dict, image_path_or_url: str) -> bool:
|
||||||
"""
|
"""
|
||||||
카드 이미지를 인스타그램 피드에 게시.
|
카드 이미지를 인스타그램 피드에 게시.
|
||||||
|
|||||||
@@ -96,28 +96,59 @@ class ClaudeWriter(BaseWriter):
|
|||||||
|
|
||||||
|
|
||||||
class OpenClawWriter(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):
|
def __init__(self, cfg: dict):
|
||||||
self.agent_name = cfg.get('agent_name', 'blog-writer')
|
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:
|
def write(self, prompt: str, system: str = '') -> str:
|
||||||
try:
|
try:
|
||||||
cmd = ['openclaw', 'run', self.agent_name, '--prompt', prompt]
|
message = f"{system}\n\n{prompt}".strip() if system else prompt
|
||||||
if system:
|
cmd = [
|
||||||
cmd += ['--system', system]
|
self._CLI, 'agent',
|
||||||
|
'--agent', self.agent_name,
|
||||||
|
'--message', message,
|
||||||
|
'--json',
|
||||||
|
]
|
||||||
result = subprocess.run(
|
result = subprocess.run(
|
||||||
cmd,
|
cmd,
|
||||||
capture_output=True,
|
capture_output=True,
|
||||||
text=True,
|
|
||||||
timeout=self.timeout,
|
timeout=self.timeout,
|
||||||
encoding='utf-8',
|
shell=False,
|
||||||
)
|
)
|
||||||
|
stderr_str = result.stderr.decode('utf-8', errors='replace').strip()
|
||||||
if result.returncode != 0:
|
if result.returncode != 0:
|
||||||
logger.error(f"OpenClawWriter 오류: {result.stderr[:300]}")
|
logger.error(f"OpenClawWriter returncode={result.returncode} stderr={stderr_str[:300]}")
|
||||||
return ''
|
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:
|
except subprocess.TimeoutExpired:
|
||||||
logger.error(f"OpenClawWriter 타임아웃 ({self.timeout}초)")
|
logger.error(f"OpenClawWriter 타임아웃 ({self.timeout}초)")
|
||||||
return ''
|
return ''
|
||||||
@@ -163,6 +194,128 @@ class GeminiWriter(BaseWriter):
|
|||||||
return ''
|
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 구현체 ─────────────────────────────────────────
|
# ─── TTS 구현체 ─────────────────────────────────────────
|
||||||
|
|
||||||
class GoogleCloudTTS(BaseTTS):
|
class GoogleCloudTTS(BaseTTS):
|
||||||
@@ -458,6 +611,8 @@ class EngineLoader:
|
|||||||
'claude': ClaudeWriter,
|
'claude': ClaudeWriter,
|
||||||
'openclaw': OpenClawWriter,
|
'openclaw': OpenClawWriter,
|
||||||
'gemini': GeminiWriter,
|
'gemini': GeminiWriter,
|
||||||
|
'claude_web': ClaudeWebWriter,
|
||||||
|
'gemini_web': GeminiWebWriter,
|
||||||
}
|
}
|
||||||
cls = writers.get(provider, ClaudeWriter)
|
cls = writers.get(provider, ClaudeWriter)
|
||||||
logger.info(f"Writer 로드: {provider} ({cls.__name__})")
|
logger.info(f"Writer 로드: {provider} ({cls.__name__})")
|
||||||
|
|||||||
@@ -18,6 +18,7 @@ load_dotenv()
|
|||||||
BASE_DIR = Path(__file__).parent.parent
|
BASE_DIR = Path(__file__).parent.parent
|
||||||
TELEGRAM_BOT_TOKEN = os.getenv('TELEGRAM_BOT_TOKEN', '')
|
TELEGRAM_BOT_TOKEN = os.getenv('TELEGRAM_BOT_TOKEN', '')
|
||||||
TELEGRAM_CHAT_ID = int(os.getenv('TELEGRAM_CHAT_ID', '0'))
|
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(
|
logging.basicConfig(
|
||||||
level=logging.INFO,
|
level=logging.INFO,
|
||||||
@@ -90,6 +91,10 @@ async def cmd_help(update: Update, context: ContextTypes.DEFAULT_TYPE):
|
|||||||
|
|
||||||
|
|
||||||
def main():
|
def main():
|
||||||
|
if not REMOTE_CLAUDE_POLLING_ENABLED:
|
||||||
|
logger.info("Remote Claude Bot polling 비활성화 — 기본 운영은 scheduler.py Telegram 리스너 사용")
|
||||||
|
return
|
||||||
|
|
||||||
if not TELEGRAM_BOT_TOKEN:
|
if not TELEGRAM_BOT_TOKEN:
|
||||||
logger.error("TELEGRAM_BOT_TOKEN이 없습니다.")
|
logger.error("TELEGRAM_BOT_TOKEN이 없습니다.")
|
||||||
return
|
return
|
||||||
|
|||||||
@@ -12,12 +12,20 @@ from datetime import datetime
|
|||||||
from logging.handlers import RotatingFileHandler
|
from logging.handlers import RotatingFileHandler
|
||||||
from pathlib import Path
|
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 apscheduler.schedulers.asyncio import AsyncIOScheduler
|
||||||
from dotenv import load_dotenv
|
from dotenv import load_dotenv
|
||||||
from telegram import Update
|
from telegram import Update
|
||||||
from telegram.ext import Application, CommandHandler, MessageHandler, filters, ContextTypes
|
from telegram.ext import Application, CommandHandler, MessageHandler, filters, ContextTypes
|
||||||
|
|
||||||
import anthropic
|
import anthropic
|
||||||
|
import re
|
||||||
|
|
||||||
load_dotenv()
|
load_dotenv()
|
||||||
|
|
||||||
@@ -120,7 +128,9 @@ def job_ai_writer():
|
|||||||
def _trigger_openclaw_writer():
|
def _trigger_openclaw_writer():
|
||||||
topics_dir = DATA_DIR / 'topics'
|
topics_dir = DATA_DIR / 'topics'
|
||||||
drafts_dir = DATA_DIR / 'drafts'
|
drafts_dir = DATA_DIR / 'drafts'
|
||||||
|
originals_dir = DATA_DIR / 'originals'
|
||||||
drafts_dir.mkdir(exist_ok=True)
|
drafts_dir.mkdir(exist_ok=True)
|
||||||
|
originals_dir.mkdir(exist_ok=True)
|
||||||
today = datetime.now().strftime('%Y%m%d')
|
today = datetime.now().strftime('%Y%m%d')
|
||||||
topic_files = sorted(topics_dir.glob(f'{today}_*.json'))
|
topic_files = sorted(topics_dir.glob(f'{today}_*.json'))
|
||||||
if not topic_files:
|
if not topic_files:
|
||||||
@@ -128,24 +138,110 @@ def _trigger_openclaw_writer():
|
|||||||
return
|
return
|
||||||
for topic_file in topic_files[:3]:
|
for topic_file in topic_files[:3]:
|
||||||
draft_check = drafts_dir / topic_file.name
|
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
|
continue
|
||||||
topic_data = json.loads(topic_file.read_text(encoding='utf-8'))
|
topic_data = json.loads(topic_file.read_text(encoding='utf-8'))
|
||||||
logger.info(f"글 작성 요청: {topic_data.get('topic', '')}")
|
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):
|
def _call_openclaw(topic_data: dict, output_path: Path):
|
||||||
logger.info(f"OpenClaw 호출 (플레이스홀더): {topic_data.get('topic', '')}")
|
logger.info(f"OpenClaw 작성 요청: {topic_data.get('topic', '')}")
|
||||||
# OpenClaw 연동 완료 후 아래 주석 해제:
|
sys.path.insert(0, str(BASE_DIR))
|
||||||
# import subprocess
|
sys.path.insert(0, str(BASE_DIR / 'bots'))
|
||||||
# result = subprocess.run(
|
|
||||||
# ['openclaw', 'run', 'blog-writer', '--input', json.dumps(topic_data)],
|
from engine_loader import EngineLoader
|
||||||
# capture_output=True, text=True
|
from article_parser import parse_output
|
||||||
# )
|
|
||||||
# output = result.stdout
|
system, prompt = _build_openclaw_prompt(topic_data)
|
||||||
topic_data['_pending_openclaw'] = True
|
writer = EngineLoader().get_writer()
|
||||||
output_path.write_text(json.dumps(topic_data, ensure_ascii=False, indent=2), encoding='utf-8')
|
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():
|
def job_convert():
|
||||||
@@ -263,6 +359,37 @@ def _distribute_instagram():
|
|||||||
logger.info(f"Instagram 발행 완료: {card_file.name}")
|
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():
|
def job_distribute_x():
|
||||||
"""11:00 — X 스레드 게시"""
|
"""11:00 — X 스레드 게시"""
|
||||||
if not _publish_enabled:
|
if not _publish_enabled:
|
||||||
@@ -810,7 +937,9 @@ def setup_scheduler() -> AsyncIOScheduler:
|
|||||||
scheduler.add_job(lambda: job_publish(1), 'cron',
|
scheduler.add_job(lambda: job_publish(1), 'cron',
|
||||||
hour=9, minute=0, id='blog_publish') # 09:00 블로그
|
hour=9, minute=0, id='blog_publish') # 09:00 블로그
|
||||||
scheduler.add_job(job_distribute_instagram, 'cron',
|
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',
|
scheduler.add_job(job_distribute_x, 'cron',
|
||||||
hour=11, minute=0, id='x_dist') # 11:00 X
|
hour=11, minute=0, id='x_dist') # 11:00 X
|
||||||
scheduler.add_job(job_distribute_tiktok, 'cron',
|
scheduler.add_job(job_distribute_tiktok, 'cron',
|
||||||
|
|||||||
271
bots/writer_bot.py
Normal file
271
bots/writer_bot.py
Normal 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()
|
||||||
@@ -1,31 +1,39 @@
|
|||||||
{
|
{
|
||||||
"_comment": "The 4th Path 블로그 자동 수익 엔진 — 엔진 설정 (v3)",
|
"_comment": "The 4th Path 블로그 자동 수익 엔진 — 엔진 설정 (v3)",
|
||||||
"_updated": "2026-03-26",
|
"_updated": "2026-03-26",
|
||||||
|
|
||||||
"writing": {
|
"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": {
|
"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": {
|
"claude": {
|
||||||
"api_key_env": "ANTHROPIC_API_KEY",
|
"api_key_env": "ANTHROPIC_API_KEY",
|
||||||
"model": "claude-opus-4-5",
|
"model": "claude-opus-4-6",
|
||||||
"max_tokens": 4096,
|
"max_tokens": 4096,
|
||||||
"temperature": 0.7
|
"temperature": 0.7
|
||||||
},
|
},
|
||||||
"openclaw": {
|
|
||||||
"agent_name": "blog-writer",
|
|
||||||
"timeout": 120
|
|
||||||
},
|
|
||||||
"gemini": {
|
"gemini": {
|
||||||
"api_key_env": "GEMINI_API_KEY",
|
"api_key_env": "GEMINI_API_KEY",
|
||||||
"model": "gemini-2.0-flash",
|
"model": "gemini-2.5-pro",
|
||||||
"max_tokens": 4096,
|
"max_tokens": 4096,
|
||||||
"temperature": 0.7
|
"temperature": 0.7
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
|
||||||
"tts": {
|
"tts": {
|
||||||
"provider": "gtts",
|
"provider": "openai",
|
||||||
"options": {
|
"options": {
|
||||||
"google_cloud": {
|
"google_cloud": {
|
||||||
"api_key_env": "GOOGLE_TTS_API_KEY",
|
"api_key_env": "GOOGLE_TTS_API_KEY",
|
||||||
@@ -52,9 +60,8 @@
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
|
||||||
"image_generation": {
|
"image_generation": {
|
||||||
"provider": "dalle",
|
"provider": "external",
|
||||||
"options": {
|
"options": {
|
||||||
"dalle": {
|
"dalle": {
|
||||||
"api_key_env": "OPENAI_API_KEY",
|
"api_key_env": "OPENAI_API_KEY",
|
||||||
@@ -67,9 +74,8 @@
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
|
||||||
"video_generation": {
|
"video_generation": {
|
||||||
"provider": "ffmpeg_slides",
|
"provider": "sora",
|
||||||
"options": {
|
"options": {
|
||||||
"ffmpeg_slides": {
|
"ffmpeg_slides": {
|
||||||
"resolution": "1080x1920",
|
"resolution": "1080x1920",
|
||||||
@@ -108,7 +114,6 @@
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
|
||||||
"publishing": {
|
"publishing": {
|
||||||
"blogger": {
|
"blogger": {
|
||||||
"enabled": true,
|
"enabled": true,
|
||||||
@@ -119,7 +124,11 @@
|
|||||||
"channel_id_env": "YOUTUBE_CHANNEL_ID",
|
"channel_id_env": "YOUTUBE_CHANNEL_ID",
|
||||||
"category": "shorts",
|
"category": "shorts",
|
||||||
"privacy": "public",
|
"privacy": "public",
|
||||||
"tags": ["쇼츠", "AI", "the4thpath"]
|
"tags": [
|
||||||
|
"쇼츠",
|
||||||
|
"AI",
|
||||||
|
"the4thpath"
|
||||||
|
]
|
||||||
},
|
},
|
||||||
"instagram": {
|
"instagram": {
|
||||||
"enabled": false,
|
"enabled": false,
|
||||||
@@ -136,7 +145,6 @@
|
|||||||
"comment": "노벨피아 연재 — 추후 활성화"
|
"comment": "노벨피아 연재 — 추후 활성화"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
|
||||||
"quality_gates": {
|
"quality_gates": {
|
||||||
"gate1_research_min_score": 60,
|
"gate1_research_min_score": 60,
|
||||||
"gate2_writing_min_score": 70,
|
"gate2_writing_min_score": 70,
|
||||||
@@ -146,7 +154,6 @@
|
|||||||
"min_word_count": 300,
|
"min_word_count": 300,
|
||||||
"safety_check": true
|
"safety_check": true
|
||||||
},
|
},
|
||||||
|
|
||||||
"schedule": {
|
"schedule": {
|
||||||
"collector": "07:00",
|
"collector": "07:00",
|
||||||
"writer": "08:00",
|
"writer": "08:00",
|
||||||
@@ -155,7 +162,6 @@
|
|||||||
"youtube_uploader": "10:00",
|
"youtube_uploader": "10:00",
|
||||||
"analytics": "22:00"
|
"analytics": "22:00"
|
||||||
},
|
},
|
||||||
|
|
||||||
"brand": {
|
"brand": {
|
||||||
"name": "The 4th Path",
|
"name": "The 4th Path",
|
||||||
"sub": "Independent Tech Media",
|
"sub": "Independent Tech Media",
|
||||||
@@ -163,11 +169,10 @@
|
|||||||
"url": "the4thpath.com",
|
"url": "the4thpath.com",
|
||||||
"cta": "팔로우하면 매일 이런 정보를 받습니다"
|
"cta": "팔로우하면 매일 이런 정보를 받습니다"
|
||||||
},
|
},
|
||||||
|
|
||||||
"optional_keys": {
|
"optional_keys": {
|
||||||
"SEEDANCE_API_KEY": "Seedance 2.0 AI 영상 생성",
|
"SEEDANCE_API_KEY": "Seedance 2.0 AI 영상 생성",
|
||||||
"ELEVENLABS_API_KEY": "ElevenLabs 고품질 TTS",
|
"ELEVENLABS_API_KEY": "ElevenLabs 고품질 TTS",
|
||||||
"GEMINI_API_KEY": "Google Gemini 글쓰기 / Veo 영상",
|
"GEMINI_API_KEY": "Google Gemini 글쓰기 / Veo 영상",
|
||||||
"RUNWAY_API_KEY": "Runway Gen-3 AI 영상 생성"
|
"RUNWAY_API_KEY": "Runway Gen-3 AI 영상 생성"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -52,7 +52,7 @@ dashboard/
|
|||||||
|
|
||||||
```bash
|
```bash
|
||||||
cd D:/workspace/blog-writer
|
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.bat` 더블클릭
|
||||||
- **개발 모드**: `start_dev.bat` 더블클릭
|
- **개발 모드**: `start_dev.bat` 더블클릭
|
||||||
|
|
||||||
|
두 스크립트 모두 프로젝트 `venv\Scripts\python.exe`가 없으면 즉시 중단합니다.
|
||||||
|
|
||||||
### Linux/Mac
|
### Linux/Mac
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
@@ -84,7 +86,7 @@ bash dashboard/start.sh dev
|
|||||||
```bash
|
```bash
|
||||||
# 터미널 1 — 백엔드
|
# 터미널 1 — 백엔드
|
||||||
cd D:/workspace/blog-writer
|
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 — 프론트엔드 (개발)
|
# 터미널 2 — 프론트엔드 (개발)
|
||||||
cd D:/workspace/blog-writer/dashboard/frontend
|
cd D:/workspace/blog-writer/dashboard/frontend
|
||||||
@@ -117,6 +119,6 @@ npm run build
|
|||||||
|
|
||||||
```bash
|
```bash
|
||||||
# 백엔드를 0.0.0.0으로 바인딩하면 Tailscale IP로 접속 가능
|
# 백엔드를 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
|
# 접속: http://<tailscale-ip>:8080
|
||||||
```
|
```
|
||||||
|
|||||||
116
dashboard/backend/api_assist.py
Normal file
116
dashboard/backend/api_assist.py
Normal 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}
|
||||||
@@ -4,13 +4,14 @@ Content 탭 API — 칸반 보드, 승인/거부, 수동 트리거
|
|||||||
"""
|
"""
|
||||||
import json
|
import json
|
||||||
import subprocess
|
import subprocess
|
||||||
import sys
|
|
||||||
from datetime import datetime
|
from datetime import datetime
|
||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
|
|
||||||
from fastapi import APIRouter, HTTPException
|
from fastapi import APIRouter, HTTPException
|
||||||
from pydantic import BaseModel
|
from pydantic import BaseModel
|
||||||
|
|
||||||
|
from runtime_guard import project_python_path, run_with_project_python
|
||||||
|
|
||||||
BASE_DIR = Path(__file__).parent.parent.parent
|
BASE_DIR = Path(__file__).parent.parent.parent
|
||||||
DATA_DIR = BASE_DIR / "data"
|
DATA_DIR = BASE_DIR / "data"
|
||||||
|
|
||||||
@@ -115,8 +116,17 @@ async def reject_content(item_id: str):
|
|||||||
@router.post("/manual-write")
|
@router.post("/manual-write")
|
||||||
async def manual_write(req: WriteRequest):
|
async def manual_write(req: WriteRequest):
|
||||||
"""collector_bot + writer_bot 수동 트리거"""
|
"""collector_bot + writer_bot 수동 트리거"""
|
||||||
python = sys.executable
|
|
||||||
bots_dir = BASE_DIR / "bots"
|
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 = []
|
results = []
|
||||||
|
|
||||||
@@ -124,8 +134,8 @@ async def manual_write(req: WriteRequest):
|
|||||||
collector = bots_dir / "collector_bot.py"
|
collector = bots_dir / "collector_bot.py"
|
||||||
if collector.exists():
|
if collector.exists():
|
||||||
try:
|
try:
|
||||||
result = subprocess.run(
|
result = run_with_project_python(
|
||||||
[python, str(collector)],
|
[str(collector)],
|
||||||
capture_output=True,
|
capture_output=True,
|
||||||
text=True,
|
text=True,
|
||||||
timeout=120,
|
timeout=120,
|
||||||
@@ -135,22 +145,23 @@ async def manual_write(req: WriteRequest):
|
|||||||
results.append({
|
results.append({
|
||||||
"step": "collector",
|
"step": "collector",
|
||||||
"success": result.returncode == 0,
|
"success": result.returncode == 0,
|
||||||
|
"python": str(python),
|
||||||
"output": result.stdout[-500:] if result.stdout else "",
|
"output": result.stdout[-500:] if result.stdout else "",
|
||||||
"error": result.stderr[-300:] if result.stderr else "",
|
"error": result.stderr[-300:] if result.stderr else "",
|
||||||
})
|
})
|
||||||
except subprocess.TimeoutExpired:
|
except subprocess.TimeoutExpired:
|
||||||
results.append({"step": "collector", "success": False, "error": "타임아웃"})
|
results.append({"step": "collector", "success": False, "python": str(python), "error": "타임아웃"})
|
||||||
except Exception as e:
|
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:
|
else:
|
||||||
results.append({"step": "collector", "success": False, "error": "파일 없음"})
|
results.append({"step": "collector", "success": False, "python": str(python), "error": "파일 없음"})
|
||||||
|
|
||||||
# writer_bot 실행
|
# writer_bot 실행
|
||||||
writer = bots_dir / "writer_bot.py"
|
writer = bots_dir / "writer_bot.py"
|
||||||
if writer.exists():
|
if writer.exists():
|
||||||
try:
|
try:
|
||||||
result = subprocess.run(
|
result = run_with_project_python(
|
||||||
[python, str(writer)],
|
[str(writer)],
|
||||||
capture_output=True,
|
capture_output=True,
|
||||||
text=True,
|
text=True,
|
||||||
timeout=300,
|
timeout=300,
|
||||||
@@ -160,14 +171,15 @@ async def manual_write(req: WriteRequest):
|
|||||||
results.append({
|
results.append({
|
||||||
"step": "writer",
|
"step": "writer",
|
||||||
"success": result.returncode == 0,
|
"success": result.returncode == 0,
|
||||||
|
"python": str(python),
|
||||||
"output": result.stdout[-500:] if result.stdout else "",
|
"output": result.stdout[-500:] if result.stdout else "",
|
||||||
"error": result.stderr[-300:] if result.stderr else "",
|
"error": result.stderr[-300:] if result.stderr else "",
|
||||||
})
|
})
|
||||||
except subprocess.TimeoutExpired:
|
except subprocess.TimeoutExpired:
|
||||||
results.append({"step": "writer", "success": False, "error": "타임아웃"})
|
results.append({"step": "writer", "success": False, "python": str(python), "error": "타임아웃"})
|
||||||
except Exception as e:
|
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:
|
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}
|
||||||
|
|||||||
@@ -8,6 +8,13 @@ dashboard/backend/server.py
|
|||||||
import os
|
import os
|
||||||
from pathlib import Path
|
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 import FastAPI
|
||||||
from fastapi.middleware.cors import CORSMiddleware
|
from fastapi.middleware.cors import CORSMiddleware
|
||||||
from fastapi.staticfiles import StaticFiles
|
from fastapi.staticfiles import StaticFiles
|
||||||
@@ -23,6 +30,7 @@ from dashboard.backend import (
|
|||||||
api_tools,
|
api_tools,
|
||||||
api_cost,
|
api_cost,
|
||||||
api_logs,
|
api_logs,
|
||||||
|
api_assist,
|
||||||
)
|
)
|
||||||
|
|
||||||
app = FastAPI(title="The 4th Path — Control Panel", version="1.0.0")
|
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_tools.router, prefix="/api")
|
||||||
app.include_router(api_cost.router, prefix="/api")
|
app.include_router(api_cost.router, prefix="/api")
|
||||||
app.include_router(api_logs.router, prefix="/api")
|
app.include_router(api_logs.router, prefix="/api")
|
||||||
|
app.include_router(api_assist.router, prefix="/api")
|
||||||
|
|
||||||
@app.get("/api/health")
|
@app.get("/api/health")
|
||||||
async def health():
|
async def health():
|
||||||
|
|||||||
@@ -1,15 +1,17 @@
|
|||||||
import React, { useState } from 'react'
|
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 Overview from './pages/Overview.jsx'
|
||||||
import Content from './pages/Content.jsx'
|
import Content from './pages/Content.jsx'
|
||||||
import Analytics from './pages/Analytics.jsx'
|
import Analytics from './pages/Analytics.jsx'
|
||||||
import Novel from './pages/Novel.jsx'
|
import Novel from './pages/Novel.jsx'
|
||||||
import SettingsPage from './pages/Settings.jsx'
|
import SettingsPage from './pages/Settings.jsx'
|
||||||
import Logs from './pages/Logs.jsx'
|
import Logs from './pages/Logs.jsx'
|
||||||
|
import Assist from './pages/Assist.jsx'
|
||||||
|
|
||||||
const TABS = [
|
const TABS = [
|
||||||
{ id: 'overview', label: '개요', icon: LayoutDashboard, component: Overview },
|
{ id: 'overview', label: '개요', icon: LayoutDashboard, component: Overview },
|
||||||
{ id: 'content', label: '콘텐츠', icon: FileText, component: Content },
|
{ id: 'content', label: '콘텐츠', icon: FileText, component: Content },
|
||||||
|
{ id: 'assist', label: '수동모드', icon: UserCheck, component: Assist },
|
||||||
{ id: 'analytics', label: '분석', icon: BarChart2, component: Analytics },
|
{ id: 'analytics', label: '분석', icon: BarChart2, component: Analytics },
|
||||||
{ id: 'novel', label: '소설', icon: BookOpen, component: Novel },
|
{ id: 'novel', label: '소설', icon: BookOpen, component: Novel },
|
||||||
{ id: 'settings', label: '설정', icon: Settings, component: SettingsPage },
|
{ id: 'settings', label: '설정', icon: Settings, component: SettingsPage },
|
||||||
|
|||||||
416
dashboard/frontend/src/pages/Assist.jsx
Normal file
416
dashboard/frontend/src/pages/Assist.jsx
Normal 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>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -9,18 +9,14 @@ echo ================================================
|
|||||||
set SCRIPT_DIR=%~dp0
|
set SCRIPT_DIR=%~dp0
|
||||||
set PROJECT_ROOT=%SCRIPT_DIR%..
|
set PROJECT_ROOT=%SCRIPT_DIR%..
|
||||||
|
|
||||||
:: Python 가상환경 활성화
|
set "PYTHON=%PROJECT_ROOT%\venv\Scripts\python.exe"
|
||||||
if exist "%PROJECT_ROOT%\venv\Scripts\activate.bat" (
|
if not exist "%PYTHON%" (
|
||||||
echo [*] 가상환경 활성화...
|
echo [ERROR] Missing project virtualenv Python: %PYTHON%
|
||||||
call "%PROJECT_ROOT%\venv\Scripts\activate.bat"
|
echo Run scripts\setup.bat or create venv and install requirements first.
|
||||||
) else if exist "%PROJECT_ROOT%\.venv\Scripts\activate.bat" (
|
pause
|
||||||
call "%PROJECT_ROOT%\.venv\Scripts\activate.bat"
|
exit /b 1
|
||||||
)
|
)
|
||||||
|
|
||||||
:: 백엔드 의존성 확인
|
|
||||||
echo [*] FastAPI 의존성 확인...
|
|
||||||
pip install fastapi uvicorn python-dotenv --quiet 2>nul
|
|
||||||
|
|
||||||
:: 프론트엔드 의존성 설치
|
:: 프론트엔드 의존성 설치
|
||||||
if not exist "%SCRIPT_DIR%frontend\node_modules" (
|
if not exist "%SCRIPT_DIR%frontend\node_modules" (
|
||||||
echo [*] npm 패키지 설치 중...
|
echo [*] npm 패키지 설치 중...
|
||||||
@@ -47,6 +43,6 @@ echo 종료하려면 이 창을 닫으세요.
|
|||||||
echo.
|
echo.
|
||||||
|
|
||||||
cd /d "%PROJECT_ROOT%"
|
cd /d "%PROJECT_ROOT%"
|
||||||
python -m uvicorn dashboard.backend.server:app --host 0.0.0.0 --port 8080
|
"%PYTHON%" blog_runtime.py server
|
||||||
|
|
||||||
pause
|
pause
|
||||||
|
|||||||
@@ -9,16 +9,14 @@ echo ================================================
|
|||||||
set SCRIPT_DIR=%~dp0
|
set SCRIPT_DIR=%~dp0
|
||||||
set PROJECT_ROOT=%SCRIPT_DIR%..
|
set PROJECT_ROOT=%SCRIPT_DIR%..
|
||||||
|
|
||||||
:: Python 가상환경 활성화
|
set "PYTHON=%PROJECT_ROOT%\venv\Scripts\python.exe"
|
||||||
if exist "%PROJECT_ROOT%\venv\Scripts\activate.bat" (
|
if not exist "%PYTHON%" (
|
||||||
call "%PROJECT_ROOT%\venv\Scripts\activate.bat"
|
echo [ERROR] Missing project virtualenv Python: %PYTHON%
|
||||||
) else if exist "%PROJECT_ROOT%\.venv\Scripts\activate.bat" (
|
echo Run scripts\setup.bat or create venv and install requirements first.
|
||||||
call "%PROJECT_ROOT%\.venv\Scripts\activate.bat"
|
pause
|
||||||
|
exit /b 1
|
||||||
)
|
)
|
||||||
|
|
||||||
:: 백엔드 의존성 확인
|
|
||||||
pip install fastapi uvicorn python-dotenv --quiet 2>nul
|
|
||||||
|
|
||||||
:: 프론트엔드 의존성 설치
|
:: 프론트엔드 의존성 설치
|
||||||
if not exist "%SCRIPT_DIR%frontend\node_modules" (
|
if not exist "%SCRIPT_DIR%frontend\node_modules" (
|
||||||
echo [*] npm 패키지 설치 중...
|
echo [*] npm 패키지 설치 중...
|
||||||
@@ -27,7 +25,7 @@ if not exist "%SCRIPT_DIR%frontend\node_modules" (
|
|||||||
)
|
)
|
||||||
|
|
||||||
echo [*] 백엔드 시작 중...
|
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 [*] 프론트엔드 개발 서버 시작 중...
|
echo [*] 프론트엔드 개발 서버 시작 중...
|
||||||
start "Vite Frontend" cmd /k "cd /d %SCRIPT_DIR%frontend && npm run dev"
|
start "Vite Frontend" cmd /k "cd /d %SCRIPT_DIR%frontend && npm run dev"
|
||||||
|
|||||||
@@ -3,6 +3,9 @@ google-api-python-client
|
|||||||
google-auth-oauthlib
|
google-auth-oauthlib
|
||||||
google-auth-httplib2
|
google-auth-httplib2
|
||||||
python-dotenv
|
python-dotenv
|
||||||
|
fastapi
|
||||||
|
uvicorn
|
||||||
|
python-multipart
|
||||||
apscheduler
|
apscheduler
|
||||||
requests
|
requests
|
||||||
beautifulsoup4
|
beautifulsoup4
|
||||||
|
|||||||
107
runtime_guard.py
Normal file
107
runtime_guard.py
Normal 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"
|
||||||
@@ -36,16 +36,27 @@ if not exist data\published mkdir data\published
|
|||||||
if not exist data\analytics mkdir data\analytics
|
if not exist data\analytics mkdir data\analytics
|
||||||
if not exist data\images mkdir data\images
|
if not exist data\images mkdir data\images
|
||||||
if not exist data\drafts mkdir data\drafts
|
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 logs mkdir logs
|
||||||
|
if not exist config\novels mkdir config\novels
|
||||||
|
|
||||||
REM Register scheduler.py in Windows Task Scheduler
|
REM Download fonts (Noto Sans KR for card/shorts converter)
|
||||||
set SCRIPT_PATH=%~dp0bots\scheduler.py
|
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
|
set PYTHON_PATH=%~dp0venv\Scripts\pythonw.exe
|
||||||
|
|
||||||
schtasks /query /tn "BlogEngine" >nul 2>&1
|
schtasks /query /tn "BlogEngine" >nul 2>&1
|
||||||
if errorlevel 1 (
|
if errorlevel 1 (
|
||||||
schtasks /create /tn "BlogEngine" /tr "\"%PYTHON_PATH%\" \"%SCRIPT_PATH%\"" /sc onlogon /rl highest /f
|
schtasks /create /tn "BlogEngine" /tr "\"%BLOG_CMD%\" scheduler" /sc onlogon /rl highest /f
|
||||||
echo [OK] BlogEngine registered in Windows Task Scheduler
|
echo [OK] BlogEngine registered in Windows Task Scheduler (blog scheduler)
|
||||||
) else (
|
) else (
|
||||||
echo [INFO] BlogEngine task already registered.
|
echo [INFO] BlogEngine task already registered.
|
||||||
)
|
)
|
||||||
@@ -58,7 +69,10 @@ echo.
|
|||||||
echo Next steps:
|
echo Next steps:
|
||||||
echo 1. Open .env and fill in all API keys
|
echo 1. Open .env and fill in all API keys
|
||||||
echo 2. Run scripts\get_token.py to get Google OAuth token
|
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 (Blogger + Search Console + YouTube OAuth)
|
||||||
echo 4. Start scheduler with: python bots\scheduler.py
|
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.
|
echo.
|
||||||
pause
|
pause
|
||||||
|
|||||||
Reference in New Issue
Block a user