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

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

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

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

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

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

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

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

View File

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