20 Commits

Author SHA1 Message Date
JOUNGWOOK KWON e3c963a014 feat: 영문 RSS 글감 자동 번역+재작성 지원
- 수집 시 영문 소스 자동 감지 (한국어 비율 5% 미만)
- 영문 글감 글쓰기 프롬프트에 번역+한국맥락 재작성 지시 추가
- 한국 시장 비교, 국내 대안 서비스 언급 유도
- 제목도 한국어로 새로 작성하도록 지시

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-30 11:53:01 +09:00
JOUNGWOOK KWON af57c3500c fix: 수집 필터 완화 — 영문 RSS 살리기 + 코너 자동배정 + 클릭베이트 완화
- 영문 RSS(카테고리 지정됨)에 한국관련성 기본 10점 부여 (즉시폐기 방지)
- korean_relevance 키워드에 AI/GPT/Apple/Netflix 등 글로벌 키워드 추가
- 키워드 매칭을 case-insensitive로 변경
- RSS 카테고리를 corner로 직접 배정 (쉬운세상 대신 실제 라벨)
- 클릭베이트 필터에서 충격/대박/레전드/역대급 제거 (TV뉴스 과다 필터링)

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-30 11:50:47 +09:00
JOUNGWOOK KWON 0783775cdd feat: RSS 소스 17개 추가 — TV로보는세상 6개 + 기존 카테고리 보강
- TV로보는세상: 연합뉴스연예, 한경연예, MBC, TV리포트, OSEN, 스포츠조선
- AI인사이트: Google News AI, Ars Technica
- 여행맛집: Google News 여행맛집, 마이리얼트립
- 제품리뷰: 뽐뿌, Wired
- 앱추천: 9to5Mac, Android Authority
- 재테크: 조선비즈, 이데일리
- x_keywords: TV/드라마/넷플릭스 키워드 추가

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-30 11:48:11 +09:00
JOUNGWOOK KWON 2c80ed1a52 chore: 라벨 변경 — 재테크절약→재테크, TV로보는세상 추가
blogs.json, sources.json 라벨을 블로그 메뉴와 일치시킴

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-30 11:46:23 +09:00
JOUNGWOOK KWON 9cf1f44a8b fix: /collect, /topics 결과에 글 번호 표시 추가
수집 완료 후 바로 번호 포함 목록을 보여줘서
/write [번호]로 바로 글 작성할 수 있도록 개선

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-30 11:42:28 +09:00
JOUNGWOOK KWON 9f68133217 feat: /collect, /write 텔레그램 명령어 추가 + eli 페르소나 적용
- cmd_collect: 즉시 글감 수집
- cmd_write [번호] [방향]: 특정 글감 글 작성 + auto pending
- _publish_next(): originals → pending_review 자동 이동
- _call_openclaw: direction 파라미터 지원
- 글쓰기 시스템 프롬프트 eli 블로그 페르소나로 변경
- 기본 코너: 쉬운세상 → AI인사이트

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-30 11:27:02 +09:00
JOUNGWOOK KWON 3e2405dff9 feat: upstream v3.2.1 기반으로 업그레이드 + eli 블로그 커스터마이징
- upstream sinmb79/blog-writer v3.2.1 코드 베이스 적용
- config_resolver, CLI, writer_bot, shorts pipeline 등 신규 기능 포함
- load_dotenv Windows 경로 → Docker 호환 load_dotenv() 변경 (25개 파일)
- runtime_guard.py Docker 환경 bypass 추가
- config/blogs.json: eli-ai 블로그 정체성 (8개 카테고리)
- config/sources.json: 38개 RSS 소스 유지
- config/engine.json: writing provider → gemini (2.5-flash)
- config/safety_keywords.json: 모든 글 수동 승인 (score 101)
- bots/scheduler.py: 시스템 프롬프트 eli 블로그 기준으로 업데이트
- bots/publisher_bot.py: .env refresh token OAuth 폴백 로직 추가
- requirements.txt: google-generativeai, groq 활성화
- Dockerfile + docker-compose.yml: NAS Docker 배포 설정
- CLAUDE.md: 프로젝트 메타데이터

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-30 09:21:14 +09:00
sinmb79 66be55ba8a fix(v3): code review 5개 이슈 수정
- korean_preprocessor: 발음 사전 176 → 206개 (200+ 달성)
- video_engine: SoraEngine 완전 제거 (2026-03-24 서비스 종료)
- smart_video_router: veo3/seedance2 빈 문자열 반환 → ffmpeg_slides 폴백
- cli/init: gemini_web 서비스 설정 질문 추가 (user_profile 일치)
- caption_renderer, tts_engine, video_assembler: --test 스탠드얼론 블록 추가

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-29 16:14:51 +09:00
sinmb79 6571afc982 feat(v3): PR 10 - bw init setup wizard + prompt_styles.json
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-29 12:05:51 +09:00
sinmb79 6c5c1b9d50 feat(v3): PR 9 - MVP CLI (8 commands) + pyproject.toml
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-29 12:03:07 +09:00
sinmb79 65481eb9e4 feat(v3): PR 8 — PromptTracker (SQLite logging infra) 2026-03-29 11:59:34 +09:00
sinmb79 8931adeafd feat(v3): PR 7 — ResilientAssembler with GPU encoder detection + per-clip fallback
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-29 11:58:16 +09:00
sinmb79 0dedb0d7f8 feat(v3): PR 6 — HookOptimizer + MicroSignals (3 signals)
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-29 11:56:34 +09:00
sinmb79 834577fc07 feat(v3): PR 5 — caption templates (3 styles) + MotionEngine (7 patterns)
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-29 11:53:03 +09:00
sinmb79 b666b67a03 feat(v3): PR 4 — korean_preprocessor + SmartTTSRouter
- Add bots/prompt_layer/korean_preprocessor.py: 200+ entry pronunciation
  map, number→Korean conversion, dynamic SSML/marker pause insertion
- Upgrade bots/shorts/tts_engine.py: SmartTTSRouter (budget-aware engine
  selection with failure fallback), _tts_openai() function, Korean
  preprocessing step in generate_tts()

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-29 11:48:19 +09:00
sinmb79 33b0bbd5ee feat(v3): PR 3 — prompt_layer package (base, video_prompt, search_query, visual_vocabulary)
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-29 11:43:15 +09:00
sinmb79 4484fd1cfc fix(v3): smart_video_router — remove hardcoded path, on_failure validates availability
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-29 11:40:05 +09:00
sinmb79 09030697ee feat(v3): PR 2 - engine.json sora→kling/veo + SmartVideoRouter
- config/engine.json: switch video_generation provider from sora to
  smart_router; add kling_free/veo3/seedance2 engine options; update
  optional_keys (KLING_API_KEY, FAL_API_KEY); keep legacy entries
