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:
74
.env.example
74
.env.example
@@ -1,17 +1,77 @@
|
||||
GOOGLE_CLIENT_ID=your-google-client-id.apps.googleusercontent.com
|
||||
GOOGLE_CLIENT_SECRET=your-google-client-secret
|
||||
# ─── Google OAuth (필수) ─────────────────────────────
|
||||
# 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=
|
||||
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_SECRET_KEY=
|
||||
TELEGRAM_BOT_TOKEN=your-telegram-bot-token
|
||||
TELEGRAM_CHAT_ID=your-telegram-chat-id
|
||||
# 이미지 모드 선택 (manual | request | auto)
|
||||
|
||||
# ─── Telegram (필수) ──────────────────────────────────
|
||||
# @BotFather에서 /newbot 명령으로 생성
|
||||
TELEGRAM_BOT_TOKEN=your_telegram_bot_token
|
||||
# @userinfobot에서 확인
|
||||
TELEGRAM_CHAT_ID=your_telegram_chat_id
|
||||
|
||||
# ─── 이미지 모드 선택 ─────────────────────────────────
|
||||
# manual — 글 발행 시점에 프롬프트 1개를 Telegram으로 전송 (기본값)
|
||||
# request — 매주 월요일 프롬프트 목록 전송 → 직접 생성 후 Telegram으로 이미지 전송
|
||||
# auto — OpenAI DALL-E API 자동 생성 (OPENAI_API_KEY 필요, 별도 비용 발생)
|
||||
IMAGE_MODE=manual
|
||||
# auto 모드 사용 시에만 입력
|
||||
|
||||
# OpenAI (auto 모드 또는 쇼츠 배경 이미지 자동 생성 시 필요)
|
||||
# https://platform.openai.com/api-keys
|
||||
OPENAI_API_KEY=
|
||||
|
||||
# 블로그 사이트 URL (Search Console 등록용)
|
||||
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
15
.gitignore
vendored
@@ -2,6 +2,21 @@
|
||||
.env
|
||||
token.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
|
||||
venv/
|
||||
|
||||
816
README.md
816
README.md
@@ -1,94 +1,155 @@
|
||||
# 블로그 자동 수익 엔진 (Blog Auto Revenue Engine)
|
||||
# 블로그 자동 수익 엔진 v3
|
||||
|
||||
AI 기반 한국어 블로그 자동화 시스템.
|
||||
트렌드 수집 → AI 글 작성 → 자동 발행 → 수익 링크 삽입 → 성과 분석까지 전 과정을 자동화합니다.
|
||||
**The 4th Path — Independent Tech Media** | by [22B Labs](https://github.com/sinmb79)
|
||||
|
||||
> **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. [실행하기](#실행하기)
|
||||
7. [Telegram 명령어](#telegram-명령어)
|
||||
8. [이미지 모드 선택](#이미지-모드-선택)
|
||||
9. [콘텐츠 코너 구성](#콘텐츠-코너-구성)
|
||||
10. [Phase 로드맵](#phase-로드맵)
|
||||
11. [자주 묻는 질문](#자주-묻는-질문)
|
||||
- [아키텍처](#아키텍처)
|
||||
- [기능 개요](#기능-개요)
|
||||
- [프로젝트 구조](#프로젝트-구조)
|
||||
- [설치](#설치)
|
||||
- [환경 변수 설정](#환경-변수-설정)
|
||||
- [Google OAuth 인증](#google-oauth-인증)
|
||||
- [실행 방법](#실행-방법)
|
||||
- [봇 상세 설명](#봇-상세-설명)
|
||||
- [변환 엔진](#변환-엔진-layer-2)
|
||||
- [배포 엔진](#배포-엔진-layer-3)
|
||||
- [콘텐츠 코너](#콘텐츠-코너)
|
||||
- [Telegram 명령어](#telegram-명령어)
|
||||
- [OpenClaw AI 에이전트 연동](#openclaw-ai-에이전트-연동)
|
||||
- [배포 스케줄](#배포-스케줄)
|
||||
- [Phase 현황](#phase-현황)
|
||||
- [자주 묻는 질문](#자주-묻는-질문)
|
||||
- [기여 가이드](#기여-가이드)
|
||||
- [라이선스](#라이선스)
|
||||
|
||||
---
|
||||
|
||||
## 시스템 구조
|
||||
## 아키텍처
|
||||
|
||||
```
|
||||
봇 레이어 (Python) AI 레이어 (OpenClaw)
|
||||
───────────────── ────────────────────
|
||||
수집봇 blog-writer 에이전트
|
||||
└─ 트렌드 수집 └─ 글감 → 완성 글 작성
|
||||
└─ 품질 점수 계산
|
||||
└─ 폐기 규칙 적용
|
||||
│
|
||||
▼
|
||||
발행봇 ── 링크봇 ── 이미지봇
|
||||
└─ 안전장치 └─ 만평 이미지
|
||||
└─ Blogger 발행
|
||||
└─ Search Console
|
||||
│
|
||||
▼
|
||||
분석봇 → Telegram 리포트
|
||||
스케줄러 → 모든 봇 시간 관리
|
||||
┌─────────────────────────────────────────────────────────────┐
|
||||
│ LAYER 1 — AI 콘텐츠 생성 │
|
||||
│ 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자 │
|
||||
│ │
|
||||
│ shorts_converter newsletter_converter │
|
||||
│ TTS+ffmpeg 쇼츠 영상 주간 HTML 뉴스레터 │
|
||||
└───────────────────────────┬─────────────────────────────────┘
|
||||
│ 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/
|
||||
├── bots/
|
||||
│ ├── collector_bot.py ← 수집봇 (Google Trends, GitHub, HN, RSS)
|
||||
│ ├── publisher_bot.py ← 발행봇 (Blogger API + 안전장치)
|
||||
│ ├── linker_bot.py ← 링크봇 (쿠팡 파트너스)
|
||||
│ ├── analytics_bot.py ← 분석봇 (5대 핵심 지표)
|
||||
│ ├── image_bot.py ← 이미지봇 (만평 3가지 모드)
|
||||
│ ├── scheduler.py ← 스케줄러 + Telegram 봇
|
||||
│ └── article_parser.py ← OpenClaw 출력 파서
|
||||
├── config/
|
||||
│ ├── blogs.json ← 블로그 ID 설정
|
||||
│ ├── schedule.json ← 발행 시간표
|
||||
│ ├── sources.json ← 수집 소스 목록
|
||||
│ ├── affiliate_links.json← 어필리에이트 링크 DB
|
||||
│ ├── quality_rules.json ← 품질 점수 기준
|
||||
│ └── safety_keywords.json← 안전장치 키워드
|
||||
├── data/ ← 런타임 데이터 (gitignore)
|
||||
│
|
||||
├── bots/ # 핵심 봇 모듈
|
||||
│ ├── collector_bot.py # 트렌드 수집 + 품질 필터
|
||||
│ ├── publisher_bot.py # Blogger API 발행
|
||||
│ ├── linker_bot.py # 쿠팡 파트너스 링크 삽입
|
||||
│ ├── analytics_bot.py # 성과 수집 + 리포트
|
||||
│ ├── image_bot.py # 만평 이미지 (manual/request/auto)
|
||||
│ ├── article_parser.py # 원고 포맷 파서
|
||||
│ ├── remote_claude.py # Claude Agent SDK Telegram 연동
|
||||
│ ├── scheduler.py # APScheduler + Telegram 리스너
|
||||
│ │
|
||||
│ ├── converters/ # LAYER 2 — 변환 엔진
|
||||
│ │ ├── blog_converter.py # HTML 정제 + Schema.org + AdSense
|
||||
│ │ ├── card_converter.py # 인스타 카드 이미지 (Pillow)
|
||||
│ │ ├── thread_converter.py # X 스레드 280자 분할
|
||||
│ │ ├── 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/
|
||||
│ ├── get_token.py ← Google OAuth 토큰 발급
|
||||
│ └── setup.bat ← Windows 설치 스크립트
|
||||
├── .env.example ← 환경변수 템플릿
|
||||
└── requirements.txt
|
||||
│ ├── setup.bat # Windows 최초 설치 스크립트
|
||||
│ ├── get_token.py # Google OAuth 토큰 발급
|
||||
│ └── download_fonts.py # NotoSansKR / 맑은고딕 설치
|
||||
│
|
||||
├── 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 구독 필요)
|
||||
### 사전 요구사항
|
||||
|
||||
### 선택
|
||||
- **쿠팡 파트너스 계정** — 링크 수익화용
|
||||
- **OpenAI API Key** — 이미지 자동 생성 모드 사용 시
|
||||
|
||||
---
|
||||
|
||||
## 설치 방법
|
||||
- **Python 3.10 이상** — [python.org](https://www.python.org/downloads/)
|
||||
- **ffmpeg** — 쇼츠 영상 생성 필수. [ffmpeg.org](https://ffmpeg.org/download.html)에서 다운로드 후 PATH 추가 또는 `.env`에 `FFMPEG_PATH` 지정
|
||||
- Windows 미니PC 권장 (24시간 운영), macOS/Linux도 동작
|
||||
|
||||
### 1. 저장소 클론
|
||||
|
||||
@@ -97,89 +158,117 @@ git clone https://github.com/sinmb79/blog-writer.git
|
||||
cd blog-writer
|
||||
```
|
||||
|
||||
### 2. 설치 스크립트 실행 (Windows)
|
||||
|
||||
탐색기에서 `scripts\setup.bat` 더블클릭 또는:
|
||||
|
||||
```cmd
|
||||
scripts\setup.bat
|
||||
```
|
||||
|
||||
스크립트가 자동으로 처리하는 것:
|
||||
- Python 가상환경(`venv`) 생성
|
||||
- 패키지 설치 (`requirements.txt`)
|
||||
- `.env` 파일 생성 (`.env.example` 복사)
|
||||
- `data/`, `logs/` 폴더 생성
|
||||
- Windows 작업 스케줄러에 자동 시작 등록
|
||||
|
||||
### 3. 수동 설치 (선택)
|
||||
### 2. 가상환경 + 패키지 설치
|
||||
|
||||
```bash
|
||||
# Windows — 자동 설치 (권장)
|
||||
scripts\setup.bat
|
||||
|
||||
# 수동 설치
|
||||
python -m venv venv
|
||||
venv\Scripts\activate # Windows
|
||||
venv\Scripts\activate # Windows
|
||||
# source venv/bin/activate # macOS/Linux
|
||||
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
|
||||
# ─── Google (필수) ───────────────────────────────────
|
||||
GOOGLE_CLIENT_ID= # Google Cloud Console에서 발급
|
||||
GOOGLE_CLIENT_SECRET= # Google Cloud Console에서 발급
|
||||
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=
|
||||
IMAGE_MODE=manual # (기본) 발행 시 Telegram으로 프롬프트 전송
|
||||
IMAGE_MODE=request # 매주 월요일 프롬프트 목록 일괄 전송
|
||||
IMAGE_MODE=auto # DALL-E 3 자동 생성 (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 인증
|
||||
|
||||
### 1. Google Cloud Console 설정
|
||||
|
||||
1. [console.cloud.google.com](https://console.cloud.google.com/) 접속
|
||||
2. 새 프로젝트 생성
|
||||
3. **API 및 서비스 → 라이브러리** 에서 아래 두 API 활성화:
|
||||
1. [console.cloud.google.com](https://console.cloud.google.com/) → 새 프로젝트 생성
|
||||
2. **API 및 서비스 → 라이브러리** 에서 아래 API 활성화:
|
||||
- `Blogger API v3`
|
||||
- `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. 토큰 발급
|
||||
|
||||
@@ -188,175 +277,436 @@ venv\Scripts\activate
|
||||
python scripts\get_token.py
|
||||
```
|
||||
|
||||
브라우저가 열리면 Google 계정으로 로그인 → 권한 허용
|
||||
터미널에 출력된 `REFRESH_TOKEN` 값을 `.env`의 `GOOGLE_REFRESH_TOKEN`에 붙여넣기
|
||||
브라우저에서 Google 계정 인증 → `token.json` 자동 저장.
|
||||
`credentials.json`과 `token.json`은 `.gitignore`에 포함 — 절대 커밋하지 마세요.
|
||||
|
||||
---
|
||||
|
||||
## 실행하기
|
||||
## 실행 방법
|
||||
|
||||
### 스케줄러 시작 (메인 프로세스)
|
||||
### 스케줄러 시작 (권장)
|
||||
|
||||
```bash
|
||||
venv\Scripts\activate
|
||||
python bots\scheduler.py
|
||||
```
|
||||
|
||||
### 각 봇 단독 테스트
|
||||
|
||||
백그라운드 실행 (Windows):
|
||||
```bash
|
||||
# 수집봇 테스트 (글감 수집)
|
||||
python bots\collector_bot.py
|
||||
|
||||
# 분석봇 테스트 (일일 리포트)
|
||||
python bots\analytics_bot.py
|
||||
|
||||
# 분석봇 주간 리포트
|
||||
python bots\analytics_bot.py weekly
|
||||
|
||||
# 이미지 프롬프트 배치 전송 (request 모드)
|
||||
python bots\image_bot.py batch
|
||||
pythonw bots\scheduler.py
|
||||
```
|
||||
|
||||
### 자동 시작 확인 (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`)에서 글감을 수집하고 품질 점수를 계산합니다.
|
||||
|
||||
**품질 점수 (0–100):**
|
||||
- 트렌드 강도, 경쟁 기사 수, 키워드 밀도 반영
|
||||
- **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 | 수집봇 — 트렌드 수집 + 품질 점수 계산 + 폐기 필터링 |
|
||||
| 08:00 | AI 글 작성 트리거 (OpenClaw 서브에이전트) |
|
||||
| 09:00 | 발행봇 — 첫 번째 글 발행 |
|
||||
| 12:00 | 발행봇 — 두 번째 글 발행 |
|
||||
| 15:00 | 발행봇 — 세 번째 글 (선택) |
|
||||
| 22:00 | 분석봇 — 일일 리포트 → Telegram 전송 |
|
||||
| 매주 일요일 22:30 | 분석봇 — 주간 리포트 |
|
||||
| 매주 월요일 10:00 | 이미지봇 — 프롬프트 배치 전송 (request 모드) |
|
||||
| `manual` | 발행 시 DALL-E 프롬프트를 Telegram 전송. 직접 생성 후 저장. |
|
||||
| `request` | 매주 월요일 프롬프트 목록 전송. 일괄 생성 후 개별 전송. |
|
||||
| `auto` | DALL-E 3 API 자동 생성. `OPENAI_API_KEY` 필요, 비용 발생. |
|
||||
|
||||
### `article_parser.py` — 원고 파서
|
||||
|
||||
OpenClaw AI가 출력하는 구조화된 원고를 파싱합니다:
|
||||
|
||||
```
|
||||
---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 앞)
|
||||
- 쿠팡 파트너스 링크봇 호출
|
||||
|
||||
| 명령 | 설명 |
|
||||
|------|------|
|
||||
| `/status` | 봇 상태 + 이미지 모드 확인 |
|
||||
| `/approve [번호]` | 수동 검토 글 승인 후 발행 |
|
||||
| `/reject [번호]` | 수동 검토 글 거부 |
|
||||
| `/images` | 이미지 제작 대기/진행/완료 현황 |
|
||||
| `/imgpick [번호]` | 해당 번호 이미지 프롬프트 받기 |
|
||||
| `/imgbatch` | 프롬프트 배치 수동 전송 |
|
||||
| `/imgcancel` | 이미지 대기 상태 취소 |
|
||||
### `card_converter.py`
|
||||
|
||||
```
|
||||
입력: article dict
|
||||
출력: data/outputs/{date}_{slug}_card.png (1080×1080)
|
||||
```
|
||||
|
||||
Pillow로 인스타그램 카드 이미지를 생성합니다:
|
||||
|
||||
```
|
||||
┌─────────────────────────┐
|
||||
│ ████ 금색 상단 바 │
|
||||
│ │
|
||||
│ [코너 배지] │
|
||||
│ │
|
||||
│ 글 제목 │
|
||||
│ │
|
||||
│ • 핵심 포인트 1 │
|
||||
│ • 핵심 포인트 2 │
|
||||
│ • 핵심 포인트 3 │
|
||||
│ │
|
||||
│ ████ 금색 하단 바 (URL) │
|
||||
└─────────────────────────┘
|
||||
```
|
||||
|
||||
코너별 배지 색상: 쉬운세상=파랑, 숨은보물=초록, 바이브리포트=보라, 팩트체크=빨강, 한컷=노랑
|
||||
|
||||
### `thread_converter.py`
|
||||
|
||||
```
|
||||
입력: article dict
|
||||
출력: data/outputs/{date}_{slug}_thread.json
|
||||
```
|
||||
|
||||
- Tweet 1: 제목 + 코너 해시태그
|
||||
- Tweet 2–4: 번호 매긴 핵심 포인트
|
||||
- 마지막 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` (기본)
|
||||
한컷 코너 글 발행 시점에 프롬프트 1개를 Telegram으로 전송.
|
||||
사용자가 직접 이미지를 생성해 `data/images/` 에 파일 저장.
|
||||
Instagram API는 공개 URL 이미지만 허용하므로 로컬 파일을 업로드합니다:
|
||||
- **ImgBB API** (기본): 무료 이미지 호스팅, `IMGBB_API_KEY` 필요
|
||||
- **로컬 HTTP 서버**: 개발/테스트용, `LOCAL_IMAGE_SERVER=true`
|
||||
|
||||
### `request` (권장)
|
||||
매주 월요일 10:00 대기 중인 프롬프트 목록을 Telegram으로 일괄 전송.
|
||||
### `instagram_bot.py`
|
||||
|
||||
**사용 흐름:**
|
||||
1. 봇이 프롬프트 목록 전송 (또는 `/imgbatch` 수동 트리거)
|
||||
2. `/imgpick 3` — 3번 프롬프트 전체 내용 수신
|
||||
3. 프롬프트 복사 → Midjourney / DALL-E 웹 / Stable Diffusion 등에 붙여넣기
|
||||
4. 생성된 이미지를 Telegram으로 전송 (캡션에 `#3` 입력 또는 `/imgpick` 후 바로 전송)
|
||||
5. 봇이 자동 저장 + 완료 처리
|
||||
Instagram Graph API v19.0 흐름:
|
||||
1. `POST /media` — 미디어 컨테이너 생성
|
||||
2. `GET /media/{id}` — `FINISHED` 상태까지 폴링 (최대 2분)
|
||||
3. `POST /media_publish` — 컨테이너 발행
|
||||
|
||||
### `auto`
|
||||
OpenAI DALL-E 3 API를 직접 호출해 자동 생성.
|
||||
`OPENAI_API_KEY` 필요. 이미지당 $0.04–0.08 비용 발생 (ChatGPT Pro 구독과 별도).
|
||||
### `x_bot.py`
|
||||
|
||||
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/테크를 누구나 따라할 수 있게 | 주 2–3회 |
|
||||
| **숨은 보물** | 모르면 손해인 무료 도구 발굴 | 주 2–3회 |
|
||||
| **바이브 리포트** | 비개발자가 AI로 만든 실제 사례 | 주 1–2회 |
|
||||
| **팩트체크** | 과대광고/거짓 주장 검증 (수동 승인 필수) | 주 1회 이하 |
|
||||
| **한 컷** | AI/테크 이슈 만평 | 주 1회 |
|
||||
| 코너 | 성격 | 글 길이 | 안전장치 |
|
||||
|------|------|---------|---------|
|
||||
| **쉬운세상** | 복잡한 이슈를 쉽게 해설 | 1,500–2,000자 | 자동 발행 |
|
||||
| **숨은보물** | 유용하지만 덜 알려진 정보 | 1,500–2,000자 | 자동 발행 |
|
||||
| **바이브리포트** | 트렌드·문화 분석 | 1,500–2,500자 | 자동 발행 |
|
||||
| **팩트체크** | [사실]/[의견]/[추정] 명시 검증 | 2,000–2,500자 | **수동 승인 필수** |
|
||||
| **한컷** | 시사 만평 + 짧은 코멘트 | 300–500자 | 자동 발행 |
|
||||
|
||||
### 안전장치 (자동 발행 차단 조건)
|
||||
|
||||
아래 조건에 해당하면 자동 발행 대신 Telegram으로 수동 검토 요청:
|
||||
- 팩트체크 코너 글 전체
|
||||
- 암호화폐/투자/법률 관련 위험 키워드 감지
|
||||
**자동 발행 차단 조건 (Telegram 수동 검토):**
|
||||
- 팩트체크 코너 전체
|
||||
- 암호화폐/투자/법률 위험 키워드 감지
|
||||
- 출처 2개 미만
|
||||
- 품질 점수 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 ← 에이전트 관리 규칙
|
||||
│ └── blog-writer/SOUL.md ← 글쓰기 에이전트 설정
|
||||
└── workspace-blog-writer/
|
||||
├── personas/tech_insider.md ← 테크인사이더 페르소나
|
||||
├── corners/ ← 5개 코너 설정 파일
|
||||
└── templates/output_format.md ← 출력 포맷 템플릿
|
||||
"오늘 발행된 글 목록 보여줘"
|
||||
"쇼츠 변환 다시 실행해줘"
|
||||
"이번 주 조회수 상위 3개 알려줘"
|
||||
```
|
||||
|
||||
이 파일들은 설치 시 `~/.openclaw/` 에 수동으로 복사해야 합니다.
|
||||
(OpenClaw 설정 완료 후 `scheduler.py`의 `_call_openclaw()` 함수를 실제 호출 코드로 교체)
|
||||
|
||||
---
|
||||
|
||||
## Phase 로드맵
|
||||
## OpenClaw AI 에이전트 연동
|
||||
|
||||
| Phase | 기간 | 목표 | 예상 수익 |
|
||||
|-------|------|------|----------|
|
||||
| **1** | Month 1–3 | 블로그 1개, 시스템 검증, AdSense 승인 | 0–5만원/월 |
|
||||
| **2** | Month 3–5 | 블로그 2개, 쿠팡 수익 집중 | 5–20만원/월 |
|
||||
| **3** | Month 5–8 | 3–4개 블로그, 어필리에이트 추가 | 10–50만원/월 |
|
||||
| **4** | Month 8+ | 영문 블로그, 글로벌 확장 | 30–100만원+/월 |
|
||||
이 프로젝트는 [OpenClaw](https://openclaw.ai) AI 에이전트와 함께 사용하도록 설계되었습니다.
|
||||
|
||||
**에이전트 설정 파일 위치:**
|
||||
|
||||
```
|
||||
~/.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 없이도 사용할 수 있나요?**
|
||||
A. 봇 레이어(수집/발행/링크/분석)는 ChatGPT 없이 동작합니다. 글 작성(AI 레이어)만 OpenClaw + ChatGPT Pro를 사용합니다. 다른 LLM으로 교체하려면 `scheduler.py`의 `_call_openclaw()` 함수를 수정하세요.
|
||||
**Q. 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 외 다른 플랫폼을 사용할 수 있나요?**
|
||||
A. `publisher_bot.py`의 `publish_to_blogger()` 함수를 교체하면 WordPress, 티스토리 등으로 변경 가능합니다.
|
||||
A. `publisher_bot.py`의 `publish_to_blogger()` 함수를 교체하면 WordPress REST API, 티스토리 등으로 변경 가능합니다.
|
||||
|
||||
**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. 수집봇이 아무것도 가져오지 못해요.**
|
||||
A. `config/sources.json`의 RSS URL이 유효한지 확인하세요. Google Trends는 간혹 요청 제한이 걸릴 수 있으며, 이 경우 `pytrends` 관련 로그를 확인하세요.
|
||||
**Q. 수집봇이 글감을 못 가져와요.**
|
||||
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>
|
||||
|
||||
@@ -44,6 +44,15 @@ def parse_output(raw_output: str) -> Optional[dict]:
|
||||
coupang_raw = sections.get('COUPANG_KEYWORDS', '')
|
||||
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 {
|
||||
'title': sections.get('TITLE', ''),
|
||||
'meta': sections.get('META', ''),
|
||||
@@ -52,6 +61,7 @@ def parse_output(raw_output: str) -> Optional[dict]:
|
||||
'corner': sections.get('CORNER', ''),
|
||||
'body': sections.get('BODY', ''),
|
||||
'coupang_keywords': coupang_keywords,
|
||||
'key_points': key_points,
|
||||
'sources': sources,
|
||||
'disclaimer': sections.get('DISCLAIMER', ''),
|
||||
}
|
||||
|
||||
0
bots/converters/__init__.py
Normal file
0
bots/converters/__init__.py
Normal file
172
bots/converters/blog_converter.py
Normal file
172
bots/converters/blog_converter.py
Normal 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])
|
||||
182
bots/converters/card_converter.py
Normal file
182
bots/converters/card_converter.py
Normal 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}")
|
||||
141
bots/converters/newsletter_converter.py
Normal file
141
bots/converters/newsletter_converter.py
Normal 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])
|
||||
875
bots/converters/shorts_converter.py
Normal file
875
bots/converters/shorts_converter.py
Normal 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}')
|
||||
152
bots/converters/thread_converter.py
Normal file
152
bots/converters/thread_converter.py
Normal 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()
|
||||
0
bots/distributors/__init__.py
Normal file
0
bots/distributors/__init__.py
Normal file
224
bots/distributors/image_host.py
Normal file
224
bots/distributors/image_host.py
Normal 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 <이미지경로>")
|
||||
207
bots/distributors/instagram_bot.py
Normal file
207
bots/distributors/instagram_bot.py
Normal 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))
|
||||
214
bots/distributors/tiktok_bot.py
Normal file
214
bots/distributors/tiktok_bot.py
Normal 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
149
bots/distributors/x_bot.py
Normal 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")
|
||||
186
bots/distributors/youtube_bot.py
Normal file
186
bots/distributors/youtube_bot.py
Normal 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)
|
||||
@@ -340,14 +340,14 @@ def publish(article: dict) -> bool:
|
||||
send_pending_review_alert(article, review_reason)
|
||||
return False
|
||||
|
||||
# 마크다운 → HTML
|
||||
body_html, toc_html = markdown_to_html(article.get('body', ''))
|
||||
|
||||
# AdSense 플레이스홀더
|
||||
body_html = insert_adsense_placeholders(body_html)
|
||||
|
||||
# 최종 HTML 조합
|
||||
full_html = build_full_html(article, body_html, toc_html)
|
||||
# 변환봇이 미리 생성한 HTML이 있으면 재사용, 없으면 직접 변환
|
||||
if article.get('_html_content'):
|
||||
full_html = article['_html_content']
|
||||
else:
|
||||
# 마크다운 → HTML (fallback)
|
||||
body_html, toc_html = markdown_to_html(article.get('body', ''))
|
||||
body_html = insert_adsense_placeholders(body_html)
|
||||
full_html = build_full_html(article, body_html, toc_html)
|
||||
|
||||
# Google 인증
|
||||
try:
|
||||
|
||||
109
bots/remote_claude.py
Normal file
109
bots/remote_claude.py
Normal 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()
|
||||
@@ -17,6 +17,8 @@ from dotenv import load_dotenv
|
||||
from telegram import Update
|
||||
from telegram.ext import Application, CommandHandler, MessageHandler, filters, ContextTypes
|
||||
|
||||
import anthropic
|
||||
|
||||
load_dotenv()
|
||||
|
||||
BASE_DIR = Path(__file__).parent.parent
|
||||
@@ -40,6 +42,42 @@ logger = logging.getLogger(__name__)
|
||||
|
||||
TELEGRAM_BOT_TOKEN = os.getenv('TELEGRAM_BOT_TOKEN', '')
|
||||
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()
|
||||
# request 모드에서 이미지 대기 시 사용하는 상태 변수
|
||||
# {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')
|
||||
|
||||
|
||||
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):
|
||||
"""09:00 — 블로그 발행 (슬롯별)"""
|
||||
if not _publish_enabled:
|
||||
logger.info(f"[스케줄] 발행 중단 — 슬롯 {slot} 건너뜀")
|
||||
return
|
||||
@@ -118,6 +228,125 @@ def job_publish(slot: int):
|
||||
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():
|
||||
drafts_dir = DATA_DIR / 'drafts'
|
||||
drafts_dir.mkdir(exist_ok=True)
|
||||
@@ -127,14 +356,12 @@ def _publish_next():
|
||||
if article.get('_pending_openclaw'):
|
||||
continue
|
||||
sys.path.insert(0, str(BASE_DIR / 'bots'))
|
||||
sys.path.insert(0, str(BASE_DIR / 'bots' / 'converters'))
|
||||
import publisher_bot
|
||||
import linker_bot
|
||||
import markdown as md_lib
|
||||
body_html = md_lib.markdown(
|
||||
article.get('body', ''), extensions=['toc', 'tables', 'fenced_code']
|
||||
)
|
||||
body_html = linker_bot.process(article, body_html)
|
||||
article['body'] = body_html
|
||||
import blog_converter
|
||||
# 변환봇으로 HTML 생성 (이미 변환된 경우 outputs에서 읽음)
|
||||
html = blog_converter.convert(article, save_file=False)
|
||||
article['_html_content'] = html
|
||||
article['_body_is_html'] = True
|
||||
publisher_bot.publish(article)
|
||||
draft_file.unlink(missing_ok=True)
|
||||
@@ -271,6 +498,26 @@ async def cmd_report(update: Update, context: ContextTypes.DEFAULT_TYPE):
|
||||
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 모드) ────────────────
|
||||
|
||||
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):
|
||||
text = update.message.text.strip()
|
||||
chat_id = update.message.chat_id
|
||||
|
||||
cmd_map = {
|
||||
'발행 중단': cmd_stop_publish,
|
||||
'발행 재개': cmd_resume_publish,
|
||||
@@ -445,26 +694,45 @@ async def handle_text(update: Update, context: ContextTypes.DEFAULT_TYPE):
|
||||
'이번 주 리포트': cmd_report,
|
||||
'대기 중인 글 보여줘': cmd_pending,
|
||||
'이미지 목록': cmd_images,
|
||||
'변환 실행': cmd_convert,
|
||||
'오늘 뭐 발행했어?': cmd_status,
|
||||
}
|
||||
if text in cmd_map:
|
||||
await cmd_map[text](update, context)
|
||||
else:
|
||||
return
|
||||
|
||||
# Claude API로 자연어 처리
|
||||
if not ANTHROPIC_API_KEY:
|
||||
await update.message.reply_text(
|
||||
"사용 가능한 명령:\n"
|
||||
"• 발행 중단 / 발행 재개\n"
|
||||
"• 오늘 수집된 글감 보여줘\n"
|
||||
"• 대기 중인 글 보여줘\n"
|
||||
"• 이번 주 리포트\n"
|
||||
"• 이미지 목록\n\n"
|
||||
"슬래시 명령:\n"
|
||||
"/approve [번호] — 글 승인\n"
|
||||
"/reject [번호] — 글 거부\n"
|
||||
"/images — 이미지 제작 현황\n"
|
||||
"/imgpick [번호] — 프롬프트 선택\n"
|
||||
"/imgbatch — 프롬프트 배치 전송\n"
|
||||
"/imgcancel — 이미지 대기 취소\n"
|
||||
"/status — 봇 상태"
|
||||
"Claude API 키가 없습니다. .env 파일에 ANTHROPIC_API_KEY를 입력하세요."
|
||||
)
|
||||
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')
|
||||
schedule_cfg = load_schedule()
|
||||
|
||||
# schedule.json 기반 동적 잡 (기존)
|
||||
job_map = {
|
||||
'collector': job_collector,
|
||||
'ai_writer': job_ai_writer,
|
||||
@@ -486,9 +755,24 @@ def setup_scheduler() -> AsyncIOScheduler:
|
||||
if fn:
|
||||
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',
|
||||
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 이미지 프롬프트 배치 전송
|
||||
if IMAGE_MODE == 'request':
|
||||
@@ -496,7 +780,7 @@ def setup_scheduler() -> AsyncIOScheduler:
|
||||
day_of_week='mon', hour=10, minute=0, id='image_batch')
|
||||
logger.info("이미지 request 모드: 매주 월요일 10:00 배치 전송 등록")
|
||||
|
||||
logger.info("스케줄러 설정 완료")
|
||||
logger.info("스케줄러 설정 완료 (v3 시차 배포 포함)")
|
||||
return scheduler
|
||||
|
||||
|
||||
@@ -515,6 +799,7 @@ async def main():
|
||||
app.add_handler(CommandHandler('pending', cmd_pending))
|
||||
app.add_handler(CommandHandler('report', cmd_report))
|
||||
app.add_handler(CommandHandler('topics', cmd_show_topics))
|
||||
app.add_handler(CommandHandler('convert', cmd_convert))
|
||||
|
||||
# 이미지 관련 (request / manual 공통 사용 가능)
|
||||
app.add_handler(CommandHandler('images', cmd_images))
|
||||
|
||||
39
config/platforms.json
Normal file
39
config/platforms.json
Normal 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 활성화 필요 (기존 프로젝트에서)"
|
||||
}
|
||||
}
|
||||
@@ -8,5 +8,15 @@ requests
|
||||
beautifulsoup4
|
||||
feedparser
|
||||
markdown
|
||||
python-telegram-bot==20.7
|
||||
python-telegram-bot>=21.0
|
||||
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
139
scripts/download_fonts.py
Normal 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()
|
||||
517
shorts-video-template-spec.txt
Normal file
517
shorts-video-template-spec.txt
Normal 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 스타일의
|
||||
뉴스 앵커 포맷으로 양질의 쇼츠를 제작한다.
|
||||
자극적 클릭베이트보다 장기 구독자 확보를 우선한다."
|
||||
|
||||
|
||||
================================================================================
|
||||
끝
|
||||
================================================================================
|
||||
57
templates/shorts_template.json
Normal file
57
templates/shorts_template.json
Normal 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
|
||||
}
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user