From 6c5c1b9d5063b185bd5c1eb3d6220ad47002dbc0 Mon Sep 17 00:00:00 2001 From: sinmb79 Date: Sun, 29 Mar 2026 12:03:07 +0900 Subject: [PATCH] feat(v3): PR 9 - MVP CLI (8 commands) + pyproject.toml Co-Authored-By: Claude Sonnet 4.6 --- blogwriter/__init__.py | 4 + blogwriter/cli.py | 325 +++++++++++++++++++++++++++++++++++++++++ pyproject.toml | 47 ++++++ 3 files changed, 376 insertions(+) create mode 100644 blogwriter/__init__.py create mode 100644 blogwriter/cli.py create mode 100644 pyproject.toml diff --git a/blogwriter/__init__.py b/blogwriter/__init__.py new file mode 100644 index 0000000..afba35c --- /dev/null +++ b/blogwriter/__init__.py @@ -0,0 +1,4 @@ +""" +blogwriter — Blog Writer CLI package +""" +__version__ = '3.0.0-dev' diff --git a/blogwriter/cli.py b/blogwriter/cli.py new file mode 100644 index 0000000..a16028e --- /dev/null +++ b/blogwriter/cli.py @@ -0,0 +1,325 @@ +""" +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]") + console.print("PR 10에서 구현 예정입니다.\n") + console.print("현재는 config/user_profile.json을 직접 편집하세요.") + console.print(f"위치: {BASE_DIR / 'config' / 'user_profile.json'}") + + +# Entry point +def main(): + """Main entry point.""" + app() + + +if __name__ == '__main__': + main() diff --git a/pyproject.toml b/pyproject.toml new file mode 100644 index 0000000..69fe9df --- /dev/null +++ b/pyproject.toml @@ -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"