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
+3 -1
View File
@@ -1,15 +1,17 @@
import React, { useState } from 'react'
import { LayoutDashboard, FileText, BarChart2, BookOpen, Settings, ScrollText } from 'lucide-react'
import { LayoutDashboard, FileText, BarChart2, BookOpen, Settings, ScrollText, UserCheck } from 'lucide-react'
import Overview from './pages/Overview.jsx'
import Content from './pages/Content.jsx'
import Analytics from './pages/Analytics.jsx'
import Novel from './pages/Novel.jsx'
import SettingsPage from './pages/Settings.jsx'
import Logs from './pages/Logs.jsx'
import Assist from './pages/Assist.jsx'
const TABS = [
{ id: 'overview', label: '개요', icon: LayoutDashboard, component: Overview },
{ id: 'content', label: '콘텐츠', icon: FileText, component: Content },
{ id: 'assist', label: '수동모드', icon: UserCheck, component: Assist },
{ id: 'analytics', label: '분석', icon: BarChart2, component: Analytics },
{ id: 'novel', label: '소설', icon: BookOpen, component: Novel },
{ id: 'settings', label: '설정', icon: Settings, component: SettingsPage },
+416
View File
@@ -0,0 +1,416 @@
import { useState, useEffect, useRef } from 'react'
import {
Link2, Upload, FolderOpen, RefreshCw, Trash2,
CheckCircle, Clock, AlertCircle, Loader, ChevronDown,
ChevronUp, Copy, Image, Video, FileText, Plus, X
} from 'lucide-react'
const API = ''
const STATUS_COLOR = {
pending: '#888880',
fetching: '#4a5abf',
generating: '#c8a84e',
awaiting: '#c8a84e',
assembling: '#4a5abf',
ready: '#3a7d5c',
error: '#bf3a3a',
}
const STATUS_ICON = {
pending: <Clock size={14} />,
fetching: <Loader size={14} className="spin" />,
generating: <Loader size={14} className="spin" />,
awaiting: <Upload size={14} />,
assembling: <Loader size={14} className="spin" />,
ready: <CheckCircle size={14} />,
error: <AlertCircle size={14} />,
}
function CopyBtn({ text }) {
const [copied, setCopied] = useState(false)
const copy = () => {
navigator.clipboard.writeText(text)
setCopied(true)
setTimeout(() => setCopied(false), 1500)
}
return (
<button onClick={copy} style={{
background: 'none', border: '1px solid #333', borderRadius: 4,
color: copied ? '#3a7d5c' : '#888', cursor: 'pointer',
padding: '2px 8px', fontSize: 11, display: 'flex', alignItems: 'center', gap: 4
}}>
<Copy size={11} /> {copied ? '복사됨' : '복사'}
</button>
)
}
function PromptCard({ prompt, index }) {
return (
<div style={{
background: '#0f0f14', border: '1px solid #2a2a32',
borderRadius: 8, padding: 12, marginBottom: 8
}}>
<div style={{ display: 'flex', justifyContent: 'space-between', marginBottom: 6 }}>
<span style={{ fontSize: 11, color: '#c8a84e', fontWeight: 600 }}>
<Image size={11} style={{ display: 'inline', marginRight: 4 }} />
이미지 #{index + 1} {prompt.purpose}
</span>
<CopyBtn text={prompt.en} />
</div>
<div style={{ fontSize: 12, color: '#b0b0a8', marginBottom: 4 }}>
<span style={{ color: '#666', marginRight: 6 }}>KO</span>{prompt.ko}
</div>
<div style={{ fontSize: 12, color: '#e0e0d8' }}>
<span style={{ color: '#666', marginRight: 6 }}>EN</span>{prompt.en}
</div>
</div>
)
}
function AssetDropZone({ sessionId, onUploaded }) {
const [dragging, setDragging] = useState(false)
const [uploading, setUploading] = useState(false)
const inputRef = useRef()
const handleFiles = async (files) => {
setUploading(true)
for (const file of files) {
const fd = new FormData()
fd.append('file', file)
fd.append('asset_type', file.type.startsWith('video') ? 'video' : 'image')
await fetch(`${API}/api/assist/session/${sessionId}/upload`, { method: 'POST', body: fd })
}
setUploading(false)
onUploaded()
}
return (
<div
onDragOver={e => { e.preventDefault(); setDragging(true) }}
onDragLeave={() => setDragging(false)}
onDrop={e => { e.preventDefault(); setDragging(false); handleFiles([...e.dataTransfer.files]) }}
onClick={() => inputRef.current?.click()}
style={{
border: `2px dashed ${dragging ? '#c8a84e' : '#333'}`,
borderRadius: 8, padding: '20px 16px', textAlign: 'center',
cursor: 'pointer', background: dragging ? '#1a1a0a' : 'transparent',
transition: 'all 0.2s',
}}
>
<input ref={inputRef} type="file" multiple accept="image/*,video/*"
style={{ display: 'none' }}
onChange={e => handleFiles([...e.target.files])} />
{uploading
? <><Loader size={20} style={{ color: '#c8a84e', marginBottom: 6 }} /><div style={{ color: '#888', fontSize: 12 }}>업로드 ...</div></>
: <>
<Upload size={20} style={{ color: '#555', marginBottom: 6 }} />
<div style={{ color: '#888', fontSize: 12 }}>이미지 / 영상 드래그 드롭 또는 클릭하여 선택</div>
<div style={{ color: '#555', fontSize: 11, marginTop: 4 }}>JPG, PNG, WebP, MP4, MOV 지원</div>
</>
}
</div>
)
}
function SessionCard({ session: initial, onDelete }) {
const [session, setSession] = useState(initial)
const [open, setOpen] = useState(initial.status === 'awaiting')
const [refreshing, setRefreshing] = useState(false)
const refresh = async () => {
setRefreshing(true)
const r = await fetch(`${API}/api/assist/session/${session.session_id}`)
if (r.ok) setSession(await r.json())
setRefreshing(false)
}
// 처리 중이면 자동 폴링
useEffect(() => {
const active = ['pending', 'fetching', 'generating']
if (!active.includes(session.status)) return
const t = setInterval(refresh, 3000)
return () => clearInterval(t)
}, [session.status])
const removeAsset = async (filename) => {
await fetch(`${API}/api/assist/session/${session.session_id}/asset/${filename}`, { method: 'DELETE' })
refresh()
}
const color = STATUS_COLOR[session.status] || '#888'
const icon = STATUS_ICON[session.status]
const prompts = session.prompts || {}
const imagePrompts = prompts.image_prompts || []
const videoPrompt = prompts.video_prompt || null
const narration = prompts.narration_script || ''
const assets = session.assets || []
return (
<div style={{
background: '#111116', border: '1px solid #222228',
borderRadius: 10, marginBottom: 12, overflow: 'hidden'
}}>
{/* 헤더 */}
<div style={{
display: 'flex', alignItems: 'center', gap: 10,
padding: '12px 16px', cursor: 'pointer',
}} onClick={() => setOpen(o => !o)}>
<div style={{ color, display: 'flex', alignItems: 'center' }}>{icon}</div>
<div style={{ flex: 1, minWidth: 0 }}>
<div style={{ fontSize: 13, fontWeight: 600, color: '#e0e0d8', overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap' }}>
{session.title || session.url}
</div>
<div style={{ fontSize: 11, color: '#555', marginTop: 2 }}>
{new Date(session.created_at).toLocaleString('ko-KR')}
{' · '}
<span style={{ color }}>{session.status_label || session.status}</span>
{assets.length > 0 && <span style={{ color: '#3a7d5c' }}> · 에셋 {assets.length}</span>}
</div>
</div>
<div style={{ display: 'flex', gap: 6, alignItems: 'center' }}>
<button onClick={e => { e.stopPropagation(); refresh() }}
style={{ background: 'none', border: 'none', color: '#555', cursor: 'pointer', padding: 4 }}>
<RefreshCw size={13} className={refreshing ? 'spin' : ''} />
</button>
<button onClick={e => { e.stopPropagation(); onDelete(session.session_id) }}
style={{ background: 'none', border: 'none', color: '#555', cursor: 'pointer', padding: 4 }}>
<Trash2 size={13} />
</button>
{open ? <ChevronUp size={14} color="#555" /> : <ChevronDown size={14} color="#555" />}
</div>
</div>
{/* 본문 */}
{open && (
<div style={{ borderTop: '1px solid #1a1a20', padding: 16 }}>
{session.status === 'error' && (
<div style={{ background: '#1a0808', border: '1px solid #bf3a3a', borderRadius: 6, padding: 10, marginBottom: 12, fontSize: 12, color: '#bf3a3a' }}>
{session.error}
</div>
)}
{session.body_preview && (
<div style={{ fontSize: 12, color: '#666', marginBottom: 14, lineHeight: 1.6 }}>
{session.body_preview}
</div>
)}
{/* 프롬프트 섹션 */}
{imagePrompts.length > 0 && (
<div style={{ marginBottom: 16 }}>
<div style={{ fontSize: 12, color: '#888', fontWeight: 600, marginBottom: 8, display: 'flex', alignItems: 'center', gap: 6 }}>
<Image size={13} /> 이미지 프롬프트
</div>
{imagePrompts.map((p, i) => <PromptCard key={i} prompt={p} index={i} />)}
</div>
)}
{videoPrompt && (
<div style={{ marginBottom: 16 }}>
<div style={{ fontSize: 12, color: '#888', fontWeight: 600, marginBottom: 8, display: 'flex', alignItems: 'center', gap: 6 }}>
<Video size={13} /> 영상 프롬프트
</div>
<div style={{ background: '#0f0f14', border: '1px solid #2a2a32', borderRadius: 8, padding: 12 }}>
<div style={{ display: 'flex', justifyContent: 'space-between', marginBottom: 6 }}>
<span style={{ fontSize: 11, color: '#4a5abf' }}>Sora / Runway / Veo</span>
<CopyBtn text={videoPrompt.en} />
</div>
<div style={{ fontSize: 12, color: '#b0b0a8', marginBottom: 4 }}>
<span style={{ color: '#666', marginRight: 6 }}>KO</span>{videoPrompt.ko}
</div>
<div style={{ fontSize: 12, color: '#e0e0d8' }}>
<span style={{ color: '#666', marginRight: 6 }}>EN</span>{videoPrompt.en}
</div>
</div>
</div>
)}
{narration && (
<div style={{ marginBottom: 16 }}>
<div style={{ fontSize: 12, color: '#888', fontWeight: 600, marginBottom: 8, display: 'flex', alignItems: 'center', gap: 6 }}>
<FileText size={13} /> 나레이션 스크립트
</div>
<div style={{ background: '#0f0f14', border: '1px solid #2a2a32', borderRadius: 8, padding: 12 }}>
<div style={{ display: 'flex', justifyContent: 'flex-end', marginBottom: 6 }}>
<CopyBtn text={narration} />
</div>
<div style={{ fontSize: 12, color: '#e0e0d8', lineHeight: 1.7, whiteSpace: 'pre-wrap' }}>
{narration}
</div>
</div>
</div>
)}
{/* 에셋 업로드 */}
{['awaiting', 'ready', 'generating'].includes(session.status) && (
<div style={{ marginBottom: 16 }}>
<div style={{ fontSize: 12, color: '#888', fontWeight: 600, marginBottom: 8, display: 'flex', alignItems: 'center', gap: 6 }}>
<Upload size={13} /> 에셋 제공
</div>
<AssetDropZone sessionId={session.session_id} onUploaded={refresh} />
</div>
)}
{/* 등록된 에셋 */}
{assets.length > 0 && (
<div>
<div style={{ fontSize: 12, color: '#888', fontWeight: 600, marginBottom: 8 }}>
등록된 에셋 ({assets.length})
</div>
<div style={{ display: 'flex', flexWrap: 'wrap', gap: 8 }}>
{assets.map((a, i) => (
<div key={i} style={{
background: '#0f0f14', border: '1px solid #2a2a32',
borderRadius: 6, padding: '6px 10px', fontSize: 11,
display: 'flex', alignItems: 'center', gap: 6
}}>
{a.type === 'video' ? <Video size={11} color="#4a5abf" /> : <Image size={11} color="#3a7d5c" />}
<span style={{ color: '#b0b0a8', maxWidth: 140, overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap' }}>
{a.filename}
</span>
<button onClick={() => removeAsset(a.filename)}
style={{ background: 'none', border: 'none', color: '#555', cursor: 'pointer', padding: 0 }}>
<X size={11} />
</button>
</div>
))}
</div>
</div>
)}
</div>
)}
</div>
)
}
export default function Assist() {
const [url, setUrl] = useState('')
const [sessions, setSessions] = useState([])
const [loading, setLoading] = useState(false)
const [inboxPath, setInboxPath] = useState('')
const load = async () => {
const [s, i] = await Promise.all([
fetch(`${API}/api/assist/sessions`).then(r => r.json()).catch(() => []),
fetch(`${API}/api/assist/inbox`).then(r => r.json()).catch(() => ({})),
])
setSessions(s)
setInboxPath(i.path || '')
}
useEffect(() => { load() }, [])
const submit = async () => {
if (!url.trim()) return
setLoading(true)
await fetch(`${API}/api/assist/session`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ url: url.trim() }),
})
setUrl('')
await load()
setLoading(false)
}
const deleteSession = async (sid) => {
if (!confirm('이 세션을 삭제하시겠습니까?')) return
await fetch(`${API}/api/assist/session/${sid}`, { method: 'DELETE' })
load()
}
return (
<div style={{ padding: '24px 28px', maxWidth: 820, margin: '0 auto' }}>
{/* 헤더 */}
<div style={{ marginBottom: 24 }}>
<h2 style={{ fontSize: 20, fontWeight: 700, color: '#e0e0d8', margin: 0 }}>
수동(어시스트) 모드
</h2>
<p style={{ fontSize: 13, color: '#666', marginTop: 6 }}>
직접 작성한 블로그 URL을 입력하면 시스템이 이미지·영상 프롬프트를 생성합니다.
생성한 에셋을 업로드하면 쇼츠 조립·배포 파이프라인으로 연결됩니다.
</p>
</div>
{/* URL 입력 */}
<div style={{
background: '#111116', border: '1px solid #222228',
borderRadius: 10, padding: 16, marginBottom: 16
}}>
<div style={{ fontSize: 12, color: '#888', marginBottom: 8, fontWeight: 600 }}>
<Link2 size={12} style={{ display: 'inline', marginRight: 6 }} />
블로그 URL
</div>
<div style={{ display: 'flex', gap: 8 }}>
<input
value={url}
onChange={e => setUrl(e.target.value)}
onKeyDown={e => e.key === 'Enter' && submit()}
placeholder="https://www.the4thpath.com/2026/03/..."
style={{
flex: 1, background: '#0a0a0d', border: '1px solid #333',
borderRadius: 6, padding: '8px 12px', color: '#e0e0d8',
fontSize: 13, outline: 'none',
}}
/>
<button
onClick={submit}
disabled={loading || !url.trim()}
style={{
background: loading ? '#333' : '#c8a84e',
color: loading ? '#888' : '#0a0a0d',
border: 'none', borderRadius: 6, padding: '8px 16px',
fontWeight: 700, fontSize: 13, cursor: loading ? 'default' : 'pointer',
display: 'flex', alignItems: 'center', gap: 6,
}}
>
{loading ? <><Loader size={13} className="spin" /> 분석 </> : <><Plus size={13} /> 분석 시작</>}
</button>
</div>
</div>
{/* inbox 폴더 안내 */}
{inboxPath && (
<div style={{
background: '#0d0d10', border: '1px solid #1e2a1e',
borderRadius: 8, padding: '10px 14px', marginBottom: 20,
display: 'flex', alignItems: 'center', gap: 10
}}>
<FolderOpen size={14} color="#3a7d5c" />
<div>
<span style={{ fontSize: 11, color: '#3a7d5c', fontWeight: 600 }}>폴더 드롭 경로</span>
<span style={{ fontSize: 11, color: '#666', marginLeft: 8 }}>{inboxPath}</span>
</div>
<div style={{ fontSize: 11, color: '#555', marginLeft: 'auto' }}>
파일명 8자리에 세션 ID를 포함하면 자동 연결됩니다
</div>
</div>
)}
{/* 세션 목록 */}
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center', marginBottom: 12 }}>
<span style={{ fontSize: 13, color: '#888', fontWeight: 600 }}>
세션 목록 ({sessions.length})
</span>
<button onClick={load} style={{ background: 'none', border: 'none', color: '#555', cursor: 'pointer', display: 'flex', alignItems: 'center', gap: 4, fontSize: 12 }}>
<RefreshCw size={12} /> 새로고침
</button>
</div>
{sessions.length === 0
? <div style={{ textAlign: 'center', color: '#444', padding: '40px 0', fontSize: 13 }}>
아직 세션이 없습니다. URL을 입력해 시작하세요.
</div>
: sessions.map(s => (
<SessionCard key={s.session_id} session={s} onDelete={deleteSession} />
))
}
<style>{`
.spin { animation: spin 1s linear infinite; }
@keyframes spin { from { transform: rotate(0deg); } to { transform: rotate(360deg); } }
`}</style>
</div>
)
}