feat: v3.2 나머지 미완성 기능 구현
[Instagram Reels] Phase 2 완성 - instagram_bot.py: publish_reels() 추가 (MP4 → Reels API) - upload_video_container(), wait_for_video_ready() 구현 - 로컬 경로 → 공개 URL 자동 변환 (image_host.get_public_video_url()) - scheduler.py: job_distribute_instagram_reels() 추가 (10:30) - image_host.py: get_public_video_url() + 로컬 비디오 서버 추가 - VIDEO_HOST_BASE_URL 환경변수 지원 (Tailscale/CDN) [writer_bot.py] 신규 — 독립 실행형 글쓰기 봇 - api_content.py manual-write 엔드포인트에서 subprocess 호출 가능 - run_pending(): 오늘 날짜 미처리 글감 자동 처리 - run_from_topic(): 직접 주제 지정 - run_from_file(): JSON 파일 지정 - CLI: python bots/writer_bot.py [--topic "..." | --file path.json | --limit N] [보조 시스템 신규] v3.1 CLI + Assist 모드 - blog.cmd: venv Python 경유 Windows 런처 - blog_runtime.py + runtime_guard.py: 실행 진입점 + venv 검증 - blog_engine_cli.py: 대시보드 API 기반 CLI (blog status, blog review 등) - bots/assist_bot.py: URL 기반 수동 어시스트 파이프라인 - dashboard/backend/api_assist.py + frontend/Assist.jsx: 수동모드 탭 [engine_loader.py] v3.1 개선 - OpenClawWriter: --json 플래그 + payloads 파싱 + plain text 폴백 - ClaudeWebWriter: Playwright 쿠키 세션 (Cloudflare 차단으로 현재 비활성) - GeminiWebWriter: gemini-webapi 비공식 클라이언트 [scheduler.py] v3.1 개선 - _call_openclaw(): 플레이스홀더 → EngineLoader 실제 호출 - _build_openclaw_prompt(): 구조화된 HTML 원고 프롬프트 - data/originals/: 원본 article JSON 저장 경로 추가 [설정/환경] 정비 - .env.example: SEEDANCE/ELEVENLABS/GEMINI/RUNWAY 복원 + VIDEO_HOST_BASE_URL, GEMINI_WEB_* , REMOTE_CLAUDE_POLLING_ENABLED 추가 - scripts/setup.bat: data/originals, outputs, assist, novels, config/novels 디렉토리 생성 + 폰트 다운로드 + blog.cmd 기반 Task Scheduler 등록 - requirements.txt: fastapi, uvicorn, python-multipart 추가 Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
@@ -0,0 +1,313 @@
|
||||
"""
|
||||
bots/assist_bot.py
|
||||
수동(어시스트) 모드 파이프라인
|
||||
|
||||
흐름:
|
||||
1. 사용자 → URL 제공
|
||||
2. 시스템 → URL 파싱 → 콘텐츠 추출
|
||||
3. 시스템 → OpenClaw로 이미지/영상/나레이션 프롬프트 생성
|
||||
4. 사용자 → 웹 AI로 에셋 생성 후 제공 (대시보드 업로드 or inbox 폴더 드롭)
|
||||
5. 시스템 → 에셋 검증 → 배포 파이프라인 연결
|
||||
"""
|
||||
import json
|
||||
import logging
|
||||
import os
|
||||
import re
|
||||
import shutil
|
||||
import subprocess
|
||||
import uuid
|
||||
from datetime import datetime
|
||||
from pathlib import Path
|
||||
from typing import Optional
|
||||
|
||||
import requests
|
||||
from dotenv import load_dotenv
|
||||
|
||||
load_dotenv()
|
||||
|
||||
BASE_DIR = Path(__file__).parent.parent
|
||||
ASSIST_DIR = BASE_DIR / 'data' / 'assist'
|
||||
SESSIONS_DIR = ASSIST_DIR / 'sessions'
|
||||
INBOX_DIR = ASSIST_DIR / 'inbox'
|
||||
LOG_DIR = BASE_DIR / 'logs'
|
||||
|
||||
for _d in [SESSIONS_DIR, INBOX_DIR, LOG_DIR]:
|
||||
_d.mkdir(parents=True, exist_ok=True)
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
if not logger.handlers:
|
||||
_h = logging.FileHandler(LOG_DIR / 'assist_bot.log', encoding='utf-8')
|
||||
_h.setFormatter(logging.Formatter('%(asctime)s [%(levelname)s] %(message)s'))
|
||||
logger.addHandler(_h)
|
||||
logger.addHandler(logging.StreamHandler())
|
||||
logger.setLevel(logging.INFO)
|
||||
|
||||
_CLI = 'openclaw.cmd' if os.name == 'nt' else 'openclaw'
|
||||
|
||||
|
||||
# ─── 상태 상수 ───────────────────────────────────────────
|
||||
|
||||
class S:
|
||||
PENDING = 'pending' # URL 접수됨
|
||||
FETCHING = 'fetching' # URL 수집 중
|
||||
GENERATING = 'generating' # 프롬프트 생성 중
|
||||
AWAITING = 'awaiting' # 에셋 대기 중
|
||||
ASSEMBLING = 'assembling' # 조립 중
|
||||
READY = 'ready' # 배포 준비 완료
|
||||
ERROR = 'error' # 오류
|
||||
|
||||
STATUS_LABEL = {
|
||||
S.PENDING: '접수됨',
|
||||
S.FETCHING: 'URL 수집 중',
|
||||
S.GENERATING: '프롬프트 생성 중',
|
||||
S.AWAITING: '에셋 대기 중',
|
||||
S.ASSEMBLING: '조립 중',
|
||||
S.READY: '배포 준비 완료',
|
||||
S.ERROR: '오류',
|
||||
}
|
||||
|
||||
IMAGE_EXTENSIONS = {'.jpg', '.jpeg', '.png', '.webp', '.gif'}
|
||||
VIDEO_EXTENSIONS = {'.mp4', '.mov', '.avi', '.webm'}
|
||||
|
||||
|
||||
# ─── 세션 I/O ────────────────────────────────────────────
|
||||
|
||||
def session_dir(sid: str) -> Path:
|
||||
return SESSIONS_DIR / sid
|
||||
|
||||
def meta_path(sid: str) -> Path:
|
||||
return session_dir(sid) / 'meta.json'
|
||||
|
||||
def load_session(sid: str) -> Optional[dict]:
|
||||
p = meta_path(sid)
|
||||
return json.loads(p.read_text(encoding='utf-8')) if p.exists() else None
|
||||
|
||||
def save_session(data: dict) -> None:
|
||||
sid = data['session_id']
|
||||
session_dir(sid).mkdir(parents=True, exist_ok=True)
|
||||
meta_path(sid).write_text(
|
||||
json.dumps(data, ensure_ascii=False, indent=2), encoding='utf-8'
|
||||
)
|
||||
|
||||
def list_sessions() -> list:
|
||||
result = []
|
||||
if not SESSIONS_DIR.exists():
|
||||
return result
|
||||
for d in sorted(SESSIONS_DIR.iterdir(), reverse=True):
|
||||
if d.is_dir():
|
||||
p = meta_path(d.name)
|
||||
if p.exists():
|
||||
try:
|
||||
result.append(json.loads(p.read_text(encoding='utf-8')))
|
||||
except Exception:
|
||||
pass
|
||||
return result
|
||||
|
||||
|
||||
# ─── URL 파싱 ────────────────────────────────────────────
|
||||
|
||||
def fetch_article(url: str) -> dict:
|
||||
"""URL에서 제목과 본문을 추출한다."""
|
||||
headers = {'User-Agent': 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36'}
|
||||
resp = requests.get(url, headers=headers, timeout=20)
|
||||
resp.raise_for_status()
|
||||
html = resp.text
|
||||
|
||||
# 제목
|
||||
m = re.search(r'<title[^>]*>([^<]+)</title>', html, re.I)
|
||||
title = re.sub(r'\s*[–\-|]\s*.+$', '', m.group(1)).strip() if m else '제목 없음'
|
||||
|
||||
# 본문 — 블로거/워드프레스/일반 HTML 순서로 시도
|
||||
body = ''
|
||||
for pat in [
|
||||
r'<div[^>]+class="[^"]*post-body[^"]*"[^>]*>(.*?)</div\s*>',
|
||||
r'<article[^>]*>(.*?)</article>',
|
||||
r'<div[^>]+class="[^"]*entry-content[^"]*"[^>]*>(.*?)</div\s*>',
|
||||
r'<main[^>]*>(.*?)</main>',
|
||||
]:
|
||||
mm = re.search(pat, html, re.DOTALL | re.I)
|
||||
if mm:
|
||||
body = mm.group(1)
|
||||
break
|
||||
if not body:
|
||||
body = html
|
||||
|
||||
body = re.sub(r'<[^>]+>', ' ', body)
|
||||
body = re.sub(r'\s+', ' ', body).strip()[:4000]
|
||||
return {'title': title, 'body': body, 'url': url}
|
||||
|
||||
|
||||
# ─── 프롬프트 생성 ───────────────────────────────────────
|
||||
|
||||
def _prompt_request(title: str, body: str) -> str:
|
||||
return f"""아래 블로그 글을 읽고, 유튜브 쇼츠(60초 이내) 영상을 만들기 위한 프롬프트를 생성해줘.
|
||||
|
||||
제목: {title}
|
||||
본문:
|
||||
{body[:2000]}
|
||||
|
||||
반드시 아래 JSON 형식만 출력하고 다른 설명은 하지 마.
|
||||
|
||||
{{
|
||||
"image_prompts": [
|
||||
{{
|
||||
"purpose": "썸네일",
|
||||
"ko": "썸네일용 이미지 설명 (한국어)",
|
||||
"en": "Thumbnail image prompt for Midjourney/DALL-E, photorealistic, cinematic"
|
||||
}},
|
||||
{{
|
||||
"purpose": "배경1",
|
||||
"ko": "영상 배경 이미지 설명 (한국어)",
|
||||
"en": "Background image prompt, widescreen 16:9"
|
||||
}},
|
||||
{{
|
||||
"purpose": "배경2",
|
||||
"ko": "영상 배경 이미지 설명2 (한국어)",
|
||||
"en": "Second background image prompt, widescreen 16:9"
|
||||
}}
|
||||
],
|
||||
"video_prompt": {{
|
||||
"ko": "AI 영상 생성용 설명 (한국어)",
|
||||
"en": "Short video generation prompt for Sora/Runway, cinematic, 10 seconds"
|
||||
}},
|
||||
"narration_script": "쇼츠 나레이션 스크립트 (30-60초 분량, 한국어, 자막 스타일)"
|
||||
}}"""
|
||||
|
||||
|
||||
def generate_prompts(title: str, body: str) -> dict:
|
||||
"""OpenClaw blog-writer 에이전트로 프롬프트를 생성한다."""
|
||||
try:
|
||||
result = subprocess.run(
|
||||
[_CLI, 'agent', '--agent', 'blog-writer',
|
||||
'--message', _prompt_request(title, body), '--json'],
|
||||
capture_output=True, timeout=180,
|
||||
)
|
||||
stderr_str = result.stderr.decode('utf-8', errors='replace').strip()
|
||||
if result.returncode != 0:
|
||||
logger.error(f"OpenClaw returncode={result.returncode} stderr={stderr_str[:200]}")
|
||||
elif not result.stdout:
|
||||
logger.error(f"OpenClaw stdout 비어있음 stderr={stderr_str[:200]}")
|
||||
else:
|
||||
stdout = result.stdout.decode('utf-8', errors='replace').strip()
|
||||
data = json.loads(stdout)
|
||||
raw = data.get('result', {}).get('payloads', [{}])[0].get('text', '')
|
||||
m = re.search(r'\{[\s\S]*\}', raw)
|
||||
if m:
|
||||
return json.loads(m.group())
|
||||
logger.warning(f"프롬프트 JSON 없음, raw={raw[:100]}")
|
||||
except Exception as e:
|
||||
logger.error(f"프롬프트 생성 오류: {e}")
|
||||
|
||||
# 폴백
|
||||
return {
|
||||
"image_prompts": [
|
||||
{"purpose": "썸네일", "ko": f"{title} 썸네일", "en": f"Thumbnail: {title}, cinematic, photorealistic"},
|
||||
{"purpose": "배경1", "ko": "추상적 기술 배경", "en": "Abstract technology background, dark blue, 16:9"},
|
||||
{"purpose": "배경2", "ko": "미니멀 배경", "en": "Clean minimal background, soft light, 16:9"},
|
||||
],
|
||||
"video_prompt": {
|
||||
"ko": f"{title}에 관한 역동적인 정보 전달 영상",
|
||||
"en": f"Cinematic explainer about {title}, 10 seconds, dynamic cuts",
|
||||
},
|
||||
"narration_script": f"{title}에 대해 알아봅시다. {body[:200]}",
|
||||
}
|
||||
|
||||
|
||||
# ─── 파이프라인 ──────────────────────────────────────────
|
||||
|
||||
def create_session(url: str) -> dict:
|
||||
sid = datetime.now().strftime('%Y%m%d_%H%M%S') + '_' + uuid.uuid4().hex[:6]
|
||||
session = {
|
||||
'session_id': sid,
|
||||
'url': url,
|
||||
'status': S.PENDING,
|
||||
'status_label': STATUS_LABEL[S.PENDING],
|
||||
'created_at': datetime.now().isoformat(),
|
||||
'title': '',
|
||||
'body_preview': '',
|
||||
'prompts': {},
|
||||
'assets': [],
|
||||
'error': '',
|
||||
}
|
||||
save_session(session)
|
||||
logger.info(f"[어시스트] 새 세션: {sid} — {url}")
|
||||
return session
|
||||
|
||||
|
||||
def run_pipeline(sid: str) -> None:
|
||||
"""백그라운드 파이프라인 실행."""
|
||||
session = load_session(sid)
|
||||
if not session:
|
||||
return
|
||||
|
||||
def _update(status: str, **kwargs):
|
||||
session['status'] = status
|
||||
session['status_label'] = STATUS_LABEL[status]
|
||||
session.update(kwargs)
|
||||
save_session(session)
|
||||
|
||||
# 1. URL 수집
|
||||
_update(S.FETCHING)
|
||||
try:
|
||||
article = fetch_article(session['url'])
|
||||
_update(S.FETCHING,
|
||||
title=article['title'],
|
||||
body_preview=article['body'][:300],
|
||||
_full_body=article['body'])
|
||||
logger.info(f"[어시스트] URL 파싱 완료: {article['title']}")
|
||||
except Exception as e:
|
||||
_update(S.ERROR, error=f"URL 수집 실패: {e}")
|
||||
logger.error(f"[어시스트] {sid} URL 오류: {e}")
|
||||
return
|
||||
|
||||
# 2. 프롬프트 생성
|
||||
_update(S.GENERATING)
|
||||
try:
|
||||
prompts = generate_prompts(session['title'], session.get('_full_body', ''))
|
||||
_update(S.AWAITING, prompts=prompts)
|
||||
logger.info(f"[어시스트] 프롬프트 준비 완료: {sid}")
|
||||
except Exception as e:
|
||||
_update(S.ERROR, error=f"프롬프트 생성 실패: {e}")
|
||||
return
|
||||
|
||||
# 3. 에셋 대기 (사용자 업로드 or inbox 드롭 대기)
|
||||
logger.info(f"[어시스트] 에셋 대기 중: {sid}")
|
||||
|
||||
|
||||
# ─── 에셋 관리 ───────────────────────────────────────────
|
||||
|
||||
def link_asset(sid: str, file_path: str) -> bool:
|
||||
"""파일을 세션 assets/ 에 복사하고 메타에 등록한다."""
|
||||
session = load_session(sid)
|
||||
if not session:
|
||||
return False
|
||||
p = Path(file_path)
|
||||
ext = p.suffix.lower()
|
||||
asset_type = 'video' if ext in VIDEO_EXTENSIONS else 'image'
|
||||
|
||||
assets_dir = session_dir(sid) / 'assets'
|
||||
assets_dir.mkdir(exist_ok=True)
|
||||
dest = assets_dir / p.name
|
||||
shutil.copy2(file_path, dest)
|
||||
|
||||
session.setdefault('assets', []).append({
|
||||
'type': asset_type,
|
||||
'path': str(dest),
|
||||
'filename': p.name,
|
||||
'added_at': datetime.now().isoformat(),
|
||||
})
|
||||
save_session(session)
|
||||
logger.info(f"[어시스트] 에셋 등록: {sid} ← {p.name} ({asset_type})")
|
||||
return True
|
||||
|
||||
|
||||
def scan_inbox(sid: str) -> list:
|
||||
"""inbox 폴더에서 세션 ID 앞 8자리 접두사 파일을 자동 연결한다."""
|
||||
found = []
|
||||
prefix = sid[:8]
|
||||
for f in INBOX_DIR.iterdir():
|
||||
if f.is_file() and (f.name.startswith(prefix) or prefix in f.name):
|
||||
if link_asset(sid, str(f)):
|
||||
f.unlink(missing_ok=True)
|
||||
found.append(f.name)
|
||||
return found
|
||||
@@ -649,11 +649,12 @@ def burn_subtitles(video_mp4: str, srt_path: str, output_mp4: str) -> bool:
|
||||
'Alignment=2,'
|
||||
'Bold=1'
|
||||
)
|
||||
# srt 경로에서 역슬래시 → 슬래시 (ffmpeg 호환)
|
||||
srt_esc = str(srt_path).replace('\\', '/').replace(':', '\\:')
|
||||
# Windows 경로는 subtitles 필터에서 옵션 구분자(:)로 오인될 수 있어
|
||||
# filename=... 형태로 명시하고 슬래시/콜론만 ffmpeg 호환 형태로 정규화한다.
|
||||
srt_esc = str(srt_path).replace('\\', '/').replace(':', '\\:').replace("'", r"\'")
|
||||
return _run_ffmpeg([
|
||||
'-i', video_mp4,
|
||||
'-vf', f'subtitles={srt_esc}:force_style=\'{style}\'',
|
||||
'-vf', f"subtitles=filename='{srt_esc}':force_style='{style}'",
|
||||
'-c:v', 'libx264', '-c:a', 'copy',
|
||||
output_mp4,
|
||||
])
|
||||
|
||||
@@ -251,10 +251,10 @@ class FFmpegSlidesEngine(VideoEngine):
|
||||
'Alignment=2,'
|
||||
'Bold=1'
|
||||
)
|
||||
srt_esc = str(srt_path).replace('\\', '/').replace(':', '\\:')
|
||||
srt_esc = str(srt_path).replace('\\', '/').replace(':', '\\:').replace("'", r"\'")
|
||||
return self._run_ffmpeg([
|
||||
'-i', video_mp4,
|
||||
'-vf', f'subtitles={srt_esc}:force_style=\'{style}\'',
|
||||
'-vf', f"subtitles=filename='{srt_esc}':force_style='{style}'",
|
||||
'-c:v', 'libx264', '-c:a', 'copy',
|
||||
output_mp4,
|
||||
])
|
||||
|
||||
@@ -215,6 +215,86 @@ def get_public_url(image_path: str) -> str:
|
||||
return ''
|
||||
|
||||
|
||||
# ─── 비디오 호스팅 (릴스용) ──────────────────────────
|
||||
|
||||
_local_video_server = None
|
||||
|
||||
|
||||
def start_local_video_server(port: int = 8766) -> str:
|
||||
"""
|
||||
로컬 HTTP 파일 서버 시작 — 비디오(MP4) 전용.
|
||||
Returns: base URL (예: http://192.168.1.100:8766)
|
||||
"""
|
||||
import socket
|
||||
import threading
|
||||
import http.server
|
||||
import functools
|
||||
|
||||
global _local_video_server
|
||||
if _local_video_server:
|
||||
return _local_video_server
|
||||
|
||||
outputs_dir = str(BASE_DIR / 'data' / 'outputs')
|
||||
handler = functools.partial(
|
||||
http.server.SimpleHTTPRequestHandler, directory=outputs_dir
|
||||
)
|
||||
server = http.server.HTTPServer(('0.0.0.0', port), handler)
|
||||
thread = threading.Thread(target=server.serve_forever, daemon=True)
|
||||
thread.start()
|
||||
|
||||
try:
|
||||
s = socket.socket(socket.AF_INET, socket.SOCK_DGRAM)
|
||||
s.connect(('8.8.8.8', 80))
|
||||
local_ip = s.getsockname()[0]
|
||||
s.close()
|
||||
except Exception:
|
||||
local_ip = '127.0.0.1'
|
||||
|
||||
base_url = f'http://{local_ip}:{port}'
|
||||
_local_video_server = base_url
|
||||
logger.info(f"로컬 비디오 서버 시작: {base_url}")
|
||||
return base_url
|
||||
|
||||
|
||||
def get_public_video_url(video_path: str) -> str:
|
||||
"""
|
||||
비디오 파일 → 공개 URL 반환 (Instagram Reels, TikTok 등).
|
||||
Instagram Reels API는 공개 접근 가능한 MP4 URL이 필요.
|
||||
|
||||
우선순위:
|
||||
1. LOCAL_IMAGE_SERVER=true → 로컬 HTTP 서버 (Tailscale 외부 접속 필요)
|
||||
2. VIDEO_HOST_BASE_URL 환경변수 → 직접 지정한 CDN/서버 URL
|
||||
|
||||
운영 환경에서는 Tailscale로 미니PC를 외부에서 접근하거나,
|
||||
Cloudflare Tunnel 등을 사용하세요.
|
||||
"""
|
||||
if not Path(video_path).exists():
|
||||
logger.error(f"비디오 파일 없음: {video_path}")
|
||||
return ''
|
||||
|
||||
# 1. 직접 지정한 CDN/서버 베이스 URL
|
||||
video_base = os.getenv('VIDEO_HOST_BASE_URL', '').rstrip('/')
|
||||
if video_base:
|
||||
filename = Path(video_path).name
|
||||
url = f'{video_base}/{filename}'
|
||||
logger.info(f"비디오 외부 URL: {url}")
|
||||
return url
|
||||
|
||||
# 2. 로컬 HTTP 서버 (Tailscale/ngrok으로 외부 접근 가능한 경우)
|
||||
if os.getenv('LOCAL_IMAGE_SERVER', '').lower() == 'true':
|
||||
base_url = start_local_video_server()
|
||||
filename = Path(video_path).name
|
||||
url = f'{base_url}/{filename}'
|
||||
logger.warning(f"로컬 비디오 서버 URL (외부 접근 필요): {url}")
|
||||
return url
|
||||
|
||||
logger.warning(
|
||||
"비디오 공개 URL 생성 불가. .env에 VIDEO_HOST_BASE_URL을 설정하거나 "
|
||||
"LOCAL_IMAGE_SERVER=true (Tailscale/ngrok)로 설정하세요."
|
||||
)
|
||||
return ''
|
||||
|
||||
|
||||
if __name__ == '__main__':
|
||||
import sys
|
||||
if len(sys.argv) > 1:
|
||||
|
||||
@@ -142,6 +142,104 @@ def publish_container(container_id: str) -> str:
|
||||
return ''
|
||||
|
||||
|
||||
def upload_video_container(video_url: str, caption: str) -> str:
|
||||
"""
|
||||
Instagram Reels 업로드 컨테이너 생성.
|
||||
video_url: 공개 접근 가능한 MP4 URL
|
||||
Returns: container_id
|
||||
"""
|
||||
if not _check_credentials():
|
||||
return ''
|
||||
|
||||
url = f"{GRAPH_API_BASE}/{INSTAGRAM_ACCOUNT_ID}/media"
|
||||
params = {
|
||||
'media_type': 'REELS',
|
||||
'video_url': video_url,
|
||||
'caption': caption,
|
||||
'share_to_feed': 'true',
|
||||
'access_token': INSTAGRAM_ACCESS_TOKEN,
|
||||
}
|
||||
try:
|
||||
resp = requests.post(url, data=params, timeout=30)
|
||||
resp.raise_for_status()
|
||||
container_id = resp.json().get('id', '')
|
||||
logger.info(f"Reels 컨테이너 생성: {container_id}")
|
||||
return container_id
|
||||
except Exception as e:
|
||||
logger.error(f"Instagram Reels 컨테이너 생성 실패: {e}")
|
||||
return ''
|
||||
|
||||
|
||||
def wait_for_video_ready(container_id: str, max_wait: int = 300) -> bool:
|
||||
"""
|
||||
비디오 컨테이너 처리 완료 대기 (최대 max_wait초).
|
||||
Reels는 인코딩 시간이 이미지보다 길다.
|
||||
"""
|
||||
status_url = f"{GRAPH_API_BASE}/{container_id}"
|
||||
for _ in range(max_wait // 10):
|
||||
try:
|
||||
resp = requests.get(
|
||||
status_url,
|
||||
params={'fields': 'status_code', 'access_token': INSTAGRAM_ACCESS_TOKEN},
|
||||
timeout=10,
|
||||
)
|
||||
status = resp.json().get('status_code', '')
|
||||
if status == 'FINISHED':
|
||||
return True
|
||||
if status in ('ERROR', 'EXPIRED'):
|
||||
logger.error(f"Reels 컨테이너 오류: {status}")
|
||||
return False
|
||||
logger.debug(f"Reels 인코딩 중: {status}")
|
||||
except Exception as e:
|
||||
logger.warning(f"Reels 상태 확인 오류: {e}")
|
||||
time.sleep(10)
|
||||
logger.warning("Reels 컨테이너 준비 시간 초과")
|
||||
return False
|
||||
|
||||
|
||||
def publish_reels(article: dict, video_path_or_url: str) -> bool:
|
||||
"""
|
||||
쇼츠 MP4를 Instagram Reels로 게시.
|
||||
video_path_or_url: 로컬 MP4 파일 경로 또는 공개 MP4 URL
|
||||
- 로컬 경로인 경우 image_host.get_public_video_url()로 공개 URL 변환
|
||||
- http/https URL인 경우 그대로 사용
|
||||
"""
|
||||
if not _check_credentials():
|
||||
logger.info("Instagram 미설정 — Reels 발행 건너뜀")
|
||||
return False
|
||||
|
||||
logger.info(f"Instagram Reels 발행 시작: {article.get('title', '')}")
|
||||
|
||||
# 로컬 경로 → 공개 URL 변환
|
||||
video_url = video_path_or_url
|
||||
if not video_path_or_url.startswith('http'):
|
||||
import sys
|
||||
sys.path.insert(0, str(Path(__file__).parent))
|
||||
from image_host import get_public_video_url
|
||||
video_url = get_public_video_url(video_path_or_url)
|
||||
if not video_url:
|
||||
logger.error(
|
||||
"Reels 공개 URL 변환 실패 — .env에 VIDEO_HOST_BASE_URL 또는 "
|
||||
"LOCAL_IMAGE_SERVER=true (Tailscale) 설정 필요"
|
||||
)
|
||||
return False
|
||||
|
||||
caption = build_caption(article)
|
||||
container_id = upload_video_container(video_url, caption)
|
||||
if not container_id:
|
||||
return False
|
||||
|
||||
if not wait_for_video_ready(container_id):
|
||||
return False
|
||||
|
||||
post_id = publish_container(container_id)
|
||||
if not post_id:
|
||||
return False
|
||||
|
||||
_log_published(article, post_id, 'instagram_reels')
|
||||
return True
|
||||
|
||||
|
||||
def publish_card(article: dict, image_path_or_url: str) -> bool:
|
||||
"""
|
||||
카드 이미지를 인스타그램 피드에 게시.
|
||||
|
||||
+164
-9
@@ -96,28 +96,59 @@ class ClaudeWriter(BaseWriter):
|
||||
|
||||
|
||||
class OpenClawWriter(BaseWriter):
|
||||
"""OpenClaw CLI를 subprocess로 호출하는 글쓰기 엔진"""
|
||||
"""OpenClaw CLI를 subprocess로 호출하는 글쓰기 엔진 (ChatGPT Pro OAuth)"""
|
||||
|
||||
# Windows에서 npm 글로벌 .cmd 스크립트 우선 사용
|
||||
_CLI = 'openclaw.cmd' if os.name == 'nt' else 'openclaw'
|
||||
|
||||
def __init__(self, cfg: dict):
|
||||
self.agent_name = cfg.get('agent_name', 'blog-writer')
|
||||
self.timeout = cfg.get('timeout', 120)
|
||||
self.timeout = cfg.get('timeout', 300)
|
||||
|
||||
def write(self, prompt: str, system: str = '') -> str:
|
||||
try:
|
||||
cmd = ['openclaw', 'run', self.agent_name, '--prompt', prompt]
|
||||
if system:
|
||||
cmd += ['--system', system]
|
||||
message = f"{system}\n\n{prompt}".strip() if system else prompt
|
||||
cmd = [
|
||||
self._CLI, 'agent',
|
||||
'--agent', self.agent_name,
|
||||
'--message', message,
|
||||
'--json',
|
||||
]
|
||||
result = subprocess.run(
|
||||
cmd,
|
||||
capture_output=True,
|
||||
text=True,
|
||||
timeout=self.timeout,
|
||||
encoding='utf-8',
|
||||
shell=False,
|
||||
)
|
||||
stderr_str = result.stderr.decode('utf-8', errors='replace').strip()
|
||||
if result.returncode != 0:
|
||||
logger.error(f"OpenClawWriter 오류: {result.stderr[:300]}")
|
||||
logger.error(f"OpenClawWriter returncode={result.returncode} stderr={stderr_str[:300]}")
|
||||
return ''
|
||||
return result.stdout.strip()
|
||||
# stdout이 비어있는 경우 — openclaw가 stderr에만 출력하거나 인증 실패
|
||||
if not result.stdout:
|
||||
logger.error(f"OpenClawWriter stdout 비어있음 (returncode=0) stderr={stderr_str[:300]}")
|
||||
return ''
|
||||
stdout = result.stdout.decode('utf-8', errors='replace').strip()
|
||||
if not stdout:
|
||||
logger.error("OpenClawWriter stdout 디코딩 후 비어있음")
|
||||
return ''
|
||||
# 1) JSON 응답 시도 — JSON 블록 추출
|
||||
json_candidate = stdout
|
||||
if not stdout.startswith('{'):
|
||||
import re
|
||||
m = re.search(r'\{[\s\S]*\}', stdout)
|
||||
json_candidate = m.group(0) if m else ''
|
||||
if json_candidate:
|
||||
try:
|
||||
data = json.loads(json_candidate)
|
||||
payloads = data.get('result', {}).get('payloads', [])
|
||||
if payloads:
|
||||
return payloads[0].get('text', '') or stdout
|
||||
except json.JSONDecodeError:
|
||||
pass
|
||||
# 2) JSON 파싱 실패 또는 payloads 없음 → plain text 그대로 반환
|
||||
logger.info(f"OpenClawWriter plain text 응답 ({len(stdout)}자)")
|
||||
return stdout
|
||||
except subprocess.TimeoutExpired:
|
||||
logger.error(f"OpenClawWriter 타임아웃 ({self.timeout}초)")
|
||||
return ''
|
||||
@@ -163,6 +194,128 @@ class GeminiWriter(BaseWriter):
|
||||
return ''
|
||||
|
||||
|
||||
class ClaudeWebWriter(BaseWriter):
|
||||
"""Playwright Chromium에 세션 쿠키를 주입해 claude.ai를 자동화하는 Writer
|
||||
|
||||
Chrome을 닫을 필요 없음 — Playwright 자체 Chromium을 별도로 실행.
|
||||
필요 환경변수:
|
||||
CLAUDE_WEB_COOKIE — __Secure-next-auth.session-token 값
|
||||
"""
|
||||
|
||||
def __init__(self, cfg: dict):
|
||||
self.cookie = os.getenv(cfg.get('cookie_env', 'CLAUDE_WEB_COOKIE'), '')
|
||||
self.timeout_ms = cfg.get('timeout', 180) * 1000
|
||||
|
||||
def write(self, prompt: str, system: str = '') -> str:
|
||||
# claude.ai는 Cloudflare Turnstile로 헤드리스 브라우저를 차단함
|
||||
# 쿠키 세션 만료 여부와 무관하게 자동화 불가 → 비활성화
|
||||
logger.warning("ClaudeWebWriter: Cloudflare 차단으로 비활성화 (수동 사용 권장)")
|
||||
return ''
|
||||
if not self.cookie:
|
||||
logger.warning("CLAUDE_WEB_COOKIE 없음 — ClaudeWebWriter 비활성화")
|
||||
return ''
|
||||
try:
|
||||
from playwright.sync_api import sync_playwright
|
||||
except ImportError:
|
||||
logger.warning("playwright 미설치 — ClaudeWebWriter 비활성화")
|
||||
return ''
|
||||
token = self.cookie.strip()
|
||||
message = f"{system}\n\n{prompt}".strip() if system else prompt
|
||||
try:
|
||||
with sync_playwright() as pw:
|
||||
browser = pw.chromium.launch(
|
||||
headless=True,
|
||||
args=['--disable-blink-features=AutomationControlled'],
|
||||
)
|
||||
ctx = browser.new_context(
|
||||
user_agent=(
|
||||
'Mozilla/5.0 (Windows NT 10.0; Win64; x64) '
|
||||
'AppleWebKit/537.36 (KHTML, like Gecko) '
|
||||
'Chrome/131.0.0.0 Safari/537.36'
|
||||
),
|
||||
)
|
||||
# 세션 쿠키 주입
|
||||
ctx.add_cookies([{
|
||||
'name': '__Secure-next-auth.session-token',
|
||||
'value': token,
|
||||
'domain': 'claude.ai',
|
||||
'path': '/',
|
||||
'secure': True,
|
||||
'httpOnly': True,
|
||||
}])
|
||||
page = ctx.new_page()
|
||||
try:
|
||||
from playwright_stealth import stealth_sync
|
||||
stealth_sync(page)
|
||||
except ImportError:
|
||||
pass
|
||||
page.goto('https://claude.ai/new', wait_until='domcontentloaded', timeout=60000)
|
||||
page.wait_for_timeout(3000)
|
||||
# 입력창 대기
|
||||
editor = page.locator('[contenteditable="true"]').first
|
||||
editor.wait_for(timeout=30000)
|
||||
editor.click()
|
||||
page.keyboard.type(message, delay=30)
|
||||
page.keyboard.press('Enter')
|
||||
# 스트리밍 완료 대기 — 전송 버튼 재활성화
|
||||
page.wait_for_selector(
|
||||
'button[aria-label="Send message"]:not([disabled])',
|
||||
timeout=self.timeout_ms,
|
||||
)
|
||||
# 응답 텍스트 추출
|
||||
blocks = page.locator('.font-claude-message')
|
||||
text = blocks.last.inner_text() if blocks.count() else ''
|
||||
browser.close()
|
||||
return text.strip()
|
||||
except Exception as e:
|
||||
logger.error(f"ClaudeWebWriter 오류: {e}")
|
||||
return ''
|
||||
|
||||
|
||||
class GeminiWebWriter(BaseWriter):
|
||||
"""gemini.google.com 웹 세션 쿠키를 사용하는 비공식 Writer (gemini-webapi)
|
||||
|
||||
필요 환경변수:
|
||||
GEMINI_WEB_1PSID — 브라우저 DevTools > Application > Cookies >
|
||||
google.com 에서 __Secure-1PSID 값
|
||||
GEMINI_WEB_1PSIDTS — 같은 위치에서 __Secure-1PSIDTS 값
|
||||
"""
|
||||
|
||||
def __init__(self, cfg: dict):
|
||||
self.psid = os.getenv(cfg.get('psid_env', 'GEMINI_WEB_1PSID'), '')
|
||||
self.psidts = os.getenv(cfg.get('psidts_env', 'GEMINI_WEB_1PSIDTS'), '')
|
||||
|
||||
def write(self, prompt: str, system: str = '') -> str:
|
||||
if not self.psid or not self.psidts:
|
||||
logger.warning("GEMINI_WEB_1PSID / GEMINI_WEB_1PSIDTS 없음 — GeminiWebWriter 비활성화")
|
||||
return ''
|
||||
try:
|
||||
import asyncio
|
||||
from gemini_webapi import GeminiClient
|
||||
|
||||
async def _run():
|
||||
client = GeminiClient(secure_1psid=self.psid, secure_1psidts=self.psidts)
|
||||
await client.init(timeout=30, auto_close=False, close_delay=300)
|
||||
message = f"{system}\n\n{prompt}".strip() if system else prompt
|
||||
resp = await client.generate_content(message)
|
||||
return resp.text
|
||||
|
||||
try:
|
||||
loop = asyncio.get_event_loop()
|
||||
if loop.is_running():
|
||||
import concurrent.futures
|
||||
with concurrent.futures.ThreadPoolExecutor() as pool:
|
||||
future = pool.submit(asyncio.run, _run())
|
||||
return future.result(timeout=120)
|
||||
else:
|
||||
return loop.run_until_complete(_run())
|
||||
except RuntimeError:
|
||||
return asyncio.run(_run())
|
||||
except Exception as e:
|
||||
logger.error(f"GeminiWebWriter 오류: {e}")
|
||||
return ''
|
||||
|
||||
|
||||
# ─── TTS 구현체 ─────────────────────────────────────────
|
||||
|
||||
class GoogleCloudTTS(BaseTTS):
|
||||
@@ -458,6 +611,8 @@ class EngineLoader:
|
||||
'claude': ClaudeWriter,
|
||||
'openclaw': OpenClawWriter,
|
||||
'gemini': GeminiWriter,
|
||||
'claude_web': ClaudeWebWriter,
|
||||
'gemini_web': GeminiWebWriter,
|
||||
}
|
||||
cls = writers.get(provider, ClaudeWriter)
|
||||
logger.info(f"Writer 로드: {provider} ({cls.__name__})")
|
||||
|
||||
@@ -18,6 +18,7 @@ load_dotenv()
|
||||
BASE_DIR = Path(__file__).parent.parent
|
||||
TELEGRAM_BOT_TOKEN = os.getenv('TELEGRAM_BOT_TOKEN', '')
|
||||
TELEGRAM_CHAT_ID = int(os.getenv('TELEGRAM_CHAT_ID', '0'))
|
||||
REMOTE_CLAUDE_POLLING_ENABLED = os.getenv('REMOTE_CLAUDE_POLLING_ENABLED', '').lower() in {'1', 'true', 'yes', 'on'}
|
||||
|
||||
logging.basicConfig(
|
||||
level=logging.INFO,
|
||||
@@ -90,6 +91,10 @@ async def cmd_help(update: Update, context: ContextTypes.DEFAULT_TYPE):
|
||||
|
||||
|
||||
def main():
|
||||
if not REMOTE_CLAUDE_POLLING_ENABLED:
|
||||
logger.info("Remote Claude Bot polling 비활성화 — 기본 운영은 scheduler.py Telegram 리스너 사용")
|
||||
return
|
||||
|
||||
if not TELEGRAM_BOT_TOKEN:
|
||||
logger.error("TELEGRAM_BOT_TOKEN이 없습니다.")
|
||||
return
|
||||
|
||||
+142
-13
@@ -12,12 +12,20 @@ from datetime import datetime
|
||||
from logging.handlers import RotatingFileHandler
|
||||
from pathlib import Path
|
||||
|
||||
from runtime_guard import ensure_project_runtime
|
||||
|
||||
ensure_project_runtime(
|
||||
"scheduler",
|
||||
["apscheduler", "python-dotenv", "python-telegram-bot", "anthropic"],
|
||||
)
|
||||
|
||||
from apscheduler.schedulers.asyncio import AsyncIOScheduler
|
||||
from dotenv import load_dotenv
|
||||
from telegram import Update
|
||||
from telegram.ext import Application, CommandHandler, MessageHandler, filters, ContextTypes
|
||||
|
||||
import anthropic
|
||||
import re
|
||||
|
||||
load_dotenv()
|
||||
|
||||
@@ -120,7 +128,9 @@ def job_ai_writer():
|
||||
def _trigger_openclaw_writer():
|
||||
topics_dir = DATA_DIR / 'topics'
|
||||
drafts_dir = DATA_DIR / 'drafts'
|
||||
originals_dir = DATA_DIR / 'originals'
|
||||
drafts_dir.mkdir(exist_ok=True)
|
||||
originals_dir.mkdir(exist_ok=True)
|
||||
today = datetime.now().strftime('%Y%m%d')
|
||||
topic_files = sorted(topics_dir.glob(f'{today}_*.json'))
|
||||
if not topic_files:
|
||||
@@ -128,24 +138,110 @@ def _trigger_openclaw_writer():
|
||||
return
|
||||
for topic_file in topic_files[:3]:
|
||||
draft_check = drafts_dir / topic_file.name
|
||||
if draft_check.exists():
|
||||
original_check = originals_dir / topic_file.name
|
||||
if draft_check.exists() or original_check.exists():
|
||||
continue
|
||||
topic_data = json.loads(topic_file.read_text(encoding='utf-8'))
|
||||
logger.info(f"글 작성 요청: {topic_data.get('topic', '')}")
|
||||
_call_openclaw(topic_data, draft_check)
|
||||
_call_openclaw(topic_data, original_check)
|
||||
|
||||
|
||||
def _safe_slug(text: str) -> str:
|
||||
slug = re.sub(r'[^a-z0-9]+', '-', text.lower()).strip('-')
|
||||
return slug or datetime.now().strftime('article-%Y%m%d-%H%M%S')
|
||||
|
||||
|
||||
def _build_openclaw_prompt(topic_data: dict) -> tuple[str, str]:
|
||||
topic = topic_data.get('topic', '').strip()
|
||||
corner = topic_data.get('corner', '쉬운세상').strip() or '쉬운세상'
|
||||
description = topic_data.get('description', '').strip()
|
||||
source = topic_data.get('source_url') or topic_data.get('source') or ''
|
||||
published_at = topic_data.get('published_at', '')
|
||||
system = (
|
||||
"당신은 The 4th Path 블로그 엔진의 전문 에디터다. "
|
||||
"반드시 아래 섹션 헤더 형식만 사용해 완성된 Blogger-ready HTML 원고를 출력하라. "
|
||||
"본문(BODY)은 HTML로 작성하고, KEY_POINTS는 3줄 이내로 작성한다."
|
||||
)
|
||||
prompt = f"""다음 글감을 바탕으로 한국어 블로그 원고를 작성해줘.
|
||||
|
||||
주제: {topic}
|
||||
코너: {corner}
|
||||
설명: {description}
|
||||
출처: {source}
|
||||
발행시점 참고: {published_at}
|
||||
|
||||
출력 형식은 아래 섹션만 정확히 사용해.
|
||||
|
||||
---TITLE---
|
||||
제목
|
||||
|
||||
---META---
|
||||
검색 설명 150자 이내
|
||||
|
||||
---SLUG---
|
||||
영문 소문자 slug
|
||||
|
||||
---TAGS---
|
||||
태그1, 태그2, 태그3
|
||||
|
||||
---CORNER---
|
||||
{corner}
|
||||
|
||||
---BODY---
|
||||
<h2>...</h2> 형식의 Blogger-ready HTML 본문
|
||||
|
||||
---KEY_POINTS---
|
||||
- 핵심포인트1
|
||||
- 핵심포인트2
|
||||
- 핵심포인트3
|
||||
|
||||
---COUPANG_KEYWORDS---
|
||||
키워드1, 키워드2
|
||||
|
||||
---SOURCES---
|
||||
{source} | 참고 출처 | {published_at}
|
||||
|
||||
---DISCLAIMER---
|
||||
필요 시 짧은 면책문구
|
||||
"""
|
||||
return system, prompt
|
||||
|
||||
|
||||
def _call_openclaw(topic_data: dict, output_path: Path):
|
||||
logger.info(f"OpenClaw 호출 (플레이스홀더): {topic_data.get('topic', '')}")
|
||||
# OpenClaw 연동 완료 후 아래 주석 해제:
|
||||
# import subprocess
|
||||
# result = subprocess.run(
|
||||
# ['openclaw', 'run', 'blog-writer', '--input', json.dumps(topic_data)],
|
||||
# capture_output=True, text=True
|
||||
# )
|
||||
# output = result.stdout
|
||||
topic_data['_pending_openclaw'] = True
|
||||
output_path.write_text(json.dumps(topic_data, ensure_ascii=False, indent=2), encoding='utf-8')
|
||||
logger.info(f"OpenClaw 작성 요청: {topic_data.get('topic', '')}")
|
||||
sys.path.insert(0, str(BASE_DIR))
|
||||
sys.path.insert(0, str(BASE_DIR / 'bots'))
|
||||
|
||||
from engine_loader import EngineLoader
|
||||
from article_parser import parse_output
|
||||
|
||||
system, prompt = _build_openclaw_prompt(topic_data)
|
||||
writer = EngineLoader().get_writer()
|
||||
raw_output = writer.write(prompt, system=system).strip()
|
||||
if not raw_output:
|
||||
raise RuntimeError('OpenClaw writer 응답이 비어 있습니다.')
|
||||
|
||||
article = parse_output(raw_output)
|
||||
if not article:
|
||||
raise RuntimeError('OpenClaw writer 출력 파싱 실패')
|
||||
|
||||
article.setdefault('title', topic_data.get('topic', '').strip())
|
||||
article['slug'] = article.get('slug') or _safe_slug(article['title'])
|
||||
article['corner'] = article.get('corner') or topic_data.get('corner', '쉬운세상')
|
||||
article['topic'] = topic_data.get('topic', '')
|
||||
article['description'] = topic_data.get('description', '')
|
||||
article['quality_score'] = topic_data.get('quality_score', 0)
|
||||
article['source'] = topic_data.get('source', '')
|
||||
article['source_url'] = topic_data.get('source_url') or topic_data.get('source') or ''
|
||||
article['published_at'] = topic_data.get('published_at', '')
|
||||
article['created_at'] = datetime.now().isoformat()
|
||||
|
||||
output_path.parent.mkdir(parents=True, exist_ok=True)
|
||||
output_path.write_text(
|
||||
json.dumps(article, ensure_ascii=False, indent=2),
|
||||
encoding='utf-8',
|
||||
)
|
||||
logger.info(f"OpenClaw 원고 저장 완료: {output_path.name}")
|
||||
|
||||
|
||||
def job_convert():
|
||||
@@ -263,6 +359,37 @@ def _distribute_instagram():
|
||||
logger.info(f"Instagram 발행 완료: {card_file.name}")
|
||||
|
||||
|
||||
def job_distribute_instagram_reels():
|
||||
"""10:30 — Instagram Reels (쇼츠 MP4) 발행"""
|
||||
if not _publish_enabled:
|
||||
return
|
||||
logger.info("[스케줄] Instagram Reels 발행")
|
||||
try:
|
||||
_distribute_instagram_reels()
|
||||
except Exception as e:
|
||||
logger.error(f"Instagram Reels 배포 오류: {e}")
|
||||
|
||||
|
||||
def _distribute_instagram_reels():
|
||||
sys.path.insert(0, str(BASE_DIR / 'bots' / 'distributors'))
|
||||
import instagram_bot
|
||||
today = datetime.now().strftime('%Y%m%d')
|
||||
outputs_dir = DATA_DIR / 'outputs'
|
||||
for shorts_file in sorted(outputs_dir.glob(f'{today}_*_shorts.mp4')):
|
||||
flag = shorts_file.with_suffix('.ig_reels_done')
|
||||
if flag.exists():
|
||||
continue
|
||||
slug = shorts_file.stem.replace(f'{today}_', '').replace('_shorts', '')
|
||||
article = _load_article_by_slug(today, slug)
|
||||
if not article:
|
||||
logger.warning(f"Instagram Reels: 원본 article 없음 ({slug})")
|
||||
continue
|
||||
success = instagram_bot.publish_reels(article, str(shorts_file))
|
||||
if success:
|
||||
flag.touch()
|
||||
logger.info(f"Instagram Reels 발행 완료: {shorts_file.name}")
|
||||
|
||||
|
||||
def job_distribute_x():
|
||||
"""11:00 — X 스레드 게시"""
|
||||
if not _publish_enabled:
|
||||
@@ -810,7 +937,9 @@ def setup_scheduler() -> AsyncIOScheduler:
|
||||
scheduler.add_job(lambda: job_publish(1), 'cron',
|
||||
hour=9, minute=0, id='blog_publish') # 09:00 블로그
|
||||
scheduler.add_job(job_distribute_instagram, 'cron',
|
||||
hour=10, minute=0, id='instagram_dist') # 10:00 인스타
|
||||
hour=10, minute=0, id='instagram_dist') # 10:00 인스타 카드
|
||||
scheduler.add_job(job_distribute_instagram_reels, 'cron',
|
||||
hour=10, minute=30, id='instagram_reels_dist') # 10:30 인스타 릴스
|
||||
scheduler.add_job(job_distribute_x, 'cron',
|
||||
hour=11, minute=0, id='x_dist') # 11:00 X
|
||||
scheduler.add_job(job_distribute_tiktok, 'cron',
|
||||
|
||||
@@ -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()
|
||||
Reference in New Issue
Block a user