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:
@@ -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__})")
|
||||
|
||||
Reference in New Issue
Block a user