feat: v3 멀티플랫폼 자동화 엔진 — 변환/배포 엔진 + 쇼츠 + README

## 변환 엔진 (bots/converters/)
- blog_converter: HTML 자동감지 + Schema.org JSON-LD + AdSense 플레이스홀더
- card_converter: Pillow 1080×1080 인스타그램 카드 이미지
- thread_converter: X 스레드 280자 자동 분할
- newsletter_converter: 주간 HTML 뉴스레터
- shorts_converter: TTS + ffmpeg 뉴스앵커 쇼츠 영상 (1080×1920)

## 배포 엔진 (bots/distributors/)
- image_host: ImgBB 업로드 / 로컬 HTTP 서버
- instagram_bot: Instagram Graph API (컨테이너 → 폴링 → 발행)
- x_bot: X API v2 OAuth1 스레드 게시
- tiktok_bot: TikTok Content Posting API v2 청크 업로드
- youtube_bot: YouTube Data API v3 재개가능 업로드

## 기타
- article_parser: KEY_POINTS 파싱 추가 (SNS/TTS용 핵심 3줄)
- publisher_bot: HTML 본문 직접 발행 지원
- scheduler: 시차 배포 스케줄 + Telegram 변환/배포 명령 추가
- remote_claude: Claude Agent SDK Telegram 연동
- templates/shorts_template.json: 코너별 색상/TTS/트랜지션 설정
- scripts/download_fonts.py: NotoSansKR / 맑은고딕 자동 설치
- .gitignore: .claude/, 기획문서, 생성 미디어 파일 추가
- .env.example: 플레이스홀더 텍스트 (실제 값 없음)
- README: v3 아키텍처 전체 문서화 (설치/API키/상세설명/FAQ)
- requirements.txt: openai, pydub 추가

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
sinmb79
2026-03-25 18:15:07 +09:00
parent 6d6ba14e76
commit b54f8e198e
24 changed files with 4367 additions and 274 deletions

View File

@@ -1,17 +1,77 @@
GOOGLE_CLIENT_ID=your-google-client-id.apps.googleusercontent.com # ─── Google OAuth (필수) ─────────────────────────────
GOOGLE_CLIENT_SECRET=your-google-client-secret # Google Cloud Console > 사용자 인증 정보 > OAuth 클라이언트 ID에서 발급
# https://console.cloud.google.com/
GOOGLE_CLIENT_ID=your_google_oauth_client_id.apps.googleusercontent.com
GOOGLE_CLIENT_SECRET=your_google_oauth_client_secret
# scripts/get_token.py 실행 후 자동 저장됨
GOOGLE_REFRESH_TOKEN= GOOGLE_REFRESH_TOKEN=
BLOG_MAIN_ID=your-blogger-blog-id # Blogger 대시보드 URL에서 확인 (숫자 18자리)
BLOG_MAIN_ID=your_blogger_blog_id
# ─── 쿠팡 파트너스 (선택) ─────────────────────────────
# https://partners.coupang.com/ 에서 발급
COUPANG_ACCESS_KEY= COUPANG_ACCESS_KEY=
COUPANG_SECRET_KEY= COUPANG_SECRET_KEY=
TELEGRAM_BOT_TOKEN=your-telegram-bot-token
TELEGRAM_CHAT_ID=your-telegram-chat-id # ─── Telegram (필수) ──────────────────────────────────
# 이미지 모드 선택 (manual | request | auto) # @BotFather에서 /newbot 명령으로 생성
TELEGRAM_BOT_TOKEN=your_telegram_bot_token
# @userinfobot에서 확인
TELEGRAM_CHAT_ID=your_telegram_chat_id
# ─── 이미지 모드 선택 ─────────────────────────────────
# manual — 글 발행 시점에 프롬프트 1개를 Telegram으로 전송 (기본값) # manual — 글 발행 시점에 프롬프트 1개를 Telegram으로 전송 (기본값)
# request — 매주 월요일 프롬프트 목록 전송 → 직접 생성 후 Telegram으로 이미지 전송 # request — 매주 월요일 프롬프트 목록 전송 → 직접 생성 후 Telegram으로 이미지 전송
# auto — OpenAI DALL-E API 자동 생성 (OPENAI_API_KEY 필요, 별도 비용 발생) # auto — OpenAI DALL-E API 자동 생성 (OPENAI_API_KEY 필요, 별도 비용 발생)
IMAGE_MODE=manual IMAGE_MODE=manual
# auto 모드 사용 시에만 입력
# OpenAI (auto 모드 또는 쇼츠 배경 이미지 자동 생성 시 필요)
# https://platform.openai.com/api-keys
OPENAI_API_KEY= OPENAI_API_KEY=
# 블로그 사이트 URL (Search Console 등록용) # 블로그 사이트 URL (Search Console 등록용)
BLOG_SITE_URL= BLOG_SITE_URL=
# ─── v3: 멀티플랫폼 배포 ──────────────────────────────
# [Instagram Graph API] Phase 1B
# Facebook Developer App에서 발급 (비즈니스 계정 필요)
# https://developers.facebook.com/
INSTAGRAM_ACCESS_TOKEN=
INSTAGRAM_ACCOUNT_ID=
# [X(Twitter) API v2] Phase 1B
# X Developer Portal에서 발급 (Free Tier 이상)
# https://developer.twitter.com/
X_API_KEY=
X_API_SECRET=
X_ACCESS_TOKEN=
X_ACCESS_SECRET=
# [Anthropic API] Telegram 자연어 명령용 (선택)
# https://console.anthropic.com/
ANTHROPIC_API_KEY=
# ─── v3: 이미지 호스팅 ────────────────────────────────
# ImgBB (무료, https://api.imgbb.com/ 에서 키 발급)
# 인스타그램 카드 이미지를 공개 URL로 변환하는 데 사용
IMGBB_API_KEY=
# 로컬 테스트용 HTTP 서버 (true/false)
LOCAL_IMAGE_SERVER=false
# ─── v3 Phase 2: 쇼츠 변환 ───────────────────────────
# Google Cloud TTS REST API (https://cloud.google.com/text-to-speech)
# 없으면 gTTS(무료)로 자동 대체됨
GOOGLE_TTS_API_KEY=
# ffmpeg 경로 (PATH에 등록되지 않은 경우)
# FFMPEG_PATH=C:/ffmpeg/bin/ffmpeg.exe
# ─── v3 Phase 2: TikTok ───────────────────────────────
# TikTok Developers (https://developers.tiktok.com/)
TIKTOK_ACCESS_TOKEN=
TIKTOK_OPEN_ID=
# ─── v3 Phase 2: YouTube ──────────────────────────────
# YouTube Data API v3 (기존 Google Cloud 프로젝트에서 API 추가 활성화)
# YouTube Studio > 채널 > 고급 설정에서 채널 ID 확인
YOUTUBE_CHANNEL_ID=

15
.gitignore vendored
View File