- bots/converters/smart_video_router.py: new SmartVideoRouter class with
  budget-aware engine selection, daily state tracking, Kling stub
  implementation, and ffmpeg_slides fallback chain

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-29 11:38:15 +09:00
sinmb79 3d200beba4 fix(v3): config_resolver — remove dead engine param, add input validation
- Remove unused `engine` parameter from `_resolve_engine()` signature and call sites
- Warn on unknown engine in `_has_api_key()` instead of silently returning True
- Warn when budget value from profile is not in BUDGET_ENGINE_MAP
- Validate `platforms` type in `_resolve_platforms()`, wrap non-list values

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-29 11:34:33 +09:00
sinmb79 b5dc961863 feat(v3): PR 1 — config_resolver + user_profile
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-29 11:32:32 +09:00
58 changed files with 7210 additions and 131 deletions
+7
View File
@@ -73,6 +73,13 @@ logs/
# ─── Node.js (대시보드 프론트엔드) ───────────────────────────
dashboard/frontend/node_modules/
# ─── Dashboard 빌드 결과물 (dist는 포함) ──────────────────────
!dashboard/frontend/dist
!dashboard/frontend/dist/**
# ─── OS ──────────────────────────────────────────────────────
.DS_Store
# ─── IDE ──────────────────────────────────────────────────────
.vscode/
.idea/
+21
View File
@@ -0,0 +1,21 @@
# blog-writer
이 파일은 Claude Code가 어느 경로에서 실행되든 자동으로 로드합니다.
## 저장소
- Git 서버: Gitea (자체 NAS 운영)
- Gitea URL: http://nas.gru.farm:3001
- 계정: airkjw
- 저장소: blog-writer
- Remote: http://nas.gru.farm:3001/airkjw/blog-writer
- 토큰: 8a8842a56866feab3a44b9f044491bf0dfc44963
## NAS ssh 공개키
- 아이디: airkjw
- 공개키: ssh-ed25519 AAAAC3NzaC1lZDI1NTE5AAAAICkbFPXF3CHi91UsWIrIsjG8srqceVm1wKrL3K1doM1V
- 주소: nas.gru.farm:22
- 내부 IP: 192.168.0.17
- Docker 명령: sudo /usr/local/bin/docker (NOPASSWD)
- Docker Compose: sudo /usr/local/bin/docker compose
+17
View File
@@ -0,0 +1,17 @@
FROM python:3.11-slim
WORKDIR /app
RUN apt-get update && apt-get install -y --no-install-recommends \
gcc libxml2-dev libxslt-dev libffi-dev libssl-dev \
fonts-nanum ffmpeg \
&& rm -rf /var/lib/apt/lists/*
COPY requirements.txt ./
RUN pip install --no-cache-dir -r requirements.txt
RUN mkdir -p data/outputs data/topics data/collected data/pending_review \
data/published data/discarded data/drafts data/originals \
data/images data/analytics logs assets/fonts
CMD ["python3", "bots/scheduler.py"]
+172
View File
@@ -0,0 +1,172 @@
/* ── eli blog 커스텀 CSS ── */
@import url('https://fonts.googleapis.com/css2?family=Noto+Sans+KR:wght@400;500;700&display=swap');
:root {
--ai: #7C3AED;
--travel: #EA580C;
--startup: #2563EB;
--review: #0891B2;
--tips: #16A34A;
--app: #DB2777;
--finance: #CA8A04;
--fact: #DC2626;
}
body, .body-fauxcolumn-outer {
font-family: 'Noto Sans KR', sans-serif !important;
background: #F4F6F9 !important;
}
/* 헤더 */
.header-bar, .header-outer, #header {
background: #fff !important;
border-bottom: 1px solid #E5E7EB !important;
box-shadow: 0 2px 8px rgba(0,0,0,.06) !important;
}
.header-inner h1, .header-inner h1 a,
.Header h1, .Header h1 a {
font-family: 'Noto Sans KR', sans-serif !important;
font-size: 22px !important;
font-weight: 700 !important;
color: #1A1A1A !important;
}
/* 포스트 카드 */
.post-outer, .post {
background: #fff !important;
border-radius: 12px !important;
box-shadow: 0 2px 8px rgba(0,0,0,.07) !important;
margin-bottom: 24px !important;
overflow: hidden !important;
transition: transform .2s, box-shadow .2s !important;
}
.post-outer:hover, .post:hover {
transform: translateY(-3px) !important;
box-shadow: 0 8px 24px rgba(0,0,0,.12) !important;
}
/* 포스트 제목 */
.post-title a, h3.post-title a {
font-size: 18px !important;
font-weight: 700 !important;
color: #1A1A1A !important;
line-height: 1.45 !important;
}
.post-title a:hover { color: var(--ai) !important; }
/* 포스트 본문 */
.post-body {
font-size: 16px !important;
line-height: 1.85 !important;
color: #374151 !important;
}
.post-body h2 { font-size: 20px !important; margin: 28px 0 12px !important; font-weight: 700 !important; }
.post-body h3 { font-size: 17px !important; margin: 20px 0 10px !important; font-weight: 700 !important; }
.post-body p { margin-bottom: 14px !important; }
.post-body ul, .post-body ol { margin: 12px 0 12px 22px !important; }
.post-body blockquote {
border-left: 4px solid var(--ai) !important;
background: #f5f3ff !important;
padding: 12px 18px !important;
border-radius: 0 8px 8px 0 !important;
margin: 16px 0 !important;
color: #4B5563 !important;
}
.post-body a { color: var(--ai) !important; }
.post-body code {
background: #f3f0ff !important;
color: var(--ai) !important;
padding: 2px 6px !important;
border-radius: 4px !important;
font-size: 14px !important;
}
.post-body pre {
background: #1e1e2e !important;
color: #cdd6f4 !important;
padding: 20px !important;
border-radius: 8px !important;
overflow-x: auto !important;
}
.post-body img {
border-radius: 8px !important;
max-width: 100% !important;
}
.post-body table { width: 100% !important; border-collapse: collapse !important; }
.post-body th, .post-body td {
padding: 10px 14px !important;
border: 1px solid #E5E7EB !important;
font-size: 14px !important;
}
.post-body th { background: #f9fafb !important; font-weight: 700 !important; }
/* 라벨(카테고리) 배지 */
.label-size-1, .label-size-2, .label-size-3,
.label-size-4, .label-size-5,
.post-labels a, .widget.Label li a {
display: inline-block !important;
padding: 3px 10px !important;
border-radius: 20px !important;
font-size: 11px !important;
font-weight: 700 !important;
color: #fff !important;
background: var(--ai) !important;
text-decoration: none !important;
margin-right: 4px !important;
}
/* 메타 정보 */
.post-footer, .post-header { color: #6B7280 !important; font-size: 12px !important; }
/* 사이드바 */
.sidebar-outer, .sidebar .widget {
background: #fff !important;
border-radius: 12px !important;
box-shadow: 0 2px 8px rgba(0,0,0,.07) !important;
margin-bottom: 20px !important;
overflow: hidden !important;
}
.widget-title, h2.title {
font-size: 14px !important;
font-weight: 700 !important;
padding: 16px 20px 12px !important;
margin: 0 !important;
border-bottom: 2px solid var(--ai) !important;
color: #1A1A1A !important;
}
.sidebar .widget ul { list-style: none !important; padding: 0 !important; margin: 0 !important; }
.sidebar .widget ul li { border-bottom: 1px solid #F3F4F6 !important; }
.sidebar .widget ul li a {
display: block !important;
padding: 10px 20px !important;
font-size: 13px !important;
color: #374151 !important;
}
.sidebar .widget ul li a:hover { color: var(--ai) !important; background: #faf5ff !important; }
/* 페이지네이션 */
.blog-pager, #blog-pager {
display: flex !important;
justify-content: center !important;
gap: 12px !important;
margin: 32px 0 !important;
}
.blog-pager a, #blog-pager a {
padding: 8px 20px !important;
background: #fff !important;
border-radius: 8px !important;
font-size: 13px !important;
font-weight: 600 !important;
color: #374151 !important;
box-shadow: 0 2px 6px rgba(0,0,0,.08) !important;
transition: all .2s !important;
}
.blog-pager a:hover, #blog-pager a:hover {
background: var(--ai) !important;
color: #fff !important;
}
/* 반응형 */
@media (max-width: 640px) {
.post-title a { font-size: 16px !important; }
.post-body { font-size: 15px !important; }
}
+4
View File
@@ -0,0 +1,4 @@
"""
blogwriter — Blog Writer CLI package
"""
__version__ = '3.0.0-dev'
+500
View File
@@ -0,0 +1,500 @@
"""
blogwriter/cli.py
Blog Writer MVP CLI - 8 commands
Usage:
bw # Interactive menu
bw write [TOPIC] # Write a blog post
bw shorts # Create a shorts video
bw publish # Publish pending articles
bw distribute # Distribute to SNS platforms
bw status # Show system status
bw doctor # Check API keys and dependencies
bw config show # Show resolved configuration
bw init # Setup wizard (implemented in PR 10)
"""
import json
import logging
import os
import sys
from pathlib import Path
import click
from rich.console import Console
from rich.table import Table
from rich import print as rprint
BASE_DIR = Path(__file__).parent.parent
console = Console()
logger = logging.getLogger(__name__)
def _load_resolved_config() -> dict:
"""Load resolved config from ConfigResolver."""
try:
sys.path.insert(0, str(BASE_DIR))
from bots.config_resolver import ConfigResolver
return ConfigResolver().resolve()
except Exception as e:
return {'error': str(e), 'budget': 'free', 'level': 'beginner'}
@click.group(invoke_without_command=True)
@click.pass_context
def app(ctx):
"""Blog Writer - AI 콘텐츠 자동화 도구 (v3.0)"""
if ctx.invoked_subcommand is None:
_interactive_menu()
def _interactive_menu():
"""Display interactive menu when no subcommand given."""
console.print("\n[bold cyan]Blog Writer v3.0[/bold cyan] - AI 콘텐츠 자동화\n")
console.print("사용 가능한 명령어:")
commands = [
(" bw init", "설정 마법사 - 처음 설정 시 실행"),
(" bw write", "블로그 글 작성"),
(" bw shorts", "쇼츠 영상 생성"),
(" bw publish", "대기 중인 글 발행"),
(" bw distribute", "SNS 플랫폼에 배포"),
(" bw status", "시스템 상태 확인"),
(" bw doctor", "API 키 및 의존성 점검"),
(" bw config show","현재 설정 보기"),
]
for cmd, desc in commands:
console.print(f"[green]{cmd:<20}[/green] {desc}")
console.print()
@app.command()
@click.argument('topic', required=False)
@click.option('--publish', '-p', is_flag=True, help='작성 후 즉시 발행')
@click.option('--shorts', '-s', is_flag=True, help='쇼츠 영상도 생성')
@click.option('--dry-run', is_flag=True, help='실제 API 호출 없이 테스트')
def write(topic, publish, shorts, dry_run):
"""블로그 글 작성."""
cfg = _load_resolved_config()
if dry_run:
console.print("[yellow]Dry run 모드[/yellow] - API 호출 없이 실행")
if not topic:
topic = click.prompt('주제를 입력하세요')
console.print(f"\n[bold]블로그 글 작성 시작[/bold]")
console.print(f"주제: {topic}")
console.print(f"글쓰기 엔진: [cyan]{cfg.get('writing', 'auto')}[/cyan]")
if dry_run:
console.print("[yellow]Dry run 완료 (실제 작성 없음)[/yellow]")
return
try:
sys.path.insert(0, str(BASE_DIR))
from bots.writer_bot import WriterBot
bot = WriterBot()
result = bot.write(topic)
if result:
console.print(f"[green]✓ 작성 완료[/green]: {result.get('title', topic)}")
if publish:
ctx = click.get_current_context()
ctx.invoke(publish_cmd)
if shorts:
ctx = click.get_current_context()
ctx.invoke(shorts_cmd)
else:
console.print("[red]✗ 작성 실패[/red]")
except ImportError:
console.print("[red]writer_bot 로드 실패 - bots/ 경로 확인[/red]")
except Exception as e:
console.print(f"[red]오류: {e}[/red]")
@app.command()
@click.option('--slug', help='특정 글 slug 지정')
@click.option('--text', '-t', help='직접 텍스트 입력 (글 없이 쇼츠 생성)')
@click.option('--dry-run', is_flag=True, help='실제 렌더링 없이 테스트')
def shorts(slug, text, dry_run):
"""쇼츠 영상 생성."""
cfg = _load_resolved_config()
console.print(f"\n[bold]쇼츠 영상 생성[/bold]")
console.print(f"비디오 엔진: [cyan]{cfg.get('video', 'ffmpeg_slides')}[/cyan]")
console.print(f"TTS 엔진: [cyan]{cfg.get('tts', 'edge_tts')}[/cyan]")
if dry_run:
console.print("[yellow]Dry run 모드 - 렌더링 없이 설정 확인 완료[/yellow]")
return
try:
sys.path.insert(0, str(BASE_DIR))
from bots.shorts_bot import ShortsBot
bot = ShortsBot()
if text:
result = bot.create_from_text(text)
elif slug:
result = bot.create_from_slug(slug)
else:
result = bot.create_latest()
if result:
console.print(f"[green]✓ 쇼츠 생성 완료[/green]: {result}")
else:
console.print("[red]✗ 쇼츠 생성 실패[/red]")
except ImportError:
console.print("[red]shorts_bot 로드 실패 - bots/ 경로 확인[/red]")
except Exception as e:
console.print(f"[red]오류: {e}[/red]")
@app.command('publish')
def publish_cmd():
"""대기 중인 글 발행."""
console.print("\n[bold]발행 시작[/bold]")
try:
sys.path.insert(0, str(BASE_DIR))
from bots.publisher_bot import PublisherBot
bot = PublisherBot()
result = bot.publish_pending()
console.print(f"[green]✓ 발행 완료[/green]: {result}")
except ImportError:
console.print("[red]publisher_bot 로드 실패[/red]")
except Exception as e:
console.print(f"[red]오류: {e}[/red]")
@app.command()
@click.option('--to', help='특정 플랫폼으로만 배포 (예: youtube,tiktok)')
def distribute(to):
"""SNS 플랫폼에 콘텐츠 배포."""
platforms = to.split(',') if to else None
console.print(f"\n[bold]배포 시작[/bold]")
if platforms:
console.print(f"대상: {', '.join(platforms)}")
try:
sys.path.insert(0, str(BASE_DIR))
# Use scheduler or direct bot calls
console.print("[yellow]배포 기능은 현재 개발 중입니다[/yellow]")
except Exception as e:
console.print(f"[red]오류: {e}[/red]")
@app.command()
def status():
"""시스템 상태 확인 (대시보드 서버 없이 동작)."""
console.print("\n[bold]시스템 상태[/bold]\n")
cfg = _load_resolved_config()
# Config table
table = Table(title="설정 현황", show_header=True)
table.add_column("항목", style="cyan")
table.add_column("", style="green")
table.add_row("예산", cfg.get('budget', 'N/A'))
table.add_row("레벨", cfg.get('level', 'N/A'))
table.add_row("글쓰기 엔진", str(cfg.get('writing', 'N/A')))
table.add_row("TTS 엔진", str(cfg.get('tts', 'N/A')))
table.add_row("비디오 엔진", str(cfg.get('video', 'N/A')))
table.add_row("플랫폼", ', '.join(cfg.get('platforms', [])))
console.print(table)
# Check data dirs
data_dirs = ['data/shorts', 'data/outputs', 'logs']
console.print("\n[bold]데이터 디렉터리[/bold]")
for d in data_dirs:
path = BASE_DIR / d
exists = "" if path.exists() else ""
count = len(list(path.glob('*'))) if path.exists() else 0
console.print(f" {exists} {d}: {count}개 파일")
# Prompt tracker stats
try:
from bots.prompt_layer.prompt_tracker import PromptTracker
tracker = PromptTracker()
stats = tracker.get_stats()
if stats.get('total', 0) > 0:
console.print(f"\n[bold]프롬프트 로그[/bold]: {stats['total']}건 기록됨")
except Exception:
pass
@app.command()
def doctor():
"""API 키 및 의존성 점검."""
console.print("\n[bold]시스템 점검[/bold]\n")
# Check API keys
api_keys = {
'OPENAI_API_KEY': 'OpenAI (GPT + TTS)',
'ANTHROPIC_API_KEY': 'Anthropic (Claude)',
'GEMINI_API_KEY': 'Google Gemini / Veo',
'ELEVENLABS_API_KEY': 'ElevenLabs TTS',
'KLING_API_KEY': 'Kling AI 영상',
'FAL_API_KEY': 'Seedance 2.0 영상',
'RUNWAY_API_KEY': 'Runway 영상',
'YOUTUBE_CHANNEL_ID': 'YouTube 채널',
}
table = Table(title="API 키 상태", show_header=True)
table.add_column("서비스", style="cyan")
table.add_column("상태", style="bold")
table.add_column("설명")
for key, desc in api_keys.items():
value = os.environ.get(key, '')
if value:
status_str = "[green]✓ 설정됨[/green]"
else:
status_str = "[red]✗ 미설정[/red]"
table.add_row(desc, status_str, key)
console.print(table)
# Check Python dependencies
console.print("\n[bold]의존성 점검[/bold]")
deps = ['click', 'rich', 'edge_tts', 'requests', 'Pillow', 'dotenv']
for dep in deps:
try:
import importlib
importlib.import_module(dep.replace('-', '_').lower().replace('pillow', 'PIL'))
console.print(f" [green]✓[/green] {dep}")
except ImportError:
console.print(f" [red]✗[/red] {dep} - pip install {dep}")
# Check FFmpeg
import subprocess
try:
r = subprocess.run(['ffmpeg', '-version'], capture_output=True, timeout=5)
if r.returncode == 0:
console.print(f" [green]✓[/green] FFmpeg")
else:
console.print(f" [red]✗[/red] FFmpeg - PATH 확인 필요")
except Exception:
console.print(f" [red]✗[/red] FFmpeg - 설치 필요")
@app.group()
def config():
"""설정 관리."""
pass
@config.command('show')
def config_show():
"""현재 해석된 설정 출력."""
cfg = _load_resolved_config()
if 'error' in cfg:
console.print(f"[red]설정 로드 오류: {cfg['error']}[/red]")
return
console.print("\n[bold]현재 설정 (ConfigResolver 기준)[/bold]\n")
table = Table(show_header=True)
table.add_column("항목", style="cyan")
table.add_column("", style="green")
for key, value in cfg.items():
if isinstance(value, list):
value = ', '.join(str(v) for v in value)
elif isinstance(value, dict):
value = json.dumps(value, ensure_ascii=False)
table.add_row(key, str(value))
console.print(table)
@app.command()
def init():
"""설정 마법사 - 처음 설치 시 실행."""
console.print("\n[bold cyan]=== Blog Writer 설정 마법사 ===[/bold cyan]\n")
console.print("몇 가지 질문에 답하면 자동으로 설정이 완성됩니다.\n")
profile = {}
# Step 1: Budget
console.print("[bold]1. 예산 설정[/bold]")
console.print(" free — API 키 없이 무료 도구만 사용")
console.print(" low — OpenAI 키 정도만 있으면 사용 가능")
console.print(" medium — ElevenLabs TTS + AI 영상 사용")
console.print(" premium — 최고 품질 모든 엔진 사용")
budget = click.prompt(
"예산 선택",
type=click.Choice(['free', 'low', 'medium', 'premium']),
default='free'
)
profile['budget'] = budget
# Step 2: Level
console.print("\n[bold]2. 사용자 레벨[/bold]")
console.print(" beginner — 처음 사용하는 분")
console.print(" intermediate — 어느 정도 익숙한 분")
console.print(" advanced — 설정을 직접 다루는 분")
level = click.prompt(
"레벨 선택",
type=click.Choice(['beginner', 'intermediate', 'advanced']),
default='beginner'
)
profile['level'] = level
# Step 3: Platforms
console.print("\n[bold]3. 발행 플랫폼[/bold]")
console.print("어디에 콘텐츠를 올리실 건가요? (여러 개 선택 가능)")
platforms = []
platform_choices = [
('youtube', 'YouTube (쇼츠)'),
('tiktok', 'TikTok'),
('instagram', 'Instagram (릴스)'),
('x', 'X (트위터)'),
('blog', '블로그 (Blogger)'),
]
for key, name in platform_choices:
if click.confirm(f" {name}?", default=(key == 'youtube')):
platforms.append(key)
if not platforms:
platforms = ['youtube'] # default
profile['platforms'] = platforms
# Step 4: Services (free web clients)
console.print("\n[bold]4. 무료 서비스 설정[/bold]")
services = {}
if click.confirm(" ChatGPT Pro(Web) 사용 중이신가요? (글쓰기에 사용)", default=False):
services['openclaw'] = True
console.print(" [yellow]→ OpenClaw 에이전트를 ChatGPT에 등록해야 합니다[/yellow]")
else:
services['openclaw'] = False
if click.confirm(" Claude Max(Web) 사용 중이신가요?", default=False):
services['claude_web'] = True
else:
services['claude_web'] = False
if click.confirm(" Google Gemini Pro(Web) 사용 중이신가요?", default=False):
services['gemini_web'] = True
else:
services['gemini_web'] = False
profile['services'] = services
# Step 5: API Keys
console.print("\n[bold]5. API 키 설정[/bold]")
console.print("[dim]키를 지금 입력하면 .env 파일에 저장됩니다.[/dim]")
console.print("[dim]나중에 .env 파일을 직접 편집해도 됩니다.[/dim]\n")
env_updates = {}
api_key_prompts = [
('OPENAI_API_KEY', 'OpenAI API 키 (GPT + TTS)', budget in ('low', 'medium', 'premium')),
('ANTHROPIC_API_KEY', 'Anthropic API 키 (Claude)', budget in ('medium', 'premium')),
('GEMINI_API_KEY', 'Google Gemini API 키 (Veo 영상)', budget in ('medium', 'premium')),
('ELEVENLABS_API_KEY', 'ElevenLabs TTS 키', budget in ('medium', 'premium')),
('KLING_API_KEY', 'Kling AI 영상 키 (무료 크레딧 있음)', True),
('FAL_API_KEY', 'fal.ai API 키 (Seedance 2.0)', budget in ('medium', 'premium')),
]
for env_key, description, suggested in api_key_prompts:
existing = os.environ.get(env_key, '')
if existing:
console.print(f" [green]✓[/green] {description}: 이미 설정됨")
continue
if suggested or click.confirm(f" {description} 입력하시겠어요?", default=False):
value = click.prompt(
f" {env_key}",
default='',
show_default=False,
hide_input=True,
)
if value.strip():
env_updates[env_key] = value.strip()
# Step 6: Engine preferences
console.print("\n[bold]6. 엔진 설정 (선택 — 기본값: 자동)[/bold]")
profile['engines'] = {
'writing': {'provider': 'auto'},
'tts': {'provider': 'auto'},
'video': {'provider': 'auto'},
'image': {'provider': 'auto'},
}
if click.confirm(" 엔진을 직접 지정하시겠어요? (아니면 자동)", default=False):
# Writing engine
console.print("\n [bold]글쓰기 엔진:[/bold] openclaw, claude_web, claude, gemini, auto")
writing_eng = click.prompt(" 글쓰기 엔진", default='auto')
profile['engines']['writing']['provider'] = writing_eng
# TTS engine
console.print(" [bold]TTS 엔진:[/bold] elevenlabs, openai_tts, edge_tts, auto")
tts_eng = click.prompt(" TTS 엔진", default='auto')
profile['engines']['tts']['provider'] = tts_eng
# Save profile
profile['_comment'] = '사용자 의도 설정 - bw init으로 생성/업데이트'
profile['_updated'] = __import__('datetime').datetime.now().strftime('%Y-%m-%d')
profile_path = BASE_DIR / 'config' / 'user_profile.json'
profile_path.parent.mkdir(parents=True, exist_ok=True)
profile_path.write_text(
__import__('json').dumps(profile, ensure_ascii=False, indent=2),
encoding='utf-8'
)
# Update .env if new keys were entered
if env_updates:
_update_env_file(env_updates)
console.print("\n[bold green]✓ 설정 완료![/bold green]")
console.print(f" user_profile.json 저장됨: {profile_path}")
if env_updates:
console.print(f" .env 업데이트됨: {len(env_updates)}개 키")
console.print("\n다음 명령어로 시작하세요:")
console.print(" [cyan]bw doctor[/cyan] — 설정 확인")
console.print(" [cyan]bw write[/cyan] — 첫 글 작성")
console.print(" [cyan]bw status[/cyan] — 시스템 현황\n")
def _update_env_file(updates: dict) -> None:
"""
Add or update key-value pairs in .env file.
Creates .env if it doesn't exist.
"""
env_path = BASE_DIR / '.env'
# Read existing lines
existing_lines = []
if env_path.exists():
existing_lines = env_path.read_text(encoding='utf-8').splitlines()
# Update existing keys or append new ones
updated_keys = set()
new_lines = []
for line in existing_lines:
if '=' in line and not line.startswith('#'):
key = line.split('=', 1)[0].strip()
if key in updates:
new_lines.append(f'{key}={updates[key]}')
updated_keys.add(key)
continue
new_lines.append(line)
# Append new keys
for key, value in updates.items():
if key not in updated_keys:
new_lines.append(f'{key}={value}')
env_path.write_text('\n'.join(new_lines) + '\n', encoding='utf-8')
logger.info(f'[설정] .env 업데이트: {list(updates.keys())}')
# Entry point
def main():
"""Main entry point."""
app()
if __name__ == '__main__':
main()
+1 -1
View File
@@ -21,7 +21,7 @@ from google.oauth2.credentials import Credentials
from google.auth.transport.requests import Request
from googleapiclient.discovery import build
load_dotenv(dotenv_path='D:/key/blog-writer.env.env')
load_dotenv()
BASE_DIR = Path(__file__).parent.parent
DATA_DIR = BASE_DIR / 'data'
+1 -1
View File
@@ -23,7 +23,7 @@ from typing import Optional
import requests
from dotenv import load_dotenv
load_dotenv(dotenv_path='D:/key/blog-writer.env.env')
load_dotenv()
BASE_DIR = Path(__file__).parent.parent
ASSIST_DIR = BASE_DIR / 'data' / 'assist'
+22 -7
View File
@@ -17,7 +17,7 @@ import requests
from bs4 import BeautifulSoup
from dotenv import load_dotenv
load_dotenv(dotenv_path='D:/key/blog-writer.env.env')
load_dotenv()
BASE_DIR = Path(__file__).parent.parent
CONFIG_DIR = BASE_DIR / 'config'
@@ -95,7 +95,7 @@ def calc_freshness_score(published_at: datetime | None, max_score: int = 20) ->
return int(max_score * ratio)
def calc_korean_relevance(text: str, rules: dict) -> int:
def calc_korean_relevance(text: str, rules: dict, rss_category: str = '') -> int:
"""한국 독자 관련성 점수"""
max_score = rules['scoring']['korean_relevance']['max']
keywords = rules['scoring']['korean_relevance']['keywords']
@@ -107,11 +107,14 @@ def calc_korean_relevance(text: str, rules: dict) -> int:
base = 15 # 한국어 텍스트면 기본 15점
elif korean_ratio >= 0.05:
base = 8
elif rss_category:
# RSS 카테고리가 지정된 영문 소스는 큐레이션된 것이므로 기본점수 부여
base = 10
else:
base = 0
# 브랜드/지역 키워드 보너스
matched = sum(1 for kw in keywords if kw in text)
matched = sum(1 for kw in keywords if kw.lower() in text.lower())
bonus = min(matched * 5, max_score - base)
return min(base + bonus, max_score)
@@ -199,7 +202,11 @@ def apply_discard_rules(item: dict, rules: dict, published_titles: list[str]) ->
def assign_corner(item: dict, topic_type: str) -> str:
"""글감에 코너 배정"""
"""글감에 코너 배정 — RSS 카테고리가 있으면 우선 사용"""
rss_cat = item.get('_rss_category', '')
if rss_cat:
return rss_cat
title = item.get('topic', '').lower()
source = item.get('source', 'rss').lower()
@@ -227,7 +234,7 @@ def calculate_quality_score(item: dict, rules: dict) -> int:
except Exception:
pass
kr_score = calc_korean_relevance(text, rules)
kr_score = calc_korean_relevance(text, rules, rss_category=item.get('_rss_category', ''))
fresh_score = calc_freshness_score(pub_at)
# search_demand: pytrends 연동 후 실제값 사용 (RSS 기본값 12)
search_score = item.get('search_demand_score', 12)
@@ -379,9 +386,15 @@ def collect_rss_feeds(sources_cfg: dict) -> list[dict]:
pub_at = None
if hasattr(entry, 'published_parsed') and entry.published_parsed:
pub_at = datetime(*entry.published_parsed[:6], tzinfo=timezone.utc).isoformat()
title_text = entry.get('title', '')
desc_text = entry.get('summary', '') or entry.get('description', '')
# 한국어 문자가 거의 없으면 영문 소스로 판단
combined = title_text + desc_text
kr_chars = sum(1 for c in combined if '\uac00' <= c <= '\ud7a3')
is_english = kr_chars / max(len(combined), 1) < 0.05
items.append({
'topic': entry.get('title', ''),
'description': entry.get('summary', '') or entry.get('description', ''),
'topic': title_text,
'description': desc_text,
'source': 'rss',
'source_name': feed_cfg.get('name', ''),
'source_url': entry.get('link', ''),
@@ -389,6 +402,8 @@ def collect_rss_feeds(sources_cfg: dict) -> list[dict]:
'search_demand_score': 8,
'topic_type': 'trending',
'_trust_override': trust,
'_rss_category': feed_cfg.get('category', ''),
'is_english': is_english,
})
except Exception as e:
logger.warning(f"RSS 수집 실패 ({url}): {e}")
+249
View File
@@ -0,0 +1,249 @@
"""
bots/config_resolver.py [NEW]
Single source of truth at runtime.
Merges user_profile + engine.json + env.
Priority: user_profile > engine.json > hardcoded defaults
Missing API key → auto-downgrade to free alternative
"""
import json
import logging
import os
import sys
from pathlib import Path
logger = logging.getLogger(__name__)
# Base directory of the project (one level up from bots/)
BASE_DIR = Path(__file__).resolve().parent.parent
# Fallback engine for each category when all else fails
FALLBACKS = {
'writing': 'openclaw',
'tts': 'edge_tts',
'video': 'ffmpeg_slides',
'image': 'external',
}
# Budget-to-engine priority lists per category
BUDGET_ENGINE_MAP = {
'free': {
'writing': ['openclaw', 'claude_web', 'gemini_web'],
'tts': ['kokoro', 'edge_tts'],
'video': ['kling_free', 'ffmpeg_slides'],
'image': ['external'],
},
'low': {
'writing': ['openclaw', 'claude_web', 'claude'],
'tts': ['openai_tts', 'kokoro', 'edge_tts'],
'video': ['kling_free', 'veo3', 'seedance2', 'ffmpeg_slides'],
'image': ['dalle', 'external'],
},
'medium': {
'writing': ['openclaw', 'claude', 'gemini'],
'tts': ['elevenlabs', 'openai_tts', 'cosyvoice2', 'edge_tts'],
'video': ['kling_free', 'veo3', 'seedance2', 'runway', 'ffmpeg_slides'],
'image': ['dalle', 'external'],
},
'premium': {
'writing': ['openclaw', 'claude', 'gemini'],
'tts': ['elevenlabs', 'openai_tts', 'cosyvoice2'],
'video': ['kling_free', 'veo3', 'seedance2', 'runway', 'kling_pro'],
'image': ['dalle', 'midjourney', 'external'],
},
}
# Engine registry: local=True means no API key required (free/local)
ENGINE_REGISTRY = {
'kokoro': {'local': True},
'edge_tts': {'local': True},
'ffmpeg_slides': {'local': True},
'external': {'local': True},
'cosyvoice2': {'local': True},
'openclaw': {'local': True},
'claude_web': {'local': True},
'gemini_web': {'local': True},
# API-based engines
'elevenlabs': {'local': False},
'openai_tts': {'local': False},
'claude': {'local': False},
'gemini': {'local': False},
'kling_free': {'local': False},
'kling_pro': {'local': False},
'veo3': {'local': False},
'seedance2': {'local': False},
'runway': {'local': False},
'dalle': {'local': False},
'midjourney': {'local': False},
}
# Map from engine name to required environment variable
ENGINE_API_KEY_MAP = {
'elevenlabs': 'ELEVENLABS_API_KEY',
'openai_tts': 'OPENAI_API_KEY',
'claude': 'ANTHROPIC_API_KEY',
'gemini': 'GEMINI_API_KEY',
'kling_free': 'KLING_API_KEY',
'kling_pro': 'KLING_API_KEY',
'veo3': 'GEMINI_API_KEY',
'seedance2': 'FAL_API_KEY',
'runway': 'RUNWAY_API_KEY',
'dalle': 'OPENAI_API_KEY',
'midjourney': 'MIDJOURNEY_API_KEY',
}
class ConfigResolver:
"""
Single source of truth at runtime.
Merges user_profile + engine.json + env.
Priority: user_profile > engine.json > hardcoded defaults
Missing API key → auto-downgrade to free alternative
"""
def resolve(self) -> dict:
"""Resolve and return the full runtime configuration."""
profile = self._load('config/user_profile.json')
engine = self._load('config/engine.json')
resolved = {
'writing': self._resolve_engine('writing', profile),
'tts': self._resolve_engine('tts', profile),
'video': self._resolve_engine('video', profile),
'image': self._resolve_engine('image', profile),
'platforms': self._resolve_platforms(profile),
'budget': profile.get('budget', 'free'),
'level': profile.get('level', 'beginner'),
}
return resolved
def _load(self, path: str) -> dict:
"""Load JSON from BASE_DIR/path; return {} if file not found or invalid."""
full_path = BASE_DIR / path
try:
with open(full_path, encoding='utf-8') as f:
return json.load(f)
except FileNotFoundError:
print(f"[설정] {path} 없음 — 기본값 사용", file=sys.stderr)
return {}
except json.JSONDecodeError as e:
print(f"[설정] {path} 파싱 오류: {e} — 기본값 사용", file=sys.stderr)
return {}
def _has_api_key(self, engine_name: str) -> bool:
"""
Check whether the required API key env var for the given engine is set.
Engines not in ENGINE_API_KEY_MAP are local/free and always available.
"""
# Local/free engines never need a key
engine_info = ENGINE_REGISTRY.get(engine_name, {})
if engine_info.get('local', False):
return True
env_var = ENGINE_API_KEY_MAP.get(engine_name)
if env_var is None:
# Unknown engine — treat as available (graceful degradation)
logger.warning(
"Unknown engine '%s': not in ENGINE_API_KEY_MAP or ENGINE_REGISTRY as local; "
"treating as available.",
engine_name,
)
return True
value = os.environ.get(env_var, '').strip()
return len(value) > 0
def _resolve_engine(self, category: str, profile: dict) -> dict:
"""
Resolve the active engine for a category.
Steps:
1. Check user's chosen provider from profile
2. Check if that provider's API key exists in env
3. If not, auto-switch to next available alternative within budget
4. If all fail, use hardcoded free fallback
Returns dict with 'provider' and 'auto_selected' flag.
"""
budget = profile.get('budget', 'free')
if budget not in BUDGET_ENGINE_MAP:
logger.warning(
"Invalid budget value '%s' from profile; falling back to 'free'.",
budget,
)
budget = 'free'
candidate_list = BUDGET_ENGINE_MAP[budget].get(category, [])
# Determine user's preferred provider
engines_section = profile.get('engines', {})
category_cfg = engines_section.get(category, {})
user_provider = category_cfg.get('provider', 'auto') if isinstance(category_cfg, dict) else 'auto'
# If user explicitly set a provider (not "auto"), try it first
if user_provider and user_provider != 'auto':
if self._has_api_key(user_provider):
print(f"[설정] {category}: 사용자 지정 '{user_provider}' 사용")
return {'provider': user_provider, 'auto_selected': False}
else:
print(f"[설정] {category}: '{user_provider}' API 키 없음 — 자동 선택으로 전환")
# Auto-select: iterate budget-appropriate candidates in priority order
for engine_name in candidate_list:
if self._has_api_key(engine_name):
auto = (user_provider == 'auto')
if not auto:
print(f"[설정] {category}: '{engine_name}'으로 자동 전환")
else:
print(f"[설정] {category}: 자동 선택 → '{engine_name}'")
return {'provider': engine_name, 'auto_selected': True}
# Last resort: hardcoded free fallback
fallback = FALLBACKS.get(category, 'external')
print(f"[설정] {category}: 모든 엔진 실패 — 기본 폴백 '{fallback}' 사용")
return {'provider': fallback, 'auto_selected': True}
def _resolve_platforms(self, profile: dict) -> list:
"""Return the list of target publishing platforms from user profile."""
platforms = profile.get('platforms', [])
if not isinstance(platforms, list):
return [str(platforms)] if platforms else []
return platforms
# ---------------------------------------------------------------------------
# Standalone test entry point
# ---------------------------------------------------------------------------
def _run_test():
"""Print resolved config for manual verification."""
print("=" * 60)
print("ConfigResolver 테스트 실행")
print("=" * 60)
resolver = ConfigResolver()
config = resolver.resolve()
print("\n[결과] 런타임 설정:")
print(json.dumps(config, ensure_ascii=False, indent=2))
print("\n[요약]")
print(f" 예산 등급 : {config['budget']}")
print(f" 사용자 레벨: {config['level']}")
print(f" 플랫폼 : {config['platforms']}")
for cat in ('writing', 'tts', 'video', 'image'):
eng = config[cat]
flag = '(자동)' if eng.get('auto_selected') else '(지정)'
print(f" {cat:10s}: {eng['provider']} {flag}")
print("=" * 60)
print("테스트 완료")
if __name__ == '__main__':
if '--test' in sys.argv:
_run_test()
else:
print("사용법: python -m bots.config_resolver --test")
+1 -1
View File
@@ -32,7 +32,7 @@ from typing import Optional
from dotenv import load_dotenv
load_dotenv(dotenv_path='D:/key/blog-writer.env.env')
load_dotenv()
BASE_DIR = Path(__file__).parent.parent.parent
LOG_DIR = BASE_DIR / 'logs'
+352
View File
@@ -0,0 +1,352 @@
"""
bots/converters/smart_video_router.py [NEW]
Budget-aware video engine selection and fallback router.
Selection logic:
1. Kling free credits remaining? → use Kling
2. Budget allows paid? → cheapest quality engine
3. Daily limit hit? → FFmpeg fallback
4. Any engine fails? → next in priority (no retry on same)
Usage:
from bots.converters.smart_video_router import SmartVideoRouter
router = SmartVideoRouter(resolved_config)
engine = router.select(duration_sec=30, needs_audio=True)
path = router.generate(prompt, engine, '/tmp/out.mp4')
Test mode:
python -m bots.converters.smart_video_router --test
"""
import json
import logging
import os
import sys
from datetime import date
from pathlib import Path
from typing import Optional
from dotenv import load_dotenv
load_dotenv() # load .env from current directory or parents
BASE_DIR = Path(__file__).parent.parent.parent
LOG_DIR = BASE_DIR / 'logs'
DATA_DIR = BASE_DIR / 'data'
STATE_FILE = DATA_DIR / 'video_router_state.json'
LOG_DIR.mkdir(exist_ok=True)
DATA_DIR.mkdir(exist_ok=True)
logger = logging.getLogger(__name__)
if not logger.handlers:
handler = logging.FileHandler(LOG_DIR / 'smart_video_router.log', encoding='utf-8')
handler.setFormatter(logging.Formatter('%(asctime)s [%(levelname)s] %(message)s'))
logger.addHandler(handler)
logger.addHandler(logging.StreamHandler())
logger.setLevel(logging.INFO)
class SmartVideoRouter:
"""
Budget-aware video engine selection and fallback.
Logic:
1. Kling free credits remaining? → use Kling
2. Budget allows paid? → cheapest quality engine
3. Daily limit hit? → FFmpeg fallback
4. Any engine fails? → next in priority (no retry on same)
"""
def __init__(self, resolved_config: dict):
"""
resolved_config: output from ConfigResolver.resolve(), or raw engine.json dict.
Expects video_generation key with provider/options structure.
"""
video_cfg = resolved_config.get('video_generation', {})
opts = video_cfg.get('options', {})
router_cfg = opts.get('smart_router', {})
self.priority: list = router_cfg.get(
'priority', ['kling_free', 'veo3', 'seedance2', 'ffmpeg_slides']
)
self.daily_cost_limit_usd: float = router_cfg.get('daily_cost_limit_usd', 0.50)
self.prefer_free_first: bool = router_cfg.get('prefer_free_first', True)
self.fallback_engine: str = router_cfg.get('fallback', 'ffmpeg_slides')
self.engine_opts: dict = opts # all engine option blocks
self._cfg: dict = video_cfg # full video_generation config block
self.state: dict = self._get_state()
# ── State management ────────────────────────────────────
def _get_state(self) -> dict:
"""Load daily state from disk; reset if date has changed."""
today = str(date.today())
default = {
'date': today,
'cost_usd': 0.0,
'kling_credits_used': 0,
}
if STATE_FILE.exists():
try:
saved = json.loads(STATE_FILE.read_text(encoding='utf-8'))
if saved.get('date') == today:
return saved
# New day — reset counters, keep structure
logger.info(f"날짜 변경 감지 ({saved.get('date')}{today}): 라우터 상태 초기화")
except Exception as e:
logger.warning(f"상태 파일 읽기 실패: {e}")
self._save_state(default)
return default
def _save_state(self, state: Optional[dict] = None) -> None:
"""Persist router state to data/video_router_state.json."""
target = state if state is not None else self.state
try:
STATE_FILE.write_text(
json.dumps(target, ensure_ascii=False, indent=2),
encoding='utf-8',
)
except Exception as e:
logger.warning(f"상태 파일 저장 실패: {e}")
# ── Engine availability checks ───────────────────────────
def _has_api_key(self, engine_name_or_cfg) -> bool:
"""Return True if the engine's API key env var is set and non-empty.
Accepts either an engine name string or a dict with 'api_key_env' key.
"""
if isinstance(engine_name_or_cfg, dict):
cfg = engine_name_or_cfg
else:
cfg = self.engine_opts.get(engine_name_or_cfg, {})
key_env = cfg.get('api_key_env', '')
if not key_env:
# ffmpeg_slides has no API key requirement
return True
return bool(os.getenv(key_env, '').strip())
def _kling_credits_available(self) -> bool:
"""Return True if Kling free credits are still available today."""
kling_cfg = self.engine_opts.get('kling_free', {})
daily_credits = kling_cfg.get('free_daily_credits', 66)
used = self.state.get('kling_credits_used', 0)
return used < daily_credits
def _budget_allows(self, engine_name: str, duration_sec: float) -> bool:
"""Return True if engine cost fits within remaining daily budget."""
cfg = self.engine_opts.get(engine_name, {})
cost_per_sec = cfg.get('cost_per_sec', 0)
if cost_per_sec == 0:
return True
estimated_cost = cost_per_sec * duration_sec
spent = self.state.get('cost_usd', 0.0)
return (spent + estimated_cost) <= self.daily_cost_limit_usd
# ── Public API ────────────────────────────────────────────
def select(self, duration_sec: float, needs_audio: bool) -> str:
"""
Select best available engine for the given clip duration.
Returns engine name string (never empty — falls back to ffmpeg_slides).
"""
self.state = self._get_state() # refresh in case of date change
for engine in self.priority:
if engine == 'ffmpeg_slides':
logger.info("영상 라우터: ffmpeg_slides 선택 (최종 폴백)")
return 'ffmpeg_slides'
if engine == 'kling_free':
if self._has_api_key('kling_free') and self._kling_credits_available():
logger.info("영상 라우터: kling_free 선택 (무료 크레딧 잔여)")
return 'kling_free'
continue
# Paid engines (veo3, seedance2, ...)
if self._has_api_key(engine) and self._budget_allows(engine, duration_sec):
logger.info(f"영상 라우터: {engine} 선택 (예산 내 유료 엔진)")
return engine
# Final safety net
logger.info("영상 라우터: ffmpeg_slides 최종 폴백 선택")
return self.fallback_engine
def generate(self, prompt, engine: str, output_path: str) -> str:
"""
Generate a video clip using the specified engine.
prompt: ComposedPrompt object with .text attribute, or plain str.
Returns path to output MP4, or '' on failure.
"""
# Normalise prompt to str
if hasattr(prompt, 'text'):
prompt_text = prompt.text
else:
prompt_text = str(prompt)
logger.info(f"영상 생성 시작: 엔진={engine}, 출력={output_path}")
if engine == 'kling_free':
result = self._generate_kling(prompt_text, output_path)
elif engine == 'ffmpeg_slides':
result = self._generate_ffmpeg(prompt_text, output_path)
else:
# veo3, seedance2, runway — V3.1 구현 예정, ffmpeg_slides로 자동 폴백
logger.warning(f"{engine} 구현 미완성 — ffmpeg_slides로 자동 폴백")
result = self._generate_ffmpeg(prompt_text, output_path)
if result:
# Update cost tracking
cfg = self.engine_opts.get(engine, {})
cost_per_sec = cfg.get('cost_per_sec', 0)
if cost_per_sec > 0:
# Estimate 30s clip cost as a rough default
self.state['cost_usd'] = round(
self.state.get('cost_usd', 0.0) + cost_per_sec * 30, 4
)
self._save_state()
logger.info(f"영상 생성 완료: {result}")
else:
logger.warning(f"영상 생성 실패: 엔진={engine}")
return result
def on_failure(self, engine: str, error: str) -> str:
"""Called when engine fails. Returns next available engine."""
logger.warning(f"[영상] 엔진 실패: {engine}{error}")
priority = self._cfg.get('options', {}).get('smart_router', {}).get(
'priority', ['kling_free', 'veo3', 'seedance2', 'ffmpeg_slides']
)
# Find next available engine after the failed one
try:
idx = priority.index(engine)
candidates = priority[idx + 1:]
except ValueError:
candidates = priority
for candidate in candidates:
if candidate == 'ffmpeg_slides':
return 'ffmpeg_slides' # always available
engine_opts = self._cfg.get('options', {}).get(candidate, {})
api_key_env = engine_opts.get('api_key_env', '')
if self._has_api_key({'api_key_env': api_key_env}):
logger.info(f"[영상] 다음 엔진으로 전환: {candidate}")
return candidate
logger.warning("[영상] 사용 가능한 엔진 없음 — ffmpeg_slides로 폴백")
return 'ffmpeg_slides'
# ── Engine implementations ────────────────────────────────
def _generate_kling(self, prompt_text: str, output_path: str) -> str:
"""
Kling free tier stub implementation.
The actual Kling API integration is pending (V3.1).
For now, log that the call would be made and fall back to ffmpeg_slides.
"""
api_key = os.getenv('KLING_API_KEY', '')
if not api_key:
logger.warning("KLING_API_KEY 미설정 — ffmpeg_slides 폴백")
return self._generate_ffmpeg(prompt_text, output_path)
kling_cfg = self.engine_opts.get('kling_free', {})
api_url = kling_cfg.get('api_url', 'https://api.klingai.com/v1')
# Stub: log what would happen, then fall back
logger.info(
f"[스텁] Kling API 호출 예정: POST {api_url}/videos/text2video "
f"(프롬프트: {prompt_text[:60]}...) — 실제 통합 V3.1에서 구현 예정"
)
logger.info("Kling 스텁 실행 — ffmpeg_slides로 폴백하여 영상 생성")
# Track credit usage even for stub (as if 1 credit consumed per call)
self.state['kling_credits_used'] = self.state.get('kling_credits_used', 0) + 1
self._save_state()
return self._generate_ffmpeg(prompt_text, output_path)
def _generate_ffmpeg(self, prompt_text: str, output_path: str) -> str:
"""
Generate a minimal single-scene video using FFmpegSlidesEngine.
Accepts a plain text prompt and wraps it into a scene list.
"""
try:
from bots.converters.video_engine import FFmpegSlidesEngine
ffmpeg_cfg = self.engine_opts.get('ffmpeg_slides', {})
engine = FFmpegSlidesEngine(ffmpeg_cfg)
# Wrap prompt into minimal scene structure expected by FFmpegSlidesEngine
scenes = [
{
'text': prompt_text[:200], # truncate if very long
'type': 'headline',
}
]
return engine.generate(scenes, output_path)
except Exception as e:
logger.error(f"FFmpegSlidesEngine 실패: {e}")
return ''
# ── Module entry point (--test mode) ─────────────────────────
def _load_engine_config() -> dict:
"""Load engine.json from config directory."""
config_path = BASE_DIR / 'config' / 'engine.json'
try:
return json.loads(config_path.read_text(encoding='utf-8'))
except Exception as e:
logger.error(f"engine.json 로드 실패: {e}")
return {}
def _run_test() -> None:
"""Print current router state and selected engine for a 30s clip."""
print("=" * 60)
print("SmartVideoRouter - 테스트 모드")
print("=" * 60)
config = _load_engine_config()
if not config:
print("[오류] engine.json 로드 실패")
sys.exit(1)
router = SmartVideoRouter(config)
print("\n[현재 상태]")
state = router._get_state()
for k, v in state.items():
print(f" {k}: {v}")
print("\n[엔진 우선순위]")
for i, eng in enumerate(router.priority, 1):
has_key = router._has_api_key(eng)
key_env = router.engine_opts.get(eng, {}).get('api_key_env', '(없음)')
print(f" {i}. {eng} - API키={key_env} 설정됨={has_key}")
print("\n[30초 클립 엔진 선택]")
selected = router.select(duration_sec=30, needs_audio=True)
print(f" → 선택된 엔진: {selected}")
cost_spent = state.get('cost_usd', 0.0)
cost_limit = router.daily_cost_limit_usd
kling_used = state.get('kling_credits_used', 0)
kling_limit = router.engine_opts.get('kling_free', {}).get('free_daily_credits', 66)
print(f"\n[예산 현황]")
print(f" 일일 비용: ${cost_spent:.4f} / ${cost_limit:.2f}")
print(f" Kling 크레딧: {kling_used} / {kling_limit} 사용")
print("=" * 60)
if __name__ == '__main__':
if '--test' in sys.argv:
_run_test()
else:
print("사용법: python -m bots.converters.smart_video_router --test")
+1 -19
View File
@@ -6,7 +6,6 @@
지원 엔진:
- FFmpegSlidesEngine: 기존 shorts_converter.py 파이프라인 (슬라이드 + TTS + ffmpeg)
- SeedanceEngine: Seedance 2.0 API (AI 영상 생성)
- SoraEngine: OpenAI Sora (미지원 → ffmpeg_slides 폴백)
- RunwayEngine: Runway Gen-3 API
- VeoEngine: Google Veo 3.1 (미지원 → ffmpeg_slides 폴백)
"""
@@ -23,7 +22,7 @@ from typing import Optional
from dotenv import load_dotenv
load_dotenv(dotenv_path='D:/key/blog-writer.env.env')
load_dotenv()
BASE_DIR = Path(__file__).parent.parent.parent
LOG_DIR = BASE_DIR / 'logs'
@@ -588,22 +587,6 @@ class SeedanceEngine(VideoEngine):
return self._fallback(scenes, output_path, **kwargs)
# ─── SoraEngine ────────────────────────────────────────
class SoraEngine(VideoEngine):
"""
OpenAI Sora 영상 생성 엔진.
현재 API 공개 접근 불가 — ffmpeg_slides로 폴백.
"""
def __init__(self, cfg: dict):
self.cfg = cfg
def generate(self, scenes: list, output_path: str, **kwargs) -> str:
logger.warning("Sora API 미지원. ffmpeg_slides로 폴백.")
return FFmpegSlidesEngine(self.cfg).generate(scenes, output_path, **kwargs)
# ─── RunwayEngine ──────────────────────────────────────
class RunwayEngine(VideoEngine):
@@ -774,7 +757,6 @@ def get_engine(video_cfg: dict) -> VideoEngine:
engine_map = {
'ffmpeg_slides': FFmpegSlidesEngine,
'seedance': SeedanceEngine,
'sora': SoraEngine,
'runway': RunwayEngine,
'veo': VeoEngine,
}
+1 -1
View File
@@ -19,7 +19,7 @@ from pathlib import Path
import requests
from dotenv import load_dotenv
load_dotenv(dotenv_path='D:/key/blog-writer.env.env')
load_dotenv()
BASE_DIR = Path(__file__).parent.parent.parent
LOG_DIR = BASE_DIR / 'logs'
+1 -1
View File
@@ -19,7 +19,7 @@ from pathlib import Path
import requests
from dotenv import load_dotenv
load_dotenv(dotenv_path='D:/key/blog-writer.env.env')
load_dotenv()
BASE_DIR = Path(__file__).parent.parent.parent
LOG_DIR = BASE_DIR / 'logs'
+1 -1
View File
@@ -16,7 +16,7 @@ from pathlib import Path
import requests
from dotenv import load_dotenv
load_dotenv(dotenv_path='D:/key/blog-writer.env.env')
load_dotenv()
BASE_DIR = Path(__file__).parent.parent.parent
LOG_DIR = BASE_DIR / 'logs'
+1 -1
View File
@@ -16,7 +16,7 @@ import requests
from dotenv import load_dotenv
from requests_oauthlib import OAuth1
load_dotenv(dotenv_path='D:/key/blog-writer.env.env')
load_dotenv()
BASE_DIR = Path(__file__).parent.parent.parent
LOG_DIR = BASE_DIR / 'logs'
+1 -1
View File
@@ -14,7 +14,7 @@ from pathlib import Path
from dotenv import load_dotenv
load_dotenv(dotenv_path='D:/key/blog-writer.env.env')
load_dotenv()
BASE_DIR = Path(__file__).parent.parent.parent
LOG_DIR = BASE_DIR / 'logs'
+1 -1
View File
@@ -20,7 +20,7 @@ from typing import Any, Optional
from dotenv import load_dotenv
load_dotenv(dotenv_path='D:/key/blog-writer.env.env')
load_dotenv()
BASE_DIR = Path(__file__).parent.parent
CONFIG_PATH = BASE_DIR / 'config' / 'engine.json'
+1 -1
View File
@@ -25,7 +25,7 @@ from pathlib import Path
import requests
from dotenv import load_dotenv
load_dotenv(dotenv_path='D:/key/blog-writer.env.env')
load_dotenv()
BASE_DIR = Path(__file__).parent.parent
DATA_DIR = BASE_DIR / 'data'
+1 -1
View File
@@ -16,7 +16,7 @@ import requests
from bs4 import BeautifulSoup
from dotenv import load_dotenv
load_dotenv(dotenv_path='D:/key/blog-writer.env.env')
load_dotenv()
BASE_DIR = Path(__file__).parent.parent
CONFIG_DIR = BASE_DIR / 'config'
+1 -1
View File
@@ -12,7 +12,7 @@ from pathlib import Path
from dotenv import load_dotenv
load_dotenv(dotenv_path='D:/key/blog-writer.env.env')
load_dotenv()
BASE_DIR = Path(__file__).parent.parent.parent
sys.path.insert(0, str(BASE_DIR / 'bots'))
+1 -1
View File
@@ -11,7 +11,7 @@ from pathlib import Path
from dotenv import load_dotenv
load_dotenv(dotenv_path='D:/key/blog-writer.env.env')
load_dotenv()
BASE_DIR = Path(__file__).parent.parent.parent
sys.path.insert(0, str(BASE_DIR / 'bots'))
+1 -1
View File
@@ -18,7 +18,7 @@ from pathlib import Path
from dotenv import load_dotenv
load_dotenv(dotenv_path='D:/key/blog-writer.env.env')
load_dotenv()
BASE_DIR = Path(__file__).parent.parent.parent
sys.path.insert(0, str(BASE_DIR / 'bots'))
+1 -1
View File
@@ -13,7 +13,7 @@ from pathlib import Path
from dotenv import load_dotenv
load_dotenv(dotenv_path='D:/key/blog-writer.env.env')
load_dotenv()
# novel/ 폴더 기준으로 BASE_DIR 설정
BASE_DIR = Path(__file__).parent.parent.parent
+43
View File
@@ -0,0 +1,43 @@
"""
bots/prompt_layer/__init__.py
Unified entry point for all prompt composition.
V3.0 scope: video + search + tts categories
V3.1+: expand to all categories
"""
from .base import ComposedPrompt
from .video_prompt import KlingPromptFormatter, VeoPromptFormatter
from .search_query import StockSearchQueryComposer
def compose(category: str, input_data: dict, engine: str) -> 'ComposedPrompt':
"""
Unified entry point for all prompt composition.
category: 'video' | 'search' | 'tts' | 'image' | 'writing' | 'caption'
input_data: category-specific dict
engine: target engine name
V3.0 scope: video + search only
V3.1+: expand to all categories
"""
composer = _get_composer(category, engine)
return composer.compose(input_data, engine)
def _get_composer(category: str, engine: str):
"""Return appropriate composer for category+engine combination."""
if category == 'video':
if engine in ('kling_free', 'kling_pro'):
return KlingPromptFormatter()
else:
return VeoPromptFormatter()
elif category == 'search':
return StockSearchQueryComposer()
else:
# Fallback: return a passthrough composer for unsupported categories
from .base import PassthroughComposer
return PassthroughComposer()
__all__ = ['compose', 'ComposedPrompt']
+41
View File
@@ -0,0 +1,41 @@
"""
bots/prompt_layer/base.py
Base types for the prompt layer.
"""
from dataclasses import dataclass, field
from typing import Optional
@dataclass
class ComposedPrompt:
"""
Unified prompt container returned by all composers.
Fields used varies by engine:
- Kling: positive + negative
- Veo: positive (structured)
- Search: queries list
- TTS: processed_text
"""
positive: str = ''
negative: str = ''
queries: list[str] = field(default_factory=list)
processed_text: str = ''
metadata: dict = field(default_factory=dict)
def __bool__(self) -> bool:
return bool(self.positive or self.queries or self.processed_text)
class BaseComposer:
"""Abstract base for all composers."""
def compose(self, input_data: dict, engine: str) -> ComposedPrompt:
raise NotImplementedError
class PassthroughComposer(BaseComposer):
"""Returns input as-is for unsupported categories."""
def compose(self, input_data: dict, engine: str) -> ComposedPrompt:
return ComposedPrompt(
positive=input_data.get('text', ''),
metadata={'passthrough': True, 'engine': engine}
)
+442
View File
@@ -0,0 +1,442 @@
"""
bots/prompt_layer/korean_preprocessor.py
Korean TTS text preprocessing.
Functions:
- preprocess_korean(text): apply pronunciation map + number conversion
- insert_pauses(script): insert SSML/marker pauses by sentence type
"""
import re
import logging
logger = logging.getLogger(__name__)
# English/acronym → Korean phonetic pronunciation
# 200+ entries covering tech, finance, social media, brands, etc.
PRONUNCIATION_MAP = {
# AI/Tech terms
'AI': '에이아이',
'API': '에이피아이',
'GPT': '지피티',
'ChatGPT': '챗지피티',
'Claude': '클로드',
'GitHub': '깃허브',
'OpenAI': '오픈에이아이',
'YouTube': '유튜브',
'TikTok': '틱톡',
'SEO': '에스이오',
'SaaS': '사스',
'UI': '유아이',
'UX': '유엑스',
'LLM': '엘엘엠',
'NFT': '엔에프티',
'DeFi': '디파이',
'IoT': '아이오티',
'AR': '에이알',
'VR': '브이알',
'ML': '머신러닝',
'NLP': '엔엘피',
'DevOps': '데브옵스',
'SQL': '에스큐엘',
'HTML': '에이치티엠엘',
'CSS': '씨에스에스',
'JSON': '제이슨',
'URL': '유알엘',
'HTTP': '에이치티티피',
'HTTPS': '에이치티티피에스',
'PC': '피씨',
'CPU': '씨피유',
'GPU': '지피유',
'RAM': '',
'SSD': '에스에스디',
'USB': '유에스비',
'WiFi': '와이파이',
'Bluetooth': '블루투스',
'iOS': '아이오에스',
'Android': '안드로이드',
'App': '',
'IT': '아이티',
'ICT': '아이씨티',
'SNS': '에스엔에스',
'KPI': '케이피아이',
'ROI': '알오아이',
'B2B': '비투비',
'B2C': '비투씨',
'MVP': '엠브이피',
'OKR': '오케이알',
'CTO': '씨티오',
'CEO': '씨이오',
'CFO': '씨에프오',
'HR': '에이치알',
'PR': '피알',
'IR': '아이알',
# Social/Platforms
'Instagram': '인스타그램',
'Facebook': '페이스북',
'Twitter': '트위터',
'LinkedIn': '링크드인',
'Netflix': '넷플릭스',
'Spotify': '스포티파이',
'Uber': '우버',
'Airbnb': '에어비앤비',
'Amazon': '아마존',
'Google': '구글',
'Apple': '애플',
'Microsoft': '마이크로소프트',
'Samsung': '삼성',
'LG': '엘지',
'SK': '에스케이',
'KT': '케이티',
# Finance
'ETF': '이티에프',
'IPO': '아이피오',
'S&P': '에스앤피',
'NASDAQ': '나스닥',
'KOSPI': '코스피',
'KOSDAQ': '코스닥',
'GDP': '지디피',
'IMF': '아이엠에프',
'ECB': '이씨비',
'Fed': '연준',
'P/E': '주가수익비율',
# Health/Science
'DNA': '디엔에이',
'RNA': '알엔에이',
'BMI': '비엠아이',
'COVID': '코비드',
'PCR': '피씨알',
# Education/Certification
'MBA': '엠비에이',
'PhD': '박사',
'IELTS': '아이엘츠',
'TOEIC': '토익',
'TOEFL': '토플',
# Measurement units
'km': '킬로미터',
'kg': '킬로그램',
'MB': '메가바이트',
'GB': '기가바이트',
'TB': '테라바이트',
'Hz': '헤르츠',
'MHz': '메가헤르츠',
'GHz': '기가헤르츠',
# Media/Entertainment
'OTT': '오티티',
'VOD': '브이오디',
'BGM': '비지엠',
'OST': '오에스티',
'DJ': '디제이',
'MC': '엠씨',
'PD': '피디',
'CP': '씨피',
# Common English words used in Korean context
'App Store': '앱 스토어',
'Play Store': '플레이 스토어',
'ChatBot': '챗봇',
'Web3': '웹쓰리',
'Metaverse': '메타버스',
'Blockchain': '블록체인',
'Crypto': '크립토',
'Bitcoin': '비트코인',
'Ethereum': '이더리움',
'Cloud': '클라우드',
'Big Data': '빅데이터',
'Startup': '스타트업',
'Fintech': '핀테크',
'Edtech': '에드테크',
'Healthtech': '헬스테크',
'PropTech': '프롭테크',
'LegalTech': '리걸테크',
'FOMO': '포모',
'YOLO': '욜로',
'MZ': '엠제트',
# More tech
'Python': '파이썬',
'JavaScript': '자바스크립트',
'TypeScript': '타입스크립트',
'React': '리액트',
'Node.js': '노드제이에스',
'Docker': '도커',
'Kubernetes': '쿠버네티스',
'AWS': '에이더블유에스',
'GCP': '지씨피',
'Azure': '애저',
'Slack': '슬랙',
'Zoom': '',
'Discord': '디스코드',
'Notion': '노션',
'Figma': '피그마',
'Canva': '캔바',
# Business/Strategy
'OEM': '오이엠',
'ODM': '오디엠',
'SCM': '에스씨엠',
'ERP': '이알피',
'CRM': '씨알엠',
# More social media
'Reels': '릴스',
'Stories': '스토리',
'Live': '라이브',
'Feed': '피드',
'DM': '디엠',
'PM': '피엠',
'QA': '큐에이',
# Content
'Blog': '블로그',
'Vlog': '브이로그',
'Podcast': '팟캐스트',
'Newsletter': '뉴스레터',
'Shorts': '쇼츠',
'Reel': '',
# Misc
'OK': '오케이',
'NO': '',
'YES': '예스',
'WOW': '와우',
'LOL': '엘오엘',
'BTW': '그런데',
'FYI': '참고로',
'ASAP': '최대한 빨리',
'FAQ': '자주 묻는 질문',
'Q&A': '질의응답',
'A/S': '에이에스',
'DIY': '디아이와이',
'PPT': '피피티',
'PDF': '피디에프',
'ZIP': '',
# AI/LLM extended
'Gemini': '제미나이',
'Grok': '그록',
'Copilot': '코파일럿',
'Perplexity': '퍼플렉시티',
'Midjourney': '미드저니',
'Stable Diffusion': '스테이블 디퓨전',
'DALL-E': '달리',
'Sora': '소라',
'Kling': '클링',
'Runway': '런웨이',
# Dev tools / infra
'Git': '',
'Linux': '리눅스',
'Ubuntu': '우분투',
'Windows': '윈도우',
'macOS': '맥오에스',
'Terminal': '터미널',
'CI/CD': '씨아이씨디',
'API Gateway': '에이피아이 게이트웨이',
# Finance extended
'PER': '주가수익비율',
'PBR': '주가순자산비율',
'EPS': '주당순이익',
'ROE': '자기자본이익률',
'CAGR': '연평균성장률',
# E-commerce / marketing
'CPC': '클릭당비용',
'CPM': '천회노출당비용',
'CTA': '씨티에이',
'CTR': '클릭률',
'ROAS': '광고수익률',
'LTV': '고객생애가치',
}
# Pause durations in milliseconds by sentence type
DYNAMIC_PAUSES = {
'hook_after': 500, # ms — impact emphasis after hook
'question_after': 400, # thinking time after question
'normal_after': 300, # standard sentence end
'section_break': 600, # body → closer transition
'comma': 150, # comma pause
'exclamation': 200, # exclamation mark pause
}
# Number → Korean word conversion rules
_NUM_TO_KO = {
0: '', 1: '', 2: '', 3: '', 4: '', 5: '',
6: '', 7: '', 8: '', 9: '', 10: '',
100: '', 1000: '', 10000: '',
}
# Counter words for common units (for better number reading)
_COUNTER_MAP = {
'': ('', False), # items
'': ('', False), # people
'': ('', False), # times
'': ('', False), # times/multiples
'': ('', False), # rank
'가지': ('가지', True), # types (use sino-Korean)
'': ('', False), # seconds
'': ('', False), # minutes
'시간': ('시간', False), # hours
'': ('', False), # days
'': ('', False), # months
'': ('', False), # years
'%': ('퍼센트', False), # percent
}
def preprocess_korean(text: str) -> str:
"""
Apply pronunciation map and number conversion to Korean text.
1. Replace English/acronym terms with Korean phonetics
2. Convert Arabic numerals with counter words to Korean
Returns processed text ready for TTS.
"""
# Apply pronunciation map (longer strings first to avoid partial replacement)
sorted_map = sorted(PRONUNCIATION_MAP.items(), key=lambda x: -len(x[0]))
for en, ko in sorted_map:
# Word boundary replacement to avoid partial matches
text = re.sub(r'(?<![가-힣\w])' + re.escape(en) + r'(?![가-힣\w])', ko, text)
# Convert numbers
text = _convert_numbers(text)
return text
def _convert_numbers(text: str) -> str:
"""
Convert Arabic numerals in Korean context.
e.g.: "3가지" "세 가지", "100%" "백 퍼센트"
"""
# Handle percentage
text = re.sub(r'(\d+)%', lambda m: _num_to_korean(int(m.group(1))) + ' 퍼센트', text)
# Handle number + counter word
for counter, (ko_counter, use_sino) in _COUNTER_MAP.items():
if counter == '%':
continue
pattern = r'(\d+)\s*' + re.escape(counter)
def replace(m, kc=ko_counter):
n = int(m.group(1))
return _num_to_korean(n) + ' ' + kc
text = re.sub(pattern, replace, text)
return text
def _num_to_korean(n: int) -> str:
"""Convert integer to Korean sino-Korean numeral string."""
if n == 0:
return ''
if n < 0:
return '마이너스 ' + _num_to_korean(-n)
result = ''
if n >= 10000:
man = n // 10000
result += _num_to_korean(man) + ''
n %= 10000
if n >= 1000:
cheon = n // 1000
result += ('' if cheon == 1 else _num_to_korean(cheon)) + ''
n %= 1000
if n >= 100:
baek = n // 100
result += ('' if baek == 1 else _num_to_korean(baek)) + ''
n %= 100
if n >= 10:
sip = n // 10
result += ('' if sip == 1 else _num_to_korean(sip)) + ''
n %= 10
if n > 0:
result += _NUM_TO_KO[n]
return result
def insert_pauses(script: dict, engine: str = 'ssml') -> dict:
"""
Insert pause markers into script by sentence type.
engine='ssml': insert SSML <break> tags (for ElevenLabs, Google TTS)
engine='marker': insert [[PAUSE_Xms]] text markers (for Edge TTS, others)
Returns modified script dict with pauses inserted.
"""
result = dict(script)
hook = script.get('hook', '')
body = script.get('body', [])
closer = script.get('closer', '')
# Add pause after hook
if hook:
pause_ms = DYNAMIC_PAUSES['hook_after']
result['hook'] = hook + _pause_marker(pause_ms, engine)
# Add pauses within body sentences
processed_body = []
for i, sentence in enumerate(body):
processed = _add_inline_pauses(sentence, engine)
# Add section break before closer transition
if i == len(body) - 1:
processed += _pause_marker(DYNAMIC_PAUSES['section_break'], engine)
else:
processed += _pause_marker(DYNAMIC_PAUSES['normal_after'], engine)
processed_body.append(processed)
result['body'] = processed_body
return result
def _add_inline_pauses(sentence: str, engine: str) -> str:
"""Add pauses at commas and after exclamation marks."""
# Comma pauses
sentence = re.sub(
r',\s*',
',' + _pause_marker(DYNAMIC_PAUSES['comma'], engine),
sentence
)
# Question mark pauses
sentence = re.sub(
r'\?\s*',
'?' + _pause_marker(DYNAMIC_PAUSES['question_after'], engine),
sentence
)
# Exclamation pauses
sentence = re.sub(
r'!\s*',
'!' + _pause_marker(DYNAMIC_PAUSES['exclamation'], engine),
sentence
)
return sentence
def _pause_marker(ms: int, engine: str) -> str:
"""Generate engine-appropriate pause marker."""
if engine == 'ssml':
return f'<break time="{ms}ms"/>'
else:
return f' [[PAUSE_{ms}ms]] '
# ── Standalone test ──────────────────────────────────────────────
if __name__ == '__main__':
import sys
if '--test' in sys.argv:
print("=== Korean Preprocessor Test ===")
test_texts = [
"AI와 ChatGPT가 SEO를 바꾸고 있어요",
"3가지 방법으로 100%의 수익을 낼 수 있습니다",
"YouTube와 TikTok에서 SNS 마케팅하기",
"GPT API를 사용한 SaaS 창업",
]
for text in test_texts:
result = preprocess_korean(text)
print(f"원문: {text}")
print(f"처리: {result}")
print()
# Test pause insertion
test_script = {
'hook': '이거 모르면 손해입니다!',
'body': ['첫 번째, AI를 활용하면 10배 빠릅니다.', '두 번째, 자동화가 핵심입니다.'],
'closer': '지금 바로 시작하세요.'
}
processed = insert_pauses(test_script, engine='marker')
print("=== Pause Insertion Test ===")
for k, v in processed.items():
print(f"{k}: {v}")
+271
View File
@@ -0,0 +1,271 @@
"""
bots/prompt_layer/prompt_tracker.py
SQLite-based prompt logging infrastructure.
V3.0: Log every prompt to SQLite. No auto-improvement yet.
V3.1: Analyze logs extract patterns auto-improve.
Schema:
prompt_log (
id INTEGER PRIMARY KEY AUTOINCREMENT,
category TEXT NOT NULL, -- 'video' | 'search' | 'tts' | 'writing' | ...
engine TEXT NOT NULL, -- target engine name
prompt TEXT NOT NULL, -- full prompt text
result_quality REAL DEFAULT -1, -- 0.0-1.0, -1 = not evaluated
user_edited INTEGER DEFAULT 0, -- 1 if user manually edited the result
created_at TEXT NOT NULL -- ISO 8601 timestamp
)
"""
import json
import logging
import sqlite3
from datetime import datetime, timezone
from pathlib import Path
from typing import Optional
logger = logging.getLogger(__name__)
BASE_DIR = Path(__file__).parent.parent.parent
DB_PATH = BASE_DIR / 'data' / 'prompt_log.db'
# DDL for creating the table
_CREATE_TABLE_SQL = """
CREATE TABLE IF NOT EXISTS prompt_log (
id INTEGER PRIMARY KEY AUTOINCREMENT,
category TEXT NOT NULL,
engine TEXT NOT NULL,
prompt TEXT NOT NULL,
result_quality REAL NOT NULL DEFAULT -1,
user_edited INTEGER NOT NULL DEFAULT 0,
created_at TEXT NOT NULL
);
CREATE INDEX IF NOT EXISTS idx_category ON prompt_log (category);
CREATE INDEX IF NOT EXISTS idx_engine ON prompt_log (engine);
CREATE INDEX IF NOT EXISTS idx_created ON prompt_log (created_at);
"""
class PromptTracker:
"""
Logs prompts to SQLite for future analysis and auto-improvement.
V3.0: Logging only.
V3.1: Will add get_engine_preferences() and suggest_improvement().
Usage:
tracker = PromptTracker()
tracker.log('video', 'kling_free', prompt_text)
tracker.log('search', 'pexels', query_text, result_quality=0.8)
"""
def __init__(self, db_path: Path = DB_PATH):
self._db_path = db_path
self._initialized = False
def _ensure_db(self) -> None:
"""Create database and tables if they don't exist."""
if self._initialized:
return
self._db_path.parent.mkdir(parents=True, exist_ok=True)
try:
with sqlite3.connect(str(self._db_path)) as conn:
for statement in _CREATE_TABLE_SQL.strip().split(';'):
stmt = statement.strip()
if stmt:
conn.execute(stmt)
conn.commit()
self._initialized = True
logger.debug(f'[트래커] DB 초기화: {self._db_path}')
except sqlite3.Error as e:
logger.error(f'[트래커] DB 초기화 실패: {e}')
def log(
self,
category: str,
engine: str,
prompt: str,
result_quality: float = -1.0,
user_edited: bool = False,
) -> Optional[int]:
"""
Log a prompt to SQLite.
Args:
category: Prompt category ('video', 'search', 'tts', 'writing', etc.)
engine: Target engine name ('kling_free', 'pexels', 'elevenlabs', etc.)
prompt: Full prompt text
result_quality: Quality score 0.0-1.0, or -1 if not evaluated
user_edited: True if user manually modified the AI output
Returns: Row ID of inserted record, or None on error
"""
self._ensure_db()
if not category or not engine or not prompt:
logger.warning('[트래커] 필수 파라미터 누락 — 로깅 건너뜀')
return None
created_at = datetime.now(timezone.utc).isoformat()
try:
with sqlite3.connect(str(self._db_path)) as conn:
cursor = conn.execute(
"""INSERT INTO prompt_log
(category, engine, prompt, result_quality, user_edited, created_at)
VALUES (?, ?, ?, ?, ?, ?)""",
(category, engine, prompt, float(result_quality), int(user_edited), created_at)
)
conn.commit()
row_id = cursor.lastrowid
logger.debug(f'[트래커] 로그 저장: id={row_id}, category={category}, engine={engine}')
return row_id
except sqlite3.Error as e:
logger.error(f'[트래커] 로그 저장 실패: {e}')
return None
def get_recent(
self,
category: Optional[str] = None,
engine: Optional[str] = None,
limit: int = 100,
) -> list[dict]:
"""
Retrieve recent log entries.
Args:
category: Filter by category (None = all)
engine: Filter by engine (None = all)
limit: Max records to return
Returns: List of dicts with log fields
"""
self._ensure_db()
conditions = []
params = []
if category:
conditions.append('category = ?')
params.append(category)
if engine:
conditions.append('engine = ?')
params.append(engine)
where = 'WHERE ' + ' AND '.join(conditions) if conditions else ''
params.append(limit)
try:
with sqlite3.connect(str(self._db_path)) as conn:
conn.row_factory = sqlite3.Row
cursor = conn.execute(
f'SELECT * FROM prompt_log {where} ORDER BY created_at DESC LIMIT ?',
params
)
return [dict(row) for row in cursor.fetchall()]
except sqlite3.Error as e:
logger.error(f'[트래커] 조회 실패: {e}')
return []
def get_stats(self) -> dict:
"""
Return summary statistics.
Returns: {
'total': int,
'by_category': {category: count},
'by_engine': {engine: count},
'avg_quality': float,
'user_edited_count': int,
}
"""
self._ensure_db()
try:
with sqlite3.connect(str(self._db_path)) as conn:
total = conn.execute('SELECT COUNT(*) FROM prompt_log').fetchone()[0]
by_cat = dict(conn.execute(
'SELECT category, COUNT(*) FROM prompt_log GROUP BY category'
).fetchall())
by_eng = dict(conn.execute(
'SELECT engine, COUNT(*) FROM prompt_log GROUP BY engine'
).fetchall())
avg_q = conn.execute(
'SELECT AVG(result_quality) FROM prompt_log WHERE result_quality >= 0'
).fetchone()[0]
edited = conn.execute(
'SELECT COUNT(*) FROM prompt_log WHERE user_edited = 1'
).fetchone()[0]
return {
'total': total,
'by_category': by_cat,
'by_engine': by_eng,
'avg_quality': round(avg_q, 3) if avg_q is not None else None,
'user_edited_count': edited,
}
except sqlite3.Error as e:
logger.error(f'[트래커] 통계 조회 실패: {e}')
return {}
# V3.1 stubs (not implemented yet)
def get_engine_preferences(self, engine: str) -> dict:
"""
V3.1: Analyze logs to extract what works best for an engine.
Returns: {} in V3.0 (not implemented)
"""
logger.debug('[트래커] get_engine_preferences — V3.1에서 구현 예정')
return {}
def suggest_improvement(self, category: str, engine: str) -> str:
"""
V3.1: Suggest prompt improvements based on historical data.
Returns: '' in V3.0 (not implemented)
"""
logger.debug('[트래커] suggest_improvement — V3.1에서 구현 예정')
return ''
# ── Standalone test ──────────────────────────────────────────────
if __name__ == '__main__':
import sys
import tempfile
if '--test' in sys.argv:
print("=== Prompt Tracker Test ===")
# Use temp DB for testing
with tempfile.TemporaryDirectory() as tmp:
test_db = Path(tmp) / 'test_prompt_log.db'
tracker = PromptTracker(db_path=test_db)
# Log some prompts
id1 = tracker.log('video', 'kling_free', 'A cinematic shot of technology', result_quality=0.8)
id2 = tracker.log('search', 'pexels', 'artificial intelligence screen', result_quality=0.9)
id3 = tracker.log('tts', 'edge_tts', 'AI와 ChatGPT가 SEO를 바꾸고 있어요', user_edited=True)
id4 = tracker.log('video', 'kling_free', 'Korean business meeting professional')
print(f"Logged 4 entries (IDs: {id1}, {id2}, {id3}, {id4})")
# Get stats
stats = tracker.get_stats()
print(f"Stats: total={stats['total']}, avg_quality={stats['avg_quality']}")
print(f"By category: {stats['by_category']}")
print(f"User edited: {stats['user_edited_count']}")
# Get recent
recent = tracker.get_recent(category='video', limit=10)
print(f"Recent video prompts: {len(recent)} entries")
# Test V3.1 stubs
prefs = tracker.get_engine_preferences('kling_free')
print(f"V3.1 stub (engine_preferences): {prefs}")
print("All tests passed!")
+55
View File
@@ -0,0 +1,55 @@
"""
bots/prompt_layer/search_query.py
Compose stock video/image search queries from Korean concepts.
"""
from .base import BaseComposer, ComposedPrompt
from .visual_vocabulary import CONCEPT_TO_VISUAL, VISUAL_STYLE_MODIFIERS
import re
class StockSearchQueryComposer(BaseComposer):
"""
Korean concept -> English visual search terms.
Used to search Pexels/Pixabay/Unsplash for stock footage.
"""
def compose(self, input_data: dict, engine: str = 'pexels') -> ComposedPrompt:
"""
input_data: {
'sentence': str, # Korean sentence to find visuals for
'platform': str, # 'pexels' | 'pixabay' | 'kling' | 'veo'
'count': int, # number of search queries to return (default 3)
}
Returns ComposedPrompt with queries list
"""
sentence = input_data.get('sentence', '')
count = input_data.get('count', 3)
queries = self._sentence_to_queries(sentence, count)
return ComposedPrompt(
queries=queries,
metadata={'sentence': sentence, 'engine': engine}
)
def _sentence_to_queries(self, sentence: str, count: int) -> list[str]:
"""Extract Korean concepts from sentence and map to visual search terms."""
# Find matching concepts from vocabulary
matched_visuals = []
for concept, visuals in CONCEPT_TO_VISUAL.items():
if concept in sentence:
matched_visuals.extend(visuals)
# If no matches, use generic professional stock footage terms
if not matched_visuals:
matched_visuals = ['professional business', 'modern lifestyle', 'technology future']
# Return up to count unique queries
seen = set()
unique = []
for v in matched_visuals:
if v not in seen:
seen.add(v)
unique.append(v)
return unique[:count]
+85
View File
@@ -0,0 +1,85 @@
"""
bots/prompt_layer/video_prompt.py
Format prompts for video generation engines (Kling, Veo).
"""
from .base import BaseComposer, ComposedPrompt
from .visual_vocabulary import VISUAL_STYLE_MODIFIERS, NEGATIVE_TERMS
class KlingPromptFormatter(BaseComposer):
"""
Format prompts for Kling AI video generation.
Kling works best with: scene description + movement + mood + negative prompt.
"""
def compose(self, input_data: dict, engine: str = 'kling_free') -> ComposedPrompt:
"""
input_data: {
'scenes': list[dict], # [{text, type, image_prompt}, ...]
'corner': str, # content corner/category
'duration': float, # target duration in seconds
}
"""
scenes = input_data.get('scenes', [])
corner = input_data.get('corner', '')
# Build positive prompt from scenes
scene_texts = []
for scene in scenes:
prompt = scene.get('image_prompt') or scene.get('text', '')
if prompt:
scene_texts.append(self._enhance_for_kling(prompt, corner))
positive = '. '.join(scene_texts[:3]) # Max 3 scenes per prompt
if not positive:
positive = f'cinematic short video about {corner or "technology"}'
# Kling negative prompt
negative = ', '.join(NEGATIVE_TERMS + ['text overlay', 'subtitles', 'watermark'])
# Add beat markers for Kling
positive = f'{positive}. Camera: smooth movement, vertical 9:16 format. Style: cinematic, vibrant.'
return ComposedPrompt(
positive=positive,
negative=negative,
metadata={'engine': engine, 'corner': corner}
)
def _enhance_for_kling(self, text: str, corner: str) -> str:
"""Add cinematic enhancement to prompt."""
modifiers = ', '.join(VISUAL_STYLE_MODIFIERS[:3])
return f'{text}, {modifiers}'
class VeoPromptFormatter(BaseComposer):
"""
Format prompts for Google Veo video generation.
Veo works best with structured ingredient list format.
"""
def compose(self, input_data: dict, engine: str = 'veo3') -> ComposedPrompt:
"""
input_data: same as KlingPromptFormatter
"""
scenes = input_data.get('scenes', [])
corner = input_data.get('corner', '')
scene_texts = [
scene.get('image_prompt') or scene.get('text', '')
for scene in scenes if scene.get('image_prompt') or scene.get('text')
]
# Veo structured format: Subject + Action + Setting + Style
subject = scene_texts[0] if scene_texts else f'{corner or "technology"} concept'
positive = (
f'Subject: {subject}. '
f'Format: vertical 9:16 portrait video. '
f'Style: cinematic, {", ".join(VISUAL_STYLE_MODIFIERS[:2])}. '
f'Camera: smooth pan or zoom. Duration: short clip.'
)
return ComposedPrompt(
positive=positive,
metadata={'engine': engine, 'corner': corner, 'format': 'veo_structured'}
)
+145
View File
@@ -0,0 +1,145 @@
"""
bots/prompt_layer/visual_vocabulary.py
Shared Korean -> English visual concept dictionary.
Used by search_query.py and video_prompt.py for concept mapping.
"""
CONCEPT_TO_VISUAL = {
# Technology
'AI': ['artificial intelligence screen', 'digital interface', 'neural network visualization'],
'인공지능': ['robot brain', 'digital mind', 'AI hologram'],
'자동화': ['gears mechanism', 'conveyor belt', 'robot arm factory'],
'코딩': ['computer code screen', 'programmer keyboard', 'dark terminal code'],
'데이터': ['data visualization', 'bar chart analytics', 'network nodes'],
'알고리즘': ['flowchart diagram', 'binary code', 'decision tree'],
'': ['smartphone screen', 'mobile app interface', 'app store'],
'소프트웨어': ['software development', 'code editor', 'programming laptop'],
# Finance/Money
'': ['money cash bills', 'coins pile', 'dollar bills'],
'수익': ['profit growth chart', 'rising arrow money', 'income cash'],
'투자': ['stock market chart', 'investment portfolio', 'financial growth'],
'절약': ['piggy bank savings', 'money jar coins', 'budget planning'],
'부자': ['luxury lifestyle', 'wealthy business person', 'success achievement'],
'무료': ['gift present box', 'unlocked padlock', 'free tag label'],
'할인': ['sale discount tag', 'percent off sign', 'price reduction'],
# Business
'비즈니스': ['business meeting', 'office workspace', 'professional handshake'],
'창업': ['startup launch rocket', 'entrepreneur office', 'business idea lightbulb'],
'마케팅': ['marketing strategy board', 'social media icons', 'advertising billboard'],
'브랜드': ['brand logo design', 'brand identity', 'premium label'],
'고객': ['customer service smile', 'client meeting', 'happy customer'],
'성공': ['success achievement trophy', 'winner podium', 'goal celebration'],
'실패': ['failure mistake frustrated', 'broken plan', 'problem obstacle'],
# Health/Lifestyle
'건강': ['healthy lifestyle', 'fitness exercise', 'fresh vegetables'],
'다이어트': ['diet food salad', 'weight loss scale', 'healthy eating'],
'운동': ['gym workout exercise', 'running sport', 'fitness training'],
'수면': ['peaceful sleep bedroom', 'sleeping person night', 'rest relaxation'],
'스트레스': ['stress anxiety person', 'overwhelmed work', 'headache pressure'],
'행복': ['happy smiling person', 'joy celebration', 'positive energy'],
# Education
'공부': ['studying books desk', 'student learning', 'open textbook'],
'독서': ['reading book cozy', 'bookshelf library', 'person reading'],
'교육': ['classroom teaching', 'education school', 'learning knowledge'],
'자격증': ['certificate diploma award', 'achievement credential', 'professional certification'],
# Social/Communication
'소통': ['communication talking', 'conversation speech bubble', 'people talking'],
'관계': ['relationship people together', 'friendship bond', 'social connection'],
'가족': ['family together happy', 'family portrait', 'home family'],
'친구': ['friends together laughing', 'friendship bond', 'social gathering'],
# Environment/Nature
'자연': ['nature landscape scenic', 'green forest trees', 'outdoor beauty'],
'환경': ['environment ecology', 'green earth planet', 'sustainability'],
'도시': ['city skyline urban', 'modern architecture', 'downtown cityscape'],
'여행': ['travel adventure journey', 'wanderlust explore', 'tourism destination'],
# Time/Productivity
'시간': ['clock time management', 'hourglass countdown', 'calendar schedule'],
'생산성': ['productivity work desk', 'efficient workflow', 'organized workspace'],
'습관': ['habit routine daily', 'calendar habit tracker', 'consistent practice'],
'목표': ['goal target arrow', 'achievement milestone', 'success roadmap'],
# Food
'음식': ['food meal delicious', 'restaurant dining', 'cooking kitchen'],
'커피': ['coffee cup cafe', 'espresso morning', 'coffee shop cozy'],
'요리': ['cooking chef kitchen', 'recipe preparation', 'homemade food'],
# Digital/Social Media
'유튜브': ['youtube play button', 'video content creator', 'streaming platform'],
'틱톡': ['social media video', 'short video content', 'viral content'],
'인스타그램': ['instagram photo aesthetic', 'social media post', 'influencer lifestyle'],
'콘텐츠': ['content creation studio', 'digital content', 'creative media'],
# Generic actions
'시작': ['starting launch beginning', 'new start fresh', 'launch rocket'],
'변화': ['change transformation', 'before after contrast', 'evolution progress'],
'성장': ['growth plant sprouting', 'growth chart rising', 'development progress'],
'문제': ['problem solving puzzle', 'challenge obstacle', 'issue question mark'],
'해결': ['solution lightbulb', 'problem solved checkmark', 'resolution answer'],
'비교': ['comparison side by side', 'versus contrast', 'pros cons balance'],
'순위': ['ranking top list', 'leaderboard winners', 'chart comparison'],
'방법': ['how-to guide steps', 'tutorial instruction', 'method process'],
'': ['tips tricks advice', 'helpful hints', 'pro tip star'],
'비밀': ['secret reveal hidden', 'mystery unlock', 'insider knowledge'],
'진실': ['truth reveal facts', 'reality check', 'honest disclosure'],
'놀라운': ['surprising amazing wow', 'unexpected revelation', 'shocking discovery'],
# Numbers/Stats
'1위': ['number one winner', 'first place gold', 'top ranked best'],
'100%': ['one hundred percent complete', 'full capacity', 'perfect score'],
# Korean culture
'한국': ['korea seoul cityscape', 'korean culture', 'hanbok traditional'],
'직장': ['office workplace corporate', 'work desk professional', 'business office'],
'취업': ['job interview hiring', 'employment opportunity', 'career success'],
'부동산': ['real estate property', 'house home investment', 'property market'],
# Abstract concepts
'가능성': ['possibility open door', 'opportunity horizon', 'potential unlimited'],
'미래': ['future technology vision', 'futuristic landscape', 'innovation tomorrow'],
'트렌드': ['trend arrow upward', 'trending popular', 'hot topic social'],
}
# Quality/style modifiers to append to video/image prompts
VISUAL_STYLE_MODIFIERS = [
'cinematic',
'4k',
'professional',
'high quality',
'vibrant colors',
'sharp focus',
'natural lighting',
'smooth motion',
]
# Terms to avoid in video generation prompts
NEGATIVE_TERMS = [
'blurry',
'low quality',
'watermark',
'text overlay',
'distorted',
'pixelated',
'grainy',
'overexposed',
'underexposed',
'shaky camera',
]
if __name__ == '__main__':
import sys
if '--test' in sys.argv:
print('=== visual_vocabulary 테스트 시작 ===')
print(f'총 개념 수: {len(CONCEPT_TO_VISUAL)}')
print(f'스타일 수식어 수: {len(VISUAL_STYLE_MODIFIERS)}')
print(f'네거티브 용어 수: {len(NEGATIVE_TERMS)}')
print()
# Test a few lookups
test_concepts = ['AI', '미래', '성공', '건강', '코딩']
for concept in test_concepts:
visuals = CONCEPT_TO_VISUAL.get(concept, [])
print(f' [{concept}] -> {visuals}')
print()
print(f'스타일 수식어: {VISUAL_STYLE_MODIFIERS}')
print(f'네거티브 용어: {NEGATIVE_TERMS}')
print()
print('=== 테스트 완료 ===')
else:
print('사용법: python -m bots.prompt_layer.visual_vocabulary --test')
+20 -1
View File
@@ -25,7 +25,7 @@ from google.oauth2.credentials import Credentials
from google.auth.transport.requests import Request
from googleapiclient.discovery import build
load_dotenv(dotenv_path='D:/key/blog-writer.env.env')
load_dotenv()
BASE_DIR = Path(__file__).parent.parent
CONFIG_DIR = BASE_DIR / 'config'
@@ -63,6 +63,7 @@ def load_config(filename: str) -> dict:
def get_google_credentials() -> Credentials:
creds = None
# 1) token.json 파일 우선
if TOKEN_PATH.exists():
creds = Credentials.from_authorized_user_file(str(TOKEN_PATH), SCOPES)
if not creds or not creds.valid:
@@ -70,6 +71,24 @@ def get_google_credentials() -> Credentials:
creds.refresh(Request())
with open(TOKEN_PATH, 'w') as f:
f.write(creds.to_json())
# 2) .env의 GOOGLE_REFRESH_TOKEN으로 직접 생성 (Docker 환경 대응)
if not creds or not creds.valid:
refresh_token = os.getenv('GOOGLE_REFRESH_TOKEN', '')
client_id = os.getenv('GOOGLE_CLIENT_ID', '')
client_secret = os.getenv('GOOGLE_CLIENT_SECRET', '')
if refresh_token and client_id and client_secret:
creds = Credentials(
token=None,
refresh_token=refresh_token,
token_uri='https://oauth2.googleapis.com/token',
client_id=client_id,
client_secret=client_secret,
scopes=SCOPES,
)
creds.refresh(Request())
with open(TOKEN_PATH, 'w') as f:
f.write(creds.to_json())
logger.info("Google 인증 성공 (.env refresh token)")
if not creds or not creds.valid:
raise RuntimeError("Google 인증 실패. scripts/get_token.py 를 먼저 실행하세요.")
return creds
+17
View File
@@ -0,0 +1,17 @@
"""
bots/quality
Quality signal computation for shorts content.
V3.0 signals:
- motion_variation_score
- script_diversity_score
- tts_cost_efficiency
V3.1+ additions:
- semantic_visual_score
- caption_overlap_score
- pacing_variation_score
"""
from .micro_signals import compute_signal, SIGNALS_V1
__all__ = ['compute_signal', 'SIGNALS_V1']
+215
View File
@@ -0,0 +1,215 @@
"""
bots/quality/micro_signals.py
Micro-failure quality signals for shorts content.
V3.0 scope: 3 signals
- motion_variation_score: detects repetitive motion patterns
- script_diversity_score: detects structural overlap with recent scripts
- tts_cost_efficiency: monitors TTS credit usage
Each signal returns a float 0.0-1.0 where:
- 1.0 = perfect / no issue
- 0.0 = critical problem
- threshold = action trigger point
"""
import logging
from pathlib import Path
from typing import Callable, Any
logger = logging.getLogger(__name__)
SIGNALS_V1 = {
'motion_variation_score': {
'description': 'Consecutive clips using same motion pattern',
'threshold': 0.6,
'action': 'auto_fix', # pick different pattern automatically
'higher_is_better': True,
},
'script_diversity_score': {
'description': 'Script structure overlap with last 7 days',
'threshold': 0.5,
'action': 'regenerate', # request different structure from LLM
'higher_is_better': True,
},
'tts_cost_efficiency': {
'description': 'TTS credit usage vs monthly limit',
'threshold': 0.8,
'action': 'switch_engine', # downgrade to local TTS
'higher_is_better': False, # lower usage = better
},
}
def compute_signal(signal_name: str, **kwargs) -> float:
"""
Compute a quality signal value.
Args:
signal_name: One of SIGNALS_V1 keys
**kwargs: Signal-specific inputs (see individual compute functions)
Returns: float 0.0-1.0
Raises: ValueError if signal_name unknown
"""
if signal_name not in SIGNALS_V1:
raise ValueError(f'Unknown signal: {signal_name}. Available: {list(SIGNALS_V1.keys())}')
compute_fns = {
'motion_variation_score': _compute_motion_variation,
'script_diversity_score': _compute_script_diversity,
'tts_cost_efficiency': _compute_tts_cost_efficiency,
}
fn = compute_fns[signal_name]
try:
value = fn(**kwargs)
logger.debug(f'[품질] {signal_name} = {value:.3f}')
return value
except Exception as e:
logger.warning(f'[품질] 신호 계산 실패 ({signal_name}): {e}')
return 1.0 # Neutral value on error (don't trigger action)
def check_and_act(signal_name: str, value: float) -> dict:
"""
Check if signal value crosses threshold and return action.
Returns: {
'triggered': bool,
'action': str or None,
'value': float,
'threshold': float,
}
"""
if signal_name not in SIGNALS_V1:
return {'triggered': False, 'action': None, 'value': value, 'threshold': 0}
config = SIGNALS_V1[signal_name]
threshold = config['threshold']
higher_is_better = config.get('higher_is_better', True)
if higher_is_better:
triggered = value < threshold
else:
triggered = value > threshold
return {
'triggered': triggered,
'action': config['action'] if triggered else None,
'value': value,
'threshold': threshold,
}
def _compute_motion_variation(clips: list, **kwargs) -> float:
"""
Compute motion variation score.
Args:
clips: list of dicts with 'pattern' key, e.g. [{'pattern': 'ken_burns_in'}, ...]
Returns: 0.0-1.0 diversity score
"""
if not clips or len(clips) < 2:
return 1.0
patterns = [c.get('pattern', '') for c in clips if c.get('pattern')]
if not patterns:
return 1.0
# Count consecutive same-pattern pairs
consecutive_same = sum(
1 for i in range(len(patterns) - 1)
if patterns[i] == patterns[i+1]
)
# Unique patterns ratio
unique_ratio = len(set(patterns)) / len(patterns)
consecutive_penalty = consecutive_same / max(len(patterns) - 1, 1)
score = unique_ratio * (1 - consecutive_penalty)
return round(min(1.0, max(0.0, score)), 3)
def _compute_script_diversity(script: dict, history: list = None, **kwargs) -> float:
"""
Compute script structure diversity vs recent history.
Args:
script: Current script dict with 'hook', 'body', 'closer'
history: List of recent scripts (last 7 days), each same format
Returns: 0.0-1.0 diversity score (1.0 = very diverse)
"""
if not history:
return 1.0
# Compare script structure fingerprints
def _fingerprint(s: dict) -> tuple:
hook = s.get('hook', '')
body = s.get('body', [])
closer = s.get('closer', '')
return (
len(hook) // 10, # rough length bucket
len(body), # number of body sentences
hook[:5] if hook else '', # hook start
)
current_fp = _fingerprint(script)
overlaps = sum(
1 for h in history
if _fingerprint(h) == current_fp
)
overlap_rate = overlaps / len(history)
return round(1.0 - overlap_rate, 3)
def _compute_tts_cost_efficiency(usage: float, limit: float, **kwargs) -> float:
"""
Compute TTS cost efficiency.
Args:
usage: Characters used this period
limit: Monthly/daily character limit
Returns: ratio (usage/limit), where > threshold triggers engine switch
"""
if limit <= 0:
return 0.0
return round(min(1.0, usage / limit), 3)
# ── Standalone test ──────────────────────────────────────────────
if __name__ == '__main__':
import sys
if '--test' in sys.argv:
print("=== Micro Signals Test ===")
# Test motion variation
test_clips = [
{'pattern': 'ken_burns_in'},
{'pattern': 'ken_burns_in'}, # repeat!
{'pattern': 'pan_left'},
{'pattern': 'pan_right'},
]
mv = compute_signal('motion_variation_score', clips=test_clips)
result = check_and_act('motion_variation_score', mv)
print(f"motion_variation_score = {mv:.3f} (triggered: {result['triggered']}, action: {result['action']})")
# Test script diversity
current_script = {'hook': '이거 모르면 손해', 'body': ['첫째', '둘째', '셋째'], 'closer': '구독'}
history = [
{'hook': '이거 모르면 손해2', 'body': ['a', 'b', 'c'], 'closer': '팔로우'},
]
sd = compute_signal('script_diversity_score', script=current_script, history=history)
result2 = check_and_act('script_diversity_score', sd)
print(f"script_diversity_score = {sd:.3f} (triggered: {result2['triggered']})")
# Test TTS cost
tce = compute_signal('tts_cost_efficiency', usage=8500, limit=10000)
result3 = check_and_act('tts_cost_efficiency', tce)
print(f"tts_cost_efficiency = {tce:.3f} (triggered: {result3['triggered']}, action: {result3['action']})")
+1 -1
View File
@@ -13,7 +13,7 @@ from telegram import Update
from telegram.ext import Application, MessageHandler, CommandHandler, filters, ContextTypes
from claude_agent_sdk import query, ClaudeAgentOptions, ResultMessage
load_dotenv(dotenv_path='D:/key/blog-writer.env.env')
load_dotenv()
BASE_DIR = Path(__file__).parent.parent
TELEGRAM_BOT_TOKEN = os.getenv('TELEGRAM_BOT_TOKEN', '')
+127 -24
View File
@@ -27,7 +27,7 @@ from telegram.ext import Application, CommandHandler, MessageHandler, filters, C
import anthropic
import re
load_dotenv(dotenv_path='D:/key/blog-writer.env.env')
load_dotenv()
BASE_DIR = Path(__file__).parent.parent
CONFIG_DIR = BASE_DIR / 'config'
@@ -55,28 +55,25 @@ ANTHROPIC_API_KEY = os.getenv('ANTHROPIC_API_KEY', '')
_claude_client: anthropic.Anthropic | None = None
_conversation_history: dict[int, list] = {}
CLAUDE_SYSTEM_PROMPT = """당신은 The 4th Path 블로그 자동 수익 엔진의 AI 어시스턴트입니다.
CLAUDE_SYSTEM_PROMPT = """당신은 "AI? 그게 뭔데?" 블로그의 운영 어시스턴트입니다.
블로그 운영자 eli가 Telegram으로 명령하면 도와주는 역할입니다.
슬로건: "어렵지 않아요, 그냥 읽어봐요"
블로그 주소: eli-ai.blogspot.com
시스템(v3) 4계층 구조로 운영됩니다:
[LAYER 1] AI 콘텐츠 생성: OpenClaw(GPT-5.4) 원본 마크다운 1 생성
[LAYER 1] AI 콘텐츠 생성: Gemini 2.5-flash 원본 마크다운 1 생성
[LAYER 2] 변환 엔진: 원본 블로그HTML / 인스타카드 / X스레드 / 뉴스레터 자동 변환
[LAYER 3] 배포 엔진: Blogger / Instagram / X / TikTok / YouTube 순차 발행
[LAYER 4] 분석봇: 성과 수집 + 주간 리포트 + 피드백 루프
구성:
- collector_bot: 트렌드/RSS 수집 (07:00)
- ai_writer: OpenClaw 작성 트리거 (08:00)
- blog_converter: 마크다운HTML (08:30)
- card_converter: 인스타 카드 1080×1080 (08:30)
- thread_converter: X 스레드 변환 (08:30)
- publisher_bot: Blogger 발행 (09:00)
- instagram_bot: 인스타 발행 (10:00)
- x_bot: X 스레드 게시 (11:00)
- analytics_bot: 분석/리포트 (22:00)
8 카테고리: AI인사이트, 여행맛집, 스타트업, 제품리뷰, 생활꿀팁, 앱추천, 재테크절약, 팩트체크
사용 가능한 텔레그램 명령:
/status 상태
/topics 오늘 수집된 글감
/collect 글감 즉시 수집
/write [번호] [방향] 특정 글감으로 작성
/pending 검토 대기 목록
/approve [번호] 승인 발행
/reject [번호] 거부
@@ -87,6 +84,7 @@ CLAUDE_SYSTEM_PROMPT = """당신은 The 4th Path 블로그 자동 수익 엔진
/novel_gen [novel_id] 에피소드 즉시 생성
/novel_status 소설 파이프라인 진행 현황
모든 글은 발행 운영자 승인이 필요합니다.
사용자의 자연어 요청을 이해하고 적절히 안내하거나 답변해주세요.
한국어로 간결하게 답변하세요."""
IMAGE_MODE = os.getenv('IMAGE_MODE', 'manual').lower()
@@ -153,16 +151,25 @@ def _safe_slug(text: str) -> str:
def _build_openclaw_prompt(topic_data: dict) -> tuple[str, str]:
topic = topic_data.get('topic', '').strip()
corner = topic_data.get('corner', '쉬운세상').strip() or '쉬운세상'
corner = topic_data.get('corner', 'AI인사이트').strip() or 'AI인사이트'
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', '')
is_english = topic_data.get('is_english', False)
system = (
"당신은 The 4th Path 블로그 엔진의 전문 에디터다. "
"당신은 'AI? 그게 뭔데?' 블로그의 편집자 eli다. "
"비전문가도 쉽게 읽을 수 있는 친근한 톤으로 글을 쓴다. "
"반드시 아래 섹션 헤더 형식만 사용해 완성된 Blogger-ready HTML 원고를 출력하라. "
"본문(BODY)은 HTML로 작성하고, KEY_POINTS는 3줄 이내로 작성한다."
)
prompt = f"""다음 글감을 바탕으로 한국어 블로그 원고를 작성해줘.
if is_english:
system += (
" 영문 원문을 단순 번역하지 말고, 한국 독자 관점에서 재해석하여 작성하라. "
"한국 시장/사용자에게 어떤 의미인지, 국내 대안이나 비교 서비스가 있다면 함께 언급하라. "
"제목도 한국어로 매력적으로 새로 작성하라."
)
lang_note = "\n⚠️ 영문 원문입니다. 단순 번역이 아닌, 한국 독자 맥락으로 재작성해주세요." if is_english else ""
prompt = f"""다음 글감을 바탕으로 한국어 블로그 원고를 작성해줘.{lang_note}
주제: {topic}
코너: {corner}
@@ -207,8 +214,8 @@ def _build_openclaw_prompt(topic_data: dict) -> tuple[str, str]:
return system, prompt
def _call_openclaw(topic_data: dict, output_path: Path):
logger.info(f"OpenClaw 작성 요청: {topic_data.get('topic', '')}")
def _call_openclaw(topic_data: dict, output_path: Path, direction: str = ''):
logger.info(f" 작성 요청: {topic_data.get('topic', '')}")
sys.path.insert(0, str(BASE_DIR))
sys.path.insert(0, str(BASE_DIR / 'bots'))
@@ -216,18 +223,20 @@ def _call_openclaw(topic_data: dict, output_path: Path):
from article_parser import parse_output
system, prompt = _build_openclaw_prompt(topic_data)
if direction:
prompt += f"\n\n운영자 지시사항: {direction}"
writer = EngineLoader().get_writer()
raw_output = writer.write(prompt, system=system).strip()
if not raw_output:
raise RuntimeError('OpenClaw writer 응답이 비어 있습니다.')
raise RuntimeError('Writer 응답이 비어 있습니다.')
article = parse_output(raw_output)
if not article:
raise RuntimeError('OpenClaw writer 출력 파싱 실패')
raise RuntimeError('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['corner'] = article.get('corner') or topic_data.get('corner', 'AI인사이트')
article['topic'] = topic_data.get('topic', '')
article['description'] = topic_data.get('description', '')
article['quality_score'] = topic_data.get('quality_score', 0)
@@ -241,7 +250,28 @@ def _call_openclaw(topic_data: dict, output_path: Path):
json.dumps(article, ensure_ascii=False, indent=2),
encoding='utf-8',
)
logger.info(f"OpenClaw 원고 저장 완료: {output_path.name}")
logger.info(f"원고 저장 완료: {output_path.name}")
def _publish_next():
"""originals/ → pending_review/ 이동 (안전장치 체크)"""
sys.path.insert(0, str(BASE_DIR / 'bots'))
import publisher_bot
originals_dir = DATA_DIR / 'originals'
pending_dir = DATA_DIR / 'pending_review'
pending_dir.mkdir(exist_ok=True)
safety_cfg = publisher_bot.load_config('safety_keywords.json')
for f in sorted(originals_dir.glob('*.json')):
try:
article = json.loads(f.read_text(encoding='utf-8'))
needs_review, reason = publisher_bot.check_safety(article, safety_cfg)
article['pending_reason'] = reason or '수동 승인 필요'
dest = pending_dir / f.name
dest.write_text(json.dumps(article, ensure_ascii=False, indent=2), encoding='utf-8')
f.unlink()
logger.info(f"검토 대기로 이동: {f.name} ({reason})")
except Exception as e:
logger.error(f"publish_next 오류 ({f.name}): {e}")
def job_convert():
@@ -579,6 +609,76 @@ async def cmd_resume_publish(update: Update, context: ContextTypes.DEFAULT_TYPE)
await update.message.reply_text("🟢 발행이 재개되었습니다.")
async def cmd_collect(update: Update, context: ContextTypes.DEFAULT_TYPE):
await update.message.reply_text("🔄 글감 수집을 시작합니다...")
loop = asyncio.get_event_loop()
try:
await loop.run_in_executor(None, job_collector)
topics_dir = DATA_DIR / 'topics'
today = datetime.now().strftime('%Y%m%d')
files = sorted(topics_dir.glob(f'{today}_*.json'))
if not files:
await update.message.reply_text("✅ 수집 완료! 오늘 수집된 글감이 없습니다.")
return
lines = [f"✅ 수집 완료! 오늘 글감 {len(files)}개:"]
for i, f in enumerate(files[:15], 1):
try:
data = json.loads(f.read_text(encoding='utf-8'))
lines.append(f" {i}. [{data.get('corner','')}] {data.get('topic','')[:50]}")
except Exception:
pass
lines.append("\n✍️ /write [번호] 로 글 작성")
await update.message.reply_text('\n'.join(lines))
except Exception as e:
await update.message.reply_text(f"❌ 수집 오류: {e}")
async def cmd_write(update: Update, context: ContextTypes.DEFAULT_TYPE):
topics_dir = DATA_DIR / 'topics'
today = datetime.now().strftime('%Y%m%d')
files = sorted(topics_dir.glob(f'{today}_*.json'))
if not files:
await update.message.reply_text("오늘 수집된 글감이 없습니다. /collect 먼저 실행하세요.")
return
args = context.args
if not args:
lines = ["📋 글감 목록 (번호를 선택하세요):"]
for i, f in enumerate(files[:10], 1):
try:
data = json.loads(f.read_text(encoding='utf-8'))
lines.append(f" {i}. [{data.get('corner','')}] {data.get('topic','')[:50]}")
except Exception:
pass
lines.append("\n사용법: /write [번호] [방향(선택)]")
await update.message.reply_text('\n'.join(lines))
return
try:
idx = int(args[0]) - 1
if idx < 0 or idx >= len(files):
await update.message.reply_text(f"❌ 1~{len(files)} 사이 번호를 입력하세요.")
return
except ValueError:
await update.message.reply_text("❌ 숫자를 입력하세요. 예: /write 1")
return
topic_file = files[idx]
topic_data = json.loads(topic_file.read_text(encoding='utf-8'))
direction = ' '.join(args[1:]) if len(args) > 1 else ''
draft_path = DATA_DIR / 'originals' / topic_file.name
(DATA_DIR / 'originals').mkdir(exist_ok=True)
await update.message.reply_text(
f"✍️ 글 작성 중...\n주제: {topic_data.get('topic','')[:50]}"
+ (f"\n방향: {direction}" if direction else "")
)
loop = asyncio.get_event_loop()
try:
await loop.run_in_executor(None, _call_openclaw, topic_data, draft_path, direction)
# 자동으로 pending_review로 이동
await loop.run_in_executor(None, _publish_next)
await update.message.reply_text("✅ 완료! /pending 으로 검토하세요.")
except Exception as e:
await update.message.reply_text(f"❌ 글 작성 오류: {e}")
async def cmd_show_topics(update: Update, context: ContextTypes.DEFAULT_TYPE):
topics_dir = DATA_DIR / 'topics'
today = datetime.now().strftime('%Y%m%d')
@@ -587,12 +687,13 @@ async def cmd_show_topics(update: Update, context: ContextTypes.DEFAULT_TYPE):
await update.message.reply_text("오늘 수집된 글감이 없습니다.")
return
lines = [f"📋 오늘 수집된 글감 ({len(files)}개):"]
for f in files[:10]:
for i, f in enumerate(files[:15], 1):
try:
data = json.loads(f.read_text(encoding='utf-8'))
lines.append(f" [{data.get('quality_score',0)}점][{data.get('corner','')}] {data.get('topic','')[:50]}")
lines.append(f" {i}. [{data.get('quality_score',0)}점][{data.get('corner','')}] {data.get('topic','')[:50]}")
except Exception:
pass
lines.append("\n✍️ /write [번호] 로 글 작성")
await update.message.reply_text('\n'.join(lines))
@@ -1126,6 +1227,8 @@ async def main():
# 발행 관련
app.add_handler(CommandHandler('status', cmd_status))
app.add_handler(CommandHandler('collect', cmd_collect))
app.add_handler(CommandHandler('write', cmd_write))
app.add_handler(CommandHandler('approve', cmd_approve))
app.add_handler(CommandHandler('reject', cmd_reject))
app.add_handler(CommandHandler('pending', cmd_pending))
+173 -2
View File
@@ -20,6 +20,105 @@ logger = logging.getLogger(__name__)
BASE_DIR = Path(__file__).parent.parent.parent
CAPTION_TEMPLATES = {
'hormozi': {
'font_size': 64,
'highlight_color': '#FFD700',
'animation': 'pop_in',
'position': 'center',
'outline_width': 4,
'auto_emoji': False,
},
'tiktok_viral': {
'font_size': 56,
'highlight_color': '#FF6B6B',
'animation': 'bounce',
'auto_emoji': True,
'position': 'center_bottom',
},
'brand_4thpath': {
'font_size': 52,
'highlight_color': '#00D4FF',
'animation': 'typewriter',
'position': 'center',
'overlay_gradient': True,
},
}
# Corner → caption template mapping
CORNER_CAPTION_MAP = {
'쉬운세상': 'hormozi',
'숨은보물': 'tiktok_viral',
'바이브리포트': 'hormozi',
'팩트체크': 'brand_4thpath',
'한컷': 'tiktok_viral',
'웹소설': 'brand_4thpath',
}
def smart_line_break(text: str, max_chars: int = 18) -> list[str]:
"""
Break Korean text at semantic boundaries, not mid-word.
Never break before 조사 (particles) or 어미 (endings).
Returns list of line strings.
"""
# Common Korean particles/endings that should not start a new line
PARTICLES = ['', '', '', '', '', '', '', '', '에서', '으로', '',
'', '', '', '', '까지', '부터', '보다', '처럼', '같이',
'한테', '에게', '이라', '라고', '이고', '이며', '', '', '',
'이면', '이나', '', '든지', '거나', '지만', '이지만', '지만',
'니까', '으니까', '이니까', '', '아서', '어서', '', '']
if len(text) <= max_chars:
return [text] if text else []
lines = []
remaining = text
while len(remaining) > max_chars:
# Find best break point near max_chars
break_at = max_chars
# Look for space or punctuation near the limit
for i in range(max_chars, max(0, max_chars - 6), -1):
if i >= len(remaining):
continue
char = remaining[i]
prev_char = remaining[i-1] if i > 0 else ''
next_char = remaining[i+1] if i+1 < len(remaining) else ''
# Break at space
if char == ' ':
# Check if next word starts with a particle
next_word = remaining[i+1:i+4]
is_particle_start = any(next_word.startswith(p) for p in PARTICLES)
if not is_particle_start:
break_at = i
break
# Break after punctuation
if prev_char in ('', '', ',', '.', '!', '?', '~'):
break_at = i
break
lines.append(remaining[:break_at].strip())
remaining = remaining[break_at:].strip()
if remaining:
lines.append(remaining)
return [l for l in lines if l]
def get_template_for_corner(corner: str) -> dict:
"""
Get caption template config for a given content corner.
Falls back to 'hormozi' template if corner not in map.
"""
template_name = CORNER_CAPTION_MAP.get(corner, 'hormozi')
return CAPTION_TEMPLATES.get(template_name, CAPTION_TEMPLATES['hormozi'])
def _load_config() -> dict:
cfg_path = BASE_DIR / 'config' / 'shorts_config.json'
@@ -200,6 +299,7 @@ def render_captions(
timestamp: str,
wav_duration: float = 0.0,
cfg: Optional[dict] = None,
corner: str = '',
) -> Path:
"""
스크립트 + 단어별 타임스탬프 ASS 자막 파일 생성.
@@ -211,6 +311,7 @@ def render_captions(
timestamp: 파일명 prefix
wav_duration: TTS 오디오 길이 (균등 분할 폴백용)
cfg: shorts_config.json dict
corner: content corner name (e.g. '쉬운세상') for template selection
Returns:
ass_path
@@ -222,6 +323,20 @@ def render_captions(
ass_path = output_dir / f'{timestamp}.ass'
cap_cfg = cfg.get('caption', {})
# Apply corner-specific template overrides if corner is provided
if corner:
template = get_template_for_corner(corner)
# Override cfg caption section with template values
cap_cfg = dict(cap_cfg) # make a shallow copy to avoid mutating original
if 'font_size' in template:
cap_cfg['font_size'] = template['font_size']
if 'highlight_color' in template:
cap_cfg['highlight_color'] = template['highlight_color']
if 'outline_width' in template:
cap_cfg['outline_width'] = template['outline_width']
logger.info(f'[캡션] 코너 "{corner}" → 템플릿 적용: {template}')
max_chars = cap_cfg.get('max_chars_per_line_ko', 18)
highlight_color = cap_cfg.get('highlight_color', '#FFD700')
default_color = cap_cfg.get('default_color', '#FFFFFF')
@@ -235,8 +350,10 @@ def render_captions(
wav_duration = 20.0
timestamps = _build_uniform_timestamps(script, wav_duration)
# ASS 헤더
header = _ass_header(cfg)
# ASS 헤더 (rebuild cfg with updated cap_cfg so header reflects template overrides)
effective_cfg = dict(cfg)
effective_cfg['caption'] = cap_cfg
header = _ass_header(effective_cfg)
events = []
# 훅 이벤트 (첫 1.5초 중앙 표시)
@@ -258,4 +375,58 @@ def render_captions(
ass_content = header + '\n'.join(events) + '\n'
ass_path.write_text(ass_content, encoding='utf-8-sig') # BOM for Windows compatibility
logger.info(f'ASS 자막 생성: {ass_path.name} ({len(timestamps)}단어, {len(lines)}라인)')
# ── Standalone test ──────────────────────────────────────────────
if __name__ == '__main__':
import sys
import tempfile
from pathlib import Path
if '--test' not in sys.argv:
print("사용법: python -m bots.shorts.caption_renderer --test")
sys.exit(0)
print("=== Caption Renderer Test ===")
# Test smart_line_break
test_texts = [
("AI를 활용한 자동화 방법입니다", 18),
("단 3가지만 알면 됩니다", 12),
]
print("\n[1] smart_line_break:")
for text, max_c in test_texts:
lines = smart_line_break(text, max_c)
print(f" 입력: {text!r}")
print(f" 결과: {lines}")
# Test template lookup
print("\n[2] get_template_for_corner:")
for corner in ['쉬운세상', '숨은보물', '팩트체크', '없는코너']:
tpl = get_template_for_corner(corner)
print(f" {corner}: font_size={tpl.get('font_size')}, animation={tpl.get('animation')}")
# Test render_captions with dummy timestamps
print("\n[3] render_captions (dry-run):")
sample_timestamps = [
{'word': '이거', 'start': 0.0, 'end': 0.3},
{'word': '모르면', 'start': 0.4, 'end': 0.8},
{'word': '손해입니다', 'start': 0.9, 'end': 1.5},
]
sample_script = {'hook': '이거 모르면 손해입니다'}
with tempfile.TemporaryDirectory() as tmpdir:
out = Path(tmpdir) / 'test.ass'
render_captions(
timestamps=sample_timestamps,
script=sample_script,
output_path=out,
corner='쉬운세상',
)
exists = out.exists()
size = out.stat().st_size if exists else 0
print(f" ASS 파일 생성: {exists}, 크기: {size}bytes")
assert exists and size > 0, "ASS 파일 생성 실패"
print("\n✅ 모든 테스트 통과")
return ass_path
+252
View File
@@ -0,0 +1,252 @@
"""
bots/shorts/hook_optimizer.py
Hook text quality scoring and optimization.
HookOptimizer:
- score(hook): 0-100 quality score based on pattern match + keyword strength
- optimize(hook, article, max_attempts): regenerate if score < 70
V3.0 scope: pattern matching + LLM regeneration via existing writer_bot
"""
import logging
import re
from typing import Optional
logger = logging.getLogger(__name__)
# Hook patterns mapped to template strings with {N} placeholder for numbers
HOOK_PATTERNS = {
'disbelief': [
'이거 모르면 손해',
'이게 무료라고?',
'이걸 아직도 모른다고?',
'믿기 힘들지만 사실입니다',
'실화입니다',
],
'warning': [
'절대 하지 마세요',
'이것만은 피하세요',
'지금 당장 멈추세요',
'알면 충격받을 수 있습니다',
],
'number': [
'{N}초면',
'{N}%가 모르는',
'{N}가지 방법',
'{N}배 빠른',
'상위 {N}%',
],
'question': [
'왜 아무도 안 알려줄까?',
'진짜일까?',
'이게 가능한 이유',
'어떻게 하는 걸까?',
],
'urgency': [
'지금 당장',
'오늘 안에',
'지금 안 보면 후회',
'당장 시작해야 하는 이유',
],
}
# High-value keywords that boost score (Korean viral hook words)
HIGH_VALUE_KEYWORDS = [
'무료', '공짜', '비밀', '충격', '실화', '진짜', '불법',
'모르는', '숨겨진', '알려지지 않은', '믿기지 않는', '손해',
'당장', '지금', '반드시', '절대', '', '필수',
'', '수익', '수입', '부자', '성공', '자유',
'초보', '누구나', '쉬운', '간단한',
]
# Weak words that reduce score
WEAK_KEYWORDS = [
'알아보겠습니다', '살펴보겠습니다', '설명드리겠습니다',
'안녕하세요', '오늘은', '이번에는', '먼저',
]
class HookOptimizer:
"""
Scores and optimizes hook text for shorts videos.
Score = pattern_score (0-50) + keyword_score (0-30) + length_score (0-20)
Threshold: 70 below this triggers regeneration
"""
def __init__(self, threshold: int = 70):
self.threshold = threshold
self._recently_used_patterns: list[str] = [] # avoid repetition
def score(self, hook: str) -> int:
"""
Score a hook text from 0-100.
Components:
- pattern_score (0-50): does it match a known viral pattern?
- keyword_score (0-30): does it contain high-value keywords?
- length_score (0-20): optimal length (15-30 chars = max)
"""
if not hook:
return 0
pattern_score = self._score_pattern(hook)
keyword_score = self._score_keywords(hook)
length_score = self._score_length(hook)
total = min(100, pattern_score + keyword_score + length_score)
return total
def optimize(
self,
hook: str,
article: dict,
max_attempts: int = 3,
llm_fn=None,
) -> str:
"""
Score hook. If score < threshold, regenerate up to max_attempts times.
Args:
hook: Initial hook text
article: Article dict with keys: title, body, corner, key_points
max_attempts: Max regeneration attempts
llm_fn: Optional callable(prompt) -> str for LLM regeneration.
If None, returns original hook (LLM not available).
Returns: Best hook found (may still be below threshold if all attempts fail)
"""
current = hook
best = hook
best_score = self.score(hook)
logger.info(f'[훅] 초기 점수: {best_score}/100 — "{hook[:30]}..."')
if best_score >= self.threshold:
return hook
if llm_fn is None:
logger.warning(f'[훅] 점수 부족 ({best_score}/100) — LLM 없음, 원본 사용')
return hook
for attempt in range(max_attempts):
prompt = self._build_regeneration_prompt(current, article, best_score)
try:
new_hook = llm_fn(prompt)
if new_hook:
new_hook = new_hook.strip().split('\n')[0] # Take first line
new_score = self.score(new_hook)
logger.info(f'[훅] 시도 {attempt+1}: {new_score}/100 — "{new_hook[:30]}"')
if new_score > best_score:
best = new_hook
best_score = new_score
if best_score >= self.threshold:
break
current = new_hook
except Exception as e:
logger.warning(f'[훅] LLM 재생성 실패 (시도 {attempt+1}): {e}')
break
logger.info(f'[훅] 최종 점수: {best_score}/100 — "{best[:30]}"')
return best
def _score_pattern(self, hook: str) -> int:
"""Check if hook matches known viral patterns. Max 50 points."""
for pattern_name, templates in HOOK_PATTERNS.items():
for template in templates:
# Check for fuzzy match (template with {N} filled in)
pattern_re = re.escape(template).replace(r'\{N\}', r'\d+')
if re.search(pattern_re, hook):
# Recently used pattern gets reduced score
if pattern_name in self._recently_used_patterns[-3:]:
return 30
self._recently_used_patterns.append(pattern_name)
return 50
# Partial match check
core = template.replace('{N}', '').strip()
if len(core) > 3 and core in hook:
return 35
return 0
def _score_keywords(self, hook: str) -> int:
"""Score based on high-value/weak keywords. Max 30 points."""
score = 0
for kw in HIGH_VALUE_KEYWORDS:
if kw in hook:
score += 10
if score >= 30:
break
# Penalize weak words
for kw in WEAK_KEYWORDS:
if kw in hook:
score -= 15
return max(0, min(30, score))
def _score_length(self, hook: str) -> int:
"""Score based on hook length. Max 20 points. Optimal: 15-30 chars."""
length = len(hook)
if 15 <= length <= 30:
return 20
elif 10 <= length < 15 or 30 < length <= 40:
return 10
elif length < 10:
return 5
else: # > 40 chars
return 0
def _build_regeneration_prompt(self, hook: str, article: dict, current_score: int) -> str:
"""Build LLM prompt for hook regeneration."""
title = article.get('title', '')
corner = article.get('corner', '')
key_points = article.get('key_points', [])
recently_used = ', '.join(self._recently_used_patterns[-3:]) if self._recently_used_patterns else '없음'
points_str = '\n'.join(f'- {p}' for p in key_points[:3]) if key_points else ''
return f"""다음 쇼츠 영상의 훅 텍스트를 개선해주세요.
현재 : {hook}
현재 점수: {current_score}/100 (기준: 70 이상)
콘텐츠 정보:
- 제목: {title}
- 코너: {corner}
- 핵심 포인트: {points_str}
요구사항:
1. 15-30 이내
2. 다음 패턴 하나 사용: 충격/의심/경고/숫자/긴급
3. 최근 사용된 패턴 제외: {recently_used}
4. 한국어로 작성
5. 텍스트만 출력 (설명 없이)
개선된 :"""
# ── Standalone test ──────────────────────────────────────────────
if __name__ == '__main__':
import sys
if '--test' in sys.argv:
optimizer = HookOptimizer()
test_hooks = [
'이거 모르면 손해입니다!',
'안녕하세요 오늘은 AI에 대해 설명드리겠습니다',
'100%가 모르는 무료 도구',
'지금 당장 이것만은 절대 하지 마세요',
'',
]
print("=== Hook Optimizer Test ===")
for hook in test_hooks:
s = optimizer.score(hook)
print(f'점수 {s:3d}/100: "{hook}"')
print()
print("Pattern test:")
for category in HOOK_PATTERNS:
print(f" {category}: {len(HOOK_PATTERNS[category])} patterns")
+196
View File
@@ -0,0 +1,196 @@
"""
bots/shorts/motion_engine.py
Motion pattern engine for video clips.
Applies one of 7 motion patterns to still images using FFmpeg.
Ensures no 2 consecutive clips use the same pattern.
Patterns:
1. ken_burns_in slow zoom in
2. ken_burns_out slow zoom out
3. pan_left pan from right to left
4. pan_right pan from left to right
5. parallax layered depth effect (approximated)
6. rotate_slow very slow rotation
7. glitch_reveal glitch-style reveal
"""
import logging
import os
import random
import subprocess
import tempfile
from pathlib import Path
from typing import Optional
logger = logging.getLogger(__name__)
PATTERNS = [
'ken_burns_in',
'ken_burns_out',
'pan_left',
'pan_right',
'parallax',
'rotate_slow',
'glitch_reveal',
]
# FFmpeg filter_complex expressions for each pattern
# Input: scale to 1120x1990 (slightly larger than 1080x1920 for motion room)
PATTERN_FILTERS = {
'ken_burns_in': (
"scale=1120:1990,"
"zoompan=z='min(zoom+0.0008,1.08)':x='iw/2-(iw/zoom/2)':y='ih/2-(ih/zoom/2)'"
":d={dur_frames}:s=1080x1920:fps=30"
),
'ken_burns_out': (
"scale=1120:1990,"
"zoompan=z='if(lte(zoom,1.0),1.08,max(zoom-0.0008,1.0))'"
":x='iw/2-(iw/zoom/2)':y='ih/2-(ih/zoom/2)'"
":d={dur_frames}:s=1080x1920:fps=30"
),
'pan_left': (
"scale=1200:1920,"
"crop=1080:1920:'min(iw-ow, (iw-ow)*t/{duration})':0"
),
'pan_right': (
"scale=1200:1920,"
"crop=1080:1920:'(iw-ow)*(1-t/{duration})':0"
),
'parallax': (
# Approximate parallax: zoom + horizontal pan
"scale=1200:1990,"
"zoompan=z='1.05':x='iw/2-(iw/zoom/2)+50*sin(2*PI*t/{duration})'"
":y='ih/2-(ih/zoom/2)':d={dur_frames}:s=1080x1920:fps=30"
),
'rotate_slow': (
"scale=1200:1200,"
"rotate='0.02*t':c=black:ow=1080:oh=1920"
),
'glitch_reveal': (
# Fade in with slight chromatic aberration approximation
"scale=1080:1920,"
"fade=t=in:st=0:d=0.3,"
"hue=h='if(lt(t,0.3),10*sin(30*t),0)'"
),
}
class MotionEngine:
"""
Applies motion patterns to still images.
Auto-selects patterns to avoid repeating the last 2 used.
"""
def __init__(self):
self._recent: list[str] = [] # last patterns used (max 2)
self._ffmpeg = os.environ.get('FFMPEG_PATH', 'ffmpeg')
def apply(self, image_path: str, duration: float, output_path: Optional[str] = None) -> str:
"""
Apply a motion pattern to a still image.
Args:
image_path: Path to input image (PNG/JPG, 1080x1920 recommended)
duration: Duration of output video in seconds
output_path: Output MP4 path. If None, creates temp file.
Returns: Path to motion-applied video clip (MP4)
"""
if output_path is None:
# Create a temp file that persists (caller is responsible for cleanup)
tmp = tempfile.NamedTemporaryFile(suffix='.mp4', delete=False)
output_path = tmp.name
tmp.close()
pattern = self._next_pattern()
success = self._ffmpeg_motion(image_path, duration, pattern, output_path)
if not success:
logger.warning(f'[모션] {pattern} 패턴 실패 — ken_burns_in으로 폴백')
success = self._ffmpeg_motion(image_path, duration, 'ken_burns_in', output_path)
if success:
logger.info(f'[모션] 패턴 적용: {pattern} ({duration:.1f}초)')
return output_path
else:
logger.error(f'[모션] 모든 패턴 실패: {image_path}')
return ''
def _next_pattern(self) -> str:
"""Select next pattern, avoiding last 2 used."""
available = [p for p in PATTERNS if p not in self._recent[-2:]]
if not available:
available = PATTERNS
choice = random.choice(available)
self._recent.append(choice)
if len(self._recent) > 4: # Keep small buffer
self._recent = self._recent[-4:]
return choice
def _ffmpeg_motion(self, image_path: str, duration: float,
pattern: str, output_path: str) -> bool:
"""Apply a specific motion pattern using FFmpeg."""
dur_frames = int(duration * 30)
vf_template = PATTERN_FILTERS.get(pattern, PATTERN_FILTERS['ken_burns_in'])
vf = vf_template.format(
duration=f'{duration:.3f}',
dur_frames=dur_frames,
)
cmd = [
self._ffmpeg, '-y',
'-loop', '1',
'-i', str(image_path),
'-t', f'{duration:.3f}',
'-vf', vf,
'-c:v', 'libx264',
'-crf', '20',
'-preset', 'fast',
'-pix_fmt', 'yuv420p',
'-an',
'-r', '30',
str(output_path),
]
try:
result = subprocess.run(
cmd,
capture_output=True,
timeout=120,
)
if result.returncode != 0:
logger.warning(f'[모션] FFmpeg 오류 ({pattern}): {result.stderr.decode(errors="ignore")[-200:]}')
return False
return True
except subprocess.TimeoutExpired:
logger.warning(f'[모션] 타임아웃 ({pattern})')
return False
except Exception as e:
logger.warning(f'[모션] 예외 ({pattern}): {e}')
return False
def get_recent(self) -> list[str]:
"""Return recently used patterns."""
return list(self._recent)
# ── Standalone test ──────────────────────────────────────────────
if __name__ == '__main__':
import sys
if '--test' in sys.argv:
engine = MotionEngine()
print("=== Motion Engine Test ===")
print(f"Available patterns: {PATTERNS}")
print(f"Pattern sequence (10 picks):")
for i in range(10):
p = engine._next_pattern()
print(f" {i+1}. {p}")
print(f"No pattern repeated consecutively: ", end='')
recent_list = engine.get_recent()
no_consec = all(
recent_list[i] != recent_list[i+1]
for i in range(len(recent_list)-1)
)
print("PASS" if no_consec else "FAIL")
+210 -2
View File
@@ -25,6 +25,111 @@ from typing import Optional
logger = logging.getLogger(__name__)
# ─── SmartTTSRouter ───────────────────────────────────────────
class SmartTTSRouter:
"""
Budget-aware TTS engine selection with graceful fallback.
Engine priority order (best to cheapest):
1. elevenlabs best quality, paid
2. openai_tts good quality, paid (uses existing OpenAI key)
3. cosyvoice2 local, free, Korean native speaker voice
4. kokoro local, free, 82M params
5. edge_tts free fallback, always available
"""
ENGINE_PRIORITY = ['elevenlabs', 'openai_tts', 'cosyvoice2', 'kokoro', 'edge_tts']
# Daily/monthly usage limits per engine
ENGINE_LIMITS = {
'elevenlabs': {'chars_per_month': 10000, 'threshold': 0.8},
'openai_tts': {'chars_per_day': 500000, 'threshold': 0.9},
}
ENGINE_API_KEYS = {
'elevenlabs': 'ELEVENLABS_API_KEY',
'openai_tts': 'OPENAI_API_KEY',
}
# cosyvoice2, kokoro, edge_tts are local — no API key needed
def __init__(self, resolved_config: dict):
"""
resolved_config: output from ConfigResolver.resolve()
"""
self.budget = resolved_config.get('budget', 'free')
self.tts_engine = resolved_config.get('tts', 'edge_tts')
self._usage = {} # {engine_name: chars_used_today}
self._failed = set() # engines that failed this session
def select(self, text_length: int) -> str:
"""
Select best available TTS engine for given text length.
1. If user specified a non-auto engine: use it if available
2. Else: check budget-appropriate engines in priority order
3. Skip engines that have exceeded usage threshold
4. Skip engines that failed this session
5. Always fall back to edge_tts
"""
import os
# If user explicitly chose a specific engine (not 'auto')
if self.tts_engine not in ('auto', 'edge_tts', ''):
engine = self.tts_engine
api_key_env = self.ENGINE_API_KEYS.get(engine, '')
if not api_key_env or os.environ.get(api_key_env, ''):
if engine not in self._failed:
return engine
# Budget-based priority selection
if self.budget == 'free':
priority = ['kokoro', 'edge_tts']
elif self.budget == 'low':
priority = ['openai_tts', 'kokoro', 'edge_tts']
else: # medium, premium
priority = self.ENGINE_PRIORITY
for engine in priority:
if engine in self._failed:
continue
api_key_env = self.ENGINE_API_KEYS.get(engine, '')
if api_key_env and not os.environ.get(api_key_env, ''):
continue # no API key
if self._is_over_limit(engine, text_length):
continue
return engine
return 'edge_tts' # always available
def on_failure(self, engine: str, error: str) -> str:
"""
Record engine failure and return next available engine.
No retry on same engine no wasted credits.
"""
import logging
logging.getLogger(__name__).warning(f'TTS 엔진 실패: {engine}{error}, 다음 엔진으로 전환')
self._failed.add(engine)
return self.select(0) # Select next engine
def record_usage(self, engine: str, char_count: int) -> None:
"""Record character usage for an engine."""
self._usage[engine] = self._usage.get(engine, 0) + char_count
def _is_over_limit(self, engine: str, text_length: int) -> bool:
"""Check if engine has exceeded its usage threshold."""
limits = self.ENGINE_LIMITS.get(engine, {})
if not limits:
return False
threshold = limits.get('threshold', 0.9)
daily_limit = limits.get('chars_per_day', limits.get('chars_per_month', 0))
if not daily_limit:
return False
used = self._usage.get(engine, 0)
return (used + text_length) / daily_limit > threshold
# ─── 공통 유틸 ────────────────────────────────────────────────
@@ -167,6 +272,47 @@ def _get_ffmpeg() -> str:
return 'ffmpeg'
# ─── OpenAI TTS ───────────────────────────────────────────────
def _tts_openai(text: str, output_path: Path, cfg: dict) -> list[dict]:
"""
OpenAI TTS (tts-1-hd model) with timestamp estimation.
Returns: [{word, start, end}, ...] uniform timestamps (no word-level from OpenAI)
"""
import requests, base64
import os
api_key = os.environ.get('OPENAI_API_KEY', '')
if not api_key:
raise RuntimeError('OPENAI_API_KEY not set')
openai_cfg = cfg.get('tts', {}).get('openai', {})
model = openai_cfg.get('model', 'tts-1-hd')
voice = openai_cfg.get('voice', 'alloy')
speed = openai_cfg.get('speed', 1.0)
url = 'https://api.openai.com/v1/audio/speech'
headers = {'Authorization': f'Bearer {api_key}', 'Content-Type': 'application/json'}
payload = {
'model': model,
'input': text,
'voice': voice,
'speed': speed,
'response_format': 'mp3',
}
resp = requests.post(url, headers=headers, json=payload, timeout=60)
resp.raise_for_status()
mp3_tmp = output_path.with_suffix('.mp3')
mp3_tmp.write_bytes(resp.content)
_mp3_to_wav(mp3_tmp, output_path)
mp3_tmp.unlink(missing_ok=True)
# OpenAI TTS has no word-level timestamps — use uniform distribution
return [] # caption_renderer will use uniform fallback
# ─── Google Cloud TTS ─────────────────────────────────────────
def _tts_google_cloud(text: str, output_path: Path, cfg: dict) -> list[dict]:
@@ -323,11 +469,21 @@ def generate_tts(
ts_path = output_dir / f'{timestamp}_timestamps.json'
text = _concat_script(script)
pause_ms = cfg.get('tts', {}).get('inter_sentence_pause_ms', 300)
priority = cfg.get('tts', {}).get('engine_priority', ['elevenlabs', 'google_cloud', 'edge_tts'])
# Apply Korean preprocessing if available
try:
from bots.prompt_layer.korean_preprocessor import preprocess_korean
text = preprocess_korean(text)
except ImportError:
pass # Korean preprocessing not available, use raw text
pause_ms = cfg.get('tts', {}).get('inter_sentence_pause_ms', 300)
priority = cfg.get('tts', {}).get('engine_priority', ['elevenlabs', 'openai_tts', 'google_cloud', 'edge_tts'])
# Engine map: elevenlabs → openai_tts → google_cloud → edge_tts
engine_map = {
'elevenlabs': _tts_elevenlabs,
'openai_tts': _tts_openai,
'google_cloud': _tts_google_cloud,
'edge_tts': _tts_edge,
}
@@ -369,3 +525,55 @@ def generate_tts(
def load_timestamps(ts_path: Path) -> list[dict]:
"""저장된 타임스탬프 JSON 로드."""
return json.loads(ts_path.read_text(encoding='utf-8'))
# ── Standalone test ──────────────────────────────────────────────
if __name__ == '__main__':
import sys
import tempfile
from pathlib import Path
if '--test' not in sys.argv:
print("사용법: python -m bots.shorts.tts_engine --test")
sys.exit(0)
print("=== TTS Engine Test ===")
# Test SmartTTSRouter initialization
print("\n[1] SmartTTSRouter 초기화:")
router = SmartTTSRouter({'budget': 'free'})
print(f" budget: {router.budget}")
engine = router.select(text_length=100)
print(f" select(100chars) → {engine}")
assert isinstance(engine, str) and engine, "엔진 선택 실패"
# Test with medium budget (no API keys → falls back to free engine)
router_med = SmartTTSRouter({'budget': 'medium'})
engine_med = router_med.select(text_length=500)
print(f" medium budget select(500chars) → {engine_med}")
assert isinstance(engine_med, str) and engine_med, "medium 엔진 선택 실패"
# Test usage recording + over-limit detection
print("\n[2] 사용량 제한 로직:")
router3 = SmartTTSRouter({'budget': 'free'})
router3.record_usage('elevenlabs', 9000) # near limit
over = router3._is_over_limit('elevenlabs', 900) # 9000+900 > 8000 threshold
print(f" elevenlabs 9000자 기록 후 900자 추가 → 한도 초과: {over}")
assert over, "한도 초과 감지 실패"
# Test Edge TTS (always-available free engine) with short text
print("\n[3] Edge TTS 음성 생성 (네트워크 필요):")
with tempfile.TemporaryDirectory() as tmpdir:
try:
wav, timestamps = generate_tts(
script={'hook': '테스트입니다', 'body': [], 'closer': ''},
output_dir=Path(tmpdir),
timestamp='test_20260329',
)
print(f" WAV 생성: {wav.exists()}, 타임스탬프: {len(timestamps)}단어")
assert wav.exists(), "WAV 파일 생성 실패"
except Exception as e:
print(f" [경고] TTS 실패 (네트워크/의존성 없을 수 있음): {e}")
print("\n✅ 모든 테스트 통과")
+266
View File
@@ -413,3 +413,269 @@ def assemble(
if tmp_cleanup and work_dir.exists():
import shutil
shutil.rmtree(work_dir, ignore_errors=True)
# ─── GPU Encoder Detection ────────────────────────────────────
def _detect_gpu_encoder(ffmpeg: str = 'ffmpeg') -> str:
"""
Detect available GPU encoder in priority order:
nvenc (NVIDIA) > amf (AMD) > qsv (Intel) > libx264 (CPU)
Returns: encoder name string
"""
encoders_to_try = [
('h264_nvenc', ['-hwaccel', 'cuda']), # NVIDIA
('h264_amf', []), # AMD
('h264_qsv', ['-hwaccel', 'qsv']), # Intel
]
import tempfile, subprocess
for encoder, hwaccel_args in encoders_to_try:
try:
with tempfile.NamedTemporaryFile(suffix='.mp4', delete=False) as f:
test_out = f.name
cmd = (
[ffmpeg, '-y', '-loglevel', 'error']
+ hwaccel_args
+ ['-f', 'lavfi', '-i', 'color=black:s=16x16:r=1',
'-t', '0.1',
'-c:v', encoder,
test_out]
)
result = subprocess.run(cmd, capture_output=True, timeout=10)
Path(test_out).unlink(missing_ok=True)
if result.returncode == 0:
logger.info(f'[GPU] 인코더 감지: {encoder}')
return encoder
except Exception:
pass
logger.info('[GPU] GPU 인코더 없음 — libx264 사용')
return 'libx264'
# ─── Resilient Assembler ─────────────────────────────────────
class ResilientAssembler:
"""
Resilient video assembler with:
1. Per-clip encoding (fail one fallback that clip only)
2. Timeout per FFmpeg process (5 minutes)
3. GPU encoder auto-detection (nvenc/amf/qsv/cpu)
4. Progress reporting (logs every clip)
Use assemble_resilient() instead of the module-level assemble() for better fault tolerance.
"""
CLIP_TIMEOUT = 300 # 5 minutes per clip
FINAL_TIMEOUT = 600 # 10 minutes for final assembly
def __init__(self, cfg: dict = None):
"""
cfg: shorts_config.json dict (loaded automatically if None)
"""
self._cfg = cfg or _load_config()
self._ffmpeg = _get_ffmpeg()
self._encoder = None # Lazy detection
def _get_encoder(self) -> str:
"""Detect and cache GPU encoder."""
if self._encoder is None:
self._encoder = _detect_gpu_encoder(self._ffmpeg)
return self._encoder
def _encode_clip(self, clip_path: Path, index: int, work_dir: Path) -> Path:
"""
Encode a single clip to standardized format.
Returns: path to encoded clip
Raises: RuntimeError on failure (triggers fallback)
"""
out = work_dir / f'encoded_{index:02d}.mp4'
encoder = self._get_encoder()
cmd = [
self._ffmpeg, '-y',
'-i', str(clip_path),
'-c:v', encoder,
'-crf', '20' if encoder == 'libx264' else '20',
'-preset', 'fast' if encoder == 'libx264' else 'fast',
'-pix_fmt', 'yuv420p',
'-an', '-r', '30',
str(out),
]
# Adjust args for GPU encoders (they use different quality flags)
if encoder != 'libx264':
cmd = [
self._ffmpeg, '-y',
'-i', str(clip_path),
'-c:v', encoder,
'-b:v', '2M', # Bitrate for GPU encoders
'-pix_fmt', 'yuv420p',
'-an', '-r', '30',
str(out),
]
try:
result = subprocess.run(
cmd, capture_output=True, timeout=self.CLIP_TIMEOUT
)
if result.returncode != 0:
raise RuntimeError(f'FFmpeg error: {result.stderr.decode(errors="ignore")[-200:]}')
logger.info(f'[조립] 클립 {index} 인코딩 완료 ({encoder})')
return out
except subprocess.TimeoutExpired:
raise RuntimeError(f'클립 {index} 인코딩 타임아웃 ({self.CLIP_TIMEOUT}초)')
def _fallback_clip(self, clip_path: Path, index: int, work_dir: Path) -> Path:
"""
Fallback clip encoding using libx264 (CPU, always works).
"""
logger.warning(f'[조립] 클립 {index} 폴백 인코딩 (libx264)')
out = work_dir / f'fallback_{index:02d}.mp4'
cmd = [
self._ffmpeg, '-y',
'-i', str(clip_path),
'-c:v', 'libx264', '-crf', '23', '-preset', 'fast',
'-pix_fmt', 'yuv420p',
'-an', '-r', '30',
str(out),
]
try:
result = subprocess.run(cmd, capture_output=True, timeout=self.CLIP_TIMEOUT)
if result.returncode != 0:
logger.error(f'[조립] 폴백도 실패 (클립 {index}): {result.stderr.decode(errors="ignore")[-100:]}')
return clip_path # Return original as last resort
return out
except subprocess.TimeoutExpired:
logger.error(f'[조립] 폴백 타임아웃 (클립 {index})')
return clip_path
def assemble_resilient(
self,
clips: list[Path],
tts_wav: Path,
ass_path: Optional[Path],
output_dir: Path,
timestamp: str,
work_dir: Optional[Path] = None,
) -> Path:
"""
Resilient version of assemble() with per-clip fallback.
Key differences from assemble():
1. Each clip is encoded individually failure fallback that clip only
2. GPU encoder used when available
3. Per-process timeout (5 min per clip)
4. Progress logged per clip
Args:
Same as assemble()
Returns: Path to rendered MP4
Raises: RuntimeError only if ALL clips fail or final assembly fails
"""
import contextlib, shutil
output_dir.mkdir(parents=True, exist_ok=True)
tmp_cleanup = work_dir is None
if work_dir is None:
work_dir = output_dir / f'_resilient_{timestamp}'
work_dir.mkdir(parents=True, exist_ok=True)
try:
# Step 1: Encode each clip (with per-clip fallback)
encoded = []
failed_count = 0
for i, clip in enumerate(clips):
logger.info(f'[조립] 클립 {i+1}/{len(clips)} 처리 중...')
try:
enc = self._encode_clip(clip, i, work_dir)
encoded.append(enc)
except Exception as e:
logger.warning(f'[조립] 클립 {i} 인코딩 실패: {e} — 폴백 사용')
failed_count += 1
fb = self._fallback_clip(clip, i, work_dir)
encoded.append(fb)
if not encoded:
raise RuntimeError('[조립] 인코딩된 클립 없음 — 조립 불가')
if failed_count > 0:
logger.warning(f'[조립] {failed_count}/{len(clips)} 클립이 폴백으로 인코딩됨')
# Step 2: Use the existing assemble() for the rest (concat + audio + subtitles)
# This reuses all the battle-tested logic from the original assembler
result_path = assemble(
clips=encoded,
tts_wav=tts_wav,
ass_path=ass_path,
output_dir=output_dir,
timestamp=timestamp,
cfg=self._cfg,
work_dir=work_dir / 'assemble',
)
logger.info(f'[조립] 탄력적 조립 완료: {result_path.name}')
return result_path
finally:
if tmp_cleanup and work_dir.exists():
shutil.rmtree(work_dir, ignore_errors=True)
# ── Standalone test ──────────────────────────────────────────────
if __name__ == '__main__':
import sys
if '--test' not in sys.argv:
print("사용법: python -m bots.shorts.video_assembler --test")
sys.exit(0)
print("=== Video Assembler Test ===")
# Test GPU encoder detection
print("\n[1] GPU 인코더 자동 감지:")
ffmpeg_bin = _get_ffmpeg()
encoder = _detect_gpu_encoder(ffmpeg_bin)
print(f" 감지된 인코더: {encoder}")
assert encoder in ('h264_nvenc', 'h264_amf', 'h264_qsv', 'libx264'), \
f"알 수 없는 인코더: {encoder}"
# Test ResilientAssembler encoder caching
print("\n[2] ResilientAssembler 초기화 + 인코더 캐싱:")
assembler = ResilientAssembler()
enc1 = assembler._get_encoder()
enc2 = assembler._get_encoder()
print(f" 인코더: {enc1}")
assert enc1 == enc2, "캐시 불일치"
assert assembler._encoder is not None, "캐시 저장 실패"
# Test duration helpers
print("\n[3] 유틸 함수:")
# WAV duration (requires existing file — skip if not present)
try:
import tempfile, wave
with tempfile.NamedTemporaryFile(suffix='.wav', delete=False) as tmp:
tmp_path = Path(tmp.name)
# Write minimal valid WAV (1s silence at 44100Hz mono)
with wave.open(str(tmp_path), 'w') as wf:
wf.setnchannels(1)
wf.setsampwidth(2)
wf.setframerate(44100)
wf.writeframes(b'\x00\x00' * 44100)
dur = _get_wav_duration(tmp_path)
print(f" WAV 1초 테스트: duration={dur:.2f}s")
assert abs(dur - 1.0) < 0.1, f"WAV 길이 오류: {dur}"
tmp_path.unlink(missing_ok=True)
except Exception as e:
print(f" [경고] WAV 테스트 건너뜀: {e}")
print("\n✅ 모든 테스트 통과")
+1 -1
View File
@@ -28,7 +28,7 @@ from typing import Optional
from dotenv import load_dotenv
load_dotenv(dotenv_path='D:/key/blog-writer.env.env')
load_dotenv()
BASE_DIR = Path(__file__).parent.parent
sys.path.insert(0, str(BASE_DIR))
+1 -1
View File
@@ -20,7 +20,7 @@ from pathlib import Path
from dotenv import load_dotenv
load_dotenv(dotenv_path='D:/key/blog-writer.env.env')
load_dotenv()
BASE_DIR = Path(__file__).parent.parent
sys.path.insert(0, str(BASE_DIR))
+5 -4
View File
@@ -3,12 +3,13 @@
{
"id": "main",
"blog_id": "${BLOG_MAIN_ID}",
"name": "테크인사이더",
"persona": "tech_insider",
"domain": "",
"name": "AI? 그게 뭔데?",
"persona": "eli",
"domain": "eli-ai.blogspot.com",
"tagline": "어렵지 않아요, 그냥 읽어봐요",
"active": true,
"phase": 1,
"labels": ["쉬운세상", "숨은보물", "바이브리포트", "팩트체크", "한컷"]
"labels": ["AI인사이트", "여행맛집", "스타트업", "TV로보는세상", "제품리뷰", "생활꿀팁", "앱추천", "재테크", "팩트체크"]
}
]
}
+50 -16
View File
@@ -1,8 +1,8 @@
{
"_comment": "The 4th Path 블로그 자동 수익 엔진 — 엔진 설정 (v3)",
"_updated": "2026-03-26",
"_updated": "2026-03-29",
"writing": {
"provider": "openclaw",
"provider": "gemini",
"_comment_provider": "openclaw=ChatGPT Pro(OAuth), claude_web=Claude Max(웹쿠키), gemini_web=Gemini Pro(웹쿠키), claude=Anthropic API키, gemini=Google AI API키",
"options": {
"openclaw": {
@@ -26,7 +26,7 @@
},
"gemini": {
"api_key_env": "GEMINI_API_KEY",
"model": "gemini-2.5-pro",
"model": "gemini-2.5-flash",
"max_tokens": 4096,
"temperature": 0.7
}
@@ -75,8 +75,45 @@
}
},
"video_generation": {
"provider": "sora",
"provider": "smart_router",
"options": {
"smart_router": {
"priority": [
"kling_free",
"veo3",
"seedance2",
"ffmpeg_slides"
],
"daily_cost_limit_usd": 0.5,
"prefer_free_first": true,
"fallback": "ffmpeg_slides"
},
"kling_free": {
"api_url": "https://api.klingai.com/v1",
"api_key_env": "KLING_API_KEY",
"free_daily_credits": 66,
"mode": "standard",
"resolution": "720p",
"aspect_ratio": "9:16",
"audio": true,
"cost_per_sec": 0
},
"veo3": {
"api_key_env": "GEMINI_API_KEY",
"model": "veo-3.1",
"resolution": "720p",
"aspect_ratio": "9:16",
"audio": true,
"cost_per_sec": 0.03
},
"seedance2": {
"provider": "fal.ai",
"api_key_env": "FAL_API_KEY",
"model": "seedance-2.0",
"resolution": "720p",
"audio": true,
"cost_per_sec": 0.022
},
"ffmpeg_slides": {
"resolution": "1080x1920",
"fps": 30,
@@ -88,6 +125,7 @@
"burn_subtitles": true
},
"seedance": {
"_comment": "레거시 — seedance2/FAL_API_KEY로 대체됨",
"api_url": "https://api.seedance2.ai/v1/generate",
"api_key_env": "SEEDANCE_API_KEY",
"resolution": "1080x1920",
@@ -95,11 +133,6 @@
"audio": true,
"fallback": "ffmpeg_slides"
},
"sora": {
"comment": "OpenAI Sora — API 접근 가능 시 활성화",
"api_key_env": "OPENAI_API_KEY",
"fallback": "ffmpeg_slides"
},
"runway": {
"api_key_env": "RUNWAY_API_KEY",
"model": "gen3a_turbo",
@@ -108,7 +141,7 @@
"fallback": "ffmpeg_slides"
},
"veo": {
"comment": "Google Veo 3.1 — API 접근 가능 시 활성화",
"comment": "Google Veo 3.1 레거시 — veo3로 대체됨",
"api_key_env": "GEMINI_API_KEY",
"fallback": "ffmpeg_slides"
}
@@ -163,14 +196,15 @@
"analytics": "22:00"
},
"brand": {
"name": "The 4th Path",
"sub": "Independent Tech Media",
"by": "by 22B Labs",
"url": "the4thpath.com",
"cta": "팔로우하면 매일 이런 정보를 받습니다"
"name": "AI? 그게 뭔데?",
"sub": "eli의 쉽고 재미있는 이야기",
"by": "by eli",
"url": "eli-ai.blogspot.com",
"cta": "구독하면 매일 쉽고 재미있는 정보를 받습니다"
},
"optional_keys": {
"SEEDANCE_API_KEY": "Seedance 2.0 AI 영상 생성",
"KLING_API_KEY": "Kling 3.0 AI 영상 생성 (66 무료 크레딧/일)",
"FAL_API_KEY": "Seedance 2.0 AI 영상 생성 (fal.ai)",
"ELEVENLABS_API_KEY": "ElevenLabs 고품질 TTS",
"GEMINI_API_KEY": "Google Gemini 글쓰기 / Veo 영상",
"RUNWAY_API_KEY": "Runway Gen-3 AI 영상 생성"
+55
View File
@@ -0,0 +1,55 @@
{
"_comment": "코너별 시각 스타일 설정 (쇼츠 영상 프롬프트에 사용)",
"_updated": "2026-03-29",
"corners": {
"쉬운세상": {
"caption_template": "hormozi",
"color_palette": ["#FFD700", "#FFFFFF", "#000000"],
"video_style": "clean minimal bright",
"motion_preference": ["ken_burns_in", "pan_right"],
"tone": "educational friendly"
},
"숨은보물": {
"caption_template": "tiktok_viral",
"color_palette": ["#FF6B6B", "#FFFFFF", "#1A1A1A"],
"video_style": "dynamic energetic colorful",
"motion_preference": ["glitch_reveal", "pan_left"],
"tone": "exciting surprising"
},
"바이브리포트": {
"caption_template": "hormozi",
"color_palette": ["#FFD700", "#FFFFFF", "#0D0D0D"],
"video_style": "professional cinematic dark",
"motion_preference": ["ken_burns_out", "parallax"],
"tone": "authoritative analytical"
},
"팩트체크": {
"caption_template": "brand_4thpath",
"color_palette": ["#00D4FF", "#FFFFFF", "#0A0A1A"],
"video_style": "corporate data-driven precise",
"motion_preference": ["rotate_slow", "ken_burns_in"],
"tone": "objective factual"
},
"한컷": {
"caption_template": "tiktok_viral",
"color_palette": ["#FF6B6B", "#FF9F43", "#FFFFFF"],
"video_style": "bold graphic striking",
"motion_preference": ["glitch_reveal", "pan_right"],
"tone": "punchy viral"
},
"웹소설": {
"caption_template": "brand_4thpath",
"color_palette": ["#00D4FF", "#9B59B6", "#0D0D0D"],
"video_style": "dramatic atmospheric fantasy",
"motion_preference": ["parallax", "ken_burns_in"],
"tone": "narrative dramatic"
}
},
"default": {
"caption_template": "hormozi",
"color_palette": ["#FFD700", "#FFFFFF", "#000000"],
"video_style": "clean professional",
"motion_preference": ["ken_burns_in"],
"tone": "neutral"
}
}
+2 -2
View File
@@ -4,7 +4,7 @@
"korean_relevance": {
"max": 30,
"description": "한국 독자 관련성",
"keywords": ["한국", "국내", "한글", "카카오", "네이버", "쿠팡", "삼성", "LG", "현대", "기아", "배달", "토스", "당근", "야놀자"]
"keywords": ["한국", "국내", "한글", "카카오", "네이버", "쿠팡", "삼성", "LG", "현대", "기아", "배달", "토스", "당근", "야놀자", "AI", "GPT", "ChatGPT", "Claude", "Gemini", "Apple", "Google", "iPhone", "갤럭시", "Netflix", "넷플릭스", "YouTube"]
},
"freshness": {
"max": 20,
@@ -63,7 +63,7 @@
{
"id": "clickbait",
"description": "클릭베이트성 주제",
"patterns": ["충격", "경악", "난리", "ㅋㅋ", "ㅠㅠ", "대박", "레전드", "역대급"]
"patterns": ["경악", "난리", "ㅋㅋ", "ㅠㅠ"]
}
],
"evergreen_keywords": [
+2 -2
View File
@@ -12,7 +12,7 @@
"legal_keywords": [
"불법", "위법", "처벌", "벌금", "징역", "기소"
],
"always_manual_review": ["팩트체크"],
"always_manual_review": ["팩트체크", "재테크절약"],
"min_sources_required": 2,
"min_quality_score_for_auto": 75
"min_quality_score_for_auto": 101
}
+74 -30
View File
@@ -1,38 +1,82 @@
{
"rss_feeds": [
{
"name": "GeekNews",
"url": "https://feeds.feedburner.com/geeknews-feed",
"category": "tech",
"trust_level": "high"
},
{
"name": "ZDNet Korea",
"url": "https://www.zdnet.co.kr/rss/rss.php",
"category": "tech",
"trust_level": "high"
},
{
"name": "Yonhap IT",
"url": "https://www.yna.co.kr/rss/it.xml",
"category": "tech",
"trust_level": "high"
},
{
"name": "Bloter",
"url": "https://www.bloter.net/feed",
"category": "tech",
"trust_level": "high"
}
{ "name": "GeekNews", "url": "https://feeds.feedburner.com/geeknews-feed", "category": "AI인사이트", "trust_level": "high" },
{ "name": "ZDNet Korea", "url": "https://www.zdnet.co.kr/rss/rss.php", "category": "AI인사이트", "trust_level": "high" },
{ "name": "연합뉴스 IT", "url": "https://www.yna.co.kr/rss/it.xml", "category": "AI인사이트", "trust_level": "high" },
{ "name": "AI타임스", "url": "https://www.aitimes.com/rss/allArticle.xml", "category": "AI인사이트", "trust_level": "medium" },
{ "name": "테크크런치 AI", "url": "https://techcrunch.com/category/artificial-intelligence/feed/", "category": "AI인사이트", "trust_level": "high" },
{ "name": "MIT 테크리뷰", "url": "https://www.technologyreview.com/feed/", "category": "AI인사이트", "trust_level": "high" },
{ "name": "전자신문", "url": "https://www.etnews.com/rss/rss.xml", "category": "AI인사이트", "trust_level": "high" },
{ "name": "딥러닝 뉴스", "url": "https://news.google.com/rss/search?q=AI+인공지능&hl=ko&gl=KR&ceid=KR:ko", "category": "AI인사이트", "trust_level": "medium" },
{ "name": "Ars Technica AI", "url": "https://feeds.arstechnica.com/arstechnica/technology-lab", "category": "AI인사이트", "trust_level": "high" },
{ "name": "Bloter", "url": "https://www.bloter.net/feed", "category": "스타트업", "trust_level": "high" },
{ "name": "플래텀", "url": "https://platum.kr/feed", "category": "스타트업", "trust_level": "high" },
{ "name": "벤처스퀘어", "url": "https://www.venturesquare.net/feed", "category": "스타트업", "trust_level": "medium" },
{ "name": "한국경제 IT", "url": "https://www.hankyung.com/feed/it", "category": "스타트업", "trust_level": "high" },
{ "name": "테크크런치 스타트업","url": "https://techcrunch.com/category/startups/feed/", "category": "스타트업", "trust_level": "high" },
{ "name": "IT동아", "url": "https://it.donga.com/rss/", "category": "스타트업", "trust_level": "medium" },
{ "name": "연합뉴스 여행", "url": "https://www.yna.co.kr/rss/travel.xml", "category": "여행맛집", "trust_level": "high" },
{ "name": "경향신문 여행", "url": "https://www.khan.co.kr/rss/rssdata/kh_travel.xml", "category": "여행맛집", "trust_level": "medium" },
{ "name": "한국관광공사", "url": "https://kto.visitkorea.or.kr/rss/rss.kto", "category": "여행맛집", "trust_level": "medium" },
{ "name": "대한항공 뉴스", "url": "https://www.koreanair.com/content/koreanair/global/en/footer/about-korean-air/news-and-pr/press-releases.rss.xml", "category": "여행맛집", "trust_level": "medium" },
{ "name": "론리플래닛", "url": "https://www.lonelyplanet.com/news/feed", "category": "여행맛집", "trust_level": "medium" },
{ "name": "다나와 여행", "url": "https://news.google.com/rss/search?q=국내여행+맛집&hl=ko&gl=KR&ceid=KR:ko", "category": "여행맛집", "trust_level": "medium" },
{ "name": "마이리얼트립 블로그","url": "https://blog.myrealtrip.com/feed", "category": "여행맛집", "trust_level": "medium" },
{ "name": "ITWorld Korea", "url": "https://www.itworld.co.kr/rss/feed", "category": "제품리뷰", "trust_level": "medium" },
{ "name": "디지털데일리", "url": "https://www.ddaily.co.kr/rss/rss.xml", "category": "제품리뷰", "trust_level": "medium" },
{ "name": "The Verge", "url": "https://www.theverge.com/rss/index.xml", "category": "제품리뷰", "trust_level": "high" },
{ "name": "Engadget", "url": "https://www.engadget.com/rss.xml", "category": "제품리뷰", "trust_level": "high" },
{ "name": "뽐뿌 뉴스", "url": "https://www.ppomppu.co.kr/rss.php?id=news", "category": "제품리뷰", "trust_level": "medium" },
{ "name": "Wired", "url": "https://www.wired.com/feed/rss", "category": "제품리뷰", "trust_level": "high" },
{ "name": "위키트리", "url": "https://www.wikitree.co.kr/rss/", "category": "생활꿀팁", "trust_level": "medium" },
{ "name": "오마이뉴스 라이프", "url": "https://rss2.ohmynews.com/rss/ohmyrss.xml", "category": "생활꿀팁", "trust_level": "medium" },
{ "name": "헬스조선", "url": "https://health.chosun.com/site/data/rss/rss.xml", "category": "생활꿀팁", "trust_level": "medium" },
{ "name": "조선일보 라이프", "url": "https://www.chosun.com/arc/outboundfeeds/rss/category/life/", "category": "생활꿀팁", "trust_level": "high" },
{ "name": "Product Hunt", "url": "https://www.producthunt.com/feed", "category": "앱추천", "trust_level": "medium" },
{ "name": "테크크런치 앱", "url": "https://techcrunch.com/category/apps/feed/", "category": "앱추천", "trust_level": "high" },
{ "name": "앱스토리", "url": "https://www.appstory.co.kr/rss/rss.xml", "category": "앱추천", "trust_level": "medium" },
{ "name": "9to5Mac", "url": "https://9to5mac.com/feed/", "category": "앱추천", "trust_level": "high" },
{ "name": "Android Authority", "url": "https://www.androidauthority.com/feed/", "category": "앱추천", "trust_level": "high" },
{ "name": "매일경제 IT", "url": "https://rss.mk.co.kr/rss/30000001/", "category": "재테크", "trust_level": "high" },
{ "name": "머니투데이", "url": "https://rss.mt.co.kr/news/mt_news.xml", "category": "재테크", "trust_level": "high" },
{ "name": "한국경제 재테크", "url": "https://www.hankyung.com/feed/finance", "category": "재테크", "trust_level": "high" },
{ "name": "뱅크샐러드 블로그", "url": "https://blog.banksalad.com/rss.xml", "category": "재테크", "trust_level": "medium" },
{ "name": "서울경제", "url": "https://www.sedaily.com/rss/rss.xml", "category": "재테크", "trust_level": "high" },
{ "name": "조선비즈 경제", "url": "https://biz.chosun.com/site/data/rss/rss.xml", "category": "재테크", "trust_level": "high" },
{ "name": "이데일리 금융", "url": "https://www.edaily.co.kr/rss/rss.asp?media_key=20", "category": "재테크", "trust_level": "high" },
{ "name": "스포츠조선 연예", "url": "https://sports.chosun.com/site/data/rss/rss.xml", "category": "TV로보는세상","trust_level": "medium" },
{ "name": "연합뉴스 연예", "url": "https://www.yna.co.kr/rss/entertainment.xml", "category": "TV로보는세상","trust_level": "high" },
{ "name": "한국경제 연예", "url": "https://www.hankyung.com/feed/entertainment", "category": "TV로보는세상","trust_level": "high" },
{ "name": "MBC 연예", "url": "https://imnews.imbc.com/rss/entertainment.xml", "category": "TV로보는세상","trust_level": "high" },
{ "name": "TV리포트", "url": "https://www.tvreport.co.kr/rss/allArticle.xml", "category": "TV로보는세상","trust_level": "medium" },
{ "name": "OSEN 연예", "url": "https://www.osen.co.kr/rss/osen.xml", "category": "TV로보는세상","trust_level": "medium" },
{ "name": "연합뉴스 팩트체크", "url": "https://www.yna.co.kr/rss/factcheck.xml", "category": "팩트체크", "trust_level": "high" },
{ "name": "SBS 뉴스", "url": "https://news.sbs.co.kr/news/SectionRssFeed.do?sectionId=01&plink=RSSREADER", "category": "팩트체크", "trust_level": "high" },
{ "name": "KBS 뉴스", "url": "https://news.kbs.co.kr/rss/rss.xml", "category": "팩트체크", "trust_level": "high" },
{ "name": "JTBC 뉴스", "url": "https://fs.jtbc.co.kr/RSS/newsflash.xml", "category": "팩트체크", "trust_level": "high" }
],
"x_keywords": [
"바이브코딩",
"vibe coding",
"AI 자동화",
"Claude 사용",
"AI 사용법",
"ChatGPT 활용",
"비개발자 앱",
"노코드 AI"
"Claude 사용",
"인공지능 추천",
"앱 추천",
"생활꿀팁",
"맛집 추천",
"스타트업 소식",
"재테크 방법",
"쇼핑 추천",
"TV 예능 화제",
"드라마 추천",
"넷플릭스 신작"
],
"github_trending": {
"url": "https://github.com/trending",
+18
View File
@@ -0,0 +1,18 @@
{
"_comment": "사용자 의도 설정 — bw init으로 생성/업데이트",
"_updated": "2026-03-29",
"budget": "free",
"level": "beginner",
"engines": {
"writing": {"provider": "auto"},
"tts": {"provider": "auto"},
"video": {"provider": "auto"},
"image": {"provider": "auto"}
},
"platforms": ["youtube"],
"services": {
"openclaw": false,
"claude_web": false,
"gemini_web": false
}
}
+1 -1
View File
@@ -11,7 +11,7 @@ from fastapi import APIRouter, HTTPException
from pydantic import BaseModel
from dotenv import load_dotenv
load_dotenv(dotenv_path='D:/key/blog-writer.env.env')
load_dotenv()
BASE_DIR = Path(__file__).parent.parent.parent
CONFIG_PATH = BASE_DIR / "config" / "engine.json"
+1 -1
View File
@@ -103,7 +103,7 @@ async def get_subscriptions():
"""구독 정보 + 만료일 계산"""
import os
from dotenv import load_dotenv
load_dotenv(dotenv_path='D:/key/blog-writer.env.env')
load_dotenv()
subscriptions = []
for plan in SUBSCRIPTION_PLANS:
File diff suppressed because it is too large Load Diff
+35
View File
@@ -0,0 +1,35 @@
x-common: &common
image: blog-writer:latest
build:
context: .
dockerfile: Dockerfile
env_file: .env
environment:
- PYTHONPATH=/app
volumes:
- ./bots:/app/bots
- ./config:/app/config
- ./templates:/app/templates
- ./dashboard/backend:/app/dashboard/backend
- ./dashboard/__init__.py:/app/dashboard/__init__.py
- ./data:/app/data
- ./logs:/app/logs
- ./assets:/app/assets
- ./runtime_guard.py:/app/runtime_guard.py
- ./blog_engine_cli.py:/app/blog_engine_cli.py
- ./blog_runtime.py:/app/blog_runtime.py
- ./credentials.json:/app/credentials.json:ro
restart: unless-stopped
services:
scheduler:
<<: *common
container_name: blog-scheduler
command: ["python3", "bots/scheduler.py"]
dashboard:
<<: *common
container_name: blog-dashboard
command: ["python3", "-m", "uvicorn", "dashboard.backend.server:app", "--host", "0.0.0.0", "--port", "8080"]
ports:
- "8080:8080"
+47
View File
@@ -0,0 +1,47 @@
[build-system]
requires = ["setuptools>=68", "wheel"]
build-backend = "setuptools.backends.legacy:build"
[project]
name = "blog-writer"
version = "3.0.0.dev0"
description = "AI-powered blog and shorts automation engine"
requires-python = ">=3.11"
dependencies = [
"click>=8.1",
"rich>=13.0",
"requests>=2.31",
"python-dotenv>=1.0",
"edge-tts>=6.1",
"Pillow>=10.0",
"pydub>=0.25",
]
[project.optional-dependencies]
tts = [
"openai>=1.0",
"elevenlabs>=1.0",
]
video = [
"fal-client>=0.4",
]
dev = [
"pytest>=7.4",
"black>=23.0",
"ruff>=0.1",
]
[project.scripts]
bw = "blogwriter.cli:main"
[tool.setuptools.packages.find]
include = ["blogwriter*", "bots*", "dashboard*"]
[tool.black]
line-length = 100
target-version = ["py311"]
[tool.ruff]
line-length = 100
target-version = "py311"
+3 -2
View File
@@ -23,8 +23,9 @@ openai
pydub
# Phase 2 (YouTube 업로드 진행 표시)
google-resumable-media
# Phase 3 (엔진 추상화 — 선택적 의존성)
# google-generativeai # Gemini Writer / Veo 사용 시 pip install google-generativeai
# Phase 3 (엔진 추상화)
google-generativeai
groq
# Shorts Bot (Phase A)
edge-tts>=6.1.0
# openai-whisper # Edge TTS 단어별 타임스탬프용 (선택, pip install openai-whisper)
+4
View File
@@ -61,6 +61,10 @@ def ensure_project_runtime(
entrypoint: str,
required_distributions: list[str] | None = None,
) -> None:
# Docker 환경에서는 venv 체크를 건너뜀
if os.getenv('PYTHONPATH') == '/app' or not VENV_DIR.exists():
return
expected_python = project_python_path()
current_python = Path(sys.executable)