@@ -2,6 +2,21 @@
.env .env
token.json token.json
credentials.json credentials.json
service_account*.json
*_secret*.json
*_key*.json
# Claude Code 내부 설정
.claude/
# 내부 기획 문서 (비공개)
blog-engine-final-masterplan-v3.txt
# 생성된 미디어 파일
data/outputs/
assets/bgm*
assets/fonts/*.ttf
assets/fonts/*.otf
# Python # Python
venv/ venv/

816
README.md
View File

@@ -1,94 +1,155 @@
# 블로그 자동 수익 엔진 (Blog Auto Revenue Engine) # 블로그 자동 수익 엔진 v3
AI 기반 한국어 블로그 자동화 시스템. **The 4th Path — Independent Tech Media** | by [22B Labs](https://github.com/sinmb79)
트렌드 수집 → AI 글 작성 → 자동 발행 → 수익 링크 삽입 → 성과 분석까지 전 과정을 자동화합니다.
> **Phase 1 목표:** Google Blogger 블로그 1개로 시작해 검색 자산 축적 + AdSense 승인 원고 하나를 AI로 작성하면 블로그, 인스타그램 카드, X 스레드, 유튜브 쇼츠, 주간 뉴스레터 — **5개 포맷으로 자동 변환·배포**하는 1인 미디어 자동화 시스템입니다.
> Python 기반, Windows 미니PC 24시간 운영 최적화.
> Google Blogger + AdSense + 쿠팡 파트너스 + 멀티플랫폼 수익 구조.
--- ---
## 목차 ## 목차
1. [시스템 구조](#시스템-구조) - [아키텍처](#아키텍처)
2. [사전 준비](#사전-준비) - [기능 개요](#기능-개요)
3. [설치 방법](#설치-방법) - [프로젝트 구조](#프로젝트-구조)
4. [API 키 설정](#api-키-설정) - [설치](#설치)
5. [Google OAuth 인증](#google-oauth-인증) - [환경 변수 설정](#환경-변수-설정)
6. [실행하기](#실행하기) - [Google OAuth 인증](#google-oauth-인증)
7. [Telegram 명령어](#telegram-명령어) - [실행 방법](#실행-방법)
8. [이미지 모드 선택](#이미지-모드-선택) - [봇 상세 설명](#봇-상세-설명)
9. [콘텐츠 코너 구성](#콘텐츠-코너-구성) - [변환 엔진](#변환-엔진-layer-2)
10. [Phase 로드맵](#phase-로드맵) - [배포 엔진](#배포-엔진-layer-3)
11. [자주 묻는 질문](#자주-묻는-질문) - [콘텐츠 코너](#콘텐츠-코너)
- [Telegram 명령어](#telegram-명령어)
- [OpenClaw AI 에이전트 연동](#openclaw-ai-에이전트-연동)
- [배포 스케줄](#배포-스케줄)
- [Phase 현황](#phase-현황)
- [자주 묻는 질문](#자주-묻는-질문)
- [기여 가이드](#기여-가이드)
- [라이선스](#라이선스)
--- ---
## 시스템 구조 ## 아키텍처
``` ```
봇 레이어 (Python) AI 레이어 (OpenClaw) ┌─────────────────────────────────────────────────────────────┐
───────────────── ──────────────────── │ LAYER 1 — AI 콘텐츠 생성 │
수집봇 blog-writer 에이전트 │ OpenClaw (GPT / Claude) → Blogger-ready HTML 원고 1개 │
└─ 트렌드 수집 └─ 글감 → 완성 글 작성 └───────────────────────────┬─────────────────────────────────┘
└─ 품질 점수 계산 │ article dict
└─ 폐기 규칙 적용 ┌───────────────────────────▼─────────────────────────────────┐
│ LAYER 2 — 변환 엔진 (Python, AI 없음)
│ │
발행봇 ── 링크봇 ── 이미지봇 │ blog_converter card_converter thread_converter │
└─ 안전장치 └─ 만평 이미지 HTML+Schema.org 1080×1080 카드 X 스레드 280자 │
└─ Blogger 발행 │ │
└─ Search Console shorts_converter newsletter_converter │
│ TTS+ffmpeg 쇼츠 영상 주간 HTML 뉴스레터
└───────────────────────────┬─────────────────────────────────┘
분석봇 → Telegram 리포트 │ 5개 포맷
스케줄러 → 모든 봇 시간 관리 ┌───────────────────────────▼─────────────────────────────────┐
│ LAYER 3 — 배포 엔진 (Python, AI 없음) │
│ Blogger Instagram X(Twitter) TikTok YouTube │
└───────────────────────────┬─────────────────────────────────┘
│ 지표
┌───────────────────────────▼─────────────────────────────────┐
│ LAYER 4 — 분석 + 피드백 │
│ Google Analytics · Search Console · Telegram 리포트 │
└─────────────────────────────────────────────────────────────┘
``` ```
### 파일 구조 ---
## 기능 개요
| 기능 | 설명 | Phase |
|------|------|-------|
| 트렌드 수집 | PyTrends + RSS 멀티소스, 품질 점수 70점 미만 자동 폐기 | 1A ✅ |
| AI 글 작성 | OpenClaw 에이전트 → Blogger-ready HTML 직접 출력 | 1A ✅ |
| 블로그 발행 | Blogger API + Schema.org JSON-LD + AdSense 플레이스홀더 | 1A ✅ |
| 쿠팡 파트너스 | 키워드 자동 링크 삽입 | 1A ✅ |
| 인스타 카드 | Pillow 1080×1080 카드 이미지 생성 + Instagram Graph API | 1B ✅ |
| X 스레드 | 280자 자동 분할 + X API v2 순차 게시 | 1B ✅ |
| 유튜브 쇼츠 | TTS + Pillow 슬라이드 + ffmpeg 영상 합성 + YouTube API | 2 ✅ |
| TikTok | TikTok Content Posting API v2 | 2 ✅ |
| 주간 뉴스레터 | 주간 HTML 뉴스레터 자동 생성 | 1A ✅ |
| 분석봇 | GA4 + Search Console + Telegram 일간/주간 리포트 | 1A ✅ |
| Telegram 제어 | 명령어 + Claude API 자연어 폴백 | 1A ✅ |
---
## 프로젝트 구조
``` ```
blog-writer/ blog-writer/
├── bots/
│ ├── collector_bot.py ← 수집봇 (Google Trends, GitHub, HN, RSS) ├── bots/ # 핵심 봇 모듈
│ ├── publisher_bot.py ← 발행봇 (Blogger API + 안전장치) │ ├── collector_bot.py # 트렌드 수집 + 품질 필터
│ ├── linker_bot.py ← 링크봇 (쿠팡 파트너스) │ ├── publisher_bot.py # Blogger API 발행
│ ├── analytics_bot.py ← 분석봇 (5대 핵심 지표) │ ├── linker_bot.py # 쿠팡 파트너스 링크 삽입
│ ├── image_bot.py ← 이미지봇 (만평 3가지 모드) │ ├── analytics_bot.py # 성과 수집 + 리포트
│ ├── scheduler.py ← 스케줄러 + Telegram 봇 │ ├── image_bot.py # 만평 이미지 (manual/request/auto)
── article_parser.py ← OpenClaw 출력 파서 ── article_parser.py # 원고 포맷 파서
├── config/ │ ├── remote_claude.py # Claude Agent SDK Telegram 연동
│ ├── blogs.json ← 블로그 ID 설정 │ ├── scheduler.py # APScheduler + Telegram 리스너
├── schedule.json ← 발행 시간표
│ ├── sources.json ← 수집 소스 목록 │ ├── converters/ # LAYER 2 — 변환 엔진
│ ├── affiliate_links.json← 어필리에이트 링크 DB │ ├── blog_converter.py # HTML 정제 + Schema.org + AdSense
│ ├── quality_rules.json ← 품질 점수 기준 │ ├── card_converter.py # 인스타 카드 이미지 (Pillow)
└── safety_keywords.json← 안전장치 키워드 │ ├── thread_converter.py # X 스레드 280자 분할
├── data/ ← 런타임 데이터 (gitignore) ├── newsletter_converter.py# 주간 뉴스레터 HTML
│ │ └── shorts_converter.py # 쇼츠 영상 (TTS + ffmpeg)
│ │
│ └── distributors/ # LAYER 3 — 배포 엔진
│ ├── image_host.py # ImgBB 업로드 / 로컬 HTTP 서버
│ ├── instagram_bot.py # Instagram Graph API
│ ├── x_bot.py # X API v2 (OAuth1)
│ ├── tiktok_bot.py # TikTok Content Posting API v2
│ └── youtube_bot.py # YouTube Data API v3
├── config/ # 설정 파일 (비밀값 없음)
│ ├── platforms.json # 플랫폼 활성화 여부
│ ├── schedule.json # 스케줄 시간 설정
│ ├── quality_rules.json # 품질 필터 규칙
│ ├── safety_keywords.json # 안전 키워드 목록
│ ├── sources.json # RSS 피드 + 트렌드 소스
│ ├── blogs.json # 블로그 설정
│ └── affiliate_links.json # 제휴 링크 목록
├── templates/
│ └── shorts_template.json # 쇼츠 코너별 설정 (색상/TTS/트랜지션)
├── scripts/ ├── scripts/
│ ├── get_token.py ← Google OAuth 토큰 발급 │ ├── setup.bat # Windows 최초 설치 스크립트
── setup.bat ← Windows 설치 스크립트 ── get_token.py # Google OAuth 토큰 발급
├── .env.example ← 환경변수 템플릿 │ └── download_fonts.py # NotoSansKR / 맑은고딕 설치
└── requirements.txt
├── assets/
│ └── fonts/ # .ttf 파일 (scripts/download_fonts.py로 설치)
├── data/ # 런타임 데이터 (.gitignore)
│ ├── originals/ # AI 생성 원고
│ ├── outputs/ # 변환 결과물 (HTML/PNG/MP4/JSON)
│ └── ...
├── logs/ # 로그 (.gitignore)
├── .env.example # 환경 변수 템플릿
├── requirements.txt
└── README.md
``` ```
--- ---
## 사전 준비 ## 설치
### 필수 ### 사전 요구사항
- **Python 3.11 이상** — [python.org](https://www.python.org/downloads/)
- **Git** — [git-scm.com](https://git-scm.com/)
- **Google 계정** — Blogger 블로그 운영용
- **Telegram 계정** — 봇 알림 수신용
- **OpenClaw** — AI 글 작성 에이전트 (ChatGPT Pro 구독 필요)
### 선택 - **Python 3.10 이상** — [python.org](https://www.python.org/downloads/)
- **쿠팡 파트너스 계정** — 링크 수익화용 - **ffmpeg** — 쇼츠 영상 생성 필수. [ffmpeg.org](https://ffmpeg.org/download.html)에서 다운로드 후 PATH 추가 또는 `.env``FFMPEG_PATH` 지정
- **OpenAI API Key** — 이미지 자동 생성 모드 사용 시 - Windows 미니PC 권장 (24시간 운영), macOS/Linux도 동작
---
## 설치 방법
### 1. 저장소 클론 ### 1. 저장소 클론
@@ -97,89 +158,117 @@ git clone https://github.com/sinmb79/blog-writer.git
cd blog-writer cd blog-writer
``` ```
### 2. 설치 스크립트 실행 (Windows) ### 2. 가상환경 + 패키지 설치
탐색기에서 `scripts\setup.bat` 더블클릭 또는:
```cmd
scripts\setup.bat
```
스크립트가 자동으로 처리하는 것:
- Python 가상환경(`venv`) 생성
- 패키지 설치 (`requirements.txt`)
- `.env` 파일 생성 (`.env.example` 복사)
- `data/`, `logs/` 폴더 생성
- Windows 작업 스케줄러에 자동 시작 등록
### 3. 수동 설치 (선택)
```bash ```bash
# Windows — 자동 설치 (권장)
scripts\setup.bat
# 수동 설치
python -m venv venv python -m venv venv
venv\Scripts\activate # Windows venv\Scripts\activate # Windows
# source venv/bin/activate # macOS/Linux
pip install -r requirements.txt pip install -r requirements.txt
copy .env.example .env ```
`setup.bat`이 처리하는 것:
- Python 가상환경 생성
- 패키지 설치
- `.env` 파일 생성 (`.env.example` 복사)
- `data/`, `logs/` 폴더 생성
- Windows 작업 스케줄러 자동 시작 등록
### 3. 폰트 설치
카드 이미지·쇼츠 영상의 한글 렌더링에 필요합니다.
```bash
python scripts/download_fonts.py
```
Windows: `맑은고딕(malgunbd.ttf)``assets/fonts/`에 자동 복사
macOS/Linux: NotoSansKR을 GitHub에서 다운로드
### 4. 환경 변수 설정
```bash
cp .env.example .env
# .env 파일을 열어 값 입력
``` ```
--- ---
## API 키 설정 ## 환경 변수 설정
`.env` 파일을 열고 아래 항목을 입력합니다. `.env.example`을 복사해 `.env`로 저장 후 아래 값을 채웁니다.
`.env` 파일은 절대 커밋하지 마세요 — `.gitignore`에 포함되어 있습니다.
### Phase 1A — 필수
| 변수 | 설명 | 발급처 |
|------|------|--------|
| `GOOGLE_CLIENT_ID` | OAuth 클라이언트 ID | [Google Cloud Console](https://console.cloud.google.com/) |
| `GOOGLE_CLIENT_SECRET` | OAuth 클라이언트 시크릿 | Google Cloud Console |
| `GOOGLE_REFRESH_TOKEN` | `scripts/get_token.py` 실행 후 자동 저장 | — |
| `BLOG_MAIN_ID` | Blogger 블로그 ID | Blogger 대시보드 URL |
| `TELEGRAM_BOT_TOKEN` | Telegram 봇 토큰 | [@BotFather](https://t.me/BotFather) |
| `TELEGRAM_CHAT_ID` | 내 Telegram 채팅 ID | [@userinfobot](https://t.me/userinfobot) |
| `COUPANG_ACCESS_KEY` | 쿠팡 파트너스 액세스 키 | [쿠팡 파트너스](https://partners.coupang.com/) |
| `COUPANG_SECRET_KEY` | 쿠팡 파트너스 시크릿 키 | 쿠팡 파트너스 |
**BLOG_MAIN_ID 확인 방법**
Blogger 관리자 → 블로그 선택 → 주소창:
```
https://www.blogger.com/blog/posts/3856391132195789013
↑ 이 숫자
```
### Phase 1B — 인스타그램 / X
| 변수 | 설명 | 발급처 |
|------|------|--------|
| `INSTAGRAM_ACCESS_TOKEN` | Instagram Graph API 장기 토큰 | [Facebook Developers](https://developers.facebook.com/) |
| `INSTAGRAM_ACCOUNT_ID` | Instagram 비즈니스 계정 ID | Facebook Developers |
| `X_API_KEY` | X API 키 | [X Developer Portal](https://developer.twitter.com/) |
| `X_API_SECRET` | X API 시크릿 | X Developer Portal |
| `X_ACCESS_TOKEN` | X 액세스 토큰 | X Developer Portal |
| `X_ACCESS_SECRET` | X 액세스 시크릿 | X Developer Portal |
| `IMGBB_API_KEY` | 이미지 공개 URL 변환 (무료) | [ImgBB](https://api.imgbb.com/) |
### Phase 2 — 쇼츠 / TikTok / YouTube
| 변수 | 설명 | 발급처 |
|------|------|--------|
| `GOOGLE_TTS_API_KEY` | Google Cloud TTS REST API | [Google Cloud Console](https://console.cloud.google.com/) |
| `FFMPEG_PATH` | ffmpeg 경로 (PATH 미등록 시만) | — |
| `OPENAI_API_KEY` | DALL-E 3 배경 이미지 (선택) | [OpenAI](https://platform.openai.com/) |
| `TIKTOK_ACCESS_TOKEN` | TikTok Content Posting API | [TikTok Developers](https://developers.tiktok.com/) |
| `TIKTOK_OPEN_ID` | TikTok 사용자 Open ID | TikTok Developers |
| `YOUTUBE_CHANNEL_ID` | YouTube 채널 ID | YouTube Studio |
| `ANTHROPIC_API_KEY` | Telegram 자연어 명령 처리 | [Anthropic Console](https://console.anthropic.com/) |
### 이미지 모드 선택
```env ```env
# ─── Google (필수) ─────────────────────────────────── IMAGE_MODE=manual # (기본) 발행 시 Telegram으로 프롬프트 전송
GOOGLE_CLIENT_ID= # Google Cloud Console에서 발급 IMAGE_MODE=request # 매주 월요일 프롬프트 목록 일괄 전송
GOOGLE_CLIENT_SECRET= # Google Cloud Console에서 발급 IMAGE_MODE=auto # DALL-E 3 자동 생성 (OPENAI_API_KEY 필요)
GOOGLE_REFRESH_TOKEN= # scripts/get_token.py 실행 후 입력
BLOG_MAIN_ID= # Blogger 대시보드 URL에서 확인
# ─── 쿠팡 파트너스 (선택, 링크 수익화) ────────────────
COUPANG_ACCESS_KEY=
COUPANG_SECRET_KEY=
# ─── Telegram (필수, 알림 수신) ──────────────────────
TELEGRAM_BOT_TOKEN= # @BotFather에서 발급
TELEGRAM_CHAT_ID= # @userinfobot에서 확인
# ─── 이미지 모드 ─────────────────────────────────────
IMAGE_MODE=manual # manual | request | auto
# ─── Search Console (선택) ───────────────────────────
BLOG_SITE_URL= # 예: https://your-blog.blogspot.com/
# ─── OpenAI (auto 모드만 필요) ───────────────────────
OPENAI_API_KEY=
``` ```
### BLOG_MAIN_ID 확인 방법
Blogger 관리자 페이지(blogger.com)에서 블로그를 선택한 뒤 브라우저 주소창을 확인합니다:
```
https://www.blogger.com/blog/posts/XXXXXXXXXXXXXXXXXX
↑ 이 숫자가 BLOG_MAIN_ID
```
### Telegram 설정 방법
1. Telegram에서 `@BotFather` 검색 → `/newbot` 명령 → 봇 생성 → **Token** 복사
2. 생성한 봇과 대화 시작 → `@userinfobot`에 메시지 → **Chat ID** 확인
--- ---
## Google OAuth 인증 ## Google OAuth 인증
### 1. Google Cloud Console 설정 ### 1. Google Cloud Console 설정
1. [console.cloud.google.com](https://console.cloud.google.com/) 접속 1. [console.cloud.google.com](https://console.cloud.google.com/) → 새 프로젝트 생성
2. 새 프로젝트 생성 2. **API 및 서비스 → 라이브러리** 에서 아래 API 활성화:
3. **API 및 서비스 → 라이브러리** 에서 아래 두 API 활성화:
- `Blogger API v3` - `Blogger API v3`
- `Google Search Console API` - `Google Search Console API`
4. **사용자 인증 정보 → OAuth 클라이언트 ID 만들기** - `YouTube Data API v3` (Phase 2)
3. **사용자 인증 정보 → OAuth 클라이언트 ID 만들기**
- 애플리케이션 유형: **데스크톱 앱** - 애플리케이션 유형: **데스크톱 앱**
5. `credentials.json` 다운로드 → 프로젝트 루트(`blog-writer/`)에 저장 4. `credentials.json` 다운로드 → 프로젝트 루트에 저장
### 2. 토큰 발급 ### 2. 토큰 발급
@@ -188,175 +277,436 @@ venv\Scripts\activate
python scripts\get_token.py python scripts\get_token.py
``` ```
브라우저가 열리면 Google 계정으로 로그인 → 권한 허용 브라우저에서 Google 계정 인증 → `token.json` 자동 저장.
터미널에 출력된 `REFRESH_TOKEN` 값을 `.env` `GOOGLE_REFRESH_TOKEN`에 붙여넣기 `credentials.json``token.json` `.gitignore`에 포함 — 절대 커밋하지 마세요.
--- ---
## 실행하기 ## 실행 방법
### 스케줄러 시작 (메인 프로세스) ### 스케줄러 시작 (권장)
```bash ```bash
venv\Scripts\activate venv\Scripts\activate
python bots\scheduler.py python bots\scheduler.py
``` ```
### 각 봇 단독 테스트 백그라운드 실행 (Windows):
```bash ```bash
# 수집봇 테스트 (글감 수집) pythonw bots\scheduler.py
python bots\collector_bot.py
# 분석봇 테스트 (일일 리포트)
python bots\analytics_bot.py
# 분석봇 주간 리포트
python bots\analytics_bot.py weekly
# 이미지 프롬프트 배치 전송 (request 모드)
python bots\image_bot.py batch
``` ```
### 자동 시작 확인 (Windows) Windows 작업 스케줄러를 통해 PC 시작 시 자동 실행되도록 `setup.bat`이 등록합니다.
작업 스케줄러(`taskschd.msc`)에서 **BlogEngine** 작업이 등록되어 있으면 PC 시작 시 자동 실행됩니다. ### 개별 봇 단독 실행
```bash
python bots\collector_bot.py # 트렌드 수집
python bots\publisher_bot.py # 블로그 발행
python bots\analytics_bot.py # 일간 리포트
python bots\analytics_bot.py weekly # 주간 리포트
python bots\image_bot.py batch # 이미지 프롬프트 배치 전송
# 변환 엔진
python bots\converters\blog_converter.py
python bots\converters\card_converter.py
python bots\converters\shorts_converter.py
```
--- ---
## 일일 자동 플로우 ## 봇 상세 설명
| 시간 | 작업 | ### `collector_bot.py` — 트렌드 수집봇
Google Trends (PyTrends) + RSS 피드 (`config/sources.json`)에서 글감을 수집하고 품질 점수를 계산합니다.
**품질 점수 (0100):**
- 트렌드 강도, 경쟁 기사 수, 키워드 밀도 반영
- **70점 미만 자동 폐기**
- 75점 미만 또는 위험 키워드 감지 시 Telegram 수동 검토 요청
**출력:** `data/collected/{date}_{slug}.json`
### `publisher_bot.py` — 발행봇
- HTML 본문 감지: AI가 HTML을 직접 출력한 경우 변환 없이 Blogger에 발행
- Schema.org `Article` JSON-LD 삽입
- Google Search Console URL 즉시 색인 요청
- 하루 최대 발행 수 제한, 중복 발행 방지
### `linker_bot.py` — 쿠팡 파트너스 링크봇
`config/affiliate_links.json`의 키워드를 HTML 본문에서 찾아 파트너스 링크로 자동 교체.
같은 키워드는 최대 2회까지만 처리합니다.
### `analytics_bot.py` — 분석봇
- Google Analytics Data API v1 (GA4) + Search Console API
- 매일 22:00 수집 → Telegram 일간 리포트
- 매주 일요일 22:30 주간 종합 리포트
### `image_bot.py` — 이미지봇
| 모드 | 동작 |
|------|------| |------|------|
| 07:00 | 수집봇 — 트렌드 수집 + 품질 점수 계산 + 폐기 필터링 | | `manual` | 발행 시 DALL-E 프롬프트를 Telegram 전송. 직접 생성 후 저장. |
| 08:00 | AI 글 작성 트리거 (OpenClaw 서브에이전트) | | `request` | 매주 월요일 프롬프트 목록 전송. 일괄 생성 후 개별 전송. |
| 09:00 | 발행봇 — 첫 번째 글 발행 | | `auto` | DALL-E 3 API 자동 생성. `OPENAI_API_KEY` 필요, 비용 발생. |
| 12:00 | 발행봇 — 두 번째 글 발행 |
| 15:00 | 발행봇 — 세 번째 글 (선택) | ### `article_parser.py` — 원고 파서
| 22:00 | 분석봇 — 일일 리포트 → Telegram 전송 |
| 매주 일요일 22:30 | 분석봇 — 주간 리포트 | OpenClaw AI가 출력하는 구조화된 원고를 파싱합니다:
| 매주 월요일 10:00 | 이미지봇 — 프롬프트 배치 전송 (request 모드) |
```
---TITLE--- 글 제목
---META--- 검색 설명 150자
---SLUG--- URL 슬러그
---TAGS--- 태그 목록
---CORNER--- 코너명
---BODY--- Blogger-ready HTML 본문
---KEY_POINTS--- 핵심 3줄 (각 30자 이내, SNS/TTS용)
---COUPANG_KEYWORDS--- 쿠팡 검색 키워드
---SOURCES--- 출처 URL 목록
---DISCLAIMER--- 면책 문구
```
### `remote_claude.py` — Claude Agent SDK 연동
Telegram 자연어 명령을 Claude Agent SDK로 처리합니다.
코드 생성·수정·실행까지 가능한 자율 에이전트 인터페이스입니다.
--- ---
## Telegram 명령어 ## 변환 엔진 (LAYER 2)
### 텍스트 명령 (키보드로 입력) ### `blog_converter.py`
| 명령 | 설명 | ```
|------|------| 입력: article dict (body = HTML 또는 Markdown)
| `발행 중단` | 자동 발행 일시 중지 | 출력: data/outputs/{date}_{slug}_blog.html
| `발행 재개` | 자동 발행 재개 | ```
| `오늘 수집된 글감 보여줘` | 오늘 수집된 글감 목록 |
| `대기 중인 글 보여줘` | 수동 검토 대기 글 목록 |
| `이번 주 리포트` | 주간 리포트 즉시 생성 |
| `이미지 목록` | 이미지 제작 현황 |
### 슬래시 명령 - **HTML 자동 감지**: AI가 HTML을 직접 출력한 경우 마크다운 변환을 건너뜀
- Schema.org `Article` JSON-LD 삽입
- AdSense 플레이스홀더 삽입 (2번째 H2 뒤, 결론 H2 앞)
- 쿠팡 파트너스 링크봇 호출
| 명령 | 설명 | ### `card_converter.py`
|------|------|
| `/status` | 봇 상태 + 이미지 모드 확인 | ```
| `/approve [번호]` | 수동 검토 글 승인 후 발행 | 입력: article dict
| `/reject [번호]` | 수동 검토 글 거부 | 출력: data/outputs/{date}_{slug}_card.png (1080×1080)
| `/images` | 이미지 제작 대기/진행/완료 현황 | ```
| `/imgpick [번호]` | 해당 번호 이미지 프롬프트 받기 |
| `/imgbatch` | 프롬프트 배치 수동 전송 | Pillow로 인스타그램 카드 이미지를 생성합니다:
| `/imgcancel` | 이미지 대기 상태 취소 |
```
┌─────────────────────────┐
│ ████ 금색 상단 바 │
│ │
│ [코너 배지] │
│ │
│ 글 제목 │
│ │
│ • 핵심 포인트 1 │
│ • 핵심 포인트 2 │
│ • 핵심 포인트 3 │
│ │
│ ████ 금색 하단 바 (URL) │
└─────────────────────────┘
```
코너별 배지 색상: 쉬운세상=파랑, 숨은보물=초록, 바이브리포트=보라, 팩트체크=빨강, 한컷=노랑
### `thread_converter.py`
```
입력: article dict
출력: data/outputs/{date}_{slug}_thread.json
```
- Tweet 1: 제목 + 코너 해시태그
- Tweet 24: 번호 매긴 핵심 포인트
- 마지막 Tweet: 블로그 URL + CTA
### `newsletter_converter.py`
```python
generate_weekly(articles: list[dict], urls: list[str] = None) -> str
```
주간 기사 목록으로 HTML 뉴스레터를 생성합니다.
**출력:** `data/outputs/weekly_{date}_newsletter.html`
### `shorts_converter.py`
뉴스 앵커 형식의 세로형 쇼츠 영상을 생성합니다 (1080×1920, 30fps).
**파이프라인:**
```
1. DALL-E 3 배경 이미지 생성 (옵션, 없으면 단색)
2. Pillow 슬라이드 합성
인트로 → 헤드라인 → 포인트1 → 포인트2 → 포인트3 → 데이터(선택) → 아웃트로
3. Google Cloud TTS (ko-KR-Wavenet-A) → gTTS 폴백
4. ffmpeg zoompan — Ken Burns 효과로 슬라이드별 MP4 클립 생성
5. ffmpeg xfade — 코너별 트랜지션으로 클립 연결
6. BGM 믹싱 (assets/bgm.mp3, 볼륨 8%)
7. SRT 자막 burn-in (흰 텍스트 + 반투명 검정 박스)
출력: data/outputs/{date}_{slug}_shorts.mp4
```
**코너별 설정** (`templates/shorts_template.json`):
| 코너 | 색상 | TTS 속도 | 트랜지션 | 특이사항 |
|------|------|----------|---------|---------|
| 쉬운세상 | 보라 `#7c3aed` | 1.0x | fade | — |
| 숨은보물 | 블루 `#1d6fb0` | 1.05x | slideleft | — |
| 바이브리포트 | 코럴 `#d85a30` | 1.1x | slideleft | — |
| 팩트체크 | 레드 `#bf3a3a` | 1.0x | fade | 데이터 카드 강제 |
| 한컷 | 골드 `#8a7a2e` | 1.0x | fade | 최대 20초 |
--- ---
## 이미지 모드 선택 ## 배포 엔진 (LAYER 3)
`.env``IMAGE_MODE` 값으로 선택합니다. ### `image_host.py`
### `manual` (기본) Instagram API는 공개 URL 이미지만 허용하므로 로컬 파일을 업로드합니다:
한컷 코너 글 발행 시점에 프롬프트 1개를 Telegram으로 전송. - **ImgBB API** (기본): 무료 이미지 호스팅, `IMGBB_API_KEY` 필요
사용자가 직접 이미지를 생성해 `data/images/` 에 파일 저장. - **로컬 HTTP 서버**: 개발/테스트용, `LOCAL_IMAGE_SERVER=true`
### `request` (권장) ### `instagram_bot.py`
매주 월요일 10:00 대기 중인 프롬프트 목록을 Telegram으로 일괄 전송.
**사용 흐름:** Instagram Graph API v19.0 흐름:
1. 봇이 프롬프트 목록 전송 (또는 `/imgbatch` 수동 트리거) 1. `POST /media` — 미디어 컨테이너 생성
2. `/imgpick 3` — 3번 프롬프트 전체 내용 수신 2. `GET /media/{id}``FINISHED` 상태까지 폴링 (최대 2분)
3. 프롬프트 복사 → Midjourney / DALL-E 웹 / Stable Diffusion 등에 붙여넣기 3. `POST /media_publish` — 컨테이너 발행
4. 생성된 이미지를 Telegram으로 전송 (캡션에 `#3` 입력 또는 `/imgpick` 후 바로 전송)
5. 봇이 자동 저장 + 완료 처리
### `auto` ### `x_bot.py`
OpenAI DALL-E 3 API를 직접 호출해 자동 생성.
`OPENAI_API_KEY` 필요. 이미지당 $0.040.08 비용 발생 (ChatGPT Pro 구독과 별도). X API v2 + OAuth1 (`requests_oauthlib`):
- 이전 트윗 ID를 `reply_to_id`로 넘겨 스레드 구성
- 각 트윗 사이 1초 딜레이로 순서 보장
### `tiktok_bot.py`
TikTok Content Posting API v2 (Direct Post):
1. `POST /v2/post/publish/video/init/` — 업로드 URL + `publish_id` 수령
2. 청크 업로드
3. `POST /v2/post/publish/status/fetch/` — 발행 완료 폴링
### `youtube_bot.py`
YouTube Data API v3:
- 기존 `token.json` Google OAuth 재사용 (별도 인증 불필요)
- 제목 끝에 `#Shorts` 자동 추가
- `google-resumable-media`로 대용량 파일 청크 업로드
--- ---
## 콘텐츠 코너 구성 ## 콘텐츠 코너
| 코너 | 컨셉 | 발행 빈도 | | 코너 | 성격 | 글 길이 | 안전장치 |
|------|------|-----------| |------|------|---------|---------|
| **쉬운 세상** | AI/테크를 누구나 따라할 수 있게 | 주 23회 | | **쉬운세상** | 복잡한 이슈를 쉽게 해설 | 1,5002,000자 | 자동 발행 |
| **숨은 보물** | 모르면 손해인 무료 도구 발굴 | 주 23회 | | **숨은보물** | 유용하지만 덜 알려진 정보 | 1,5002,000자 | 자동 발행 |
| **바이브 리포트** | 비개발자가 AI로 만든 실제 사례 | 주 12회 | | **바이브리포트** | 트렌드·문화 분석 | 1,5002,500자 | 자동 발행 |
| **팩트체크** | 과대광고/거짓 주장 검증 (수동 승인 필수) | 주 1회 이하 | | **팩트체크** | [사실]/[의견]/[추정] 명시 검증 | 2,0002,500자 | **수동 승인 필수** |
| **한 ** | AI/테크 이슈 만평 | 주 1회 | | **한컷** | 시사 만평 + 짧은 코멘트 | 300500자 | 자동 발행 |
### 안전장치 (자동 발행 차단 조건) **자동 발행 차단 조건 (Telegram 수동 검토):**
- 팩트체크 코너 전체
아래 조건에 해당하면 자동 발행 대신 Telegram으로 수동 검토 요청: - 암호화폐/투자/법률 위험 키워드 감지
- 팩트체크 코너 글 전체
- 암호화폐/투자/법률 관련 위험 키워드 감지
- 출처 2개 미만 - 출처 2개 미만
- 품질 점수 75점 미만 - 품질 점수 75점 미만
--- ---
## OpenClaw 서브에이전트 설정 ## Telegram 명령어
`~/.openclaw/` 디렉토리에 아래 파일을 배치합니다: 스케줄러 실행 중 Telegram 봇에 명령을 보낼 수 있습니다.
### 슬래시 명령
| 명령 | 설명 |
|------|------|
| `/start` | 봇 소개 |
| `/status` | 스케줄러 상태 + 오늘 발행 수 |
| `/collect` | 즉시 트렌드 수집 |
| `/publish` | 즉시 블로그 발행 |
| `/convert` | 즉시 변환 파이프라인 실행 |
| `/report` | 즉시 분석 리포트 |
| `/pause` | 자동 스케줄 일시 중지 |
| `/resume` | 자동 스케줄 재개 |
| `/approve [번호]` | 수동 검토 글 승인 후 발행 |
| `/reject [번호]` | 수동 검토 글 거부 |
| `/images` | 이미지 제작 현황 |
| `/imgbatch` | 이미지 프롬프트 배치 전송 |
| `/help` | 명령어 목록 |
### 자연어 명령 (`ANTHROPIC_API_KEY` 설정 시)
``` ```
~/.openclaw/ "오늘 발행된 글 목록 보여줘"
├── agents/ "쇼츠 변환 다시 실행해줘"
│ ├── main/AGENTS.md ← 에이전트 관리 규칙 "이번 주 조회수 상위 3개 알려줘"
│ └── blog-writer/SOUL.md ← 글쓰기 에이전트 설정
└── workspace-blog-writer/
├── personas/tech_insider.md ← 테크인사이더 페르소나
├── corners/ ← 5개 코너 설정 파일
└── templates/output_format.md ← 출력 포맷 템플릿
``` ```
이 파일들은 설치 시 `~/.openclaw/` 에 수동으로 복사해야 합니다.
(OpenClaw 설정 완료 후 `scheduler.py``_call_openclaw()` 함수를 실제 호출 코드로 교체)
--- ---
## Phase 로드맵 ## OpenClaw AI 에이전트 연동
| Phase | 기간 | 목표 | 예상 수익 | 이 프로젝트는 [OpenClaw](https://openclaw.ai) AI 에이전트와 함께 사용하도록 설계되었습니다.
|-------|------|------|----------|
| **1** | Month 13 | 블로그 1개, 시스템 검증, AdSense 승인 | 05만원/월 | **에이전트 설정 파일 위치:**
| **2** | Month 35 | 블로그 2개, 쿠팡 수익 집중 | 520만원/월 |
| **3** | Month 58 | 34개 블로그, 어필리에이트 추가 | 1050만원/월 | ```
| **4** | Month 8+ | 영문 블로그, 글로벌 확장 | 30100만원+/월 | ~/.openclaw/agents/blog-writer/SOUL.md
역할, 글쓰기 원칙, Blogger-ready HTML 출력 조건
~/.openclaw/workspace-blog-writer/templates/output_format.md
원고 출력 포맷 (섹션 구조 정의)
```
**AI 원고 출력 포맷** (`output_format.md` 기반):
```
---TITLE--- 제목 (SEO 키워드 포함, 클릭베이트 없음)
---META--- 검색 설명 150자 이내
---SLUG--- URL 슬러그 (영문 소문자, 하이픈)
---TAGS--- 태그 쉼표 구분
---CORNER--- 코너명
---BODY--- Blogger-ready HTML 본문 (마크다운 금지)
---KEY_POINTS--- 핵심 3줄 (각 30자 이내, SNS/TTS용)
---COUPANG_KEYWORDS--- 쿠팡 검색 키워드
---SOURCES--- 출처 URL 목록
---DISCLAIMER--- 면책 문구 (팩트체크 필수)
```
**HTML 본문 필수 구성요소:**
- `<style>` 블록 맨 앞 (`.post-title` 숨김 포함)
- eyebrow 배지 · h1 제목 + 부제 · 메타 정보
- 섹션 트래커 (소문자 영문 앵커) · h3 소제목
- pull quote · 데이터 카드/그리드 · balance-box (반론)
- 출처 cite · 클로징 박스 · 태그 목록
- 저자: `22B Labs · The 4th Path`
OpenClaw 없이도 동작합니다 — 위 포맷에 맞춰 `data/originals/`에 파일을 직접 넣으면 파이프라인이 실행됩니다.
---
## 배포 스케줄
스케줄러 실행 시 매일 자동으로 실행됩니다 (`config/schedule.json`에서 시간 변경 가능):
| 시간 | 작업 |
|------|------|
| 07:00 | 트렌드 수집 (collector_bot) |
| 08:00 | AI 글 작성 트리거 (OpenClaw) |
| 08:30 | 변환 파이프라인 (5개 포맷 동시 생성) |
| 09:00 | 블로그 발행 (Blogger) |
| 10:00 | 인스타그램 카드 게시 |
| 11:00 | X 스레드 게시 |
| 18:00 | TikTok 쇼츠 업로드 |
| 20:00 | YouTube 쇼츠 업로드 |
| 22:00 | 일간 분석 리포트 (Telegram) |
| 일요일 22:30 | 주간 뉴스레터 + 종합 리포트 |
---
## Phase 현황
### Phase 1A — 완료 ✅
모든 핵심 봇 구현 완료. 블로그 자동 수집→작성→발행 파이프라인 작동.
### Phase 1B — 코드 완료 ✅, API 키 설정 필요 ⚙️
- [ ] `IMGBB_API_KEY` 발급 ([api.imgbb.com](https://api.imgbb.com/))
- [ ] Facebook Developer App에서 `INSTAGRAM_ACCESS_TOKEN` / `INSTAGRAM_ACCOUNT_ID` 발급
- [ ] X Developer Portal에서 API 키 4종 발급
### Phase 2 — 코드 완료 ✅, 환경 설정 필요 ⚙️
- [ ] `ffmpeg` 설치 및 PATH 등록
- [ ] `GOOGLE_TTS_API_KEY` 발급 (또는 `gTTS` 무료 사용)
- [ ] `TIKTOK_ACCESS_TOKEN` / `YOUTUBE_CHANNEL_ID` 발급
--- ---
## 자주 묻는 질문 ## 자주 묻는 질문
**Q. ChatGPT Pro 없이도 사용할 수 있나요?** **Q. OpenClaw 없이도 사용할 수 있나요?**
A. 봇 레이어(수집/발행/링크/분석)는 ChatGPT 없이 동작합니다. 글 작성(AI 레이어)만 OpenClaw + ChatGPT Pro를 사용합니다. 다른 LLM으로 교체하려면 `scheduler.py``_call_openclaw()` 함수를 수정하세요. A. 봇 레이어(수집/변환/발행/분석)는 완전히 독립적으로 동작합니다. `data/originals/`에 지정된 포맷으로 원고 파일을 직접 넣으면 파이프라인이 실행됩니다. 다른 AI(GPT API, Gemini 등)로 글을 생성한 후 넣어도 됩니다.
**Q. TTS 없이 쇼츠를 만들 수 있나요?**
A. `GOOGLE_TTS_API_KEY` 없이도 `gTTS`(무료)로 폴백됩니다. 품질은 낮지만 비용 없이 동작합니다. `requirements.txt``gTTS`가 포함되어 있습니다.
**Q. DALL-E 없이 쇼츠를 만들 수 있나요?**
A. `OPENAI_API_KEY`가 없으면 코너 색상 단색 배경으로 대체됩니다. 별도 조치 없이 자동으로 폴백됩니다.
**Q. Blogger 외 다른 플랫폼을 사용할 수 있나요?** **Q. Blogger 외 다른 플랫폼을 사용할 수 있나요?**
A. `publisher_bot.py``publish_to_blogger()` 함수를 교체하면 WordPress, 티스토리 등으로 변경 가능합니다. A. `publisher_bot.py``publish_to_blogger()` 함수를 교체하면 WordPress REST API, 티스토리 등으로 변경 가능합니다.
**Q. Windows가 아닌 환경에서 사용하려면?** **Q. Windows가 아닌 환경에서 사용하려면?**
A. `setup.bat` 대신 수동으로 venv 생성하고 패키지 설치하세요. `scheduler.py`는 크로스 플랫폼으로 동작합니다. Windows 작업 스케줄러 등록 부분만 Linux cron 또는 macOS launchd로 대체하세요. A. `setup.bat` 대신 수동으로 venv 생성 패키지 설치, `scheduler.py`는 크로스 플랫폼 동작합니다. Windows 작업 스케줄러 등록 부분만 Linux cron 또는 macOS launchd로 대체하세요.
**Q. 수집봇이 아무것도 가져오지 못해요.** **Q. 수집봇이 글감을 못 가져와요.**
A. `config/sources.json`의 RSS URL 유효한지 확인하세요. Google Trends는 간혹 요청 제한이 걸릴 수 있으며, 이 경우 `pytrends` 관련 로그를 확인하세요. A. `config/sources.json`의 RSS URL 유효성을 확인하세요. Google Trends는 요청 제한이 걸릴 수 있습니다 — `logs/collector.log`에서 상세 오류를 확인하세요.
---
## 기여 가이드
PR과 이슈를 환영합니다.
### 로컬 개발 환경
```bash
git clone https://github.com/sinmb79/blog-writer.git
cd blog-writer
python -m venv venv && venv\Scripts\activate
pip install -r requirements.txt
cp .env.example .env # 값 채우기
python scripts/download_fonts.py
```
### 브랜치 규칙
```
master — 안정 버전
feature/* — 새 기능
fix/* — 버그 수정
```
### 코드 스타일
- Python 3.10+, 타입 힌트 권장
- 모듈별 `logger = logging.getLogger(__name__)` 사용
- 환경 변수는 반드시 `.env`에서만 (`python-dotenv`)
- **비밀값 하드코딩 절대 금지**
### 보안 주의사항
- `.env`, `token.json`, `credentials.json``.gitignore`에 포함 — 절대 커밋하지 마세요
- PR 전 `git diff --staged`로 비밀값이 포함되지 않았는지 반드시 확인하세요
--- ---
## 라이선스 ## 라이선스
MIT License — 자유롭게 사용, 수정, 배포 가능합니다. MIT License — 자유롭게 사용·수정·배포 가능합니다.
상업적 이용 시 브랜드명 "The 4th Path"와 "22B Labs"는 제거해 주세요.
---
<p align="center">
<strong>The 4th Path</strong> · Independent Tech Media<br>
by <a href="https://github.com/sinmb79">22B Labs</a>
</p>

View File

@@ -44,6 +44,15 @@ def parse_output(raw_output: str) -> Optional[dict]:
coupang_raw = sections.get('COUPANG_KEYWORDS', '') coupang_raw = sections.get('COUPANG_KEYWORDS', '')
coupang_keywords = [k.strip() for k in coupang_raw.split(',') if k.strip()] coupang_keywords = [k.strip() for k in coupang_raw.split(',') if k.strip()]
# KEY_POINTS 파싱 (변환 엔진용 핵심 3줄)
key_points_raw = sections.get('KEY_POINTS', '')
key_points = []
for line in key_points_raw.splitlines():
line = line.strip().lstrip('•-*').strip()
if line:
key_points.append(line)
key_points = key_points[:3] # 최대 3개
return { return {
'title': sections.get('TITLE', ''), 'title': sections.get('TITLE', ''),
'meta': sections.get('META', ''), 'meta': sections.get('META', ''),
@@ -52,6 +61,7 @@ def parse_output(raw_output: str) -> Optional[dict]:
'corner': sections.get('CORNER', ''), 'corner': sections.get('CORNER', ''),
'body': sections.get('BODY', ''), 'body': sections.get('BODY', ''),
'coupang_keywords': coupang_keywords, 'coupang_keywords': coupang_keywords,
'key_points': key_points,
'sources': sources, 'sources': sources,
'disclaimer': sections.get('DISCLAIMER', ''), 'disclaimer': sections.get('DISCLAIMER', ''),
} }

View File

View File

@@ -0,0 +1,172 @@
"""
블로그 변환봇 (converters/blog_converter.py)
역할: 원본 마크다운 → 블로그 HTML 변환 (LAYER 2)
- 마크다운 → HTML (목차, 테이블, 코드블록)
- AdSense 플레이스홀더 삽입
- Schema.org Article JSON-LD
- 쿠팡 링크봇 호출
출력: data/outputs/{date}_{slug}_blog.html
"""
import json
import logging
import sys
from datetime import datetime, timezone
from pathlib import Path
import markdown
from bs4 import BeautifulSoup
BASE_DIR = Path(__file__).parent.parent.parent
sys.path.insert(0, str(BASE_DIR / 'bots'))
LOG_DIR = BASE_DIR / 'logs'
LOG_DIR.mkdir(exist_ok=True)
OUTPUT_DIR = BASE_DIR / 'data' / 'outputs'
OUTPUT_DIR.mkdir(exist_ok=True)
logging.basicConfig(
level=logging.INFO,
format='%(asctime)s [%(levelname)s] %(message)s',
handlers=[
logging.FileHandler(LOG_DIR / 'converter.log', encoding='utf-8'),
logging.StreamHandler(),
]
)
logger = logging.getLogger(__name__)
BLOG_BASE_URL = 'https://the4thpath.com'
def markdown_to_html(md_text: str) -> tuple[str, str]:
"""마크다운 → HTML (목차 포함)"""
md = markdown.Markdown(
extensions=['toc', 'tables', 'fenced_code', 'attr_list'],
extension_configs={
'toc': {'title': '목차', 'toc_depth': '2-3'}
}
)
html = md.convert(md_text)
toc = md.toc
return html, toc
def insert_adsense_placeholders(html: str) -> str:
"""두 번째 H2 뒤 + 결론 H2 앞에 AdSense 슬롯 삽입"""
AD_SLOT_1 = '\n<!-- AD_SLOT_1 -->\n'
AD_SLOT_2 = '\n<!-- AD_SLOT_2 -->\n'
soup = BeautifulSoup(html, 'lxml')
h2_tags = soup.find_all('h2')
if len(h2_tags) >= 2:
ad_tag = BeautifulSoup(AD_SLOT_1, 'html.parser')
h2_tags[1].insert_after(ad_tag)
for h2 in soup.find_all('h2'):
if any(kw in h2.get_text() for kw in ['결론', '마무리', '정리', '요약', 'conclusion']):
ad_tag2 = BeautifulSoup(AD_SLOT_2, 'html.parser')
h2.insert_before(ad_tag2)
break
return str(soup)
def build_json_ld(article: dict, post_url: str = '') -> str:
"""Schema.org Article JSON-LD"""
schema = {
"@context": "https://schema.org",
"@type": "Article",
"headline": article.get('title', ''),
"description": article.get('meta', ''),
"datePublished": datetime.now(timezone.utc).isoformat(),
"dateModified": datetime.now(timezone.utc).isoformat(),
"author": {"@type": "Person", "name": "테크인사이더"},
"publisher": {
"@type": "Organization",
"name": "The 4th Path",
"logo": {"@type": "ImageObject", "url": f"{BLOG_BASE_URL}/logo.png"}
},
"mainEntityOfPage": {"@type": "WebPage", "@id": post_url or BLOG_BASE_URL},
}
return f'<script type="application/ld+json">\n{json.dumps(schema, ensure_ascii=False, indent=2)}\n</script>'
def build_full_html(article: dict, body_html: str, toc_html: str,
post_url: str = '') -> str:
"""JSON-LD + 목차 + 본문 + 면책 조합"""
json_ld = build_json_ld(article, post_url)
disclaimer = article.get('disclaimer', '')
parts = [json_ld]
if toc_html:
parts.append(f'<div class="toc-wrapper">{toc_html}</div>')
parts.append(body_html)
if disclaimer:
parts.append(f'<hr/><p class="disclaimer"><small>{disclaimer}</small></p>')
return '\n'.join(parts)
def _is_html_body(body: str) -> bool:
"""AI가 이미 HTML을 출력했는지 감지 (마크다운 변환 건너뜀)"""
stripped = body.lstrip()
return stripped.startswith('<') and any(
tag in stripped[:200].lower()
for tag in ['<style', '<div', '<h1', '<section', '<article', '<p>']
)
def convert(article: dict, save_file: bool = True) -> str:
"""
article dict → 블로그 HTML 문자열 반환.
save_file=True이면 data/outputs/에 저장.
BODY가 이미 HTML이면 마크다운 변환을 건너뜀.
"""
logger.info(f"블로그 변환 시작: {article.get('title', '')}")
body = article.get('body', '')
if _is_html_body(body):
# AI가 Blogger-ready HTML을 직접 출력한 경우 — 변환 없이 그대로 사용
logger.info("HTML 본문 감지 — 마크다운 변환 건너뜀")
body_html = body
toc_html = ''
else:
# 레거시 마크다운 → HTML 변환
body_html, toc_html = markdown_to_html(body)
# AdSense 삽입
body_html = insert_adsense_placeholders(body_html)
# 쿠팡 링크 삽입
try:
import linker_bot
body_html = linker_bot.process(article, body_html)
except Exception as e:
logger.warning(f"쿠팡 링크 삽입 실패 (건너뜀): {e}")
# 최종 HTML
html = build_full_html(article, body_html, toc_html)
if save_file:
slug = article.get('slug', 'article')
date_str = datetime.now().strftime('%Y%m%d')
filename = f"{date_str}_{slug}_blog.html"
output_path = OUTPUT_DIR / filename
output_path.write_text(html, encoding='utf-8')
logger.info(f"저장: {output_path}")
logger.info("블로그 변환 완료")
return html
if __name__ == '__main__':
sample = {
'title': '테스트 글',
'meta': '테스트 설명',
'slug': 'test-article',
'corner': '쉬운세상',
'body': '## 소개\n\n본문입니다.\n\n## 결론\n\n마무리입니다.',
'coupang_keywords': [],
'disclaimer': '',
'key_points': ['포인트 1', '포인트 2', '포인트 3'],
}
html = convert(sample)
print(html[:500])

View File

@@ -0,0 +1,182 @@
"""
카드 변환봇 (converters/card_converter.py)
역할: 원본 마크다운 → 인스타그램 카드 이미지 (LAYER 2)
- 크기: 1080×1080 (정사각형)
- 배경: 흰색 + 골드 액센트 (#c8a84e)
- 폰트: Noto Sans KR (없으면 기본 폰트)
- 구성: 로고 + 코너 배지 + 제목 + 핵심 3줄 + URL
출력: data/outputs/{date}_{slug}_card.png
"""
import logging
import textwrap
from datetime import datetime
from pathlib import Path
BASE_DIR = Path(__file__).parent.parent.parent
LOG_DIR = BASE_DIR / 'logs'
LOG_DIR.mkdir(exist_ok=True)
OUTPUT_DIR = BASE_DIR / 'data' / 'outputs'
OUTPUT_DIR.mkdir(exist_ok=True)
ASSETS_DIR = BASE_DIR / 'assets'
FONTS_DIR = ASSETS_DIR / 'fonts'
logging.basicConfig(
level=logging.INFO,
format='%(asctime)s [%(levelname)s] %(message)s',
handlers=[
logging.FileHandler(LOG_DIR / 'converter.log', encoding='utf-8'),
logging.StreamHandler(),
]
)
logger = logging.getLogger(__name__)
# 디자인 상수
CARD_SIZE = (1080, 1080)
COLOR_WHITE = (255, 255, 255)
COLOR_GOLD = (200, 168, 78) # #c8a84e
COLOR_DARK = (30, 30, 30)
COLOR_GRAY = (120, 120, 120)
COLOR_GOLD_LIGHT = (255, 248, 220)
CORNER_COLORS = {
'쉬운세상': (52, 152, 219), # 파랑
'숨은보물': (46, 204, 113), # 초록
'바이브리포트': (155, 89, 182), # 보라
'팩트체크': (231, 76, 60), # 빨강
'한컷': (241, 196, 15), # 노랑
}
BLOG_URL = 'the4thpath.com'
BRAND_NAME = 'The 4th Path'
SUB_BRAND = 'by 22B Labs'
def _load_font(size: int):
"""Noto Sans KR 폰트 로드 (없으면 기본 폰트)"""
try:
from PIL import ImageFont
for fname in ['NotoSansKR-Bold.ttf', 'NotoSansKR-Regular.ttf', 'NotoSansKR-Medium.ttf']:
font_path = FONTS_DIR / fname
if font_path.exists():
return ImageFont.truetype(str(font_path), size)
# Windows 기본 한글 폰트 시도
for path in [
'C:/Windows/Fonts/malgun.ttf',
'C:/Windows/Fonts/malgunbd.ttf',
'C:/Windows/Fonts/NanumGothic.ttf',
]:
if Path(path).exists():
return ImageFont.truetype(path, size)
except Exception:
pass
try:
from PIL import ImageFont
return ImageFont.load_default()
except Exception:
return None
def _draw_rounded_rect(draw, xy, radius: int, fill):
"""PIL로 둥근 사각형 그리기"""
from PIL import ImageDraw
x1, y1, x2, y2 = xy
draw.rectangle([x1 + radius, y1, x2 - radius, y2], fill=fill)
draw.rectangle([x1, y1 + radius, x2, y2 - radius], fill=fill)
draw.ellipse([x1, y1, x1 + radius * 2, y1 + radius * 2], fill=fill)
draw.ellipse([x2 - radius * 2, y1, x2, y1 + radius * 2], fill=fill)
draw.ellipse([x1, y2 - radius * 2, x1 + radius * 2, y2], fill=fill)
draw.ellipse([x2 - radius * 2, y2 - radius * 2, x2, y2], fill=fill)
def convert(article: dict, save_file: bool = True) -> str:
"""
article dict → 카드 이미지 PNG.
Returns: 저장 경로 문자열 (save_file=False면 빈 문자열)
"""
try:
from PIL import Image, ImageDraw
except ImportError:
logger.error("Pillow가 설치되지 않음. pip install Pillow")
return ''
title = article.get('title', '')
corner = article.get('corner', '쉬운세상')
key_points = article.get('key_points', [])
logger.info(f"카드 변환 시작: {title}")
# 캔버스
img = Image.new('RGB', CARD_SIZE, COLOR_WHITE)
draw = ImageDraw.Draw(img)
# 골드 상단 바 (80px)
draw.rectangle([0, 0, 1080, 80], fill=COLOR_GOLD)
# 브랜드명 (좌상단)
font_brand = _load_font(36)
font_sub = _load_font(22)
font_corner = _load_font(26)
font_title = _load_font(52)
font_point = _load_font(38)
font_url = _load_font(28)
if font_brand:
draw.text((40, 22), BRAND_NAME, font=font_brand, fill=COLOR_WHITE)
if font_sub:
draw.text((460, 28), SUB_BRAND, font=font_sub, fill=(240, 235, 210))
# 코너 배지
badge_color = CORNER_COLORS.get(corner, COLOR_GOLD)
_draw_rounded_rect(draw, [40, 110, 250, 160], 20, badge_color)
if font_corner:
draw.text((60, 122), corner, font=font_corner, fill=COLOR_WHITE)
# 제목 (멀티라인, 최대 3줄)
title_lines = textwrap.wrap(title, width=18)[:3]
y_title = 200
for line in title_lines:
if font_title:
draw.text((40, y_title), line, font=font_title, fill=COLOR_DARK)
y_title += 65
# 구분선
draw.rectangle([40, y_title + 10, 1040, y_title + 14], fill=COLOR_GOLD)
# 핵심 포인트
y_points = y_title + 40
for i, point in enumerate(key_points[:3]):
# 불릿 원
draw.ellipse([40, y_points + 8, 64, y_points + 32], fill=COLOR_GOLD)
if font_point:
point_short = textwrap.shorten(point, width=22, placeholder='...')
draw.text((76, y_points), point_short, font=font_point, fill=COLOR_DARK)
y_points += 60
# 하단 바 (URL + 브랜딩)
draw.rectangle([0, 980, 1080, 1080], fill=COLOR_GOLD)
if font_url:
draw.text((40, 1008), BLOG_URL, font=font_url, fill=COLOR_WHITE)
# 저장
output_path = ''
if save_file:
slug = article.get('slug', 'article')
date_str = datetime.now().strftime('%Y%m%d')
filename = f"{date_str}_{slug}_card.png"
output_path = str(OUTPUT_DIR / filename)
img.save(output_path, 'PNG')
logger.info(f"카드 저장: {output_path}")
logger.info("카드 변환 완료")
return output_path
if __name__ == '__main__':
sample = {
'title': 'ChatGPT 처음 쓰는 사람을 위한 완전 가이드',
'slug': 'chatgpt-guide',
'corner': '쉬운세상',
'key_points': ['무료로 바로 시작 가능', 'GPT-4는 유료지만 3.5도 충분', '프롬프트가 결과를 결정한다'],
}
path = convert(sample)
print(f"저장: {path}")

View File

@@ -0,0 +1,141 @@
"""
뉴스레터 변환봇 (converters/newsletter_converter.py)
역할: 원본 마크다운 → 주간 뉴스레터 HTML 발췌 (LAYER 2)
- TITLE + META + KEY_POINTS 발췌
- 주간 단위로 모아서 뉴스레터 HTML 생성
출력: data/outputs/weekly_{date}_newsletter.html
Phase 3에서 Substack 등 연동 예정
"""
import json
import logging
from datetime import datetime
from pathlib import Path
BASE_DIR = Path(__file__).parent.parent.parent
LOG_DIR = BASE_DIR / 'logs'
OUTPUT_DIR = BASE_DIR / 'data' / 'outputs'
OUTPUT_DIR.mkdir(exist_ok=True)
logging.basicConfig(
level=logging.INFO,
format='%(asctime)s [%(levelname)s] %(message)s',
handlers=[
logging.FileHandler(LOG_DIR / 'converter.log', encoding='utf-8'),
logging.StreamHandler(),
]
)
logger = logging.getLogger(__name__)
BLOG_BASE_URL = 'https://the4thpath.com'
def extract_newsletter_item(article: dict, blog_url: str = '') -> dict:
"""단일 글에서 뉴스레터용 발췌 추출"""
return {
'title': article.get('title', ''),
'meta': article.get('meta', ''),
'corner': article.get('corner', ''),
'key_points': article.get('key_points', []),
'url': blog_url or f"{BLOG_BASE_URL}/{article.get('slug', '')}",
'extracted_at': datetime.now().isoformat(),
}
def build_newsletter_html(items: list[dict], week_str: str = '') -> str:
"""주간 뉴스레터 HTML 생성"""
if not week_str:
week_str = datetime.now().strftime('%Y년 %m월 %d일 주간')
article_blocks = []
for item in items:
points_html = ''.join(
f'<li>{p}</li>' for p in item.get('key_points', [])
)
block = f"""
<div style="margin-bottom:32px;padding-bottom:24px;border-bottom:1px solid #eee;">
<p style="color:#c8a84e;font-size:12px;margin:0 0 4px;">{item.get('corner','')}</p>
<h2 style="font-size:20px;margin:0 0 8px;">
<a href="{item.get('url','')}" style="color:#1a1a1a;text-decoration:none;">{item.get('title','')}</a>
</h2>
<p style="color:#555;font-size:14px;margin:0 0 12px;">{item.get('meta','')}</p>
<ul style="color:#333;font-size:14px;margin:0;padding-left:20px;">
{points_html}
</ul>
<p style="margin:12px 0 0;">
<a href="{item.get('url','')}" style="color:#c8a84e;font-size:13px;">전체 읽기 →</a>
</p>
</div>"""
article_blocks.append(block)
articles_html = '\n'.join(article_blocks)
return f"""<!DOCTYPE html>
<html lang="ko">
<head>
<meta charset="UTF-8">
<title>The 4th Path 주간 뉴스레터 — {week_str}</title>
</head>
<body style="font-family:'Noto Sans KR',sans-serif;max-width:600px;margin:0 auto;padding:20px;color:#1a1a1a;">
<div style="background:#c8a84e;padding:24px;margin-bottom:32px;">
<h1 style="color:#fff;margin:0;font-size:28px;">The 4th Path</h1>
<p style="color:#fff5dc;margin:4px 0 0;font-size:14px;">{week_str} 뉴스레터</p>
</div>
{articles_html}
<div style="margin-top:32px;padding-top:16px;border-top:2px solid #c8a84e;text-align:center;">
<p style="color:#888;font-size:12px;">
<a href="{BLOG_BASE_URL}" style="color:#c8a84e;">the4thpath.com</a> | by 22B Labs
</p>
</div>
</body>
</html>"""
def generate_weekly(articles: list[dict], urls: list[str] = None,
save_file: bool = True) -> str:
"""
여러 글을 모아 주간 뉴스레터 HTML 생성.
articles: article dict 리스트
urls: 각 글의 발행 URL (없으면 slug로 생성)
"""
logger.info(f"주간 뉴스레터 생성 시작: {len(articles)}개 글")
items = []
for i, article in enumerate(articles):
url = (urls[i] if urls and i < len(urls) else '')
items.append(extract_newsletter_item(article, url))
week_str = datetime.now().strftime('%Y년 %m월 %d')
html = build_newsletter_html(items, week_str)
if save_file:
date_str = datetime.now().strftime('%Y%m%d')
filename = f"weekly_{date_str}_newsletter.html"
output_path = OUTPUT_DIR / filename
output_path.write_text(html, encoding='utf-8')
logger.info(f"뉴스레터 저장: {output_path}")
logger.info("주간 뉴스레터 생성 완료")
return html
if __name__ == '__main__':
samples = [
{
'title': 'ChatGPT 처음 쓰는 사람을 위한 완전 가이드',
'meta': 'ChatGPT를 처음 사용하는 분을 위한 단계별 가이드',
'slug': 'chatgpt-guide',
'corner': '쉬운세상',
'key_points': ['무료로 바로 시작', 'GPT-3.5로도 충분', '프롬프트가 핵심'],
},
{
'title': '개발자 생산성 10배 높이는 AI 도구 5선',
'meta': '실제 사용해본 AI 개발 도구 정직한 리뷰',
'slug': 'ai-dev-tools',
'corner': '숨은보물',
'key_points': ['Cursor로 코딩 속도 3배', 'Copilot과 차이점', '무료 플랜으로 충분'],
},
]
html = generate_weekly(samples)
print(html[:500])

View File

@@ -0,0 +1,875 @@
"""
쇼츠 변환봇 (converters/shorts_converter.py)
역할: 원본 마크다운 → 뉴스앵커 포맷 쇼츠 MP4 (LAYER 2)
설계서: shorts-video-template-spec.txt
파이프라인:
1. 슬라이드 구성 결정 (intro/headline/point×3/data?/outro)
2. 각 섹션 TTS 생성 → 개별 WAV
3. DALL-E 배경 이미지 생성 (선택)
4. Pillow UI 오버레이 합성 → 슬라이드 PNG × N
5. 슬라이드 → 개별 클립 MP4 (Ken Burns zoompan)
6. xfade 전환으로 클립 결합
7. BGM 믹스 (8%)
8. SRT 자막 burn-in
9. 최종 MP4 저장
출력: data/outputs/{date}_{slug}_shorts.mp4 (1080×1920, 30~60초)
사전 조건:
pip install Pillow pydub google-cloud-texttospeech openai gTTS
ffmpeg 설치 후 PATH 등록 또는 FFMPEG_PATH 환경변수
"""
import base64
import json
import logging
import os
import subprocess
import textwrap
from datetime import datetime
from pathlib import Path
from typing import Optional
from dotenv import load_dotenv
load_dotenv()
BASE_DIR = Path(__file__).parent.parent.parent
LOG_DIR = BASE_DIR / 'logs'
OUTPUT_DIR = BASE_DIR / 'data' / 'outputs'
ASSETS_DIR = BASE_DIR / 'assets'
FONTS_DIR = ASSETS_DIR / 'fonts'
TEMPLATE_PATH = BASE_DIR / 'templates' / 'shorts_template.json'
BGM_PATH = ASSETS_DIR / 'bgm.mp3'
LOG_DIR.mkdir(exist_ok=True)
OUTPUT_DIR.mkdir(exist_ok=True)
logger = logging.getLogger(__name__)
if not logger.handlers:
handler = logging.FileHandler(LOG_DIR / 'converter.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)
FFMPEG = os.getenv('FFMPEG_PATH', 'ffmpeg')
FFPROBE = os.getenv('FFPROBE_PATH', 'ffprobe')
OPENAI_API_KEY = os.getenv('OPENAI_API_KEY', '')
GOOGLE_TTS_API_KEY = os.getenv('GOOGLE_TTS_API_KEY', '')
# 컬러 상수
COLOR_DARK = (10, 10, 13) # #0a0a0d
COLOR_DARK2 = (15, 10, 30) # #0f0a1e
COLOR_GOLD = (200, 168, 78) # #c8a84e
COLOR_WHITE = (255, 255, 255)
COLOR_BLACK = (0, 0, 0)
COLOR_TICKER_BG = (0, 0, 0, 200)
# ─── 설정 로드 ────────────────────────────────────────
def _load_template() -> dict:
if TEMPLATE_PATH.exists():
return json.loads(TEMPLATE_PATH.read_text(encoding='utf-8'))
return {}
# ─── 폰트 헬퍼 ───────────────────────────────────────
def _load_font(size: int, bold: bool = False):
"""NotoSansKR 로드, 없으면 Windows 맑은고딕, 없으면 기본 폰트"""
try:
from PIL import ImageFont
candidates = (
['NotoSansKR-Bold.ttf', 'NotoSansKR-Medium.ttf'] if bold
else ['NotoSansKR-Regular.ttf', 'NotoSansKR-Medium.ttf']
)
for fname in candidates:
p = FONTS_DIR / fname
if p.exists():
return ImageFont.truetype(str(p), size)
win_font = 'malgunbd.ttf' if bold else 'malgun.ttf'
wp = Path(f'C:/Windows/Fonts/{win_font}')
if wp.exists():
return ImageFont.truetype(str(wp), size)
return ImageFont.load_default()
except Exception:
return None
def _text_size(draw, text: str, font) -> tuple[int, int]:
"""PIL 버전 호환 텍스트 크기 측정"""
try:
bb = draw.textbbox((0, 0), text, font=font)
return bb[2] - bb[0], bb[3] - bb[1]
except AttributeError:
return draw.textsize(text, font=font)
# ─── Pillow 헬퍼 ─────────────────────────────────────
def _hex_to_rgb(hex_color: str) -> tuple[int, int, int]:
h = hex_color.lstrip('#')
return tuple(int(h[i:i+2], 16) for i in (0, 2, 4))
def _draw_rounded_rect(draw, xy, radius: int, fill):
x1, y1, x2, y2 = xy
r = radius
draw.rectangle([x1 + r, y1, x2 - r, y2], fill=fill)
draw.rectangle([x1, y1 + r, x2, y2 - r], fill=fill)
for cx, cy in [(x1, y1), (x2 - 2*r, y1), (x1, y2 - 2*r), (x2 - 2*r, y2 - 2*r)]:
draw.ellipse([cx, cy, cx + 2*r, cy + 2*r], fill=fill)
def _draw_gradient_overlay(img, top_alpha: int = 0, bottom_alpha: int = 200):
"""하단 다크 그라데이션 오버레이"""
from PIL import Image
W, H = img.size
overlay = Image.new('RGBA', (W, H), (0, 0, 0, 0))
import struct
for y in range(H // 2, H):
t = (y - H // 2) / (H // 2)
alpha = int(top_alpha + (bottom_alpha - top_alpha) * t)
for x in range(W):
overlay.putpixel((x, y), (0, 0, 0, alpha))
return Image.alpha_composite(img.convert('RGBA'), overlay).convert('RGB')
def _wrap_text_lines(text: str, font, max_width: int, draw) -> list[str]:
"""폰트 기준 줄 바꿈"""
words = text.split()
lines = []
current = ''
for word in words:
test = (current + ' ' + word).strip()
w, _ = _text_size(draw, test, font)
if w <= max_width:
current = test
else:
if current:
lines.append(current)
current = word
if current:
lines.append(current)
return lines
# ─── TTS ──────────────────────────────────────────────
def _tts_google_rest(text: str, output_path: str, voice: str, speed: float) -> bool:
"""Google Cloud TTS REST API (API Key 방식)"""
if not GOOGLE_TTS_API_KEY:
return False
try:
import requests as req
url = f'https://texttospeech.googleapis.com/v1/text:synthesize?key={GOOGLE_TTS_API_KEY}'
lang = 'ko-KR' if voice.startswith('ko') else 'en-US'
payload = {
'input': {'text': text},
'voice': {'languageCode': lang, 'name': voice},
'audioConfig': {
'audioEncoding': 'LINEAR16',
'speakingRate': speed,
'pitch': 0,
},
}
resp = req.post(url, json=payload, timeout=30)
resp.raise_for_status()
audio_b64 = resp.json().get('audioContent', '')
if audio_b64:
Path(output_path).write_bytes(base64.b64decode(audio_b64))
return True
except Exception as e:
logger.warning(f"Google Cloud TTS 실패: {e}")
return False
def _tts_gtts(text: str, output_path: str) -> bool:
"""gTTS 무료 (mp3 → pydub으로 wav 변환)"""
try:
from gtts import gTTS
mp3_path = output_path.replace('.wav', '_tmp.mp3')
tts = gTTS(text=text, lang='ko', slow=False)
tts.save(mp3_path)
# mp3 → wav
_run_ffmpeg(['-i', mp3_path, '-ar', '24000', output_path], quiet=True)
Path(mp3_path).unlink(missing_ok=True)
return Path(output_path).exists()
except Exception as e:
logger.warning(f"gTTS 실패: {e}")
return False
def synthesize_section(text: str, output_path: str, voice: str, speed: float) -> bool:
"""섹션별 TTS 생성 (Google Cloud REST → gTTS fallback)"""
if _tts_google_rest(text, output_path, voice, speed):
return True
return _tts_gtts(text, output_path)
def get_audio_duration(wav_path: str) -> float:
"""ffprobe로 오디오 파일 길이(초) 측정"""
try:
result = subprocess.run(
[FFPROBE, '-v', 'quiet', '-print_format', 'json',
'-show_format', wav_path],
capture_output=True, text=True, timeout=10
)
data = json.loads(result.stdout)
return float(data['format']['duration'])
except Exception:
# 폴백: 텍스트 길이 추정 (한국어 약 4자/초)
return max(2.0, len(text) / 4.0) if 'text' in dir() else 5.0
# ─── DALL-E 배경 이미지 ────────────────────────────────
def generate_background_dalle(prompt: str, corner: str) -> Optional['Image']:
"""
DALL-E 3로 배경 이미지 생성 (1024×1792 → 1080×1920 리사이즈).
OPENAI_API_KEY 없으면 None 반환 → 단색 배경 사용.
"""
if not OPENAI_API_KEY:
return None
try:
from openai import OpenAI
from PIL import Image
import io, requests as req
client = OpenAI(api_key=OPENAI_API_KEY)
full_prompt = prompt + ' No text, no letters, no numbers, no watermarks.'
response = client.images.generate(
model='dall-e-3',
prompt=full_prompt,
size='1024x1792',
quality='standard',
n=1,
)
img_url = response.data[0].url
img_bytes = req.get(img_url, timeout=30).content
img = Image.open(io.BytesIO(img_bytes)).convert('RGB')
img = img.resize((1080, 1920), Image.LANCZOS)
logger.info(f"DALL-E 배경 생성 완료: {corner}")
return img
except Exception as e:
logger.warning(f"DALL-E 배경 생성 실패 (단색 사용): {e}")
return None
def solid_background(color: tuple) -> 'Image':
"""단색 배경 이미지 생성"""
from PIL import Image
return Image.new('RGB', (1080, 1920), color)
# ─── 슬라이드 합성 ────────────────────────────────────
def compose_intro_slide(cfg: dict) -> str:
"""인트로 슬라이드: 다크 배경 + 로고 + 브랜드"""
from PIL import Image, ImageDraw
img = solid_background(COLOR_DARK)
draw = ImageDraw.Draw(img)
W, H = 1080, 1920
# 골드 수평선 (상단 1/3)
draw.rectangle([60, H//3 - 2, W - 60, H//3], fill=COLOR_GOLD)
# 브랜드명
font_brand = _load_font(cfg.get('font_title_size', 72), bold=True)
font_sub = _load_font(cfg.get('font_body_size', 48))
font_meta = _load_font(cfg.get('font_meta_size', 32))
brand = cfg.get('brand_name', 'The 4th Path')
sub = cfg.get('brand_sub', 'Independent Tech Media')
by_text = cfg.get('brand_by', 'by 22B Labs')
if font_brand:
bw, bh = _text_size(draw, brand, font_brand)
draw.text(((W - bw) // 2, H // 3 + 60), brand, font=font_brand, fill=COLOR_GOLD)
if font_sub:
sw, sh = _text_size(draw, sub, font_sub)
draw.text(((W - sw) // 2, H // 3 + 60 + (bh if font_brand else 72) + 24),
sub, font=font_sub, fill=COLOR_WHITE)
if font_meta:
mw, mh = _text_size(draw, by_text, font_meta)
draw.text(((W - mw) // 2, H * 2 // 3), by_text, font=font_meta, fill=COLOR_GOLD)
path = str(_tmp_slide('intro'))
img.save(path)
return path
def compose_headline_slide(article: dict, cfg: dict, bg_img=None) -> str:
"""헤드라인 슬라이드: DALL-E 배경 + 코너 배지 + 제목 + 날짜"""
from PIL import Image, ImageDraw
corner = article.get('corner', '쉬운세상')
corner_cfg = cfg.get('corners', {}).get(corner, {})
corner_color = _hex_to_rgb(corner_cfg.get('color', '#c8a84e'))
if bg_img is None:
bg_img = solid_background((20, 20, 35))
img = _draw_gradient_overlay(bg_img.copy())
draw = ImageDraw.Draw(img)
W, H = 1080, 1920
font_badge = _load_font(36)
font_title = _load_font(cfg.get('font_title_size', 72), bold=True)
font_meta = _load_font(cfg.get('font_meta_size', 32))
# 코너 배지
_draw_rounded_rect(draw, [60, 120, 60 + len(corner) * 28 + 40, 190], 20, corner_color)
if font_badge:
draw.text((80, 133), corner, font=font_badge, fill=COLOR_WHITE)
# 제목 (최대 3줄)
title = article.get('title', '')
if font_title:
lines = _wrap_text_lines(title, font_title, W - 120, draw)[:3]
y = H // 2 - (len(lines) * 90) // 2
for line in lines:
draw.text((60, y), line, font=font_title, fill=COLOR_WHITE)
y += 90
# 날짜 + 브랜드
meta_text = f"{datetime.now().strftime('%Y.%m.%d')} · 22B Labs"
if font_meta:
draw.text((60, H - 160), meta_text, font=font_meta, fill=COLOR_GOLD)
# 하단 골드 선
draw.rectangle([0, H - 100, W, H - 96], fill=COLOR_GOLD)
path = str(_tmp_slide('headline'))
img.save(path)
return path
def compose_point_slide(point: str, num: int, article: dict, cfg: dict,
bg_img=None) -> str:
"""포인트 슬라이드: 번호 배지 + 핵심 포인트 + 뉴스 티커"""
from PIL import Image, ImageDraw
corner = article.get('corner', '쉬운세상')
corner_cfg = cfg.get('corners', {}).get(corner, {})
corner_color = _hex_to_rgb(corner_cfg.get('color', '#c8a84e'))
if bg_img is None:
bg_img = solid_background((20, 15, 35))
# 배경 어둡게
from PIL import ImageEnhance
img = ImageEnhance.Brightness(bg_img.copy()).enhance(0.4)
draw = ImageDraw.Draw(img)
W, H = 1080, 1920
font_num = _load_font(80, bold=True)
font_point = _load_font(cfg.get('font_body_size', 48))
font_ticker = _load_font(cfg.get('font_ticker_size', 28))
# 번호 원형 배지
badges = ['', '', '']
badge_char = badges[num - 1] if num <= 3 else str(num)
if font_num:
draw.ellipse([60, 160, 200, 300], fill=corner_color)
bw, bh = _text_size(draw, badge_char, font_num)
draw.text((60 + (140 - bw) // 2, 160 + (140 - bh) // 2),
badge_char, font=font_num, fill=COLOR_WHITE)
# 포인트 텍스트
if font_point:
lines = _wrap_text_lines(point, font_point, W - 120, draw)[:4]
y = H // 2 - (len(lines) * 70) // 2
for line in lines:
draw.text((60, y), line, font=font_point, fill=COLOR_WHITE)
y += 70
# 뉴스 티커 바 (하단)
ticker_text = cfg.get('ticker_text', 'The 4th Path · {corner} · {date}')
ticker_text = ticker_text.format(
corner=corner, date=datetime.now().strftime('%Y.%m.%d')
)
draw.rectangle([0, H - 100, W, H], fill=COLOR_BLACK)
if font_ticker:
draw.text((30, H - 78), ticker_text, font=font_ticker, fill=COLOR_GOLD)
path = str(_tmp_slide(f'point{num}'))
img.save(path)
return path
def compose_data_slide(article: dict, cfg: dict) -> str:
"""데이터 카드 슬라이드: 다크 배경 + 수치 카드 2~3개"""
from PIL import Image, ImageDraw
img = solid_background(COLOR_DARK2)
draw = ImageDraw.Draw(img)
W, H = 1080, 1920
font_num = _load_font(100, bold=True)
font_label = _load_font(40)
font_meta = _load_font(30)
# KEY_POINTS에서 수치 추출 시도 (간단 파싱)
key_points = article.get('key_points', [])
import re
data_items = []
for kp in key_points:
nums = re.findall(r'\d[\d,.%억만조]+|\d+[%배x]', kp)
if nums:
data_items.append({'value': nums[0], 'label': kp[:20]})
# 수치가 없으면 포인트를 카드로 표시
if not data_items:
data_items = [{'value': f'0{i+1}', 'label': kp[:20]}
for i, kp in enumerate(key_points[:3])]
# 카드 그리기 (최대 3개)
card_w = 420
card_h = 300
items = data_items[:3]
cols = min(len(items), 2)
x_start = (W - cols * card_w - (cols - 1) * 30) // 2
y_start = H // 2 - card_h // 2 - (len(items) > 2) * (card_h // 2 + 20)
for i, item in enumerate(items):
col = i % cols
row = i // cols
x = x_start + col * (card_w + 30)
y = y_start + row * (card_h + 30)
_draw_rounded_rect(draw, [x, y, x + card_w, y + card_h], 16,
(30, 25, 60))
draw.rectangle([x, y, x + card_w, y + 6], fill=COLOR_GOLD) # 상단 강조선
if font_num:
vw, vh = _text_size(draw, item['value'], font_num)
draw.text((x + (card_w - vw) // 2, y + 60),
item['value'], font=font_num, fill=COLOR_GOLD)
if font_label:
lw, lh = _text_size(draw, item['label'], font_label)
draw.text((x + (card_w - lw) // 2, y + 190),
item['label'], font=font_label, fill=COLOR_WHITE)
# 출처 표시
sources = article.get('sources', [])
if sources and font_meta:
src_title = sources[0].get('title', '')[:40]
draw.text((60, H - 200), f'출처: {src_title}', font=font_meta,
fill=(150, 150, 150))
path = str(_tmp_slide('data'))
img.save(path)
return path
def compose_outro_slide(cfg: dict) -> str:
"""아웃트로 슬라이드: 다크 배경 + CTA + URL"""
from PIL import Image, ImageDraw
img = solid_background(COLOR_DARK)
draw = ImageDraw.Draw(img)
W, H = 1080, 1920
font_brand = _load_font(64, bold=True)
font_cta = _load_font(48)
font_url = _load_font(52, bold=True)
font_sub = _load_font(36)
# 골드 선 장식
draw.rectangle([60, H // 3, W - 60, H // 3 + 4], fill=COLOR_GOLD)
draw.rectangle([60, H * 2 // 3 + 80, W - 60, H * 2 // 3 + 84], fill=COLOR_GOLD)
cta = '더 자세한 내용은'
url = cfg.get('outro_url', 'the4thpath.com')
follow = cfg.get('outro_cta', '팔로우하면 매일 이런 정보를 받습니다')
brand = cfg.get('brand_name', 'The 4th Path')
y = H // 3 + 60
for text, font, color in [
(cta, font_cta, COLOR_WHITE),
(url, font_url, COLOR_GOLD),
('', None, None),
(brand, font_brand, COLOR_WHITE),
(follow, font_sub, (180, 180, 180)),
]:
if not font:
y += 40
continue
tw, th = _text_size(draw, text, font)
draw.text(((W - tw) // 2, y), text, font=font, fill=color)
y += th + 24
path = str(_tmp_slide('outro'))
img.save(path)
return path
# ─── ffmpeg 헬퍼 ──────────────────────────────────────
def _run_ffmpeg(args: list, quiet: bool = False) -> bool:
cmd = [FFMPEG, '-y'] + args
if quiet:
cmd = [FFMPEG, '-y', '-loglevel', 'error'] + args
result = subprocess.run(cmd, capture_output=True, text=True, timeout=300)
if result.returncode != 0:
logger.error(f"ffmpeg 오류: {result.stderr[-400:]}")
return result.returncode == 0
def _check_ffmpeg() -> bool:
try:
r = subprocess.run([FFMPEG, '-version'], capture_output=True, timeout=5)
return r.returncode == 0
except Exception:
return False
def make_clip(slide_png: str, audio_wav: str, output_mp4: str) -> float:
"""
슬라이드 PNG + 오디오 WAV → MP4 클립 (Ken Burns zoompan).
Returns: 클립 실제 길이(초)
"""
duration = get_audio_duration(audio_wav) + 0.3 # 약간 여유
ok = _run_ffmpeg([
'-loop', '1', '-i', slide_png,
'-i', audio_wav,
'-c:v', 'libx264', '-tune', 'stillimage',
'-c:a', 'aac', '-b:a', '192k',
'-pix_fmt', 'yuv420p',
'-vf', (
'scale=1080:1920,'
'zoompan=z=\'min(zoom+0.0003,1.05)\':'
'x=\'iw/2-(iw/zoom/2)\':'
'y=\'ih/2-(ih/zoom/2)\':'
'd=1:s=1080x1920:fps=30'
),
'-shortest',
'-r', '30',
output_mp4,
], quiet=True)
return duration if ok else 0.0
def concat_clips_xfade(clips: list[dict], output_mp4: str,
transition: str = 'fade', trans_dur: float = 0.5) -> bool:
"""
여러 클립을 xfade 전환으로 결합.
clips: [{'video': path, 'audio': path, 'duration': float}, ...]
"""
if len(clips) < 2:
return _run_ffmpeg(['-i', clips[0]['mp4'], '-c', 'copy', output_mp4])
# xfade filter_complex 구성
n = len(clips)
inputs = []
for c in clips:
inputs += ['-i', c['mp4']]
# 비디오 xfade 체인
filter_parts = []
offset = 0.0
prev_v = '[0:v]'
prev_a = '[0:a]'
for i in range(1, n):
offset = sum(c['duration'] for c in clips[:i]) - trans_dur * i
out_v = f'[f{i}v]' if i < n - 1 else '[video]'
out_a = f'[f{i}a]' if i < n - 1 else '[audio]'
filter_parts.append(
f'{prev_v}[{i}:v]xfade=transition={transition}:'
f'duration={trans_dur}:offset={offset:.3f}{out_v}'
)
filter_parts.append(
f'{prev_a}[{i}:a]acrossfade=d={trans_dur}{out_a}'
)
prev_v = out_v
prev_a = out_a
filter_complex = '; '.join(filter_parts)
ok = _run_ffmpeg(
inputs + [
'-filter_complex', filter_complex,
'-map', '[video]', '-map', '[audio]',
'-c:v', 'libx264', '-c:a', 'aac',
'-pix_fmt', 'yuv420p',
output_mp4,
]
)
return ok
def mix_bgm(video_mp4: str, bgm_path: str, output_mp4: str,
volume: float = 0.08) -> bool:
"""BGM을 낮은 볼륨으로 믹스"""
if not Path(bgm_path).exists():
logger.warning(f"BGM 파일 없음 ({bgm_path}) — BGM 없이 진행")
import shutil
shutil.copy2(video_mp4, output_mp4)
return True
return _run_ffmpeg([
'-i', video_mp4,
'-i', bgm_path,
'-filter_complex',
f'[1:a]volume={volume}[bgm];[0:a][bgm]amix=inputs=2:duration=first[a]',
'-map', '0:v', '-map', '[a]',
'-c:v', 'copy', '-c:a', 'aac',
'-shortest',
output_mp4,
])
def burn_subtitles(video_mp4: str, srt_path: str, output_mp4: str) -> bool:
"""SRT 자막 burn-in"""
font_name = 'NanumGothic'
# Windows 맑은고딕 폰트명 확인
for fname in ['NotoSansKR-Regular.ttf', 'malgun.ttf']:
fp = FONTS_DIR / fname
if not fp.exists():
fp = Path(f'C:/Windows/Fonts/{fname}')
if fp.exists():
font_name = fp.stem
break
style = (
f'FontName={font_name},'
'FontSize=22,'
'PrimaryColour=&H00FFFFFF,'
'OutlineColour=&H80000000,'
'BorderStyle=4,'
'BackColour=&H80000000,'
'Outline=0,Shadow=0,'
'MarginV=120,'
'Alignment=2,'
'Bold=1'
)
# srt 경로에서 역슬래시 → 슬래시 (ffmpeg 호환)
srt_esc = str(srt_path).replace('\\', '/').replace(':', '\\:')
return _run_ffmpeg([
'-i', video_mp4,
'-vf', f'subtitles={srt_esc}:force_style=\'{style}\'',
'-c:v', 'libx264', '-c:a', 'copy',
output_mp4,
])
# ─── SRT 생성 ─────────────────────────────────────────
def build_srt(script_sections: list[dict]) -> str:
"""
섹션별 자막 생성.
script_sections: [{'text': str, 'start': float, 'duration': float}, ...]
"""
lines = []
for i, section in enumerate(script_sections, 1):
start = section['start']
end = start + section['duration']
# 문장을 2줄로 분할
text = section['text']
mid = len(text) // 2
if len(text) > 30:
space = text.rfind(' ', 0, mid)
if space > 0:
text = text[:space] + '\n' + text[space+1:]
lines += [str(i), f'{_sec_to_srt(start)} --> {_sec_to_srt(end)}', text, '']
return '\n'.join(lines)
def _sec_to_srt(s: float) -> str:
h, rem = divmod(int(s), 3600)
m, sec = divmod(rem, 60)
ms = int((s - int(s)) * 1000)
return f'{h:02d}:{m:02d}:{sec:02d},{ms:03d}'
# ─── 임시 파일 경로 ────────────────────────────────────
_tmp_dir: Optional[Path] = None
def _set_tmp_dir(d: Path):
global _tmp_dir
_tmp_dir = d
def _tmp_slide(name: str) -> Path:
return _tmp_dir / f'slide_{name}.png'
def _tmp_wav(name: str) -> Path:
return _tmp_dir / f'tts_{name}.wav'
def _tmp_clip(name: str) -> Path:
return _tmp_dir / f'clip_{name}.mp4'
# ─── 메인 클래스 ──────────────────────────────────────
class ShortsConverter:
"""
뉴스앵커 포맷 쇼츠 변환기.
사용:
sc = ShortsConverter()
mp4_path = sc.generate(article)
"""
def __init__(self):
self.cfg = _load_template()
def generate(self, article: dict) -> str:
"""메인 파이프라인. Returns: 최종 MP4 경로 또는 ''"""
import tempfile
if not _check_ffmpeg():
logger.error("ffmpeg 없음. PATH 또는 FFMPEG_PATH 확인")
return ''
key_points = article.get('key_points', [])
if not key_points:
logger.warning("KEY_POINTS 없음 — 쇼츠 생성 불가")
return ''
title = article.get('title', '')
corner = article.get('corner', '쉬운세상')
slug = article.get('slug', 'article')
date_str = datetime.now().strftime('%Y%m%d')
corner_cfg = self.cfg.get('corners', {}).get(corner, {})
tts_speed = corner_cfg.get('tts_speed', self.cfg.get('tts_speaking_rate_default', 1.05))
transition = corner_cfg.get('transition', 'fade')
trans_dur = self.cfg.get('transition_duration', 0.5)
voice = self.cfg.get('tts_voice_ko', 'ko-KR-Wavenet-A')
is_oncut = corner == '한컷'
force_data = corner_cfg.get('force_data_card', False)
logger.info(f"쇼츠 변환 시작: {title} / {corner}")
with tempfile.TemporaryDirectory() as tmp:
_set_tmp_dir(Path(tmp))
# ── 1. DALL-E 배경 생성 ─────────────────
bg_prompt = corner_cfg.get('bg_prompt_style')
bg_img = generate_background_dalle(bg_prompt, corner) if bg_prompt else None
# ── 2. TTS 스크립트 구성 ────────────────
title_short = title[:40] + ('...' if len(title) > 40 else '')
scripts = {
'intro': f'오늘은 {title_short}에 대해 알아보겠습니다.',
'headline': f'{title_short}',
}
for i, kp in enumerate(key_points[:3], 1):
scripts[f'point{i}'] = kp
if force_data or (not is_oncut and len(key_points) > 2):
scripts['data'] = '관련 데이터를 확인해보겠습니다.'
scripts['outro'] = (
f'자세한 내용은 {self.cfg.get("outro_url","the4thpath.com")}에서 확인하세요. '
'팔로우 부탁드립니다.'
)
# ── 3. 슬라이드 합성 ────────────────────
slides = {
'intro': compose_intro_slide(self.cfg),
'headline': compose_headline_slide(article, self.cfg, bg_img),
}
for i, kp in enumerate(key_points[:3], 1):
slides[f'point{i}'] = compose_point_slide(kp, i, article, self.cfg, bg_img)
if 'data' in scripts:
slides['data'] = compose_data_slide(article, self.cfg)
slides['outro'] = compose_outro_slide(self.cfg)
# ── 4. TTS 합성 + 클립 생성 ──────────────
clips = []
for key in scripts:
wav_path = str(_tmp_wav(key))
clip_path = str(_tmp_clip(key))
slide_path = slides.get(key)
if not slide_path or not Path(slide_path).exists():
continue
ok = synthesize_section(scripts[key], wav_path, voice, tts_speed)
if not ok:
logger.warning(f"TTS 실패: {key} — 슬라이드만 사용")
# 무음 WAV 생성 (2초)
_run_ffmpeg(['-f', 'lavfi', '-i', 'anullsrc=r=24000:cl=mono',
'-t', '2', wav_path], quiet=True)
dur = make_clip(slide_path, wav_path, clip_path)
if dur > 0:
clips.append({'mp4': clip_path, 'duration': dur})
if not clips:
logger.error("생성된 클립 없음")
return ''
# ── 5. 클립 결합 (xfade) ─────────────────
merged = str(Path(tmp) / 'merged.mp4')
if len(clips) == 1:
import shutil
shutil.copy2(clips[0]['mp4'], merged)
else:
if not concat_clips_xfade(clips, merged, transition, trans_dur):
logger.error("클립 결합 실패")
return ''
# ── 6. BGM 믹스 ──────────────────────────
with_bgm = str(Path(tmp) / 'with_bgm.mp4')
mix_bgm(merged, str(BGM_PATH), with_bgm, self.cfg.get('bgm_volume', 0.08))
source_for_srt = with_bgm if Path(with_bgm).exists() else merged
# ── 7. SRT 자막 생성 ─────────────────────
srt_sections = []
t = 0.0
for clip_data in clips:
srt_sections.append({'text': '', 'start': t, 'duration': clip_data['duration']})
t += clip_data['duration'] - trans_dur
# 섹션별 텍스트 채우기
keys = list(scripts.keys())
for i, section in enumerate(srt_sections):
if i < len(keys):
section['text'] = scripts[keys[i]]
srt_content = build_srt([s for s in srt_sections if s['text']])
srt_path = str(Path(tmp) / 'subtitles.srt')
Path(srt_path).write_text(srt_content, encoding='utf-8-sig')
# ── 8. 자막 burn-in ───────────────────────
output_path = str(OUTPUT_DIR / f'{date_str}_{slug}_shorts.mp4')
if not burn_subtitles(source_for_srt, srt_path, output_path):
# 자막 실패 시 자막 없는 버전으로
import shutil
shutil.copy2(source_for_srt, output_path)
logger.info(f"쇼츠 생성 완료: {output_path}")
return output_path
# ─── 모듈 레벨 진입점 (scheduler 호환) ────────────────
def convert(article: dict, card_path: str = '', save_file: bool = True) -> str:
"""
scheduler.py/_run_conversion_pipeline()에서 호출하는 진입점.
card_path: 사용하지 않음 (이전 버전 호환 파라미터)
"""
sc = ShortsConverter()
return sc.generate(article)
if __name__ == '__main__':
sample = {
'title': 'ChatGPT 처음 쓰는 사람을 위한 완전 가이드',
'slug': 'chatgpt-shorts-test',
'corner': '쉬운세상',
'key_points': [
'무료로 바로 시작할 수 있다',
'GPT-3.5도 일반 용도엔 충분하다',
'프롬프트의 질이 결과를 결정한다',
],
'sources': [{'title': 'OpenAI 공식 블로그', 'url': 'https://openai.com'}],
}
sc = ShortsConverter()
path = sc.generate(sample)
print(f'완료: {path}')

View File

@@ -0,0 +1,152 @@
"""
X 스레드 변환봇 (converters/thread_converter.py)
역할: 원본 마크다운 → X(트위터) 스레드 JSON (LAYER 2)
- TITLE + KEY_POINTS → 280자 트윗 3-5개로 분할
- 첫 트윗: 흥미 유발 + 코너 해시태그
- 중간 트윗: 핵심 포인트
- 마지막 트윗: 블로그 링크 + CTA
출력: data/outputs/{date}_{slug}_thread.json
"""
import json
import logging
import textwrap
from datetime import datetime
from pathlib import Path
BASE_DIR = Path(__file__).parent.parent.parent
LOG_DIR = BASE_DIR / 'logs'
OUTPUT_DIR = BASE_DIR / 'data' / 'outputs'
OUTPUT_DIR.mkdir(exist_ok=True)
logging.basicConfig(
level=logging.INFO,
format='%(asctime)s [%(levelname)s] %(message)s',
handlers=[
logging.FileHandler(LOG_DIR / 'converter.log', encoding='utf-8'),
logging.StreamHandler(),
]
)
logger = logging.getLogger(__name__)
BLOG_BASE_URL = 'https://the4thpath.com'
TWEET_MAX = 280
CORNER_HASHTAGS = {
'쉬운세상': '#쉬운세상 #AI활용 #디지털라이프',
'숨은보물': '#숨은보물 #AI도구 #생산성',
'바이브리포트': '#바이브리포트 #트렌드 #AI시대',
'팩트체크': '#팩트체크 #AI뉴스',
'한컷': '#한컷 #AI만평',
}
BRAND_TAG = '#The4thPath'
def _split_to_tweet(text: str, max_len: int = TWEET_MAX) -> list[str]:
"""텍스트를 280자 단위로 자연스럽게 분할"""
if len(text) <= max_len:
return [text]
tweets = []
sentences = text.replace('. ', '.\n').replace('다. ', '다.\n').split('\n')
current = ''
for sentence in sentences:
sentence = sentence.strip()
if not sentence:
continue
test = (current + ' ' + sentence).strip() if current else sentence
if len(test) <= max_len:
current = test
else:
if current:
tweets.append(current)
# 문장 자체가 너무 길면 강제 분할
if len(sentence) > max_len:
chunks = textwrap.wrap(sentence, max_len - 5)
tweets.extend(chunks[:-1])
current = chunks[-1] if chunks else ''
else:
current = sentence
if current:
tweets.append(current)
return tweets or [text[:max_len]]
def convert(article: dict, blog_url: str = '', save_file: bool = True) -> list[dict]:
"""
article dict → X 스레드 트윗 리스트.
각 트윗: {'order': int, 'text': str, 'char_count': int}
"""
title = article.get('title', '')
corner = article.get('corner', '')
key_points = article.get('key_points', [])
tags = article.get('tags', [])
slug = article.get('slug', 'article')
logger.info(f"스레드 변환 시작: {title}")
hashtags = CORNER_HASHTAGS.get(corner, '')
tag_str = ' '.join(f'#{t}' for t in tags[:3] if t)
if tag_str:
hashtags = hashtags + ' ' + tag_str
tweets = []
# 트윗 1: 흥미 유발 + 제목 + 코너 해시태그
intro_text = f"👀 {title}\n\n{hashtags} {BRAND_TAG}"
if len(intro_text) <= TWEET_MAX:
tweets.append(intro_text)
else:
short_title = textwrap.shorten(title, width=100, placeholder='...')
tweets.append(f"👀 {short_title}\n\n{hashtags}")
# 트윗 2-4: 핵심 포인트
for i, point in enumerate(key_points[:3], 1):
bullets = ['', '', '']
bullet = bullets[i - 1] if i <= 3 else f'{i}.'
tweet_text = f"{bullet} {point}"
if len(tweet_text) <= TWEET_MAX:
tweets.append(tweet_text)
else:
split_tweets = _split_to_tweet(tweet_text)
tweets.extend(split_tweets)
# 마지막 트윗: CTA + 블로그 링크
post_url = blog_url or f"{BLOG_BASE_URL}/{slug}"
cta_text = f"전체 내용 보기 👇\n{post_url}\n\n{BRAND_TAG}"
tweets.append(cta_text)
result = [
{'order': i + 1, 'text': t, 'char_count': len(t)}
for i, t in enumerate(tweets)
]
if save_file:
date_str = datetime.now().strftime('%Y%m%d')
filename = f"{date_str}_{slug}_thread.json"
output_path = OUTPUT_DIR / filename
output_path.write_text(
json.dumps(result, ensure_ascii=False, indent=2), encoding='utf-8'
)
logger.info(f"스레드 저장: {output_path} ({len(result)}개 트윗)")
logger.info("스레드 변환 완료")
return result
if __name__ == '__main__':
sample = {
'title': 'ChatGPT 처음 쓰는 사람을 위한 완전 가이드',
'slug': 'chatgpt-guide',
'corner': '쉬운세상',
'tags': ['ChatGPT', 'AI', '가이드'],
'key_points': [
'무료로 바로 시작할 수 있다 — chat.openai.com 접속',
'GPT-4는 유료지만 GPT-3.5도 일반 용도엔 충분하다',
'프롬프트의 질이 답변의 질을 결정한다',
],
}
threads = convert(sample)
for t in threads:
print(f"[{t['order']}] ({t['char_count']}자) {t['text']}")
print()

View File

View File

@@ -0,0 +1,224 @@
"""
이미지 호스팅 헬퍼 (distributors/image_host.py)
역할: 로컬 카드 이미지 → 공개 URL 변환
Instagram Graph API는 공개 URL이 필요하므로
카드 이미지를 외부에서 접근 가능한 URL로 변환한다.
지원 방식:
1. ImgBB (무료 API, 키 필요) ← IMGBB_API_KEY 설정 시
2. Blogger 미디어 업로드 (기존 OAuth) ← 기본값 (추가 비용 없음)
3. 로컬 HTTP 서버 (개발/테스트용) ← LOCAL_IMAGE_SERVER=true 시
"""
import base64
import json
import logging
import os
from pathlib import Path
import requests
from dotenv import load_dotenv
load_dotenv()
BASE_DIR = Path(__file__).parent.parent.parent
LOG_DIR = BASE_DIR / 'logs'
LOG_DIR.mkdir(exist_ok=True)
logging.basicConfig(
level=logging.INFO,
format='%(asctime)s [%(levelname)s] %(message)s',
handlers=[
logging.FileHandler(LOG_DIR / 'distributor.log', encoding='utf-8'),
logging.StreamHandler(),
]
)
logger = logging.getLogger(__name__)
IMGBB_API_KEY = os.getenv('IMGBB_API_KEY', '')
IMGBB_API_URL = 'https://api.imgbb.com/v1/upload'
# ─── 방식 1: ImgBB ────────────────────────────────────
def upload_to_imgbb(image_path: str, expiration: int = 0) -> str:
"""
ImgBB에 이미지 업로드.
expiration: 0=영구, 초 단위 만료 시간 (예: 86400=1일)
Returns: 공개 URL 또는 ''
"""
if not IMGBB_API_KEY:
logger.debug("IMGBB_API_KEY 없음 — ImgBB 건너뜀")
return ''
try:
with open(image_path, 'rb') as f:
image_data = base64.b64encode(f.read()).decode('utf-8')
payload = {
'key': IMGBB_API_KEY,
'image': image_data,
}
if expiration > 0:
payload['expiration'] = expiration
resp = requests.post(IMGBB_API_URL, data=payload, timeout=30)
resp.raise_for_status()
result = resp.json()
if result.get('success'):
url = result['data']['url']
logger.info(f"ImgBB 업로드 완료: {url}")
return url
else:
logger.warning(f"ImgBB 오류: {result.get('error', {})}")
return ''
except Exception as e:
logger.error(f"ImgBB 업로드 실패: {e}")
return ''
# ─── 방식 2: Blogger 미디어 업로드 ───────────────────
def upload_to_blogger(image_path: str) -> str:
"""
Blogger에 이미지를 첨부파일로 업로드 후 공개 URL 반환.
기존 Google OAuth (token.json) 재사용.
Returns: 공개 URL 또는 ''
"""
try:
import sys
sys.path.insert(0, str(BASE_DIR / 'bots'))
from publisher_bot import get_google_credentials
from googleapiclient.discovery import build
from googleapiclient.http import MediaFileUpload
blog_id = os.getenv('BLOG_MAIN_ID', '')
if not blog_id:
logger.warning("BLOG_MAIN_ID 없음")
return ''
creds = get_google_credentials()
service = build('blogger', 'v3', credentials=creds)
# Blogger API: 미디어 업로드 (pages나 posts에 이미지 첨부)
# 참고: Blogger는 직접 미디어 API가 없으므로 임시 draft 포스트로 업로드
media = MediaFileUpload(image_path, mimetype='image/png', resumable=True)
# 임시 draft 포스트에 이미지 삽입 → URL 추출 → 포스트 삭제
img_data = open(image_path, 'rb').read()
img_b64 = base64.b64encode(img_data).decode()
img_html = f'<img src="data:image/png;base64,{img_b64}" />'
draft = service.posts().insert(
blogId=blog_id,
body={'title': '__img_upload__', 'content': img_html},
isDraft=True,
).execute()
post_url = draft.get('url', '')
post_id = draft.get('id', '')
# draft 삭제
if post_id:
service.posts().delete(blogId=blog_id, postId=post_id).execute()
# base64 embedded는 직접 URL이 아니므로 ImgBB fallback 필요
# Blogger는 외부 이미지 호스팅 역할을 하지 않음
# → 실제 운영 시 ImgBB 또는 CDN 사용 권장
logger.warning("Blogger 미디어 업로드: base64 방식은 인스타 공개 URL로 부적합. ImgBB 권장.")
return ''
except Exception as e:
logger.error(f"Blogger 업로드 실패: {e}")
return ''
# ─── 방식 3: 로컬 HTTP 서버 (개발용) ─────────────────
_local_server = None
def start_local_server(port: int = 8765) -> str:
"""
로컬 HTTP 파일 서버 시작 (개발/테스트용).
Returns: base URL (예: http://192.168.1.100:8765)
"""
import socket
import threading
import http.server
import functools
global _local_server
if _local_server:
return _local_server
outputs_dir = str(BASE_DIR / 'data' / 'outputs')
handler = functools.partial(
http.server.SimpleHTTPRequestHandler, directory=outputs_dir
)
server = http.server.HTTPServer(('0.0.0.0', port), handler)
def run():
server.serve_forever()
thread = threading.Thread(target=run, daemon=True)
thread.start()
# 로컬 IP 확인
try:
s = socket.socket(socket.AF_INET, socket.SOCK_DGRAM)
s.connect(('8.8.8.8', 80))
local_ip = s.getsockname()[0]
s.close()
except Exception:
local_ip = '127.0.0.1'
base_url = f'http://{local_ip}:{port}'
_local_server = base_url
logger.info(f"로컬 이미지 서버 시작: {base_url}")
return base_url
def get_local_url(image_path: str, port: int = 8765) -> str:
"""로컬 서버 URL 반환 (개발/ngrok 사용 시)"""
base_url = start_local_server(port)
filename = Path(image_path).name
return f'{base_url}/{filename}'
# ─── 메인 함수 ───────────────────────────────────────
def get_public_url(image_path: str) -> str:
"""
이미지 파일 → 공개 URL 반환.
우선순위: ImgBB → 로컬 서버(개발용)
"""
if not Path(image_path).exists():
logger.error(f"이미지 파일 없음: {image_path}")
return ''
# 1. ImgBB (API 키 있을 때)
url = upload_to_imgbb(image_path, expiration=86400 * 7) # 7일
if url:
return url
# 2. 로컬 HTTP 서버 (ngrok 또는 내부망 테스트용)
if os.getenv('LOCAL_IMAGE_SERVER', '').lower() == 'true':
url = get_local_url(image_path)
logger.warning(f"로컬 서버 URL 사용 (인터넷 접근 필요): {url}")
return url
logger.warning(
"공개 URL 생성 불가. .env에 IMGBB_API_KEY를 설정하거나 "
"LOCAL_IMAGE_SERVER=true로 설정하세요."
)
return ''
if __name__ == '__main__':
import sys
if len(sys.argv) > 1:
url = get_public_url(sys.argv[1])
print(f"공개 URL: {url}")
else:
print("사용법: python image_host.py <이미지경로>")

View File

@@ -0,0 +1,207 @@
"""
인스타그램 배포봇 (distributors/instagram_bot.py)
역할: 카드 이미지 → Instagram Graph API 업로드 (LAYER 3)
- 피드 포스트: 카드 이미지 업로드
- 릴스: 쇼츠 영상 업로드 (Phase 2)
- 캡션: KEY_POINTS + 해시태그 + 블로그 링크(프로필)
사전 조건:
- Facebook Page + Instagram Business 계정 연결
- Instagram Graph API 앱 등록
- .env: INSTAGRAM_ACCESS_TOKEN, INSTAGRAM_ACCOUNT_ID
"""
import json
import logging
import os
import time
from pathlib import Path
import requests
from dotenv import load_dotenv
load_dotenv()
BASE_DIR = Path(__file__).parent.parent.parent
LOG_DIR = BASE_DIR / 'logs'
LOG_DIR.mkdir(exist_ok=True)
DATA_DIR = BASE_DIR / 'data'
logging.basicConfig(
level=logging.INFO,
format='%(asctime)s [%(levelname)s] %(message)s',
handlers=[
logging.FileHandler(LOG_DIR / 'distributor.log', encoding='utf-8'),
logging.StreamHandler(),
]
)
logger = logging.getLogger(__name__)
INSTAGRAM_ACCESS_TOKEN = os.getenv('INSTAGRAM_ACCESS_TOKEN', '')
INSTAGRAM_ACCOUNT_ID = os.getenv('INSTAGRAM_ACCOUNT_ID', '')
GRAPH_API_BASE = 'https://graph.facebook.com/v19.0'
BLOG_URL = 'https://the4thpath.com'
BRAND_TAG = '#The4thPath #테크인사이더 #22BLabs'
def _check_credentials() -> bool:
if not INSTAGRAM_ACCESS_TOKEN or not INSTAGRAM_ACCOUNT_ID:
logger.warning("Instagram 자격증명 없음 (.env: INSTAGRAM_ACCESS_TOKEN, INSTAGRAM_ACCOUNT_ID)")
return False
return True
def build_caption(article: dict) -> str:
"""인스타그램 캡션 생성"""
title = article.get('title', '')
corner = article.get('corner', '')
key_points = article.get('key_points', [])
tags = article.get('tags', [])
lines = [f"{title}", ""]
if key_points:
for point in key_points[:3]:
lines.append(f"{point}")
lines.append("")
lines.append(f"전체 내용: 프로필 링크 🔗")
lines.append("")
hashtags = [f'#{corner.replace(" ", "")}'] if corner else []
hashtags += [f'#{t}' for t in tags[:5] if t]
hashtags.append(BRAND_TAG)
lines.append(' '.join(hashtags))
return '\n'.join(lines)
def upload_image_container(image_url: str, caption: str) -> str:
"""
인스타 이미지 컨테이너 생성.
image_url: 공개 접근 가능한 이미지 URL (Instagram이 직접 다운로드)
Returns: container_id
"""
if not _check_credentials():
return ''
url = f"{GRAPH_API_BASE}/{INSTAGRAM_ACCOUNT_ID}/media"
params = {
'image_url': image_url,
'caption': caption,
'access_token': INSTAGRAM_ACCESS_TOKEN,
}
try:
resp = requests.post(url, data=params, timeout=30)
resp.raise_for_status()
container_id = resp.json().get('id', '')
logger.info(f"이미지 컨테이너 생성: {container_id}")
return container_id
except Exception as e:
logger.error(f"Instagram 컨테이너 생성 실패: {e}")
return ''
def publish_container(container_id: str) -> str:
"""컨테이너 → 실제 발행. Returns: post_id"""
if not _check_credentials() or not container_id:
return ''
# 컨테이너 준비 대기 (최대 60초)
status_url = f"{GRAPH_API_BASE}/{container_id}"
for _ in range(12):
try:
status_resp = requests.get(
status_url,
params={'fields': 'status_code', 'access_token': INSTAGRAM_ACCESS_TOKEN},
timeout=10
)
status = status_resp.json().get('status_code', '')
if status == 'FINISHED':
break
if status in ('ERROR', 'EXPIRED'):
logger.error(f"컨테이너 오류: {status}")
return ''
except Exception:
pass
time.sleep(5)
# 발행
publish_url = f"{GRAPH_API_BASE}/{INSTAGRAM_ACCOUNT_ID}/media_publish"
params = {
'creation_id': container_id,
'access_token': INSTAGRAM_ACCESS_TOKEN,
}
try:
resp = requests.post(publish_url, data=params, timeout=30)
resp.raise_for_status()
post_id = resp.json().get('id', '')
logger.info(f"Instagram 발행 완료: {post_id}")
return post_id
except Exception as e:
logger.error(f"Instagram 발행 실패: {e}")
return ''
def publish_card(article: dict, image_path_or_url: str) -> bool:
"""
카드 이미지를 인스타그램 피드에 게시.
image_path_or_url: 로컬 파일 경로 또는 공개 URL
- 로컬 경로인 경우 image_host.py로 공개 URL 변환
- http/https URL인 경우 그대로 사용
"""
if not _check_credentials():
logger.info("Instagram 미설정 — 발행 건너뜀")
return False
logger.info(f"Instagram 발행 시작: {article.get('title', '')}")
# 로컬 경로 → 공개 URL 변환
image_url = image_path_or_url
if not image_path_or_url.startswith('http'):
import sys
sys.path.insert(0, str(Path(__file__).parent))
from image_host import get_public_url
image_url = get_public_url(image_path_or_url)
if not image_url:
logger.error("공개 URL 변환 실패 — .env에 IMGBB_API_KEY 설정 필요")
return False
caption = build_caption(article)
container_id = upload_image_container(image_url, caption)
if not container_id:
return False
post_id = publish_container(container_id)
if not post_id:
return False
_log_published(article, post_id, 'instagram_card')
return True
def _log_published(article: dict, post_id: str, platform: str):
"""플랫폼별 발행 이력 저장"""
pub_dir = DATA_DIR / 'published'
pub_dir.mkdir(exist_ok=True)
from datetime import datetime
record = {
'platform': platform,
'post_id': post_id,
'title': article.get('title', ''),
'corner': article.get('corner', ''),
'published_at': datetime.now().isoformat(),
}
filename = f"{datetime.now().strftime('%Y%m%d_%H%M%S')}_{platform}_{post_id}.json"
with open(pub_dir / filename, 'w', encoding='utf-8') as f:
json.dump(record, f, ensure_ascii=False, indent=2)
if __name__ == '__main__':
# 테스트 (실제 API 없이 출력 확인)
sample = {
'title': '테스트 글',
'corner': '쉬운세상',
'key_points': ['포인트 1', '포인트 2'],
'tags': ['AI', '테스트'],
}
print(build_caption(sample))

View File

@@ -0,0 +1,214 @@
"""
틱톡 배포봇 (distributors/tiktok_bot.py)
역할: 쇼츠 MP4 → TikTok Content Posting API 업로드 (LAYER 3)
Phase 2.
사전 조건:
- TikTok Developer 계정 + 앱 등록 (Content Posting API 승인)
- .env: TIKTOK_ACCESS_TOKEN, TIKTOK_OPEN_ID
"""
import json
import logging
import os
import time
from pathlib import Path
import requests
from dotenv import load_dotenv
load_dotenv()
BASE_DIR = Path(__file__).parent.parent.parent
LOG_DIR = BASE_DIR / 'logs'
LOG_DIR.mkdir(exist_ok=True)
DATA_DIR = BASE_DIR / 'data'
logging.basicConfig(
level=logging.INFO,
format='%(asctime)s [%(levelname)s] %(message)s',
handlers=[
logging.FileHandler(LOG_DIR / 'distributor.log', encoding='utf-8'),
logging.StreamHandler(),
]
)
logger = logging.getLogger(__name__)
TIKTOK_ACCESS_TOKEN = os.getenv('TIKTOK_ACCESS_TOKEN', '')
TIKTOK_OPEN_ID = os.getenv('TIKTOK_OPEN_ID', '')
TIKTOK_API_BASE = 'https://open.tiktokapis.com/v2'
CORNER_HASHTAGS = {
'쉬운세상': ['쉬운세상', 'AI활용', '디지털라이프', 'The4thPath'],
'숨은보물': ['숨은보물', 'AI도구', '생산성', 'The4thPath'],
'바이브리포트': ['바이브리포트', '트렌드', 'AI시대', 'The4thPath'],
'팩트체크': ['팩트체크', 'AI뉴스', 'The4thPath'],
'한컷': ['한컷만평', 'AI시사', 'The4thPath'],
}
def _check_credentials() -> bool:
if not TIKTOK_ACCESS_TOKEN:
logger.warning("TIKTOK_ACCESS_TOKEN 없음")
return False
return True
def _get_headers() -> dict:
return {
'Authorization': f'Bearer {TIKTOK_ACCESS_TOKEN}',
'Content-Type': 'application/json; charset=UTF-8',
}
def build_caption(article: dict) -> str:
"""틱톡 캡션 생성 (제목 + 핵심 1줄 + 해시태그)"""
title = article.get('title', '')
key_points = article.get('key_points', [])
corner = article.get('corner', '')
caption_parts = [title]
if key_points:
caption_parts.append(key_points[0])
hashtags = CORNER_HASHTAGS.get(corner, ['The4thPath'])
tag_str = ' '.join(f'#{t}' for t in hashtags)
caption_parts.append(tag_str)
return '\n'.join(caption_parts)
def init_upload(video_size: int, video_duration: float) -> tuple[str, str]:
"""
TikTok 업로드 초기화 (Direct Post).
Returns: (upload_url, publish_id)
"""
url = f'{TIKTOK_API_BASE}/post/publish/video/init/'
payload = {
'post_info': {
'title': '', # 영상에서 추출되므로 빈칸 가능
'privacy_level': 'PUBLIC_TO_EVERYONE',
'disable_duet': False,
'disable_comment': False,
'disable_stitch': False,
},
'source_info': {
'source': 'FILE_UPLOAD',
'video_size': video_size,
'chunk_size': min(video_size, 64 * 1024 * 1024), # 64MB
'total_chunk_count': 1,
},
}
try:
resp = requests.post(url, json=payload, headers=_get_headers(), timeout=30)
resp.raise_for_status()
data = resp.json().get('data', {})
upload_url = data.get('upload_url', '')
publish_id = data.get('publish_id', '')
logger.info(f"TikTok 업로드 초기화: publish_id={publish_id}")
return upload_url, publish_id
except Exception as e:
logger.error(f"TikTok 업로드 초기화 실패: {e}")
return '', ''
def upload_chunk(upload_url: str, video_path: str, video_size: int) -> bool:
"""동영상 업로드"""
try:
with open(video_path, 'rb') as f:
video_data = f.read()
headers = {
'Content-Range': f'bytes 0-{video_size-1}/{video_size}',
'Content-Length': str(video_size),
'Content-Type': 'video/mp4',
}
resp = requests.put(upload_url, data=video_data, headers=headers, timeout=300)
if resp.status_code in (200, 201, 206):
logger.info("TikTok 동영상 업로드 완료")
return True
logger.error(f"TikTok 업로드 HTTP {resp.status_code}: {resp.text[:200]}")
return False
except Exception as e:
logger.error(f"TikTok 업로드 실패: {e}")
return False
def check_publish_status(publish_id: str, max_wait: int = 120) -> bool:
"""발행 상태 확인 (최대 max_wait초 대기)"""
url = f'{TIKTOK_API_BASE}/post/publish/status/fetch/'
payload = {'publish_id': publish_id}
for _ in range(max_wait // 5):
try:
resp = requests.post(url, json=payload, headers=_get_headers(), timeout=10)
resp.raise_for_status()
status = resp.json().get('data', {}).get('status', '')
if status == 'PUBLISH_COMPLETE':
logger.info("TikTok 발행 완료")
return True
if status in ('FAILED', 'CANCELED'):
logger.error(f"TikTok 발행 실패: {status}")
return False
except Exception as e:
logger.warning(f"상태 확인 오류: {e}")
time.sleep(5)
logger.warning("TikTok 발행 상태 확인 시간 초과")
return False
def publish_shorts(article: dict, video_path: str) -> bool:
"""
쇼츠 MP4 → TikTok 업로드.
video_path: shorts_converter.convert()가 생성한 MP4
"""
if not _check_credentials():
logger.info("TikTok 미설정 — 발행 건너뜀")
return False
if not Path(video_path).exists():
logger.error(f"영상 파일 없음: {video_path}")
return False
title = article.get('title', '')
logger.info(f"TikTok 발행 시작: {title}")
video_size = Path(video_path).stat().st_size
# 업로드 초기화
upload_url, publish_id = init_upload(video_size, 30.0)
if not upload_url or not publish_id:
return False
# 동영상 업로드
if not upload_chunk(upload_url, video_path, video_size):
return False
# 발행 상태 확인
if not check_publish_status(publish_id):
return False
_log_published(article, publish_id, 'tiktok')
return True
def _log_published(article: dict, post_id: str, platform: str):
pub_dir = DATA_DIR / 'published'
pub_dir.mkdir(exist_ok=True)
from datetime import datetime
record = {
'platform': platform,
'post_id': post_id,
'title': article.get('title', ''),
'corner': article.get('corner', ''),
'published_at': datetime.now().isoformat(),
}
filename = f"{datetime.now().strftime('%Y%m%d_%H%M%S')}_{platform}_{post_id}.json"
with open(pub_dir / filename, 'w', encoding='utf-8') as f:
json.dump(record, f, ensure_ascii=False, indent=2)
if __name__ == '__main__':
sample = {
'title': '테스트 글',
'corner': '쉬운세상',
'key_points': ['포인트 1'],
}
print(build_caption(sample))

149
bots/distributors/x_bot.py Normal file
View File

@@ -0,0 +1,149 @@
"""
X(트위터) 배포봇 (distributors/x_bot.py)
역할: X 스레드 JSON → X API v2로 순차 트윗 게시 (LAYER 3)
사전 조건:
- X Developer 계정 + 앱 등록
- .env: X_API_KEY, X_API_SECRET, X_ACCESS_TOKEN, X_ACCESS_SECRET
"""
import json
import logging
import os
import time
from pathlib import Path
import requests
from dotenv import load_dotenv
from requests_oauthlib import OAuth1
load_dotenv()
BASE_DIR = Path(__file__).parent.parent.parent
LOG_DIR = BASE_DIR / 'logs'
LOG_DIR.mkdir(exist_ok=True)
DATA_DIR = BASE_DIR / 'data'
logging.basicConfig(
level=logging.INFO,
format='%(asctime)s [%(levelname)s] %(message)s',
handlers=[
logging.FileHandler(LOG_DIR / 'distributor.log', encoding='utf-8'),
logging.StreamHandler(),
]
)
logger = logging.getLogger(__name__)
X_API_KEY = os.getenv('X_API_KEY', '')
X_API_SECRET = os.getenv('X_API_SECRET', '')
X_ACCESS_TOKEN = os.getenv('X_ACCESS_TOKEN', '')
X_ACCESS_SECRET = os.getenv('X_ACCESS_SECRET', '')
X_API_V2 = 'https://api.twitter.com/2/tweets'
def _check_credentials() -> bool:
if not all([X_API_KEY, X_API_SECRET, X_ACCESS_TOKEN, X_ACCESS_SECRET]):
logger.warning("X API 자격증명 없음 (.env: X_API_KEY, X_API_SECRET, X_ACCESS_TOKEN, X_ACCESS_SECRET)")
return False
return True
def _get_auth() -> OAuth1:
return OAuth1(X_API_KEY, X_API_SECRET, X_ACCESS_TOKEN, X_ACCESS_SECRET)
def post_tweet(text: str, reply_to_id: str = '') -> str:
"""
단일 트윗 게시.
reply_to_id: 스레드 연결용 이전 트윗 ID
Returns: 트윗 ID
"""
if not _check_credentials():
return ''
payload = {'text': text}
if reply_to_id:
payload['reply'] = {'in_reply_to_tweet_id': reply_to_id}
try:
auth = _get_auth()
resp = requests.post(X_API_V2, json=payload, auth=auth, timeout=15)
resp.raise_for_status()
tweet_id = resp.json().get('data', {}).get('id', '')
logger.info(f"트윗 게시: {tweet_id} ({len(text)}자)")
return tweet_id
except Exception as e:
logger.error(f"트윗 게시 실패: {e}")
return ''
def publish_thread(article: dict, thread_data: list[dict]) -> bool:
"""
스레드 JSON → 순차 트윗 게시.
thread_data: thread_converter.convert() 반환값
"""
if not _check_credentials():
logger.info("X API 미설정 — 발행 건너뜀")
return False
title = article.get('title', '')
logger.info(f"X 스레드 발행 시작: {title} ({len(thread_data)}개 트윗)")
prev_id = ''
tweet_ids = []
for tweet in sorted(thread_data, key=lambda x: x['order']):
text = tweet['text']
tweet_id = post_tweet(text, prev_id)
if not tweet_id:
logger.error(f"스레드 중단: {tweet['order']}번 트윗 실패")
return False
tweet_ids.append(tweet_id)
prev_id = tweet_id
time.sleep(1) # rate limit 방지
logger.info(f"X 스레드 발행 완료: {len(tweet_ids)}")
_log_published(article, tweet_ids[0] if tweet_ids else '', 'x_thread')
return True
def publish_thread_from_file(article: dict, thread_file: str) -> bool:
"""파일에서 스레드 데이터 로드 후 게시"""
try:
data = json.loads(Path(thread_file).read_text(encoding='utf-8'))
return publish_thread(article, data)
except Exception as e:
logger.error(f"스레드 파일 로드 실패: {e}")
return False
def _log_published(article: dict, post_id: str, platform: str):
pub_dir = DATA_DIR / 'published'
pub_dir.mkdir(exist_ok=True)
from datetime import datetime
record = {
'platform': platform,
'post_id': post_id,
'title': article.get('title', ''),
'corner': article.get('corner', ''),
'published_at': datetime.now().isoformat(),
}
filename = f"{datetime.now().strftime('%Y%m%d_%H%M%S')}_{platform}_{post_id}.json"
with open(pub_dir / filename, 'w', encoding='utf-8') as f:
json.dump(record, f, ensure_ascii=False, indent=2)
if __name__ == '__main__':
import sys
sys.path.insert(0, str(BASE_DIR / 'bots' / 'converters'))
import thread_converter
sample = {
'title': 'ChatGPT 처음 쓰는 사람을 위한 완전 가이드',
'slug': 'chatgpt-guide',
'corner': '쉬운세상',
'tags': ['ChatGPT', 'AI'],
'key_points': ['무료로 바로 시작', 'GPT-3.5로도 충분', '프롬프트가 핵심'],
}
threads = thread_converter.convert(sample, save_file=False)
for t in threads:
print(f"[{t['order']}] {t['text']}\n")

View File

@@ -0,0 +1,186 @@
"""
유튜브 배포봇 (distributors/youtube_bot.py)
역할: 쇼츠 MP4 → YouTube Data API v3 업로드 (LAYER 3)
Phase 2.
사전 조건:
- Google Cloud에서 YouTube Data API v3 활성화 (기존 프로젝트에 추가)
- .env: YOUTUBE_CHANNEL_ID (기존 Google OAuth token.json 재사용)
"""
import json
import logging
import os
from pathlib import Path
from dotenv import load_dotenv
load_dotenv()
BASE_DIR = Path(__file__).parent.parent.parent
LOG_DIR = BASE_DIR / 'logs'
LOG_DIR.mkdir(exist_ok=True)
DATA_DIR = BASE_DIR / 'data'
TOKEN_PATH = BASE_DIR / 'token.json'
logging.basicConfig(
level=logging.INFO,
format='%(asctime)s [%(levelname)s] %(message)s',
handlers=[
logging.FileHandler(LOG_DIR / 'distributor.log', encoding='utf-8'),
logging.StreamHandler(),
]
)
logger = logging.getLogger(__name__)
YOUTUBE_CHANNEL_ID = os.getenv('YOUTUBE_CHANNEL_ID', '')
YOUTUBE_SCOPES = [
'https://www.googleapis.com/auth/youtube.upload',
'https://www.googleapis.com/auth/youtube',
]
CORNER_TAGS = {
'쉬운세상': ['AI활용', '디지털라이프', '쉬운세상', 'The4thPath', 'AI가이드'],
'숨은보물': ['숨은보물', 'AI도구', '생산성', 'The4thPath', 'AI툴'],
'바이브리포트': ['트렌드', 'AI시대', '바이브리포트', 'The4thPath'],
'팩트체크': ['팩트체크', 'AI뉴스', 'The4thPath'],
'한컷': ['한컷만평', 'AI시사', 'The4thPath'],
}
def _get_credentials():
"""기존 Google OAuth token.json 재사용"""
try:
from google.oauth2.credentials import Credentials
from google.auth.transport.requests import Request
if not TOKEN_PATH.exists():
raise RuntimeError("token.json 없음. scripts/get_token.py 먼저 실행")
creds = Credentials.from_authorized_user_file(str(TOKEN_PATH), YOUTUBE_SCOPES)
if not creds or not creds.valid:
if creds and creds.expired and creds.refresh_token:
creds.refresh(Request())
TOKEN_PATH.write_text(creds.to_json())
return creds
except Exception as e:
logger.error(f"YouTube 인증 실패: {e}")
return None
def build_video_metadata(article: dict) -> dict:
"""유튜브 업로드용 메타데이터 구성"""
title = article.get('title', '')
meta = article.get('meta', '')
corner = article.get('corner', '')
key_points = article.get('key_points', [])
slug = article.get('slug', '')
# 쇼츠는 #Shorts 태그 필수
description_parts = [meta, '']
if key_points:
for point in key_points[:3]:
description_parts.append(f'{point}')
description_parts.append('')
description_parts.append('the4thpath.com')
description_parts.append('#Shorts')
tags = CORNER_TAGS.get(corner, ['The4thPath']) + ['Shorts', 'AI']
return {
'snippet': {
'title': f'{title} #Shorts',
'description': '\n'.join(description_parts),
'tags': tags,
'categoryId': '28', # Science & Technology
},
'status': {
'privacyStatus': 'public',
'selfDeclaredMadeForKids': False,
},
}
def publish_shorts(article: dict, video_path: str) -> bool:
"""
쇼츠 MP4 → YouTube 업로드.
video_path: shorts_converter.convert()가 생성한 MP4
"""
if not Path(video_path).exists():
logger.error(f"영상 파일 없음: {video_path}")
return False
logger.info(f"YouTube 쇼츠 발행 시작: {article.get('title', '')}")
creds = _get_credentials()
if not creds:
return False
try:
from googleapiclient.discovery import build
from googleapiclient.http import MediaFileUpload
service = build('youtube', 'v3', credentials=creds)
metadata = build_video_metadata(article)
media = MediaFileUpload(
video_path,
mimetype='video/mp4',
resumable=True,
chunksize=5 * 1024 * 1024, # 5MB chunks
)
request = service.videos().insert(
part='snippet,status',
body=metadata,
media_body=media,
)
response = None
while response is None:
status, response = request.next_chunk()
if status:
pct = int(status.progress() * 100)
logger.info(f"업로드 진행: {pct}%")
video_id = response.get('id', '')
video_url = f'https://www.youtube.com/shorts/{video_id}'
logger.info(f"YouTube 쇼츠 발행 완료: {video_url}")
_log_published(article, video_id, 'youtube_shorts', video_url)
return True
except Exception as e:
logger.error(f"YouTube 업로드 실패: {e}")
return False
def _log_published(article: dict, post_id: str, platform: str, url: str = ''):
pub_dir = DATA_DIR / 'published'
pub_dir.mkdir(exist_ok=True)
from datetime import datetime
record = {
'platform': platform,
'post_id': post_id,
'url': url,
'title': article.get('title', ''),
'corner': article.get('corner', ''),
'published_at': datetime.now().isoformat(),
}
filename = f"{datetime.now().strftime('%Y%m%d_%H%M%S')}_{platform}_{post_id}.json"
with open(pub_dir / filename, 'w', encoding='utf-8') as f:
json.dump(record, f, ensure_ascii=False, indent=2)
if __name__ == '__main__':
sample = {
'title': 'ChatGPT 처음 쓰는 사람을 위한 완전 가이드',
'meta': 'ChatGPT를 처음 쓰는 분을 위한 단계별 가이드',
'slug': 'chatgpt-guide',
'corner': '쉬운세상',
'key_points': ['무료로 바로 시작', 'GPT-3.5로도 충분', '프롬프트가 핵심'],
}
meta = build_video_metadata(sample)
import pprint
pprint.pprint(meta)

View File

@@ -340,14 +340,14 @@ def publish(article: dict) -> bool:
send_pending_review_alert(article, review_reason) send_pending_review_alert(article, review_reason)
return False return False
# 마크다운 → HTML # 변환봇이 미리 생성한 HTML이 있으면 재사용, 없으면 직접 변환
body_html, toc_html = markdown_to_html(article.get('body', '')) if article.get('_html_content'):
full_html = article['_html_content']
# AdSense 플레이스홀더 else:
body_html = insert_adsense_placeholders(body_html) # 마크다운 → HTML (fallback)
body_html, toc_html = markdown_to_html(article.get('body', ''))
# 최종 HTML 조합 body_html = insert_adsense_placeholders(body_html)
full_html = build_full_html(article, body_html, toc_html) full_html = build_full_html(article, body_html, toc_html)
# Google 인증 # Google 인증
try: try:

109
bots/remote_claude.py Normal file
View File

@@ -0,0 +1,109 @@
"""
Remote Claude Bot
텔레그램 메시지를 Claude Agent SDK에 전달하고 결과를 돌려보냅니다.
실행: python bots/remote_claude.py
"""
import asyncio
import logging
import os
from pathlib import Path
from dotenv import load_dotenv
from telegram import Update
from telegram.ext import Application, MessageHandler, CommandHandler, filters, ContextTypes
from claude_agent_sdk import query, ClaudeAgentOptions, ResultMessage
load_dotenv()
BASE_DIR = Path(__file__).parent.parent
TELEGRAM_BOT_TOKEN = os.getenv('TELEGRAM_BOT_TOKEN', '')
TELEGRAM_CHAT_ID = int(os.getenv('TELEGRAM_CHAT_ID', '0'))
logging.basicConfig(
level=logging.INFO,
format='%(asctime)s [%(levelname)s] %(message)s',
)
logger = logging.getLogger(__name__)
MAX_MSG_LEN = 4000
def split_message(text: str) -> list[str]:
chunks = []
while len(text) > MAX_MSG_LEN:
chunks.append(text[:MAX_MSG_LEN])
text = text[MAX_MSG_LEN:]
if text:
chunks.append(text)
return chunks
async def run_claude(prompt: str) -> str:
result_text = ""
try:
async for message in query(
prompt=prompt,
options=ClaudeAgentOptions(
cwd=str(BASE_DIR),
allowed_tools=["Read", "Write", "Edit", "Bash", "Glob", "Grep"],
permission_mode="bypassPermissions",
max_turns=30,
)
):
if isinstance(message, ResultMessage):
result_text = message.result
except Exception as e:
result_text = f"오류: {e}"
return result_text or "(완료)"
async def handle_message(update: Update, context: ContextTypes.DEFAULT_TYPE):
if update.message.chat_id != TELEGRAM_CHAT_ID:
return
prompt = update.message.text.strip()
logger.info(f"명령 수신: {prompt[:80]}")
await update.message.reply_text("처리 중...")
await context.bot.send_chat_action(chat_id=TELEGRAM_CHAT_ID, action="typing")
result = await run_claude(prompt)
for chunk in split_message(result):
await update.message.reply_text(chunk)
async def cmd_help(update: Update, context: ContextTypes.DEFAULT_TYPE):
if update.message.chat_id != TELEGRAM_CHAT_ID:
return
await update.message.reply_text(
"Remote Claude Bot\n\n"
"자연어로 아무 지시나 입력하세요.\n\n"
"예시:\n"
"• scheduler.py 상태 확인해줘\n"
"• 수집봇 지금 실행해줘\n"
"• .env 파일 내용 보여줘\n"
"• requirements.txt에 패키지 추가해줘\n"
"• 오늘 로그 확인해줘\n\n"
"/help — 이 메시지"
)
def main():
if not TELEGRAM_BOT_TOKEN:
logger.error("TELEGRAM_BOT_TOKEN이 없습니다.")
return
app = Application.builder().token(TELEGRAM_BOT_TOKEN).build()
app.add_handler(CommandHandler('help', cmd_help))
app.add_handler(MessageHandler(
filters.TEXT & ~filters.COMMAND & filters.Chat(TELEGRAM_CHAT_ID),
handle_message
))
logger.info(f"Remote Claude Bot 시작 (chat_id={TELEGRAM_CHAT_ID})")
app.run_polling(drop_pending_updates=True)
if __name__ == '__main__':
main()

View File

@@ -17,6 +17,8 @@ from dotenv import load_dotenv
from telegram import Update from telegram import Update
from telegram.ext import Application, CommandHandler, MessageHandler, filters, ContextTypes from telegram.ext import Application, CommandHandler, MessageHandler, filters, ContextTypes
import anthropic
load_dotenv() load_dotenv()
BASE_DIR = Path(__file__).parent.parent BASE_DIR = Path(__file__).parent.parent
@@ -40,6 +42,42 @@ logger = logging.getLogger(__name__)
TELEGRAM_BOT_TOKEN = os.getenv('TELEGRAM_BOT_TOKEN', '') TELEGRAM_BOT_TOKEN = os.getenv('TELEGRAM_BOT_TOKEN', '')
TELEGRAM_CHAT_ID = os.getenv('TELEGRAM_CHAT_ID', '') TELEGRAM_CHAT_ID = os.getenv('TELEGRAM_CHAT_ID', '')
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 어시스턴트입니다.
이 시스템(v3)은 4계층 구조로 운영됩니다:
[LAYER 1] AI 콘텐츠 생성: OpenClaw(GPT-5.4)가 원본 마크다운 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)
사용 가능한 텔레그램 명령:
/status — 봇 상태
/topics — 오늘 수집된 글감
/pending — 검토 대기 글 목록
/approve [번호] — 글 승인 및 발행
/reject [번호] — 글 거부
/report — 주간 리포트
/images — 이미지 제작 현황
/convert — 수동 변환 실행
사용자의 자연어 요청을 이해하고 적절히 안내하거나 답변해주세요.
한국어로 간결하게 답변하세요."""
IMAGE_MODE = os.getenv('IMAGE_MODE', 'manual').lower() IMAGE_MODE = os.getenv('IMAGE_MODE', 'manual').lower()
# request 모드에서 이미지 대기 시 사용하는 상태 변수 # request 모드에서 이미지 대기 시 사용하는 상태 변수
# {chat_id: prompt_id} — 다음에 받은 이미지를 어느 프롬프트에 연결할지 기억 # {chat_id: prompt_id} — 다음에 받은 이미지를 어느 프롬프트에 연결할지 기억
@@ -107,7 +145,79 @@ def _call_openclaw(topic_data: dict, output_path: Path):
output_path.write_text(json.dumps(topic_data, ensure_ascii=False, indent=2), encoding='utf-8') output_path.write_text(json.dumps(topic_data, ensure_ascii=False, indent=2), encoding='utf-8')
def job_convert():
"""08:30 — 변환 엔진: 원본 마크다운 → 5개 포맷 생성"""
if not _publish_enabled:
logger.info("[스케줄] 발행 중단 — 변환 건너뜀")
return
logger.info("[스케줄] 변환 엔진 시작")
try:
_run_conversion_pipeline()
except Exception as e:
logger.error(f"변환 엔진 오류: {e}")
def _run_conversion_pipeline():
"""originals/ 폴더의 미변환 원본을 5개 포맷으로 변환"""
originals_dir = DATA_DIR / 'originals'
originals_dir.mkdir(exist_ok=True)
today = datetime.now().strftime('%Y%m%d')
converters_path = str(BASE_DIR / 'bots' / 'converters')
sys.path.insert(0, converters_path)
sys.path.insert(0, str(BASE_DIR / 'bots'))
for orig_file in sorted(originals_dir.glob(f'{today}_*.json')):
converted_flag = orig_file.with_suffix('.converted')
if converted_flag.exists():
continue
try:
article = json.loads(orig_file.read_text(encoding='utf-8'))
slug = article.get('slug', 'article')
# 1. 블로그 HTML
import blog_converter
blog_converter.convert(article, save_file=True)
# 2. 인스타 카드
import card_converter
card_path = card_converter.convert(article, save_file=True)
if card_path:
article['_card_path'] = card_path
# 3. X 스레드
import thread_converter
thread_converter.convert(article, save_file=True)
# 4. 쇼츠 영상 (Phase 2 — card 생성 후 시도, 실패해도 계속)
if card_path:
try:
import shorts_converter
shorts_converter.convert(article, card_path=card_path, save_file=True)
except Exception as shorts_err:
logger.debug(f"쇼츠 변환 건너뜀 (Phase 2): {shorts_err}")
# 5. 뉴스레터 발췌 (주간 묶음용 — 개별 저장은 weekly_report에서)
# newsletter_converter는 주간 단위로 묶어서 처리
# 변환 완료 플래그
converted_flag.touch()
logger.info(f"변환 완료: {slug}")
# drafts에 복사 (발행봇이 읽도록)
drafts_dir = DATA_DIR / 'drafts'
drafts_dir.mkdir(exist_ok=True)
draft_path = drafts_dir / orig_file.name
if not draft_path.exists():
draft_path.write_text(
orig_file.read_text(encoding='utf-8'), encoding='utf-8'
)
except Exception as e:
logger.error(f"변환 오류 ({orig_file.name}): {e}")
def job_publish(slot: int): def job_publish(slot: int):
"""09:00 — 블로그 발행 (슬롯별)"""
if not _publish_enabled: if not _publish_enabled:
logger.info(f"[스케줄] 발행 중단 — 슬롯 {slot} 건너뜀") logger.info(f"[스케줄] 발행 중단 — 슬롯 {slot} 건너뜀")
return return
@@ -118,6 +228,125 @@ def job_publish(slot: int):
logger.error(f"발행봇 오류: {e}") logger.error(f"발행봇 오류: {e}")
def job_distribute_instagram():
"""10:00 — 인스타그램 카드 발행"""
if not _publish_enabled:
return
logger.info("[스케줄] 인스타그램 발행")
try:
_distribute_instagram()
except Exception as e:
logger.error(f"인스타그램 배포 오류: {e}")
def _distribute_instagram():
sys.path.insert(0, str(BASE_DIR / 'bots' / 'distributors'))
import instagram_bot
today = datetime.now().strftime('%Y%m%d')
outputs_dir = DATA_DIR / 'outputs'
for card_file in sorted(outputs_dir.glob(f'{today}_*_card.png')):
ig_flag = card_file.with_suffix('.ig_done')
if ig_flag.exists():
continue
slug = card_file.stem.replace(f'{today}_', '').replace('_card', '')
article = _load_article_by_slug(today, slug)
if not article:
logger.warning(f"Instagram: 원본 article 없음 ({slug})")
continue
# image_host.py가 로컬 경로 → 공개 URL 변환 처리
success = instagram_bot.publish_card(article, str(card_file))
if success:
ig_flag.touch()
logger.info(f"Instagram 발행 완료: {card_file.name}")
def job_distribute_x():
"""11:00 — X 스레드 게시"""
if not _publish_enabled:
return
logger.info("[스케줄] X 스레드 게시")
try:
_distribute_x()
except Exception as e:
logger.error(f"X 배포 오류: {e}")
def _distribute_x():
sys.path.insert(0, str(BASE_DIR / 'bots' / 'distributors'))
import x_bot
today = datetime.now().strftime('%Y%m%d')
outputs_dir = DATA_DIR / 'outputs'
for thread_file in sorted(outputs_dir.glob(f'{today}_*_thread.json')):
x_flag = thread_file.with_suffix('.x_done')
if x_flag.exists():
continue
slug = thread_file.stem.replace(f'{today}_', '').replace('_thread', '')
article = _load_article_by_slug(today, slug)
if not article:
continue
thread_data = json.loads(thread_file.read_text(encoding='utf-8'))
success = x_bot.publish_thread(article, thread_data)
if success:
x_flag.touch()
def job_distribute_tiktok():
"""18:00 — TikTok 쇼츠 업로드"""
if not _publish_enabled:
return
logger.info("[스케줄] TikTok 쇼츠 업로드")
try:
_distribute_shorts('tiktok')
except Exception as e:
logger.error(f"TikTok 배포 오류: {e}")
def job_distribute_youtube():
"""20:00 — YouTube 쇼츠 업로드"""
if not _publish_enabled:
return
logger.info("[스케줄] YouTube 쇼츠 업로드")
try:
_distribute_shorts('youtube')
except Exception as e:
logger.error(f"YouTube 배포 오류: {e}")
def _distribute_shorts(platform: str):
"""틱톡/유튜브 쇼츠 MP4 배포 공통 로직"""
sys.path.insert(0, str(BASE_DIR / 'bots' / 'distributors'))
if platform == 'tiktok':
import tiktok_bot as dist_bot
else:
import youtube_bot as dist_bot
today = datetime.now().strftime('%Y%m%d')
outputs_dir = DATA_DIR / 'outputs'
for shorts_file in sorted(outputs_dir.glob(f'{today}_*_shorts.mp4')):
done_flag = shorts_file.with_suffix(f'.{platform}_done')
if done_flag.exists():
continue
slug = shorts_file.stem.replace(f'{today}_', '').replace('_shorts', '')
article = _load_article_by_slug(today, slug)
if not article:
logger.warning(f"{platform}: 원본 article 없음 ({slug})")
continue
success = dist_bot.publish_shorts(article, str(shorts_file))
if success:
done_flag.touch()
def _load_article_by_slug(date_str: str, slug: str) -> dict:
"""날짜+slug로 원본 article 로드"""
originals_dir = DATA_DIR / 'originals'
for f in originals_dir.glob(f'{date_str}_*{slug}*.json'):
try:
return json.loads(f.read_text(encoding='utf-8'))
except Exception:
pass
return {}
def _publish_next(): def _publish_next():
drafts_dir = DATA_DIR / 'drafts' drafts_dir = DATA_DIR / 'drafts'
drafts_dir.mkdir(exist_ok=True) drafts_dir.mkdir(exist_ok=True)
@@ -127,14 +356,12 @@ def _publish_next():
if article.get('_pending_openclaw'): if article.get('_pending_openclaw'):
continue continue
sys.path.insert(0, str(BASE_DIR / 'bots')) sys.path.insert(0, str(BASE_DIR / 'bots'))
sys.path.insert(0, str(BASE_DIR / 'bots' / 'converters'))
import publisher_bot import publisher_bot
import linker_bot import blog_converter
import markdown as md_lib # 변환봇으로 HTML 생성 (이미 변환된 경우 outputs에서 읽음)
body_html = md_lib.markdown( html = blog_converter.convert(article, save_file=False)
article.get('body', ''), extensions=['toc', 'tables', 'fenced_code'] article['_html_content'] = html
)
body_html = linker_bot.process(article, body_html)
article['body'] = body_html
article['_body_is_html'] = True article['_body_is_html'] = True
publisher_bot.publish(article) publisher_bot.publish(article)
draft_file.unlink(missing_ok=True) draft_file.unlink(missing_ok=True)
@@ -271,6 +498,26 @@ async def cmd_report(update: Update, context: ContextTypes.DEFAULT_TYPE):
analytics_bot.weekly_report() analytics_bot.weekly_report()
async def cmd_convert(update: Update, context: ContextTypes.DEFAULT_TYPE):
"""수동 변환 실행"""
await update.message.reply_text("변환 엔진 실행 중...")
try:
_run_conversion_pipeline()
outputs_dir = DATA_DIR / 'outputs'
today = datetime.now().strftime('%Y%m%d')
blogs = len(list(outputs_dir.glob(f'{today}_*_blog.html')))
cards = len(list(outputs_dir.glob(f'{today}_*_card.png')))
threads = len(list(outputs_dir.glob(f'{today}_*_thread.json')))
await update.message.reply_text(
f"변환 완료\n"
f"블로그 HTML: {blogs}\n"
f"인스타 카드: {cards}\n"
f"X 스레드: {threads}"
)
except Exception as e:
await update.message.reply_text(f"변환 오류: {e}")
# ─── 이미지 관련 명령 (request 모드) ──────────────── # ─── 이미지 관련 명령 (request 모드) ────────────────
async def cmd_images(update: Update, context: ContextTypes.DEFAULT_TYPE): async def cmd_images(update: Update, context: ContextTypes.DEFAULT_TYPE):
@@ -438,6 +685,8 @@ async def handle_document(update: Update, context: ContextTypes.DEFAULT_TYPE):
async def handle_text(update: Update, context: ContextTypes.DEFAULT_TYPE): async def handle_text(update: Update, context: ContextTypes.DEFAULT_TYPE):
text = update.message.text.strip() text = update.message.text.strip()
chat_id = update.message.chat_id
cmd_map = { cmd_map = {
'발행 중단': cmd_stop_publish, '발행 중단': cmd_stop_publish,
'발행 재개': cmd_resume_publish, '발행 재개': cmd_resume_publish,
@@ -445,26 +694,45 @@ async def handle_text(update: Update, context: ContextTypes.DEFAULT_TYPE):
'이번 주 리포트': cmd_report, '이번 주 리포트': cmd_report,
'대기 중인 글 보여줘': cmd_pending, '대기 중인 글 보여줘': cmd_pending,
'이미지 목록': cmd_images, '이미지 목록': cmd_images,
'변환 실행': cmd_convert,
'오늘 뭐 발행했어?': cmd_status,
} }
if text in cmd_map: if text in cmd_map:
await cmd_map[text](update, context) await cmd_map[text](update, context)
else: return
# Claude API로 자연어 처리
if not ANTHROPIC_API_KEY:
await update.message.reply_text( await update.message.reply_text(
"사용 가능한 명령:\n" "Claude API 키가 없습니다. .env 파일에 ANTHROPIC_API_KEY를 입력하세요."
"• 발행 중단 / 발행 재개\n"
"• 오늘 수집된 글감 보여줘\n"
"• 대기 중인 글 보여줘\n"
"• 이번 주 리포트\n"
"• 이미지 목록\n\n"
"슬래시 명령:\n"
"/approve [번호] — 글 승인\n"
"/reject [번호] — 글 거부\n"
"/images — 이미지 제작 현황\n"
"/imgpick [번호] — 프롬프트 선택\n"
"/imgbatch — 프롬프트 배치 전송\n"
"/imgcancel — 이미지 대기 취소\n"
"/status — 봇 상태"
) )
return
global _claude_client
if _claude_client is None:
_claude_client = anthropic.Anthropic(api_key=ANTHROPIC_API_KEY)
history = _conversation_history.setdefault(chat_id, [])
history.append({"role": "user", "content": text})
# 대화 기록이 너무 길면 최근 20개만 유지
if len(history) > 20:
history[:] = history[-20:]
try:
await context.bot.send_chat_action(chat_id=chat_id, action="typing")
response = _claude_client.messages.create(
model="claude-opus-4-6",
max_tokens=1024,
system=CLAUDE_SYSTEM_PROMPT,
messages=history,
)
reply = response.content[0].text
history.append({"role": "assistant", "content": reply})
await update.message.reply_text(reply)
except Exception as e:
logger.error(f"Claude API 오류: {e}")
await update.message.reply_text(f"오류가 발생했습니다: {e}")
# ─── 스케줄러 설정 + 메인 ───────────────────────────── # ─── 스케줄러 설정 + 메인 ─────────────────────────────
@@ -473,6 +741,7 @@ def setup_scheduler() -> AsyncIOScheduler:
scheduler = AsyncIOScheduler(timezone='Asia/Seoul') scheduler = AsyncIOScheduler(timezone='Asia/Seoul')
schedule_cfg = load_schedule() schedule_cfg = load_schedule()
# schedule.json 기반 동적 잡 (기존)
job_map = { job_map = {
'collector': job_collector, 'collector': job_collector,
'ai_writer': job_ai_writer, 'ai_writer': job_ai_writer,
@@ -486,9 +755,24 @@ def setup_scheduler() -> AsyncIOScheduler:
if fn: if fn:
scheduler.add_job(fn, 'cron', hour=job['hour'], minute=job['minute'], id=job['id']) scheduler.add_job(fn, 'cron', hour=job['hour'], minute=job['minute'], id=job['id'])
# 고정 스케줄 # v3 고정 스케줄: 시차 배포
# 07:00 수집봇 (schedule.json에서 관리)
# 08:00 AI 글 작성 (schedule.json에서 관리)
scheduler.add_job(job_convert, 'cron', hour=8, minute=30, id='convert') # 08:30 변환
scheduler.add_job(lambda: job_publish(1), 'cron',
hour=9, minute=0, id='blog_publish') # 09:00 블로그
scheduler.add_job(job_distribute_instagram, 'cron',
hour=10, minute=0, id='instagram_dist') # 10:00 인스타
scheduler.add_job(job_distribute_x, 'cron',
hour=11, minute=0, id='x_dist') # 11:00 X
scheduler.add_job(job_distribute_tiktok, 'cron',
hour=18, minute=0, id='tiktok_dist') # 18:00 틱톡
scheduler.add_job(job_distribute_youtube, 'cron',
hour=20, minute=0, id='youtube_dist') # 20:00 유튜브
scheduler.add_job(job_analytics_daily, 'cron',
hour=22, minute=0, id='daily_report') # 22:00 분석
scheduler.add_job(job_analytics_weekly, 'cron', scheduler.add_job(job_analytics_weekly, 'cron',
day_of_week='sun', hour=22, minute=30, id='weekly_report') day_of_week='sun', hour=22, minute=30, id='weekly_report') # 일요일 주간
# request 모드: 매주 월요일 10:00 이미지 프롬프트 배치 전송 # request 모드: 매주 월요일 10:00 이미지 프롬프트 배치 전송
if IMAGE_MODE == 'request': if IMAGE_MODE == 'request':
@@ -496,7 +780,7 @@ def setup_scheduler() -> AsyncIOScheduler:
day_of_week='mon', hour=10, minute=0, id='image_batch') day_of_week='mon', hour=10, minute=0, id='image_batch')
logger.info("이미지 request 모드: 매주 월요일 10:00 배치 전송 등록") logger.info("이미지 request 모드: 매주 월요일 10:00 배치 전송 등록")
logger.info("스케줄러 설정 완료") logger.info("스케줄러 설정 완료 (v3 시차 배포 포함)")
return scheduler return scheduler
@@ -515,6 +799,7 @@ async def main():
app.add_handler(CommandHandler('pending', cmd_pending)) app.add_handler(CommandHandler('pending', cmd_pending))
app.add_handler(CommandHandler('report', cmd_report)) app.add_handler(CommandHandler('report', cmd_report))
app.add_handler(CommandHandler('topics', cmd_show_topics)) app.add_handler(CommandHandler('topics', cmd_show_topics))
app.add_handler(CommandHandler('convert', cmd_convert))
# 이미지 관련 (request / manual 공통 사용 가능) # 이미지 관련 (request / manual 공통 사용 가능)
app.add_handler(CommandHandler('images', cmd_images)) app.add_handler(CommandHandler('images', cmd_images))

39
config/platforms.json Normal file
View File

@@ -0,0 +1,39 @@
{
"_comment": "플랫폼별 API 키/설정. 실제 값은 .env에서 관리.",
"blogger": {
"enabled": true,
"publish_time": "09:00",
"api": "Blogger API v3",
"env_keys": ["BLOG_MAIN_ID", "GOOGLE_CLIENT_ID", "GOOGLE_CLIENT_SECRET", "GOOGLE_REFRESH_TOKEN"]
},
"instagram": {
"enabled": false,
"publish_time": "10:00",
"api": "Instagram Graph API v19.0",
"env_keys": ["INSTAGRAM_ACCESS_TOKEN", "INSTAGRAM_ACCOUNT_ID"],
"setup_notes": "Facebook Page + Instagram Business 계정 필요"
},
"x": {
"enabled": false,
"publish_time": "11:00",
"api": "X API v2",
"env_keys": ["X_API_KEY", "X_API_SECRET", "X_ACCESS_TOKEN", "X_ACCESS_SECRET"],
"setup_notes": "X Developer 계정 + 앱 등록 필요"
},
"tiktok": {
"enabled": false,
"publish_time": "18:00",
"api": "TikTok Content Posting API",
"env_keys": ["TIKTOK_ACCESS_TOKEN"],
"phase": "Phase 2",
"setup_notes": "TikTok Developer 계정 + 앱 등록 필요"
},
"youtube": {
"enabled": false,
"publish_time": "20:00",
"api": "YouTube Data API v3",
"env_keys": ["YOUTUBE_CHANNEL_ID"],
"phase": "Phase 2",
"setup_notes": "Google Cloud YouTube API 활성화 필요 (기존 프로젝트에서)"
}
}

View File

@@ -8,5 +8,15 @@ requests
beautifulsoup4 beautifulsoup4
feedparser feedparser
markdown markdown
python-telegram-bot==20.7 python-telegram-bot>=21.0
lxml lxml
anthropic
Pillow
requests-oauthlib
claude-agent-sdk
# Phase 2 (쇼츠 변환)
gTTS
openai
pydub
# Phase 2 (YouTube 업로드 진행 표시)
google-resumable-media

139
scripts/download_fonts.py Normal file
View File

@@ -0,0 +1,139 @@
"""
폰트 다운로드 스크립트
Noto Sans KR 폰트를 assets/fonts/에 다운로드.
카드 변환봇(card_converter.py)에서 사용.
실행: python scripts/download_fonts.py
"""
import os
import sys
import urllib.request
from pathlib import Path
BASE_DIR = Path(__file__).parent.parent
FONTS_DIR = BASE_DIR / 'assets' / 'fonts'
FONTS_DIR.mkdir(parents=True, exist_ok=True)
# Google Fonts에서 직접 다운로드 (Noto Sans KR)
FONTS = {
'NotoSansKR-Regular.ttf': (
'https://github.com/notofonts/noto-cjk/raw/main/Sans/OTF/Korean/'
'NotoSansCJKkr-Regular.otf'
),
'NotoSansKR-Bold.ttf': (
'https://github.com/notofonts/noto-cjk/raw/main/Sans/OTF/Korean/'
'NotoSansCJKkr-Bold.otf'
),
'NotoSansKR-Medium.ttf': (
'https://github.com/notofonts/noto-cjk/raw/main/Sans/OTF/Korean/'
'NotoSansCJKkr-Medium.otf'
),
}
# Windows에 이미 설치된 한글 폰트를 복사하는 대안
WINDOWS_FONT_CANDIDATES = [
('malgunbd.ttf', 'NotoSansKR-Bold.ttf'), # 맑은고딕 Bold
('malgun.ttf', 'NotoSansKR-Regular.ttf'), # 맑은고딕 Regular
('malgun.ttf', 'NotoSansKR-Medium.ttf'),
]
def copy_windows_fonts() -> list[str]:
"""Windows 기본 한글 폰트를 assets/fonts/에 복사"""
import shutil
win_fonts = Path('C:/Windows/Fonts')
copied = []
for src_name, dst_name in WINDOWS_FONT_CANDIDATES:
src = win_fonts / src_name
dst = FONTS_DIR / dst_name
if dst.exists():
print(f" 이미 존재: {dst_name}")
copied.append(dst_name)
continue
if src.exists():
shutil.copy2(src, dst)
print(f" 복사: {src_name}{dst_name}")
copied.append(dst_name)
else:
print(f" 없음: {src_name}")
return copied
def download_from_url(url: str, dst_path: Path) -> bool:
"""URL에서 폰트 파일 다운로드"""
try:
print(f" 다운로드 중: {dst_path.name} ...")
headers = {'User-Agent': 'Mozilla/5.0'}
req = urllib.request.Request(url, headers=headers)
with urllib.request.urlopen(req, timeout=30) as resp:
dst_path.write_bytes(resp.read())
print(f" 완료: {dst_path.name} ({dst_path.stat().st_size // 1024} KB)")
return True
except Exception as e:
print(f" 실패: {e}")
return False
def verify_font(font_path: Path) -> bool:
"""PIL로 폰트 로드 테스트"""
try:
from PIL import ImageFont
ImageFont.truetype(str(font_path), 30)
return True
except Exception as e:
print(f" 폰트 검증 실패: {e}")
return False
def main():
print("=== Noto Sans KR 폰트 설치 ===\n")
print(f"대상 폴더: {FONTS_DIR}\n")
# 1단계: Windows 기본 폰트 복사 시도 (가장 빠름)
print("[1단계] Windows 기본 한글 폰트 복사...")
copied = copy_windows_fonts()
if len(copied) >= 2:
print(f"\n✅ Windows 폰트 복사 완료 ({len(copied)}개)")
_verify_all()
return
# 2단계: GitHub에서 직접 다운로드
print("\n[2단계] GitHub Noto Sans CJK KR 다운로드...")
downloaded = 0
for filename, url in FONTS.items():
dst = FONTS_DIR / filename
if dst.exists() and dst.stat().st_size > 1000:
print(f" 이미 존재: {filename}")
downloaded += 1
continue
if download_from_url(url, dst):
downloaded += 1
if downloaded > 0:
print(f"\n✅ 다운로드 완료 ({downloaded}개)")
_verify_all()
else:
print("\n❌ 폰트 설치 실패. 수동 설치 방법:")
print(" 1. https://fonts.google.com/noto/specimen/Noto+Sans+KR 에서 다운로드")
print(f" 2. TTF 파일들을 {FONTS_DIR} 에 복사")
print(" 3. NotoSansKR-Regular.ttf, NotoSansKR-Bold.ttf 로 이름 변경")
sys.exit(1)
def _verify_all():
print("\n[검증] 폰트 로드 테스트...")
ok = True
for f in FONTS_DIR.glob('*.ttf'):
if verify_font(f):
print(f"{f.name}")
else:
print(f"{f.name}")
ok = False
if ok:
print("\n카드 변환봇 준비 완료!")
else:
print("\n일부 폰트 오류. card_converter.py는 대체 폰트로 동작합니다.")
if __name__ == '__main__':
main()

View File

@@ -0,0 +1,517 @@
================================================================================
The 4th Path — 쇼츠 영상 템플릿 설계서
News Anchor Format · DALL-E + Google TTS + ffmpeg
================================================================================
용도: Claude Code에서 shorts_converter.py 구현 시 참조
연관: 마스터플랜 v3, converters/shorts_converter.py
================================================================================
1. 영상 기본 사양
================================================================================
포맷: MP4 (H.264 + AAC)
비율: 9:16 세로 (1080×1920)
길이: 30~60초
FPS: 30
음성: Google Cloud TTS (WaveNet, ko-KR)
자막: SRT burn-in (단어별 하이라이트)
배경음악: YouTube Audio Library (무료, 별도 다운로드)
전환효과: ffmpeg xfade (fade, slideleft, dissolve)
================================================================================
2. 영상 구조 (타임라인)
================================================================================
┌─────────────────────────────────────────────┐
│ [0:00 ~ 0:02] INTRO │
│ │
│ ┌──────────────────────────────────────┐ │
│ │ ┌─────────────────────────┐ │ │
│ │ │ The 4th Path │ LOGO │ │
│ │ └─────────────────────────┘ │ │
│ │ │ │
│ │ Independent Tech Media │ │
│ │ by 22B Labs │ │
│ │ │ │
│ │ 배경: 다크(#0a0a0d) + 골드 라인 │ │
│ │ 사운드: 짧은 뉴스 인트로 효과 │ │
│ └──────────────────────────────────────┘ │
│ │
│ 전환: fade (0.5초) │
├─────────────────────────────────────────────┤
│ [0:02 ~ 0:06] HEADLINE SLIDE │
│ │
│ ┌──────────────────────────────────────┐ │
│ │ ┌───────────────┐ │ │
│ │ │ 🟢 쉬운 세상 │ eyebrow badge │ │
│ │ └───────────────┘ │ │
│ │ │ │
│ │ "글 제목이 여기에" │ │
│ │ 큰 글씨, 2~3줄 이내 │ │
│ │ │ │
│ │ ─────────────────────── │ │
│ │ 2026.03.25 · 22B Labs │ │
│ │ │ │
│ │ 배경: DALL-E 이미지 (블러 처리) │ │
│ │ 오버레이: 반투명 다크 그라데이션 │ │
│ └──────────────────────────────────────┘ │
│ │
│ 음성: "오늘은 [제목 요약]에 대해 │
│ 알아보겠습니다." │
│ 전환: slideleft (0.5초) │
├─────────────────────────────────────────────┤
│ [0:06 ~ 0:14] POINT 1 SLIDE │
│ │
│ ┌──────────────────────────────────────┐ │
│ │ │ │
│ │ ① 핵심 포인트 첫 번째 │ │
│ │ │ │
│ │ 설명 텍스트 2~3줄 │ │
│ │ (읽기 편한 크기) │ │
│ │ │ │
│ │ ──────────────────────── ticker │ │
│ │ The 4th Path · 쉬운 세상 · 날짜 │ │
│ │ │ │
│ │ 배경: DALL-E 이미지 또는 단색 │ │
│ └──────────────────────────────────────┘ │
│ │
│ 음성: 핵심 포인트 1 설명 (8초 분량) │
│ 자막: 단어별 하이라이트 │
│ 전환: fade (0.5초) │
├─────────────────────────────────────────────┤
│ [0:14 ~ 0:22] POINT 2 SLIDE │
│ (구조 동일, 색상 미세 변화) │
├─────────────────────────────────────────────┤
│ [0:22 ~ 0:30] POINT 3 SLIDE │
│ (구조 동일) │
├─────────────────────────────────────────────┤
│ [0:30 ~ 0:35] DATA CARD (선택적) │
│ │
│ ┌──────────────────────────────────────┐ │
│ │ │ │
│ │ ┌────────┐ ┌────────┐ │ │
│ │ │ 86% │ │ $2.5T │ │ │
│ │ │ AI도입 │ │ AI투자 │ │ │
│ │ └────────┘ └────────┘ │ │
│ │ │ │
│ │ 출처: NVIDIA 2026 Report │ │
│ │ │ │
│ │ 배경: 다크 (#0f0a1e) │ │
│ └──────────────────────────────────────┘ │
│ │
│ 음성: 데이터 설명 (5초) │
│ 전환: dissolve (0.5초) │
├─────────────────────────────────────────────┤
│ [0:35 ~ 0:40] OUTRO │
│ │
│ ┌──────────────────────────────────────┐ │
│ │ │ │
│ │ "더 자세한 내용은" │ │
│ │ │ │
│ │ ┌─────────────────────────┐ │ │
│ │ │ the4thpath.com │ │ │
│ │ └─────────────────────────┘ │ │
│ │ │ │
│ │ The 4th Path │ │
│ │ Independent Tech Media │ │
│ │ │ │
│ │ "팔로우하면 매일 이런 정보를" │ │
│ │ │ │
│ │ 배경: 다크(#0a0a0d) + 골드 액센트 │ │
│ └──────────────────────────────────────┘ │
│ │
│ 음성: "자세한 내용은 the4thpath.com에서 │
│ 확인하세요. 팔로우 부탁드립니다." │
│ 사운드: 짧은 아웃트로 효과 │
└─────────────────────────────────────────────┘
================================================================================
3. 슬라이드 이미지 생성 파이프라인
================================================================================
각 슬라이드는 2단계로 생성:
(A) DALL-E로 배경 이미지 생성
(B) Pillow로 UI 오버레이 합성
── (A) DALL-E 배경 이미지 ──────────────────────
프롬프트 템플릿 (코너별):
[쉬운 세상]
"Minimalist abstract background, soft purple gradient,
subtle tech grid pattern, clean, modern, editorial.
Vertical 9:16 aspect ratio, no text, no people."
[숨은 보물]
"Minimalist abstract background, deep blue with
golden light accents, treasure map subtle texture,
clean editorial. Vertical 9:16, no text."
[바이브 리포트]
"Minimalist abstract background, warm coral and
white, creative energy waves, modern editorial.
Vertical 9:16, no text."
[팩트체크]
"Minimalist abstract background, dark red and white,
data visualization grid subtle texture, serious
editorial. Vertical 9:16, no text."
[한 컷]
→ 만평 이미지를 직접 사용 (DALL-E 불필요)
출력: 1080×1920 PNG
주의: "no text" 필수 (DALL-E 텍스트 렌더링 불안정)
── (B) Pillow UI 오버레이 합성 ─────────────────
DALL-E 이미지 위에 다음 요소를 합성:
[인트로/아웃트로 슬라이드]
- 배경: 단색 다크 (#0a0a0d)
- 로고: assets/logo.png (중앙 상단)
- 텍스트: "Independent Tech Media" (골드 #c8a84e)
- 하단: "by 22B Labs"
[헤드라인 슬라이드]
- 배경: DALL-E 이미지 + 하단 50% 다크 그라데이션 오버레이
- 상단: 코너 배지 (Pillow로 그린 둥근 사각형 + 텍스트)
- 중앙: 제목 텍스트 (흰색, 굵게, 최대 3줄)
- 하단: 날짜 · 22B Labs (작은 글씨)
[포인트 슬라이드]
- 배경: DALL-E 이미지 (밝기 40%로 어둡게)
- 좌상단: 번호 원형 배지 (①②③)
- 중앙: 핵심 포인트 텍스트 (흰색, 2~3줄)
- 하단 바: 뉴스 티커 (배경 #000, 텍스트 #c8a84e)
"The 4th Path · [코너명] · [날짜]"
[데이터 카드 슬라이드]
- 배경: 단색 다크 (#0f0a1e)
- 중앙: 수치 카드 2~3개 (박스 + 큰 숫자 + 라벨)
- 하단: 출처 텍스트 (작은 글씨)
폰트:
- 제목: Noto Sans KR Bold (assets/fonts/NotoSansKR-Bold.ttf)
- 본문: Noto Sans KR Regular
- 숫자: Noto Sans KR Black
- 크기: 제목 72px, 본문 48px, 메타 32px, 티커 28px
================================================================================
4. 음성(TTS) 파이프라인
================================================================================
── Google Cloud TTS 설정 ───────────────────────
API: texttospeech.googleapis.com
인증: Google Cloud 서비스 계정 또는 OAuth
음성 파라미터:
language_code: "ko-KR"
name: "ko-KR-Wavenet-A" (여성) 또는 "ko-KR-Wavenet-C" (남성)
speaking_rate: 1.05 (약간 빠르게 — 쇼츠 시청 패턴)
pitch: 0 (기본)
audio_encoding: LINEAR16 (WAV)
영문 글의 경우:
language_code: "en-US"
name: "en-US-Wavenet-D" (남성) 또는 "en-US-Wavenet-F" (여성)
── TTS 스크립트 생성 규칙 ──────────────────────
원본 마크다운의 ---KEY_POINTS--- 섹션에서 추출.
스크립트 구조:
[INTRO] "오늘은 {제목 요약}에 대해 알아보겠습니다."
[POINT1] "{핵심 포인트 1 설명, 2~3문장}"
[POINT2] "{핵심 포인트 2 설명, 2~3문장}"
[POINT3] "{핵심 포인트 3 설명, 2~3문장}"
[DATA] "{데이터 설명, 1~2문장}" (선택적)
[OUTRO] "자세한 내용은 the4thpath.com에서 확인하세요.
팔로우 부탁드립니다."
각 섹션을 개별 WAV 파일로 생성:
tts_intro.wav, tts_p1.wav, tts_p2.wav, tts_p3.wav,
tts_data.wav (선택), tts_outro.wav
── TTS 비용 추정 ──────────────────────────────
WaveNet 가격: $16 / 100만 문자
1개 쇼츠 스크립트: ~500자 = $0.008
하루 2~3개: ~$0.02
월 60~90개: ~$0.60
$300 크레딧으로: 약 500개월 분량 (실질적 무제한)
── 크레딧 소진 후 대안 ─────────────────────────
옵션 1: gTTS (완전 무료, 품질 하락)
옵션 2: OpenAI TTS API (품질 유지, 비용 발생)
옵션 3: ElevenLabs 무료 티어 (월 10분, 품질 최고)
권장: 크레딧 소진 시점에 수익 상황 보고 결정
================================================================================
5. ffmpeg 파이프라인
================================================================================
── 전체 흐름 ───────────────────────────────────
입력:
slide_intro.png (1080×1920)
slide_headline.png (1080×1920)
slide_p1.png (1080×1920)
slide_p2.png (1080×1920)
slide_p3.png (1080×1920)
slide_data.png (1080×1920, 선택)
slide_outro.png (1080×1920)
tts_intro.wav
tts_p1.wav
tts_p2.wav
tts_p3.wav
tts_data.wav (선택)
tts_outro.wav
bgm.mp3 (배경음악, 볼륨 10%)
subtitles.srt
출력:
{date}_{slug}_shorts.mp4
── Step 1: 슬라이드 → 개별 영상 클립 ──────────
각 슬라이드를 해당 TTS 길이에 맞춰 영상으로:
ffmpeg -loop 1 -i slide_p1.png -i tts_p1.wav \
-c:v libx264 -tune stillimage -c:a aac \
-b:a 192k -pix_fmt yuv420p \
-vf "scale=1080:1920,zoompan=z='min(zoom+0.0005,1.08)':
x='iw/2-(iw/zoom/2)':y='ih/2-(ih/zoom/2)':
d=1:s=1080x1920:fps=30" \
-shortest clip_p1.mp4
zoompan: 느린 줌인 (Ken Burns) → 정적 슬라이드에 생동감
── Step 2: 클립 결합 + 전환 효과 ──────────────
ffmpeg \
-i clip_intro.mp4 \
-i clip_headline.mp4 \
-i clip_p1.mp4 \
-i clip_p2.mp4 \
-i clip_p3.mp4 \
-i clip_outro.mp4 \
-filter_complex \
"[0][1]xfade=transition=fade:duration=0.5:offset=1.5[f0]; \
[f0][2]xfade=transition=slideleft:duration=0.5:offset=5.5[f1]; \
[f1][3]xfade=transition=fade:duration=0.5:offset=13.5[f2]; \
[f2][4]xfade=transition=fade:duration=0.5:offset=21.5[f3]; \
[f3][5]xfade=transition=dissolve:duration=0.5:offset=29.5[video]; \
[0:a][1:a][2:a][3:a][4:a][5:a]concat=n=6:v=0:a=1[audio]" \
-map "[video]" -map "[audio]" \
-c:v libx264 -c:a aac \
temp_no_bgm.mp4
참고: offset 값은 각 TTS 길이에 따라 동적으로 계산해야 함.
offset = 이전 클립 총 길이 - 전환 duration
── Step 3: 배경음악 믹스 ──────────────────────
ffmpeg -i temp_no_bgm.mp4 -i bgm.mp3 \
-filter_complex "[1:a]volume=0.08[bgm]; \
[0:a][bgm]amix=inputs=2:duration=first[a]" \
-map 0:v -map "[a]" -c:v copy -c:a aac \
-shortest temp_with_bgm.mp4
배경음악 볼륨: 8% (TTS 음성이 명확하게 들려야 함)
── Step 4: 자막 burn-in ───────────────────────
ffmpeg -i temp_with_bgm.mp4 -vf \
"subtitles=subtitles.srt:force_style='\
FontName=Noto Sans KR,\
FontSize=22,\
PrimaryColour=&H00FFFFFF,\
OutlineColour=&H80000000,\
BorderStyle=4,\
BackColour=&H80000000,\
Outline=0,\
Shadow=0,\
MarginV=120,\
Alignment=2,\
Bold=1'" \
-c:v libx264 -c:a copy \
output_final.mp4
자막 위치: 하단 120px 위 (티커 바 위)
스타일: 흰색 글씨 + 반투명 검정 배경 박스
── Step 5: 정리 ───────────────────────────────
임시 파일 삭제:
clip_*.mp4, temp_*.mp4, tts_*.wav, slide_*.png
최종 파일만 유지:
data/outputs/{date}_{slug}_shorts.mp4
================================================================================
6. SRT 자막 생성
================================================================================
TTS 스크립트에서 자동 생성.
각 TTS WAV 파일의 길이를 ffprobe로 측정 → 타이밍 계산.
방법 1 (간단): 문장 단위 자막
1
00:00:02,000 --> 00:00:06,000
오늘은 ChatGPT 숨겨진 기능에 대해
알아보겠습니다.
방법 2 (고급): 단어별 하이라이트
Google TTS의 timepoint 응답을 활용하여
단어별 시작/끝 시간을 추출.
ASS 자막 포맷으로 단어별 색상 변경 적용.
권장: Phase 1은 문장 단위로 시작.
Phase 2에서 단어별 하이라이트 추가.
================================================================================
7. 코너별 영상 스타일 변화
================================================================================
[쉬운 세상] Easy Guide
배경: 보라 그라데이션
톤: 친절, 차분
TTS 속도: 1.0 (약간 느리게)
전환: fade (부드러운)
[숨은 보물] Hidden Gems
배경: 딥블루 + 골드
톤: 발견의 흥분
TTS 속도: 1.05
전환: slideleft
[바이브 리포트] Vibe Report
배경: 코랄 + 화이트
톤: 에너지, 감탄
TTS 속도: 1.1 (빠르게)
전환: slideleft
[팩트체크] Fact Check
배경: 다크레드 + 화이트
톤: 진지, 분석적
TTS 속도: 1.0
전환: fade (무거운)
추가: 데이터 카드 슬라이드 필수
[한 컷] One Cut
배경: 만평 이미지 직접 사용
톤: 위트, 짧은 해설
TTS 속도: 1.0
길이: 15~20초 (짧게)
구조: 만평 이미지 → 해설 1줄 → 아웃트로
================================================================================
8. 파일 구조
================================================================================
shorts_converter.py 내부 구조:
class ShortsConverter:
def __init__(self, config):
"""설정 로드 (templates/shorts_template.json)"""
def generate(self, original_md_path, output_dir):
"""메인 파이프라인"""
content = self.parse_markdown(original_md_path)
script = self.generate_tts_script(content)
audio_files = self.synthesize_tts(script)
bg_image = self.generate_background(content)
slides = self.compose_slides(content, bg_image)
subtitles = self.generate_srt(script, audio_files)
video = self.assemble_video(slides, audio_files, subtitles)
return video
def parse_markdown(self, path):
"""원본 마크다운에서 제목/코너/KEY_POINTS 추출"""
def generate_tts_script(self, content):
"""KEY_POINTS → TTS 스크립트 생성"""
def synthesize_tts(self, script):
"""Google Cloud TTS API 호출 → WAV 파일들"""
def generate_background(self, content):
"""DALL-E API 호출 → 배경 이미지 생성"""
def compose_slides(self, content, bg_image):
"""Pillow: 배경 + UI 오버레이 합성 → PNG 슬라이드들"""
def generate_srt(self, script, audio_files):
"""WAV 길이 측정 → SRT 자막 파일 생성"""
def assemble_video(self, slides, audio_files, subtitles):
"""ffmpeg: 슬라이드 + 오디오 + 자막 + 전환 → MP4"""
필요 라이브러리:
Pillow (이미지 합성)
google-cloud-texttospeech (TTS)
openai (DALL-E API, 또는 ChatGPT Pro 경유)
ffmpeg-python 또는 subprocess (ffmpeg 호출)
pydub (오디오 길이 측정)
templates/shorts_template.json:
{
"resolution": [1080, 1920],
"fps": 30,
"transition_duration": 0.5,
"bgm_volume": 0.08,
"tts_voice_ko": "ko-KR-Wavenet-A",
"tts_voice_en": "en-US-Wavenet-D",
"tts_speaking_rate": 1.05,
"font_title": "assets/fonts/NotoSansKR-Bold.ttf",
"font_body": "assets/fonts/NotoSansKR-Regular.ttf",
"font_title_size": 72,
"font_body_size": 48,
"font_meta_size": 32,
"font_ticker_size": 28,
"brand_color": "#c8a84e",
"dark_bg": "#0a0a0d",
"ticker_text": "The 4th Path · {corner} · {date}",
"outro_url": "the4thpath.com",
"outro_cta": "팔로우하면 매일 이런 정보를 받습니다",
"corners": {
"쉬운세상": {"color": "#7c3aed", "bg_prompt_style": "purple gradient, tech grid"},
"숨은보물": {"color": "#1d6fb0", "bg_prompt_style": "deep blue, golden light"},
"바이브리포트": {"color": "#d85a30", "bg_prompt_style": "warm coral, energy waves"},
"팩트체크": {"color": "#bf3a3a", "bg_prompt_style": "dark red, data grid"},
"한컷": {"color": "#8a7a2e", "bg_prompt_style": null}
}
}
================================================================================
9. 마스터플랜 v3 반영 사항
================================================================================
이 설계서의 내용을 마스터플랜 v3의 다음 섹션에 반영:
- PART 2, 섹션 6 (변환 엔진 상세) → [변환 3] 쇼츠 영상
- PART 5, 섹션 13 → Phase 2A 세팅 항목
- PART 6, 섹션 14 → 일일 자동 플로우
영상 퀄리티 원칙 (마스터플랜에 추가):
"카드+TTS 대량생산이 아닌, CNN/Bloomberg 스타일의
뉴스 앵커 포맷으로 양질의 쇼츠를 제작한다.
자극적 클릭베이트보다 장기 구독자 확보를 우선한다."
================================================================================
================================================================================

View File

@@ -0,0 +1,57 @@
{
"resolution": [1080, 1920],
"fps": 30,
"transition_duration": 0.5,
"bgm_volume": 0.08,
"tts_voice_ko": "ko-KR-Wavenet-A",
"tts_voice_en": "en-US-Wavenet-D",
"tts_speaking_rate_default": 1.05,
"font_title": "assets/fonts/NotoSansKR-Bold.ttf",
"font_body": "assets/fonts/NotoSansKR-Regular.ttf",
"font_title_size": 72,
"font_body_size": 48,
"font_meta_size": 32,
"font_ticker_size": 28,
"brand_color": "#c8a84e",
"dark_bg": "#0a0a0d",
"ticker_text": "The 4th Path · {corner} · {date}",
"outro_url": "the4thpath.com",
"outro_cta": "팔로우하면 매일 이런 정보를 받습니다",
"brand_name": "The 4th Path",
"brand_sub": "Independent Tech Media",
"brand_by": "by 22B Labs",
"corners": {
"쉬운세상": {
"color": "#7c3aed",
"bg_prompt_style": "Minimalist abstract background, soft purple gradient, subtle tech grid pattern, clean, modern, editorial. Vertical 9:16 aspect ratio, no text, no people.",
"tts_speed": 1.0,
"transition": "fade"
},
"숨은보물": {
"color": "#1d6fb0",
"bg_prompt_style": "Minimalist abstract background, deep blue with golden light accents, treasure map subtle texture, clean editorial. Vertical 9:16, no text.",
"tts_speed": 1.05,
"transition": "slideleft"
},
"바이브리포트": {
"color": "#d85a30",
"bg_prompt_style": "Minimalist abstract background, warm coral and white, creative energy waves, modern editorial. Vertical 9:16, no text.",
"tts_speed": 1.1,
"transition": "slideleft"
},
"팩트체크": {
"color": "#bf3a3a",
"bg_prompt_style": "Minimalist abstract background, dark red and white, data visualization grid subtle texture, serious editorial. Vertical 9:16, no text.",
"tts_speed": 1.0,
"transition": "fade",
"force_data_card": true
},
"한컷": {
"color": "#8a7a2e",
"bg_prompt_style": null,
"tts_speed": 1.0,
"transition": "fade",
"max_duration": 20
}
}
}