Initial commit: 블로그 자동 수익 엔진 v2

- 수집봇: Google Trends, GitHub Trending, HN, Product Hunt, RSS 수집
  + 품질 점수(0-100) 시스템 + 6가지 폐기 규칙
- 발행봇: Blogger API v3 자동 발행 + 안전장치(팩트체크/위험키워드)
- 링크봇: 쿠팡 파트너스 HMAC 서명 + 자동 링크 삽입
- 분석봇: 색인률/CTR/14일성과 등 5대 핵심 지표 + Telegram 리포트
- 이미지봇: manual/request/auto 3가지 모드
  request 모드 — 주기적 프롬프트 전송 → Telegram으로 이미지 수령
- 스케줄러: APScheduler + Telegram 봇 명령 리스너

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
sinmb79
2026-03-25 06:54:43 +09:00
commit 15eb007b5a
20 changed files with 4507 additions and 0 deletions

17
.env.example Normal file
View File

@@ -0,0 +1,17 @@
GOOGLE_CLIENT_ID=your-google-client-id.apps.googleusercontent.com
GOOGLE_CLIENT_SECRET=your-google-client-secret
GOOGLE_REFRESH_TOKEN=
BLOG_MAIN_ID=your-blogger-blog-id
COUPANG_ACCESS_KEY=
COUPANG_SECRET_KEY=
TELEGRAM_BOT_TOKEN=your-telegram-bot-token
TELEGRAM_CHAT_ID=your-telegram-chat-id
# 이미지 모드 선택 (manual | request | auto)
# manual — 글 발행 시점에 프롬프트 1개를 Telegram으로 전송 (기본값)
# request — 매주 월요일 프롬프트 목록 전송 → 직접 생성 후 Telegram으로 이미지 전송
# auto — OpenAI DALL-E API 자동 생성 (OPENAI_API_KEY 필요, 별도 비용 발생)
IMAGE_MODE=manual
# auto 모드 사용 시에만 입력
OPENAI_API_KEY=
# 블로그 사이트 URL (Search Console 등록용)
BLOG_SITE_URL=

34
.gitignore vendored Normal file
View File

@@ -0,0 +1,34 @@
# 환경 변수 / 시크릿 — 절대 커밋하지 말 것
.env
token.json
credentials.json
# Python
venv/
__pycache__/
*.pyc
*.pyo
*.pyd
*.egg-info/
dist/
build/
.eggs/
# 런타임 데이터 (개인 글감/발행 이력)
data/topics/*.json
data/collected/*.json
data/discarded/*.json
data/pending_review/*.json
data/published/*.json
data/analytics/*.json
data/drafts/*.json
data/images/
data/images/pending_prompts.json
# 로그
logs/
# IDE
.vscode/
.idea/
*.swp

362
README.md Normal file
View File

@@ -0,0 +1,362 @@
# 블로그 자동 수익 엔진 (Blog Auto Revenue Engine)
AI 기반 한국어 블로그 자동화 시스템.
트렌드 수집 → AI 글 작성 → 자동 발행 → 수익 링크 삽입 → 성과 분석까지 전 과정을 자동화합니다.
> **Phase 1 목표:** Google Blogger 블로그 1개로 시작해 검색 자산 축적 + AdSense 승인
---
## 목차
1. [시스템 구조](#시스템-구조)
2. [사전 준비](#사전-준비)
3. [설치 방법](#설치-방법)
4. [API 키 설정](#api-키-설정)
5. [Google OAuth 인증](#google-oauth-인증)
6. [실행하기](#실행하기)
7. [Telegram 명령어](#telegram-명령어)
8. [이미지 모드 선택](#이미지-모드-선택)
9. [콘텐츠 코너 구성](#콘텐츠-코너-구성)
10. [Phase 로드맵](#phase-로드맵)
11. [자주 묻는 질문](#자주-묻는-질문)
---
## 시스템 구조
```
봇 레이어 (Python) AI 레이어 (OpenClaw)
───────────────── ────────────────────
수집봇 blog-writer 에이전트
└─ 트렌드 수집 └─ 글감 → 완성 글 작성
└─ 품질 점수 계산
└─ 폐기 규칙 적용
발행봇 ── 링크봇 ── 이미지봇
└─ 안전장치 └─ 만평 이미지
└─ Blogger 발행
└─ Search Console
분석봇 → Telegram 리포트
스케줄러 → 모든 봇 시간 관리
```
### 파일 구조
```
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)
├── scripts/
│ ├── get_token.py ← Google OAuth 토큰 발급
│ └── setup.bat ← Windows 설치 스크립트
├── .env.example ← 환경변수 템플릿
└── requirements.txt
```
---
## 사전 준비
### 필수
- **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** — 이미지 자동 생성 모드 사용 시
---
## 설치 방법
### 1. 저장소 클론
```bash
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. 수동 설치 (선택)
```bash
python -m venv venv
venv\Scripts\activate # Windows
pip install -r requirements.txt
copy .env.example .env
```
---
## API 키 설정
`.env` 파일을 열고 아래 항목을 입력합니다.
```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=
```
### 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 활성화:
- `Blogger API v3`
- `Google Search Console API`
4. **사용자 인증 정보 → OAuth 클라이언트 ID 만들기**
- 애플리케이션 유형: **데스크톱 앱**
5. `credentials.json` 다운로드 → 프로젝트 루트(`blog-writer/`)에 저장
### 2. 토큰 발급
```bash
venv\Scripts\activate
python scripts\get_token.py
```
브라우저가 열리면 Google 계정으로 로그인 → 권한 허용
터미널에 출력된 `REFRESH_TOKEN` 값을 `.env``GOOGLE_REFRESH_TOKEN`에 붙여넣기
---
## 실행하기
### 스케줄러 시작 (메인 프로세스)
```bash
venv\Scripts\activate
python bots\scheduler.py
```
### 각 봇 단독 테스트
```bash
# 수집봇 테스트 (글감 수집)
python bots\collector_bot.py
# 분석봇 테스트 (일일 리포트)
python bots\analytics_bot.py
# 분석봇 주간 리포트
python bots\analytics_bot.py weekly
# 이미지 프롬프트 배치 전송 (request 모드)
python bots\image_bot.py batch
```
### 자동 시작 확인 (Windows)
작업 스케줄러(`taskschd.msc`)에서 **BlogEngine** 작업이 등록되어 있으면 PC 시작 시 자동 실행됩니다.
---
## 일일 자동 플로우
| 시간 | 작업 |
|------|------|
| 07:00 | 수집봇 — 트렌드 수집 + 품질 점수 계산 + 폐기 필터링 |
| 08:00 | AI 글 작성 트리거 (OpenClaw 서브에이전트) |
| 09:00 | 발행봇 — 첫 번째 글 발행 |
| 12:00 | 발행봇 — 두 번째 글 발행 |
| 15:00 | 발행봇 — 세 번째 글 (선택) |
| 22:00 | 분석봇 — 일일 리포트 → Telegram 전송 |
| 매주 일요일 22:30 | 분석봇 — 주간 리포트 |
| 매주 월요일 10:00 | 이미지봇 — 프롬프트 배치 전송 (request 모드) |
---
## Telegram 명령어
### 텍스트 명령 (키보드로 입력)
| 명령 | 설명 |
|------|------|
| `발행 중단` | 자동 발행 일시 중지 |
| `발행 재개` | 자동 발행 재개 |
| `오늘 수집된 글감 보여줘` | 오늘 수집된 글감 목록 |
| `대기 중인 글 보여줘` | 수동 검토 대기 글 목록 |
| `이번 주 리포트` | 주간 리포트 즉시 생성 |
| `이미지 목록` | 이미지 제작 현황 |
### 슬래시 명령
| 명령 | 설명 |
|------|------|
| `/status` | 봇 상태 + 이미지 모드 확인 |
| `/approve [번호]` | 수동 검토 글 승인 후 발행 |
| `/reject [번호]` | 수동 검토 글 거부 |
| `/images` | 이미지 제작 대기/진행/완료 현황 |
| `/imgpick [번호]` | 해당 번호 이미지 프롬프트 받기 |
| `/imgbatch` | 프롬프트 배치 수동 전송 |
| `/imgcancel` | 이미지 대기 상태 취소 |
---
## 이미지 모드 선택
`.env``IMAGE_MODE` 값으로 선택합니다.
### `manual` (기본)
한컷 코너 글 발행 시점에 프롬프트 1개를 Telegram으로 전송.
사용자가 직접 이미지를 생성해 `data/images/` 에 파일 저장.
### `request` (권장)
매주 월요일 10:00 대기 중인 프롬프트 목록을 Telegram으로 일괄 전송.
**사용 흐름:**
1. 봇이 프롬프트 목록 전송 (또는 `/imgbatch` 수동 트리거)
2. `/imgpick 3` — 3번 프롬프트 전체 내용 수신
3. 프롬프트 복사 → Midjourney / DALL-E 웹 / Stable Diffusion 등에 붙여넣기
4. 생성된 이미지를 Telegram으로 전송 (캡션에 `#3` 입력 또는 `/imgpick` 후 바로 전송)
5. 봇이 자동 저장 + 완료 처리
### `auto`
OpenAI DALL-E 3 API를 직접 호출해 자동 생성.
`OPENAI_API_KEY` 필요. 이미지당 $0.040.08 비용 발생 (ChatGPT Pro 구독과 별도).
---
## 콘텐츠 코너 구성
| 코너 | 컨셉 | 발행 빈도 |
|------|------|-----------|
| **쉬운 세상** | AI/테크를 누구나 따라할 수 있게 | 주 23회 |
| **숨은 보물** | 모르면 손해인 무료 도구 발굴 | 주 23회 |
| **바이브 리포트** | 비개발자가 AI로 만든 실제 사례 | 주 12회 |
| **팩트체크** | 과대광고/거짓 주장 검증 (수동 승인 필수) | 주 1회 이하 |
| **한 컷** | AI/테크 이슈 만평 | 주 1회 |
### 안전장치 (자동 발행 차단 조건)
아래 조건에 해당하면 자동 발행 대신 Telegram으로 수동 검토 요청:
- 팩트체크 코너 글 전체
- 암호화폐/투자/법률 관련 위험 키워드 감지
- 출처 2개 미만
- 품질 점수 75점 미만
---
## OpenClaw 서브에이전트 설정
`~/.openclaw/` 디렉토리에 아래 파일을 배치합니다:
```
~/.openclaw/
├── agents/
│ ├── main/AGENTS.md ← 에이전트 관리 규칙
│ └── blog-writer/SOUL.md ← 글쓰기 에이전트 설정
└── workspace-blog-writer/
├── personas/tech_insider.md ← 테크인사이더 페르소나
├── corners/ ← 5개 코너 설정 파일
└── templates/output_format.md ← 출력 포맷 템플릿
```
이 파일들은 설치 시 `~/.openclaw/` 에 수동으로 복사해야 합니다.
(OpenClaw 설정 완료 후 `scheduler.py``_call_openclaw()` 함수를 실제 호출 코드로 교체)
---
## Phase 로드맵
| Phase | 기간 | 목표 | 예상 수익 |
|-------|------|------|----------|
| **1** | Month 13 | 블로그 1개, 시스템 검증, AdSense 승인 | 05만원/월 |
| **2** | Month 35 | 블로그 2개, 쿠팡 수익 집중 | 520만원/월 |
| **3** | Month 58 | 34개 블로그, 어필리에이트 추가 | 1050만원/월 |
| **4** | Month 8+ | 영문 블로그, 글로벌 확장 | 30100만원+/월 |
---
## 자주 묻는 질문
**Q. ChatGPT Pro 없이도 사용할 수 있나요?**
A. 봇 레이어(수집/발행/링크/분석)는 ChatGPT 없이 동작합니다. 글 작성(AI 레이어)만 OpenClaw + ChatGPT Pro를 사용합니다. 다른 LLM으로 교체하려면 `scheduler.py``_call_openclaw()` 함수를 수정하세요.
**Q. Blogger 외 다른 플랫폼을 사용할 수 있나요?**
A. `publisher_bot.py``publish_to_blogger()` 함수를 교체하면 WordPress, 티스토리 등으로 변경 가능합니다.
**Q. Windows가 아닌 환경에서 사용하려면?**
A. `setup.bat` 대신 수동으로 venv를 생성하고 패키지를 설치하세요. `scheduler.py`는 크로스 플랫폼으로 동작합니다. Windows 작업 스케줄러 등록 부분만 Linux cron 또는 macOS launchd로 대체하세요.
**Q. 수집봇이 아무것도 가져오지 못해요.**
A. `config/sources.json`의 RSS URL이 유효한지 확인하세요. Google Trends는 간혹 요청 제한이 걸릴 수 있으며, 이 경우 `pytrends` 관련 로그를 확인하세요.
---
## 라이선스
MIT License — 자유롭게 사용, 수정, 배포 가능합니다.

File diff suppressed because it is too large Load Diff

370
bots/analytics_bot.py Normal file
View File

@@ -0,0 +1,370 @@
"""
분석봇 (analytics_bot.py)
역할: 블로그 성과 데이터 수집 및 리포트 생성
5대 핵심 지표:
1. 색인률 (Search Console)
2. 검색 CTR (Search Console)
3. 발행 후 14일 성과
4. 어필리에이트 클릭률 (수동 입력)
5. 체류시간 (Blogger 통계)
"""
import json
import logging
import os
import re
from datetime import datetime, timedelta, timezone
from pathlib import Path
import requests
from dotenv import load_dotenv
from google.oauth2.credentials import Credentials
from google.auth.transport.requests import Request
from googleapiclient.discovery import build
load_dotenv()
BASE_DIR = Path(__file__).parent.parent
DATA_DIR = BASE_DIR / 'data'
LOG_DIR = BASE_DIR / 'logs'
TOKEN_PATH = BASE_DIR / 'token.json'
LOG_DIR.mkdir(exist_ok=True)
logging.basicConfig(
level=logging.INFO,
format='%(asctime)s [%(levelname)s] %(message)s',
handlers=[
logging.FileHandler(LOG_DIR / 'analytics.log', encoding='utf-8'),
logging.StreamHandler(),
]
)
logger = logging.getLogger(__name__)
TELEGRAM_BOT_TOKEN = os.getenv('TELEGRAM_BOT_TOKEN', '')
TELEGRAM_CHAT_ID = os.getenv('TELEGRAM_CHAT_ID', '')
BLOG_MAIN_ID = os.getenv('BLOG_MAIN_ID', '')
SCOPES = [
'https://www.googleapis.com/auth/blogger.readonly',
'https://www.googleapis.com/auth/webmasters.readonly',
]
def get_google_credentials() -> Credentials:
creds = None
if TOKEN_PATH.exists():
creds = Credentials.from_authorized_user_file(str(TOKEN_PATH), SCOPES)
if not creds or not creds.valid:
if creds and creds.expired and creds.refresh_token:
creds.refresh(Request())
with open(TOKEN_PATH, 'w') as f:
f.write(creds.to_json())
return creds
def load_published_records() -> list[dict]:
"""발행 이력 전체 로드"""
records = []
published_dir = DATA_DIR / 'published'
for f in published_dir.glob('*.json'):
try:
records.append(json.loads(f.read_text(encoding='utf-8')))
except Exception:
pass
return sorted(records, key=lambda x: x.get('published_at', ''), reverse=True)
def send_telegram(text: str):
if not TELEGRAM_BOT_TOKEN or not TELEGRAM_CHAT_ID:
logger.warning("Telegram 설정 없음")
print(text)
return
url = f'https://api.telegram.org/bot{TELEGRAM_BOT_TOKEN}/sendMessage'
try:
requests.post(url, json={
'chat_id': TELEGRAM_CHAT_ID,
'text': text,
'parse_mode': 'HTML',
}, timeout=10)
except Exception as e:
logger.error(f"Telegram 전송 실패: {e}")
# ─── Search Console 데이터 ────────────────────────────
def get_search_console_data(site_url: str, start_date: str, end_date: str,
creds: Credentials) -> dict:
"""Search Console API로 검색 성과 조회"""
try:
service = build('searchconsole', 'v1', credentials=creds)
request_body = {
'startDate': start_date,
'endDate': end_date,
'dimensions': ['page'],
'rowLimit': 1000,
}
resp = service.searchanalytics().query(
siteUrl=site_url, body=request_body
).execute()
return resp
except Exception as e:
logger.warning(f"Search Console API 오류: {e}")
return {}
def calc_index_rate(published_records: list[dict], sc_data: dict) -> float:
"""색인률 계산: 발행 글 중 Search Console에 데이터가 있는 비율"""
if not published_records:
return 0.0
sc_urls = set()
for row in sc_data.get('rows', []):
sc_urls.add(row.get('keys', [''])[0])
indexed = sum(1 for r in published_records if r.get('url', '') in sc_urls)
return round(indexed / len(published_records) * 100, 1)
def calc_average_ctr(sc_data: dict) -> float:
"""평균 CTR 계산"""
rows = sc_data.get('rows', [])
if not rows:
return 0.0
total_clicks = sum(r.get('clicks', 0) for r in rows)
total_impressions = sum(r.get('impressions', 0) for r in rows)
if total_impressions == 0:
return 0.0
return round(total_clicks / total_impressions * 100, 2)
def get_14day_performance(published_records: list[dict], sc_data: dict) -> list[dict]:
"""발행 후 14일 경과한 글들의 성과"""
now = datetime.now(timezone.utc)
cutoff = now - timedelta(days=14)
sc_rows_by_url = {}
for row in sc_data.get('rows', []):
url = row.get('keys', [''])[0]
sc_rows_by_url[url] = row
results = []
for record in published_records:
pub_str = record.get('published_at', '')
try:
pub_dt = datetime.fromisoformat(pub_str)
if pub_dt.tzinfo is None:
pub_dt = pub_dt.replace(tzinfo=timezone.utc)
except Exception:
continue
if pub_dt > cutoff:
continue # 14일 미경과
url = record.get('url', '')
sc_row = sc_rows_by_url.get(url, {})
clicks = sc_row.get('clicks', 0)
impressions = sc_row.get('impressions', 0)
results.append({
'title': record.get('title', ''),
'corner': record.get('corner', ''),
'published_at': pub_str,
'clicks_14d': clicks,
'impressions_14d': impressions,
'url': url,
})
return results
# ─── 리포트 생성 ──────────────────────────────────────
def format_daily_report(
today_published: list[dict],
index_rate: float,
avg_ctr: float,
total_published: int,
) -> str:
today_str = datetime.now().strftime('%Y-%m-%d')
today_count = len(today_published)
today_titles = '\n'.join(
f" • [{r.get('corner', '')}] {r.get('title', '')}" for r in today_published
)
return (
f"📊 <b>일일 리포트 — {today_str}</b>\n\n"
f"📝 오늘 발행: {today_count}\n"
f"{today_titles}\n\n"
f"📈 누적 발행: {total_published}\n"
f"🔍 색인률: {index_rate}%\n"
f"🖱 평균 CTR: {avg_ctr}%\n\n"
f"Phase 1 목표: 색인률 80%+, CTR 3%+"
)
def format_weekly_report(
index_rate: float,
avg_ctr: float,
by_corner: dict,
low_performers: list[dict],
) -> str:
today_str = datetime.now().strftime('%Y-%m-%d')
corner_lines = '\n'.join(
f"{corner}: {count}" for corner, count in by_corner.items()
)
low_lines = '\n'.join(
f"{r['title']} (클릭 {r['clicks_14d']}회)" for r in low_performers[:5]
) or ' 없음'
return (
f"📊 <b>주간 리포트 — {today_str}</b>\n\n"
f"🔍 색인률: {index_rate}%\n"
f"🖱 평균 CTR: {avg_ctr}%\n\n"
f"📁 코너별 발행 수:\n{corner_lines}\n\n"
f"⚠ 14일 성과 부진 글 (클릭 0):\n{low_lines}\n\n"
f"💡 피드백 루프 적용 완료 → 다음 주 글감 조정"
)
def save_analytics(data: dict, filename: str):
analytics_dir = DATA_DIR / 'analytics'
analytics_dir.mkdir(exist_ok=True)
with open(analytics_dir / filename, 'w', encoding='utf-8') as f:
json.dump(data, f, ensure_ascii=False, indent=2)
def generate_feedback_json(index_rate: float, avg_ctr: float,
low_performers: list[dict], by_corner: dict) -> dict:
"""수집봇에 피드백할 데이터 생성"""
feedback = {
'generated_at': datetime.now().isoformat(),
'metrics': {
'index_rate': index_rate,
'avg_ctr': avg_ctr,
},
'adjustments': [],
}
if index_rate < 50:
feedback['adjustments'].append({
'type': 'warning',
'message': '색인률 50% 미만 — 글 구조/Schema 점검 필요',
})
if avg_ctr < 1:
feedback['adjustments'].append({
'type': 'title_meta',
'message': 'CTR 1% 미만 — 제목/메타 설명 스타일 변경 권고',
})
# 성과 좋은 코너 확대
max_corner = max(by_corner, key=by_corner.get) if by_corner else None
if max_corner:
feedback['adjustments'].append({
'type': 'corner_boost',
'corner': max_corner,
'message': f'{max_corner} 코너 성과 우수 — 비율 확대 권고',
})
# 14일 성과 0인 글감 유형 축소
if low_performers:
bad_corners = list({r['corner'] for r in low_performers if r['clicks_14d'] == 0})
for corner in bad_corners:
feedback['adjustments'].append({
'type': 'corner_reduce',
'corner': corner,
'message': f'{corner} 코너 14일 성과 부진 — 주제 유형 축소 권고',
})
return feedback
# ─── 메인 실행 ───────────────────────────────────────
def daily_report():
"""일일 리포트 생성 및 Telegram 전송"""
logger.info("=== 분석봇 일일 리포트 시작 ===")
published_records = load_published_records()
# 오늘 발행 글
today_str = datetime.now().strftime('%Y-%m-%d')
today_published = [
r for r in published_records
if r.get('published_at', '').startswith(today_str)
]
# Search Console 데이터 (최근 7일)
sc_data = {}
try:
creds = get_google_credentials()
if creds and creds.valid:
end_date = datetime.now().strftime('%Y-%m-%d')
start_date = (datetime.now() - timedelta(days=7)).strftime('%Y-%m-%d')
# site_url은 블로그 URL (예: https://techinsider-kr.blogspot.com/)
# 설정에서 읽어오거나 환경변수로 관리
site_url = os.getenv('BLOG_SITE_URL', '')
if site_url:
sc_data = get_search_console_data(site_url, start_date, end_date, creds)
except Exception as e:
logger.warning(f"Search Console 조회 실패: {e}")
index_rate = calc_index_rate(published_records, sc_data)
avg_ctr = calc_average_ctr(sc_data)
report_text = format_daily_report(
today_published, index_rate, avg_ctr, len(published_records)
)
send_telegram(report_text)
# 저장
save_analytics({
'date': today_str,
'today_published': len(today_published),
'total_published': len(published_records),
'index_rate': index_rate,
'avg_ctr': avg_ctr,
}, f'{today_str}_daily.json')
logger.info("=== 분석봇 일일 리포트 완료 ===")
def weekly_report():
"""주간 리포트 생성 및 Telegram 전송"""
logger.info("=== 분석봇 주간 리포트 시작 ===")
published_records = load_published_records()
# Search Console 데이터 (최근 28일)
sc_data = {}
try:
creds = get_google_credentials()
if creds and creds.valid:
end_date = datetime.now().strftime('%Y-%m-%d')
start_date = (datetime.now() - timedelta(days=28)).strftime('%Y-%m-%d')
site_url = os.getenv('BLOG_SITE_URL', '')
if site_url:
sc_data = get_search_console_data(site_url, start_date, end_date, creds)
except Exception as e:
logger.warning(f"Search Console 조회 실패: {e}")
index_rate = calc_index_rate(published_records, sc_data)
avg_ctr = calc_average_ctr(sc_data)
perf_14d = get_14day_performance(published_records, sc_data)
# 코너별 발행 수
by_corner: dict[str, int] = {}
for r in published_records:
corner = r.get('corner', '기타')
by_corner[corner] = by_corner.get(corner, 0) + 1
# 14일 성과 부진 글
low_performers = [r for r in perf_14d if r['clicks_14d'] == 0]
report_text = format_weekly_report(index_rate, avg_ctr, by_corner, low_performers)
send_telegram(report_text)
# 피드백 JSON 생성
feedback = generate_feedback_json(index_rate, avg_ctr, low_performers, by_corner)
save_analytics(feedback, f"{datetime.now().strftime('%Y%m%d')}_feedback.json")
logger.info("=== 분석봇 주간 리포트 완료 ===")
return feedback
if __name__ == '__main__':
import sys
if len(sys.argv) > 1 and sys.argv[1] == 'weekly':
weekly_report()
else:
daily_report()

99
bots/article_parser.py Normal file
View File

@@ -0,0 +1,99 @@
"""
article_parser.py
OpenClaw blog-writer 출력(output_format.md 형식)을 파싱하여
발행봇이 사용할 수 있는 dict로 변환.
"""
import re
from typing import Optional
def parse_output(raw_output: str) -> Optional[dict]:
"""
OpenClaw 출력 문자열을 파싱.
Returns: dict 또는 None (파싱 실패 시)
"""
sections = {}
pattern = re.compile(r'---(\w+)---\n(.*?)(?=---\w+---|$)', re.DOTALL)
matches = pattern.findall(raw_output)
for key, value in matches:
sections[key.strip()] = value.strip()
if not sections.get('TITLE') or not sections.get('BODY'):
return None
# 출처 파싱
sources = []
sources_raw = sections.get('SOURCES', '')
for line in sources_raw.splitlines():
line = line.strip()
if not line:
continue
parts = [p.strip() for p in line.split('|')]
sources.append({
'url': parts[0] if len(parts) > 0 else '',
'title': parts[1] if len(parts) > 1 else '',
'date': parts[2] if len(parts) > 2 else '',
})
# 태그 파싱
tags_raw = sections.get('TAGS', '')
tags = [t.strip() for t in tags_raw.split(',') if t.strip()]
# 쿠팡 키워드 파싱
coupang_raw = sections.get('COUPANG_KEYWORDS', '')
coupang_keywords = [k.strip() for k in coupang_raw.split(',') if k.strip()]
return {
'title': sections.get('TITLE', ''),
'meta': sections.get('META', ''),
'slug': sections.get('SLUG', ''),
'tags': tags,
'corner': sections.get('CORNER', ''),
'body': sections.get('BODY', ''),
'coupang_keywords': coupang_keywords,
'sources': sources,
'disclaimer': sections.get('DISCLAIMER', ''),
}
if __name__ == '__main__':
sample = """---TITLE---
ChatGPT 처음 쓰는 사람을 위한 완전 가이드
---META---
ChatGPT를 처음 사용하는 분을 위한 단계별 가이드입니다.
---SLUG---
chatgpt-beginners-complete-guide
---TAGS---
ChatGPT, AI, 가이드, 입문
---CORNER---
쉬운세상
---BODY---
## ChatGPT란?
ChatGPT는 OpenAI가 만든 AI 챗봇입니다.
## 어떻게 시작하나요?
1단계: chat.openai.com 접속
## 결론
오늘부터 바로 시작해보세요.
---COUPANG_KEYWORDS---
키보드, 마우스
---SOURCES---
https://openai.com/blog | OpenAI 공식 블로그 | 2026-03-24
---DISCLAIMER---
"""
result = parse_output(sample)
import json
print(json.dumps(result, ensure_ascii=False, indent=2))

514
bots/collector_bot.py Normal file
View File

@@ -0,0 +1,514 @@
"""
수집봇 (collector_bot.py)
역할: 트렌드/도구/사례 수집 + 품질 점수 계산 + 폐기 규칙 적용
실행: 매일 07:00 (스케줄러 호출)
"""
import json
import logging
import os
import re
import hashlib
from datetime import datetime, timedelta, timezone
from difflib import SequenceMatcher
from pathlib import Path
import feedparser
import requests
from bs4 import BeautifulSoup
from dotenv import load_dotenv
load_dotenv()
BASE_DIR = Path(__file__).parent.parent
CONFIG_DIR = BASE_DIR / 'config'
DATA_DIR = BASE_DIR / 'data'
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 / 'collector.log', encoding='utf-8'),
logging.StreamHandler(),
]
)
logger = logging.getLogger(__name__)
# 코너별 타입
CORNER_TYPES = {
'easy_guide': '쉬운세상',
'hidden_gems': '숨은보물',
'vibe_report': '바이브리포트',
'fact_check': '팩트체크',
'one_cut': '한컷',
}
# 글감 타입 비율: 에버그린 50%, 트렌드 30%, 개성 20%
TOPIC_RATIO = {'evergreen': 0.5, 'trending': 0.3, 'personality': 0.2}
def load_config(filename: str) -> dict:
with open(CONFIG_DIR / filename, 'r', encoding='utf-8') as f:
return json.load(f)
def load_published_titles() -> list[str]:
"""발행 이력에서 제목 목록을 불러옴 (유사도 비교용)"""
titles = []
published_dir = DATA_DIR / 'published'
for f in published_dir.glob('*.json'):
try:
data = json.loads(f.read_text(encoding='utf-8'))
if 'title' in data:
titles.append(data['title'])
except Exception:
pass
return titles
def title_similarity(a: str, b: str) -> float:
return SequenceMatcher(None, a, b).ratio()
def is_duplicate(title: str, published_titles: list[str], threshold: float = 0.8) -> bool:
for pub_title in published_titles:
if title_similarity(title, pub_title) >= threshold:
return True
return False
def calc_freshness_score(published_at: datetime | None, max_score: int = 20) -> int:
"""발행 시간 기준 신선도 점수 (24h 이내 만점, 7일 초과 0점)"""
if published_at is None:
return max_score // 2
now = datetime.now(timezone.utc)
if published_at.tzinfo is None:
published_at = published_at.replace(tzinfo=timezone.utc)
age_hours = (now - published_at).total_seconds() / 3600
if age_hours <= 24:
return max_score
elif age_hours >= 168:
return 0
else:
ratio = 1 - (age_hours - 24) / (168 - 24)
return int(max_score * ratio)
def calc_korean_relevance(text: str, rules: dict) -> int:
"""한국 독자 관련성 점수"""
keywords = rules['scoring']['korean_relevance']['keywords']
matched = sum(1 for kw in keywords if kw in text)
score = min(matched * 6, rules['scoring']['korean_relevance']['max'])
return score
def calc_source_trust(source_url: str, rules: dict) -> tuple[int, str]:
"""출처 신뢰도 점수 + 레벨"""
trust_cfg = rules['scoring']['source_trust']
high_src = trust_cfg.get('high_sources', [])
low_src = trust_cfg.get('low_sources', [])
url_lower = source_url.lower()
for s in low_src:
if s in url_lower:
return trust_cfg['levels']['low'], 'low'
for s in high_src:
if s in url_lower:
return trust_cfg['levels']['high'], 'high'
return trust_cfg['levels']['medium'], 'medium'
def calc_monetization(text: str, rules: dict) -> int:
"""수익 연결 가능성 점수"""
keywords = rules['scoring']['monetization']['keywords']
matched = sum(1 for kw in keywords if kw in text)
return min(matched * 5, rules['scoring']['monetization']['max'])
def is_evergreen(title: str, rules: dict) -> bool:
evergreen_kws = rules.get('evergreen_keywords', [])
return any(kw in title for kw in evergreen_kws)
def apply_discard_rules(item: dict, rules: dict, published_titles: list[str]) -> str | None:
"""
폐기 규칙 적용. 폐기 사유 반환(None이면 통과).
"""
title = item.get('topic', '')
text = title + ' ' + item.get('description', '')
discard_rules = rules.get('discard_rules', [])
for rule in discard_rules:
rule_id = rule['id']
if rule_id == 'no_korean_relevance':
if item.get('korean_relevance_score', 0) == 0:
return '한국 독자 관련성 없음'
elif rule_id == 'unverified_source':
if item.get('source_trust_level') == 'unknown':
return '출처 불명'
elif rule_id == 'duplicate_topic':
threshold = rule.get('similarity_threshold', 0.8)
if is_duplicate(title, published_titles, threshold):
return f'기발행 주제와 유사도 {threshold*100:.0f}% 이상'
elif rule_id == 'stale_trend':
if not item.get('is_evergreen', False):
max_days = rule.get('max_age_days', 7)
pub_at = item.get('published_at')
if pub_at:
if isinstance(pub_at, str):
try:
pub_at = datetime.fromisoformat(pub_at)
except Exception:
pub_at = None
if pub_at:
if pub_at.tzinfo is None:
pub_at = pub_at.replace(tzinfo=timezone.utc)
age_days = (datetime.now(timezone.utc) - pub_at).days
if age_days > max_days:
return f'{age_days}일 지난 트렌드'
elif rule_id == 'promotional':
kws = rule.get('keywords', [])
if any(kw in text for kw in kws):
return '광고성/홍보성 콘텐츠'
elif rule_id == 'clickbait':
patterns = rule.get('patterns', [])
if any(p in text for p in patterns):
return '클릭베이트성 주제'
return None
def assign_corner(item: dict, topic_type: str) -> str:
"""글감에 코너 배정"""
title = item.get('topic', '').lower()
source = item.get('source', 'rss').lower()
if topic_type == 'evergreen':
if any(kw in title for kw in ['가이드', '방법', '사용법', '입문', '튜토리얼', '기초']):
return '쉬운세상'
return '숨은보물'
elif topic_type == 'trending':
if source in ['github', 'product_hunt']:
return '숨은보물'
return '쉬운세상'
else: # personality
return '바이브리포트'
def calculate_quality_score(item: dict, rules: dict) -> int:
"""0-100점 품질 점수 계산"""
text = item.get('topic', '') + ' ' + item.get('description', '')
source_url = item.get('source_url', '')
pub_at_str = item.get('published_at')
pub_at = None
if pub_at_str:
try:
pub_at = datetime.fromisoformat(pub_at_str)
except Exception:
pass
kr_score = calc_korean_relevance(text, rules)
fresh_score = calc_freshness_score(pub_at)
# search_demand: pytrends 연동 후 실제값 사용 (현재 기본값 10)
search_score = item.get('search_demand_score', 10)
trust_score, trust_level = calc_source_trust(source_url, rules)
mono_score = calc_monetization(text, rules)
item['korean_relevance_score'] = kr_score
item['source_trust_level'] = trust_level
item['is_evergreen'] = is_evergreen(item.get('topic', ''), rules)
total = kr_score + fresh_score + search_score + trust_score + mono_score
return min(total, 100)
# ─── 수집 소스별 함수 ─────────────────────────────────
def collect_google_trends() -> list[dict]:
"""Google Trends (pytrends) — 한국 일간 트렌딩"""
items = []
try:
from pytrends.request import TrendReq
pytrends = TrendReq(hl='ko', tz=540, timeout=(10, 30))
trending_df = pytrends.trending_searches(pn='south_korea')
for keyword in trending_df[0].tolist()[:20]:
items.append({
'topic': keyword,
'description': f'Google Trends 한국 트렌딩 키워드: {keyword}',
'source': 'google_trends',
'source_url': f'https://trends.google.co.kr/trends/explore?q={keyword}&geo=KR',
'published_at': datetime.now(timezone.utc).isoformat(),
'search_demand_score': 15,
'topic_type': 'trending',
})
except Exception as e:
logger.warning(f"Google Trends 수집 실패: {e}")
return items
def collect_github_trending(sources_cfg: dict) -> list[dict]:
"""GitHub Trending 크롤링"""
items = []
cfg = sources_cfg.get('github_trending', {})
languages = cfg.get('languages', [''])
since = cfg.get('since', 'daily')
for lang in languages:
url = f"https://github.com/trending/{lang}?since={since}"
try:
resp = requests.get(url, timeout=15, headers={'User-Agent': 'Mozilla/5.0'})
soup = BeautifulSoup(resp.text, 'lxml')
repos = soup.select('article.Box-row')
for repo in repos[:10]:
name_el = repo.select_one('h2 a')
desc_el = repo.select_one('p')
stars_el = repo.select_one('a[href*="stargazers"]')
if not name_el:
continue
repo_path = name_el.get('href', '').strip('/')
topic = repo_path.replace('/', ' / ')
desc = desc_el.get_text(strip=True) if desc_el else ''
stars = stars_el.get_text(strip=True) if stars_el else '0'
items.append({
'topic': topic,
'description': desc,
'source': 'github',
'source_url': f'https://github.com/{repo_path}',
'published_at': datetime.now(timezone.utc).isoformat(),
'search_demand_score': 12,
'topic_type': 'trending',
'extra': {'stars': stars},
})
except Exception as e:
logger.warning(f"GitHub Trending 수집 실패 ({lang}): {e}")
return items
def collect_hacker_news(sources_cfg: dict) -> list[dict]:
"""Hacker News API 상위 스토리"""
items = []
cfg = sources_cfg.get('hacker_news', {})
api_url = cfg.get('url', 'https://hacker-news.firebaseio.com/v0/topstories.json')
top_n = cfg.get('top_n', 30)
try:
resp = requests.get(api_url, timeout=10)
story_ids = resp.json()[:top_n]
for sid in story_ids:
story_resp = requests.get(
f'https://hacker-news.firebaseio.com/v0/item/{sid}.json', timeout=5
)
story = story_resp.json()
if not story or story.get('type') != 'story':
continue
pub_ts = story.get('time')
pub_at = datetime.fromtimestamp(pub_ts, tz=timezone.utc).isoformat() if pub_ts else None
items.append({
'topic': story.get('title', ''),
'description': story.get('url', ''),
'source': 'hacker_news',
'source_url': story.get('url', f'https://news.ycombinator.com/item?id={sid}'),
'published_at': pub_at,
'search_demand_score': 8,
'topic_type': 'trending',
})
except Exception as e:
logger.warning(f"Hacker News 수집 실패: {e}")
return items
def collect_product_hunt(sources_cfg: dict) -> list[dict]:
"""Product Hunt RSS"""
items = []
cfg = sources_cfg.get('product_hunt', {})
rss_url = cfg.get('rss_url', 'https://www.producthunt.com/feed')
try:
feed = feedparser.parse(rss_url)
for entry in feed.entries[:15]:
pub_at = None
if hasattr(entry, 'published_parsed') and entry.published_parsed:
pub_at = datetime(*entry.published_parsed[:6], tzinfo=timezone.utc).isoformat()
items.append({
'topic': entry.get('title', ''),
'description': entry.get('summary', ''),
'source': 'product_hunt',
'source_url': entry.get('link', ''),
'published_at': pub_at,
'search_demand_score': 10,
'topic_type': 'trending',
})
except Exception as e:
logger.warning(f"Product Hunt 수집 실패: {e}")
return items
def collect_rss_feeds(sources_cfg: dict) -> list[dict]:
"""설정된 RSS 피드 수집"""
items = []
feeds = sources_cfg.get('rss_feeds', [])
for feed_cfg in feeds:
url = feed_cfg.get('url', '')
trust = feed_cfg.get('trust_level', 'medium')
try:
feed = feedparser.parse(url)
for entry in feed.entries[:10]:
pub_at = None
if hasattr(entry, 'published_parsed') and entry.published_parsed:
pub_at = datetime(*entry.published_parsed[:6], tzinfo=timezone.utc).isoformat()
items.append({
'topic': entry.get('title', ''),
'description': entry.get('summary', '') or entry.get('description', ''),
'source': 'rss',
'source_name': feed_cfg.get('name', ''),
'source_url': entry.get('link', ''),
'published_at': pub_at,
'search_demand_score': 8,
'topic_type': 'trending',
'_trust_override': trust,
})
except Exception as e:
logger.warning(f"RSS 수집 실패 ({url}): {e}")
return items
def extract_coupang_keywords(topic: str, description: str) -> list[str]:
"""글감에서 쿠팡 검색 키워드 추출"""
product_keywords = [
'마이크', '웹캠', '키보드', '마우스', '모니터', '노트북', '이어폰',
'헤드셋', '외장하드', 'USB허브', '책상', '의자', '서적', '', '스피커',
]
text = topic + ' ' + description
found = [kw for kw in product_keywords if kw in text]
if not found:
# IT 기기 류 글이면 기본 키워드
if any(kw in text for kw in ['도구', '', '', '소프트웨어', '서비스']):
found = ['키보드', '마우스']
return found
def save_discarded(item: dict, reason: str):
"""폐기된 글감 로그 저장"""
discard_dir = DATA_DIR / 'discarded'
discard_dir.mkdir(exist_ok=True)
today = datetime.now().strftime('%Y%m%d')
log_file = discard_dir / f'{today}_discarded.jsonl'
record = {**item, 'discard_reason': reason, 'discarded_at': datetime.now().isoformat()}
with open(log_file, 'a', encoding='utf-8') as f:
f.write(json.dumps(record, ensure_ascii=False) + '\n')
def save_topic(item: dict):
"""합격한 글감을 data/topics/에 저장"""
topics_dir = DATA_DIR / 'topics'
topics_dir.mkdir(exist_ok=True)
topic_id = hashlib.md5(item['topic'].encode()).hexdigest()[:8]
filename = f"{datetime.now().strftime('%Y%m%d')}_{topic_id}.json"
with open(topics_dir / filename, 'w', encoding='utf-8') as f:
json.dump(item, f, ensure_ascii=False, indent=2)
def run():
logger.info("=== 수집봇 시작 ===")
rules = load_config('quality_rules.json')
sources_cfg = load_config('sources.json')
published_titles = load_published_titles()
min_score = rules.get('min_score', 70)
# 수집
all_items = []
all_items += collect_google_trends()
all_items += collect_github_trending(sources_cfg)
all_items += collect_product_hunt(sources_cfg)
all_items += collect_hacker_news(sources_cfg)
all_items += collect_rss_feeds(sources_cfg)
logger.info(f"수집 완료: {len(all_items)}")
passed = []
discarded_count = 0
for item in all_items:
if not item.get('topic'):
continue
# 신뢰도 오버라이드 (RSS 피드별 설정)
trust_override = item.pop('_trust_override', None)
if trust_override:
trust_levels = rules['scoring']['source_trust']['levels']
item['source_trust_level'] = trust_override
item['_trust_score'] = trust_levels.get(trust_override, trust_levels['medium'])
# 품질 점수 계산
score = calculate_quality_score(item, rules)
item['quality_score'] = score
# 폐기 규칙 검사
discard_reason = apply_discard_rules(item, rules, published_titles)
if discard_reason:
save_discarded(item, discard_reason)
discarded_count += 1
logger.debug(f"폐기: [{score}점] {item['topic']}{discard_reason}")
continue
if score < min_score:
save_discarded(item, f'품질 점수 미달 ({score}점 < {min_score}점)')
discarded_count += 1
logger.debug(f"폐기: [{score}점] {item['topic']}")
continue
# 코너 배정
topic_type = item.get('topic_type', 'trending')
corner = assign_corner(item, topic_type)
item['corner'] = corner
# 쿠팡 키워드 추출
item['coupang_keywords'] = extract_coupang_keywords(
item.get('topic', ''), item.get('description', '')
)
# 트렌딩 경과 시간 표시
pub_at_str = item.get('published_at')
if pub_at_str:
try:
pub_at = datetime.fromisoformat(pub_at_str)
if pub_at.tzinfo is None:
pub_at = pub_at.replace(tzinfo=timezone.utc)
hours_ago = int((datetime.now(timezone.utc) - pub_at).total_seconds() / 3600)
item['trending_since'] = f'{hours_ago}시간 전' if hours_ago < 24 else f'{hours_ago // 24}일 전'
except Exception:
item['trending_since'] = '알 수 없음'
# sources 필드 정리
item['sources'] = [{'url': item.get('source_url', ''), 'title': item.get('topic', ''),
'date': item.get('published_at', '')}]
item['related_keywords'] = item.get('topic', '').split()[:5]
passed.append(item)
# 에버그린/트렌드/개성 비율 맞추기
total_target = len(passed)
evergreen = [i for i in passed if i.get('is_evergreen')]
trending = [i for i in passed if not i.get('is_evergreen') and i.get('topic_type') == 'trending']
personality = [i for i in passed if i.get('topic_type') == 'personality']
logger.info(
f"합격: {len(passed)}개 (에버그린 {len(evergreen)}, 트렌드 {len(trending)}, "
f"개성 {len(personality)}) / 폐기: {discarded_count}"
)
# 글감 저장
for item in passed:
save_topic(item)
logger.info(f"[{item['quality_score']}점][{item['corner']}] {item['topic']}")
logger.info("=== 수집봇 완료 ===")
return passed
if __name__ == '__main__':
run()

366
bots/image_bot.py Normal file
View File

@@ -0,0 +1,366 @@
"""
이미지봇 (image_bot.py)
역할: 만평 코너용 이미지 생성/관리
IMAGE_MODE 환경변수로 모드 선택:
manual (기본) — 한컷 글 발행 시점에 프롬프트 1개를 Telegram으로 전송.
사용자가 직접 생성 후 data/images/ 에 파일 저장.
request — 스케줄러가 주기적으로 대기 중인 프롬프트 목록을 Telegram 전송.
사용자가 생성형 AI로 이미지 제작 후 Telegram으로 이미지 전송하면 자동 저장.
/images 명령으로 대기 목록 확인, /imgpick [번호]로 선택.
auto — OpenAI Images API (dall-e-3) 직접 호출. OPENAI_API_KEY 필요.
비용: 이미지당 $0.04-0.08 (ChatGPT Pro 구독과 별도).
"""
import json
import logging
import os
import re
import uuid
from datetime import datetime
from pathlib import Path
import requests
from dotenv import load_dotenv
load_dotenv()
BASE_DIR = Path(__file__).parent.parent
DATA_DIR = BASE_DIR / 'data'
IMAGES_DIR = DATA_DIR / 'images'
LOG_DIR = BASE_DIR / 'logs'
PENDING_PROMPTS_FILE = IMAGES_DIR / 'pending_prompts.json'
LOG_DIR.mkdir(exist_ok=True)
IMAGES_DIR.mkdir(exist_ok=True)
logging.basicConfig(
level=logging.INFO,
format='%(asctime)s [%(levelname)s] %(message)s',
handlers=[
logging.FileHandler(LOG_DIR / 'image_bot.log', encoding='utf-8'),
logging.StreamHandler(),
]
)
logger = logging.getLogger(__name__)
TELEGRAM_BOT_TOKEN = os.getenv('TELEGRAM_BOT_TOKEN', '')
TELEGRAM_CHAT_ID = os.getenv('TELEGRAM_CHAT_ID', '')
OPENAI_API_KEY = os.getenv('OPENAI_API_KEY', '')
IMAGE_MODE = os.getenv('IMAGE_MODE', 'manual').lower() # manual | request | auto
# ─── Telegram 전송 ────────────────────────────────────
def send_telegram(text: str):
if not TELEGRAM_BOT_TOKEN or not TELEGRAM_CHAT_ID:
logger.warning("Telegram 설정 없음")
print(text)
return
url = f'https://api.telegram.org/bot{TELEGRAM_BOT_TOKEN}/sendMessage'
try:
requests.post(url, json={
'chat_id': TELEGRAM_CHAT_ID,
'text': text,
'parse_mode': 'HTML',
}, timeout=10)
except Exception as e:
logger.error(f"Telegram 전송 실패: {e}")
# ─── 프롬프트 생성 ────────────────────────────────────
def build_cartoon_prompt(topic: str, description: str = '') -> str:
"""만평 스타일 이미지 프롬프트 생성 (범용 — 어떤 생성형 AI에도 사용 가능)"""
desc_part = f" {description}" if description else ""
prompt = (
f"Korean editorial cartoon style, single panel.{desc_part} "
f"Topic: {topic}. "
f"Style: simple line art, expressive characters, thought-provoking social commentary, "
f"Korean newspaper cartoon aesthetic, minimal color, black and white with accent colors. "
f"No text in the image. Square format 1:1."
)
return prompt
# ─── 대기 프롬프트 관리 ───────────────────────────────
def load_pending_prompts() -> list[dict]:
"""pending_prompts.json 로드"""
if not PENDING_PROMPTS_FILE.exists():
return []
try:
return json.loads(PENDING_PROMPTS_FILE.read_text(encoding='utf-8'))
except Exception:
return []
def save_pending_prompts(prompts: list[dict]):
"""pending_prompts.json 저장"""
PENDING_PROMPTS_FILE.write_text(
json.dumps(prompts, ensure_ascii=False, indent=2), encoding='utf-8'
)
def add_pending_prompt(topic: str, description: str, article_ref: str = '') -> dict:
"""새 프롬프트 대기 목록에 추가. 생성된 항목 반환."""
prompts = load_pending_prompts()
# 같은 주제가 이미 있으면 추가하지 않음
for p in prompts:
if p['topic'] == topic and p['status'] == 'pending':
logger.info(f"이미 대기 중인 프롬프트: {topic}")
return p
prompt_text = build_cartoon_prompt(topic, description)
item = {
'id': str(len(prompts) + 1), # 사람이 읽기 쉬운 번호
'uid': uuid.uuid4().hex[:8],
'topic': topic,
'description': description,
'prompt': prompt_text,
'article_ref': article_ref,
'status': 'pending', # pending | selected | done
'created_at': datetime.now().isoformat(),
'image_path': '',
}
prompts.append(item)
save_pending_prompts(prompts)
logger.info(f"프롬프트 추가 #{item['id']}: {topic}")
return item
def get_pending_prompts(status: str = 'pending') -> list[dict]:
"""상태별 프롬프트 목록"""
return [p for p in load_pending_prompts() if p['status'] == status]
def mark_prompt_selected(prompt_id: str) -> dict | None:
"""사용자가 선택한 프롬프트를 selected 상태로 변경"""
prompts = load_pending_prompts()
for p in prompts:
if p['id'] == str(prompt_id):
p['status'] = 'selected'
p['selected_at'] = datetime.now().isoformat()
save_pending_prompts(prompts)
return p
return None
def mark_prompt_done(prompt_id: str, image_path: str) -> dict | None:
"""이미지 수령 완료 처리"""
prompts = load_pending_prompts()
for p in prompts:
if p['id'] == str(prompt_id):
p['status'] = 'done'
p['image_path'] = image_path
p['done_at'] = datetime.now().isoformat()
save_pending_prompts(prompts)
logger.info(f"프롬프트 #{prompt_id} 완료: {image_path}")
return p
return None
def get_prompt_by_id(prompt_id: str) -> dict | None:
for p in load_pending_prompts():
if p['id'] == str(prompt_id):
return p
return None
# ─── 이미지 수신 저장 ─────────────────────────────────
def save_image_from_bytes(image_bytes: bytes, topic: str, prompt_id: str) -> str:
"""bytes로 받은 이미지를 data/images/ 에 저장. 경로 반환."""
safe_name = re.sub(r'[^\w가-힣-]', '_', topic)[:50]
filename = f"{datetime.now().strftime('%Y%m%d_%H%M%S')}_p{prompt_id}_{safe_name}.png"
save_path = IMAGES_DIR / filename
save_path.write_bytes(image_bytes)
logger.info(f"이미지 저장: {save_path}")
return str(save_path)
def save_image_from_telegram(file_bytes: bytes, prompt_id: str) -> str | None:
"""Telegram으로 받은 이미지 저장 및 프롬프트 완료 처리"""
prompt = get_prompt_by_id(prompt_id)
if not prompt:
logger.warning(f"프롬프트 #{prompt_id} 없음")
return None
image_path = save_image_from_bytes(file_bytes, prompt['topic'], prompt_id)
mark_prompt_done(prompt_id, image_path)
return image_path
# ─── request 모드 — 배치 전송 ──────────────────────────
def send_prompt_batch():
"""
request 모드 주기 실행.
data/topics/ 에서 한컷 코너 글감을 스캔해 프롬프트 대기 목록에 추가하고
현재 pending 상태인 프롬프트 전체를 Telegram으로 전송.
"""
logger.info("=== 이미지 프롬프트 배치 전송 시작 ===")
# 한컷 글감 스캔 → 대기 목록에 추가
topics_dir = DATA_DIR / 'topics'
for f in sorted(topics_dir.glob('*.json')):
try:
data = json.loads(f.read_text(encoding='utf-8'))
if data.get('corner') == '한컷':
add_pending_prompt(
topic=data.get('topic', ''),
description=data.get('description', ''),
article_ref=str(f),
)
except Exception:
pass
pending = get_pending_prompts('pending')
selected = get_pending_prompts('selected')
active = pending + selected
if not active:
send_telegram("🎨 현재 이미지 제작 요청이 없습니다.")
logger.info("대기 프롬프트 없음")
return
lines = [
f"🎨 <b>[이미지 제작 요청 — {len(active)}건]</b>\n",
"아래 목록에서 제작하실 항목을 선택해주세요.\n",
f"/imgpick [번호] 로 선택 → 생성형 AI(Midjourney, DALL-E, Stable Diffusion 등)로 제작 → "
f"이미지를 이 채팅에 전송해주세요.\n",
]
for item in active:
status_icon = '🔄' if item['status'] == 'selected' else ''
lines.append(
f"{status_icon} <b>#{item['id']}</b> {item['topic']}\n"
f" 📝 <code>{item['prompt'][:200]}...</code>\n"
)
lines.append("\n/images — 전체 목록 재확인")
send_telegram('\n'.join(lines))
logger.info(f"배치 전송 완료: {len(active)}")
def send_single_prompt(prompt_id: str):
"""특정 프롬프트 1개를 전체 내용으로 Telegram 전송"""
prompt = get_prompt_by_id(prompt_id)
if not prompt:
send_telegram(f"❌ #{prompt_id} 번 프롬프트를 찾을 수 없습니다.")
return
mark_prompt_selected(prompt_id)
msg = (
f"🎨 <b>[이미지 제작 — #{prompt['id']}]</b>\n\n"
f"📌 주제: <b>{prompt['topic']}</b>\n\n"
f"📝 프롬프트 (복사해서 생성형 AI에 붙여넣으세요):\n\n"
f"<code>{prompt['prompt']}</code>\n\n"
f"✅ 이미지 완성 후 <b>이 채팅에 이미지를 전송</b>하면 자동으로 저장됩니다.\n"
f"(전송 시 캡션에 <code>#{prompt['id']}</code> 를 입력해주세요)"
)
send_telegram(msg)
logger.info(f"단일 프롬프트 전송 #{prompt_id}: {prompt['topic']}")
# ─── auto 모드 ────────────────────────────────────────
def generate_image_auto(prompt: str, topic: str) -> str | None:
"""OpenAI DALL-E 3 API로 이미지 자동 생성"""
if not OPENAI_API_KEY:
logger.error("OPENAI_API_KEY 없음 — 자동 이미지 생성 불가")
return None
try:
resp = requests.post(
'https://api.openai.com/v1/images/generations',
headers={
'Authorization': f'Bearer {OPENAI_API_KEY}',
'Content-Type': 'application/json',
},
json={
'model': 'dall-e-3',
'prompt': prompt,
'n': 1,
'size': '1024x1024',
'quality': 'standard',
},
timeout=60,
)
resp.raise_for_status()
image_url = resp.json()['data'][0]['url']
img_bytes = requests.get(image_url, timeout=30).content
safe_name = re.sub(r'[^\w가-힣-]', '_', topic)[:50]
filename = f"{datetime.now().strftime('%Y%m%d_%H%M%S')}_{safe_name}.png"
save_path = IMAGES_DIR / filename
save_path.write_bytes(img_bytes)
logger.info(f"자동 이미지 저장: {save_path}")
return str(save_path)
except Exception as e:
logger.error(f"자동 이미지 생성 실패: {e}")
return None
# ─── manual 모드 ──────────────────────────────────────
def process_manual_mode(topic: str, description: str = '') -> str:
"""글 발행 시점에 프롬프트 1개 Telegram 전송 (파일 저장은 사용자 직접)"""
prompt = build_cartoon_prompt(topic, description)
safe_name = re.sub(r'[^\w가-힣-]', '_', topic)[:50]
expected_path = IMAGES_DIR / f"{datetime.now().strftime('%Y%m%d')}_{safe_name}.png"
send_telegram(
f"🎨 <b>[만평 이미지 요청 — manual]</b>\n\n"
f"📌 주제: <b>{topic}</b>\n\n"
f"📝 프롬프트:\n<code>{prompt}</code>\n\n"
f"이미지 생성 후 아래 경로에 저장해주세요:\n"
f"<code>{expected_path}</code>"
)
logger.info(f"manual 모드 프롬프트 전송: {topic}")
return str(expected_path)
# ─── 메인 진입점 ──────────────────────────────────────
def process(article: dict) -> str | None:
"""
한컷 코너 글에 대해 모드에 따라 이미지 처리.
Returns: 이미지 경로 (request 모드에서는 None — 비동기로 나중에 수령)
"""
if article.get('corner') != '한컷':
return None
topic = article.get('title', '')
description = article.get('meta', '')
logger.info(f"이미지봇 실행: {topic} (모드: {IMAGE_MODE})")
if IMAGE_MODE == 'auto':
prompt = build_cartoon_prompt(topic, description)
image_path = generate_image_auto(prompt, topic)
if image_path:
send_telegram(
f"🎨 <b>[자동 이미지 생성 완료]</b>\n\n📌 {topic}\n경로: <code>{image_path}</code>"
)
return image_path
elif IMAGE_MODE == 'request':
item = add_pending_prompt(topic, description, article_ref=article.get('_source_file', ''))
send_telegram(
f"🎨 <b>[이미지 제작 요청 추가됨]</b>\n\n"
f"📌 주제: <b>{topic}</b>\n"
f"번호: <b>#{item['id']}</b>\n\n"
f"/imgpick {item['id']} — 이 주제 프롬프트 받기\n"
f"/images — 전체 대기 목록 보기"
)
return None # 이미지는 나중에 Telegram으로 수령
else: # manual (기본)
return process_manual_mode(topic, description)
if __name__ == '__main__':
import sys
if len(sys.argv) > 1 and sys.argv[1] == 'batch':
send_prompt_batch()
else:
sample = {'corner': '한컷', 'title': 'AI가 직업을 빼앗는다?', 'meta': ''}
print(process(sample))

222
bots/linker_bot.py Normal file
View File

@@ -0,0 +1,222 @@
"""
링크봇 (linker_bot.py)
역할: 글 본문에 쿠팡 파트너스 링크와 어필리에이트 링크 자동 삽입
"""
import hashlib
import hmac
import json
import logging
import os
import re
from datetime import datetime, timezone
from pathlib import Path
from urllib.parse import urlencode
import requests
from bs4 import BeautifulSoup
from dotenv import load_dotenv
load_dotenv()
BASE_DIR = Path(__file__).parent.parent
CONFIG_DIR = BASE_DIR / 'config'
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 / 'linker.log', encoding='utf-8'),
logging.StreamHandler(),
]
)
logger = logging.getLogger(__name__)
COUPANG_ACCESS_KEY = os.getenv('COUPANG_ACCESS_KEY', '')
COUPANG_SECRET_KEY = os.getenv('COUPANG_SECRET_KEY', '')
COUPANG_API_BASE = 'https://api-gateway.coupang.com'
def load_config(filename: str) -> dict:
with open(CONFIG_DIR / filename, 'r', encoding='utf-8') as f:
return json.load(f)
# ─── 쿠팡 파트너스 API ────────────────────────────────
def _generate_coupang_hmac(method: str, url: str, query: str) -> dict:
"""쿠팡 HMAC 서명 생성"""
datetime_str = datetime.now(timezone.utc).strftime('%y%m%dT%H%M%SZ')
path = url.split(COUPANG_API_BASE)[-1].split('?')[0]
message = datetime_str + method + path + query
signature = hmac.new(
COUPANG_SECRET_KEY.encode('utf-8'),
message.encode('utf-8'),
hashlib.sha256
).hexdigest()
return {
'Authorization': f'CEA algorithm=HmacSHA256, access-key={COUPANG_ACCESS_KEY}, '
f'signed-date={datetime_str}, signature={signature}',
'Content-Type': 'application/json;charset=UTF-8',
}
def search_coupang_products(keyword: str, limit: int = 3) -> list[dict]:
"""쿠팡 파트너스 API로 상품 검색"""
if not COUPANG_ACCESS_KEY or not COUPANG_SECRET_KEY:
logger.warning("쿠팡 API 키 없음 — 링크 삽입 건너뜀")
return []
path = '/v2/providers/affiliate_api/apis/openapi/products/search'
params = {
'keyword': keyword,
'limit': limit,
'subId': 'blog-writer',
}
query_string = urlencode(params)
url = f'{COUPANG_API_BASE}{path}?{query_string}'
try:
headers = _generate_coupang_hmac('GET', url, query_string)
resp = requests.get(url, headers=headers, timeout=10)
resp.raise_for_status()
data = resp.json()
products = data.get('data', {}).get('productData', [])
return [
{
'name': p.get('productName', keyword),
'price': p.get('productPrice', 0),
'url': p.get('productUrl', ''),
'image': p.get('productImage', ''),
}
for p in products[:limit]
]
except Exception as e:
logger.warning(f"쿠팡 API 오류 ({keyword}): {e}")
return []
def build_coupang_link_html(product: dict) -> str:
"""쿠팡 상품 링크 HTML 생성"""
name = product.get('name', '')
url = product.get('url', '')
price = product.get('price', 0)
price_str = f"{int(price):,}" if price else ''
return (
f'<p class="coupang-link">'
f'🛒 <a href="{url}" target="_blank" rel="nofollow">{name}</a>'
f'{"" + price_str if price_str else ""}'
f'</p>\n'
)
# ─── 본문 링크 삽입 ──────────────────────────────────
def insert_links_into_html(html_content: str, coupang_keywords: list[str],
fixed_links: list[dict]) -> str:
"""HTML 본문에 쿠팡 링크와 고정 링크 삽입"""
soup = BeautifulSoup(html_content, 'lxml')
# 고정 링크 (키워드 텍스트가 본문에 있으면 첫 번째 등장 위치에 링크)
for fixed in fixed_links:
kw = fixed.get('keyword', '')
link_url = fixed.get('url', '')
label = fixed.get('label', kw)
if not kw or not link_url:
continue
for p in soup.find_all(['p', 'li']):
text = p.get_text()
if kw in text:
# 이미 링크가 있으면 건너뜀
if p.find('a', string=re.compile(re.escape(kw))):
break
new_html = p.decode_contents().replace(
kw,
f'<a href="{link_url}" target="_blank">{kw}</a>',
1
)
p.clear()
p.append(BeautifulSoup(new_html, 'lxml'))
break
# 쿠팡 링크: 결론/추천 섹션 앞에 상품 박스 삽입
if coupang_keywords and (COUPANG_ACCESS_KEY and COUPANG_SECRET_KEY):
coupang_block_parts = []
for kw in coupang_keywords[:3]: # 최대 3개 키워드
products = search_coupang_products(kw, limit=2)
for product in products:
coupang_block_parts.append(build_coupang_link_html(product))
if coupang_block_parts:
coupang_block_html = (
'<div class="coupang-products">\n'
'<p><strong>관련 상품 추천</strong></p>\n'
+ ''.join(coupang_block_parts) +
'</div>\n'
)
# 결론 H2 앞에 삽입
for h2 in soup.find_all('h2'):
if any(kw in h2.get_text() for kw in ['결론', '마무리', '정리', '요약']):
block = BeautifulSoup(coupang_block_html, 'lxml')
h2.insert_before(block)
break
else:
# 결론 섹션 없으면 본문 끝에 추가
body_tag = soup.find('body') or soup
block = BeautifulSoup(coupang_block_html, 'lxml')
body_tag.append(block)
return str(soup)
def add_disclaimer(html_content: str, disclaimer_text: str) -> str:
"""쿠팡 필수 면책 문구 추가 (이미 있으면 건너뜀)"""
if disclaimer_text in html_content:
return html_content
disclaimer_html = (
f'\n<hr/>\n'
f'<p class="affiliate-disclaimer"><small>⚠️ {disclaimer_text}</small></p>\n'
)
return html_content + disclaimer_html
# ─── 메인 함수 ───────────────────────────────────────
def process(article: dict, html_content: str) -> str:
"""
링크봇 메인: HTML 본문에 쿠팡/어필리에이트 링크 삽입 후 반환
"""
logger.info(f"링크 삽입 시작: {article.get('title', '')}")
affiliate_cfg = load_config('affiliate_links.json')
coupang_keywords = article.get('coupang_keywords', [])
fixed_links = affiliate_cfg.get('fixed_links', [])
disclaimer_text = affiliate_cfg.get('disclaimer_text', '')
# 링크 삽입
html_content = insert_links_into_html(html_content, coupang_keywords, fixed_links)
# 쿠팡 키워드가 있으면 면책 문구 추가
if coupang_keywords and disclaimer_text:
html_content = add_disclaimer(html_content, disclaimer_text)
logger.info("링크 삽입 완료")
return html_content
if __name__ == '__main__':
sample_html = '''
<h2>ChatGPT 소개</h2>
<p>ChatGPT Plus를 사용하면 더 빠른 응답을 받을 수 있습니다.</p>
<h2>키보드 추천</h2>
<p>좋은 키보드는 생산성을 높입니다.</p>
<h2>결론</h2>
<p>AI 도구를 잘 활용하세요.</p>
'''
sample_article = {
'title': '테스트 글',
'coupang_keywords': ['키보드', '마우스'],
}
result = process(sample_article, sample_html)
print(result[:500])

464
bots/publisher_bot.py Normal file
View File

@@ -0,0 +1,464 @@
"""
발행봇 (publisher_bot.py)
역할: AI가 작성한 글을 Blogger에 자동 발행
- 마크다운 → HTML 변환
- 목차 자동 생성
- AdSense 플레이스홀더 삽입
- Schema.org Article JSON-LD
- 안전장치 (팩트체크/위험 키워드/출처 부족 → 수동 검토)
- Blogger API v3 발행
- Search Console URL 제출
- Telegram 알림
"""
import json
import logging
import os
import re
from datetime import datetime, timezone
from pathlib import Path
import markdown
import requests
from bs4 import BeautifulSoup
from dotenv import load_dotenv
from google.oauth2.credentials import Credentials
from google.auth.transport.requests import Request
from googleapiclient.discovery import build
load_dotenv()
BASE_DIR = Path(__file__).parent.parent
CONFIG_DIR = BASE_DIR / 'config'
DATA_DIR = BASE_DIR / 'data'
LOG_DIR = BASE_DIR / 'logs'
TOKEN_PATH = BASE_DIR / 'token.json'
LOG_DIR.mkdir(exist_ok=True)
logging.basicConfig(
level=logging.INFO,
format='%(asctime)s [%(levelname)s] %(message)s',
handlers=[
logging.FileHandler(LOG_DIR / 'publisher.log', encoding='utf-8'),
logging.StreamHandler(),
]
)
logger = logging.getLogger(__name__)
TELEGRAM_BOT_TOKEN = os.getenv('TELEGRAM_BOT_TOKEN', '')
TELEGRAM_CHAT_ID = os.getenv('TELEGRAM_CHAT_ID', '')
BLOG_MAIN_ID = os.getenv('BLOG_MAIN_ID', '')
SCOPES = [
'https://www.googleapis.com/auth/blogger',
'https://www.googleapis.com/auth/webmasters',
]
def load_config(filename: str) -> dict:
with open(CONFIG_DIR / filename, 'r', encoding='utf-8') as f:
return json.load(f)
# ─── Google 인증 ─────────────────────────────────────
def get_google_credentials() -> Credentials:
creds = None
if TOKEN_PATH.exists():
creds = Credentials.from_authorized_user_file(str(TOKEN_PATH), SCOPES)
if not creds or not creds.valid:
if creds and creds.expired and creds.refresh_token:
creds.refresh(Request())
with open(TOKEN_PATH, 'w') as f:
f.write(creds.to_json())
if not creds or not creds.valid:
raise RuntimeError("Google 인증 실패. scripts/get_token.py 를 먼저 실행하세요.")
return creds
# ─── 안전장치 ─────────────────────────────────────────
def check_safety(article: dict, safety_cfg: dict) -> tuple[bool, str]:
"""
수동 검토가 필요한지 판단.
Returns: (needs_review, reason)
"""
corner = article.get('corner', '')
body = article.get('body', '')
sources = article.get('sources', [])
quality_score = article.get('quality_score', 100)
# 팩트체크 코너는 무조건 수동 검토
manual_corners = safety_cfg.get('always_manual_review', ['팩트체크'])
if corner in manual_corners:
return True, f'코너 "{corner}" 는 항상 수동 검토 필요'
# 위험 키워드 감지
all_keywords = (
safety_cfg.get('crypto_keywords', []) +
safety_cfg.get('criticism_keywords', []) +
safety_cfg.get('investment_keywords', []) +
safety_cfg.get('legal_keywords', [])
)
for kw in all_keywords:
if kw in body:
return True, f'위험 키워드 감지: "{kw}"'
# 출처 2개 미만
min_sources = safety_cfg.get('min_sources_required', 2)
if len(sources) < min_sources:
return True, f'출처 {len(sources)}개 — {min_sources}개 이상 필요'
# 품질 점수 미달
min_score = safety_cfg.get('min_quality_score_for_auto', 75)
if quality_score < min_score:
return True, f'품질 점수 {quality_score}점 (자동 발행 최소: {min_score}점)'
return False, ''
# ─── HTML 변환 ─────────────────────────────────────────
def markdown_to_html(md_text: str) -> str:
"""마크다운 → HTML 변환 (목차 extension 포함)"""
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 # 목차 HTML
return html, toc
def insert_adsense_placeholders(html: str) -> str:
"""두 번째 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')
# 두 번째 H2 뒤에 AD_SLOT_1 삽입
if len(h2_tags) >= 2:
second_h2 = h2_tags[1]
ad_tag = BeautifulSoup(AD_SLOT_1, 'html.parser')
second_h2.insert_after(ad_tag)
# 결론 H2 앞에 AD_SLOT_2 삽입
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, blog_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": "테크인사이더",
"logo": {
"@type": "ImageObject",
"url": ""
}
},
"mainEntityOfPage": {
"@type": "WebPage",
"@id": blog_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) -> str:
"""최종 HTML 조합: JSON-LD + 목차 + 본문 + 면책 문구"""
json_ld = build_json_ld(article)
disclaimer = article.get('disclaimer', '')
html_parts = [json_ld]
if toc_html:
html_parts.append(f'<div class="toc-wrapper">{toc_html}</div>')
html_parts.append(body_html)
if disclaimer:
html_parts.append(f'<hr/><p class="disclaimer"><small>{disclaimer}</small></p>')
return '\n'.join(html_parts)
# ─── Blogger API ──────────────────────────────────────
def publish_to_blogger(article: dict, html_content: str, creds: Credentials) -> dict:
"""Blogger API v3로 글 발행"""
service = build('blogger', 'v3', credentials=creds)
blog_id = BLOG_MAIN_ID
labels = [article.get('corner', '')]
tags = article.get('tags', [])
if isinstance(tags, str):
tags = [t.strip() for t in tags.split(',')]
labels.extend(tags)
labels = list(set(filter(None, labels)))
body = {
'title': article.get('title', ''),
'content': html_content,
'labels': labels,
}
result = service.posts().insert(
blogId=blog_id,
body=body,
isDraft=False,
).execute()
return result
def submit_to_search_console(url: str, creds: Credentials):
"""Google Search Console URL 색인 요청"""
try:
service = build('searchconsole', 'v1', credentials=creds)
# URL Inspection API (실제 indexing 요청)
# 참고: 일반적으로 Blogger sitemap이 자동 제출되므로 보조 수단
logger.info(f"Search Console 제출: {url}")
# indexing API는 별도 서비스 계정 필요. 여기서는 로그만 남김.
# 실제 색인 촉진은 Blogger 내장 sitemap에 의존
except Exception as e:
logger.warning(f"Search Console 제출 실패: {e}")
# ─── Telegram ────────────────────────────────────────
def send_telegram(text: str, parse_mode: str = 'HTML'):
"""Telegram 메시지 전송"""
if not TELEGRAM_BOT_TOKEN or not TELEGRAM_CHAT_ID:
logger.warning("Telegram 설정 없음 — 알림 건너뜀")
return
url = f'https://api.telegram.org/bot{TELEGRAM_BOT_TOKEN}/sendMessage'
payload = {
'chat_id': TELEGRAM_CHAT_ID,
'text': text,
'parse_mode': parse_mode,
}
try:
resp = requests.post(url, json=payload, timeout=10)
resp.raise_for_status()
except Exception as e:
logger.error(f"Telegram 전송 실패: {e}")
def send_pending_review_alert(article: dict, reason: str):
"""수동 검토 대기 알림 (Telegram)"""
title = article.get('title', '(제목 없음)')
corner = article.get('corner', '')
preview = article.get('body', '')[:300].replace('<', '&lt;').replace('>', '&gt;')
msg = (
f"🔍 <b>[수동 검토 필요]</b>\n\n"
f"📌 <b>{title}</b>\n"
f"코너: {corner}\n"
f"사유: {reason}\n\n"
f"미리보기:\n{preview}...\n\n"
f"명령: <code>승인</code> 또는 <code>거부</code>"
)
send_telegram(msg)
# ─── 발행 이력 ───────────────────────────────────────
def log_published(article: dict, post_result: dict):
"""발행 이력 저장"""
published_dir = DATA_DIR / 'published'
published_dir.mkdir(exist_ok=True)
record = {
'title': article.get('title', ''),
'corner': article.get('corner', ''),
'url': post_result.get('url', ''),
'post_id': post_result.get('id', ''),
'published_at': datetime.now(timezone.utc).isoformat(),
'quality_score': article.get('quality_score', 0),
'tags': article.get('tags', []),
'sources': article.get('sources', []),
}
filename = f"{datetime.now().strftime('%Y%m%d_%H%M%S')}_{record['post_id']}.json"
with open(published_dir / filename, 'w', encoding='utf-8') as f:
json.dump(record, f, ensure_ascii=False, indent=2)
return record
def save_pending_review(article: dict, reason: str):
"""수동 검토 대기 글 저장"""
pending_dir = DATA_DIR / 'pending_review'
pending_dir.mkdir(exist_ok=True)
record = {**article, 'pending_reason': reason, 'created_at': datetime.now().isoformat()}
filename = f"{datetime.now().strftime('%Y%m%d_%H%M%S')}_pending.json"
with open(pending_dir / filename, 'w', encoding='utf-8') as f:
json.dump(record, f, ensure_ascii=False, indent=2)
return pending_dir / filename
def load_pending_review_file(filepath: str) -> dict:
with open(filepath, 'r', encoding='utf-8') as f:
return json.load(f)
# ─── 메인 발행 함수 ──────────────────────────────────
def publish(article: dict) -> bool:
"""
article: OpenClaw blog-writer가 출력한 파싱된 글 dict
{
title, meta, slug, tags, corner, body (markdown),
coupang_keywords, sources, disclaimer, quality_score
}
Returns: True(발행 성공) / False(수동 검토 대기)
"""
logger.info(f"발행 시도: {article.get('title', '')}")
safety_cfg = load_config('safety_keywords.json')
# 안전장치 검사
needs_review, review_reason = check_safety(article, safety_cfg)
if needs_review:
logger.warning(f"수동 검토 대기: {review_reason}")
save_pending_review(article, review_reason)
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)
# Google 인증
try:
creds = get_google_credentials()
except RuntimeError as e:
logger.error(str(e))
return False
# Blogger 발행
try:
post_result = publish_to_blogger(article, full_html, creds)
post_url = post_result.get('url', '')
logger.info(f"발행 완료: {post_url}")
except Exception as e:
logger.error(f"Blogger 발행 실패: {e}")
return False
# Search Console 제출
if post_url:
submit_to_search_console(post_url, creds)
# 발행 이력 저장
log_published(article, post_result)
# Telegram 알림
title = article.get('title', '')
corner = article.get('corner', '')
send_telegram(
f"✅ <b>발행 완료!</b>\n\n"
f"📌 <b>{title}</b>\n"
f"코너: {corner}\n"
f"URL: {post_url}"
)
return True
def approve_pending(filepath: str) -> bool:
"""수동 검토 대기 글 승인 후 발행"""
try:
article = load_pending_review_file(filepath)
article.pop('pending_reason', None)
article.pop('created_at', None)
# 안전장치 우회하여 강제 발행
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)
creds = get_google_credentials()
post_result = publish_to_blogger(article, full_html, creds)
post_url = post_result.get('url', '')
log_published(article, post_result)
# 대기 파일 삭제
Path(filepath).unlink(missing_ok=True)
send_telegram(
f"✅ <b>[수동 승인] 발행 완료!</b>\n\n"
f"📌 {article.get('title', '')}\n"
f"URL: {post_url}"
)
logger.info(f"수동 승인 발행 완료: {post_url}")
return True
except Exception as e:
logger.error(f"승인 발행 실패: {e}")
return False
def reject_pending(filepath: str):
"""수동 검토 대기 글 거부 (파일 삭제)"""
try:
article = load_pending_review_file(filepath)
Path(filepath).unlink(missing_ok=True)
send_telegram(f"🗑 <b>[거부]</b> {article.get('title', '')} — 폐기됨")
logger.info(f"수동 검토 거부: {filepath}")
except Exception as e:
logger.error(f"거부 처리 실패: {e}")
def get_pending_list() -> list[dict]:
"""수동 검토 대기 목록 반환"""
pending_dir = DATA_DIR / 'pending_review'
pending_dir.mkdir(exist_ok=True)
result = []
for f in sorted(pending_dir.glob('*_pending.json')):
try:
data = json.loads(f.read_text(encoding='utf-8'))
data['_filepath'] = str(f)
result.append(data)
except Exception:
pass
return result
if __name__ == '__main__':
# 테스트용: 샘플 아티클 발행 시도
sample = {
'title': '테스트 글',
'meta': '테스트 메타 설명',
'slug': 'test-article',
'tags': ['테스트', 'AI'],
'corner': '쉬운세상',
'body': '## 제목\n\n본문 내용입니다.\n\n## 결론\n\n마무리입니다.',
'coupang_keywords': ['키보드'],
'sources': [
{'url': 'https://example.com/1', 'title': '출처1', 'date': '2026-03-24'},
{'url': 'https://example.com/2', 'title': '출처2', 'date': '2026-03-24'},
],
'disclaimer': '',
'quality_score': 80,
}
result = publish(sample)
print('발행 결과:', result)

559
bots/scheduler.py Normal file
View File

@@ -0,0 +1,559 @@
"""
스케줄러 (scheduler.py)
역할: 모든 봇의 실행 시간 관리 + Telegram 수동 명령 리스너
라이브러리: APScheduler + python-telegram-bot
"""
import asyncio
import json
import logging
import os
import sys
from datetime import datetime
from logging.handlers import RotatingFileHandler
from pathlib import Path
from apscheduler.schedulers.asyncio import AsyncIOScheduler
from dotenv import load_dotenv
from telegram import Update
from telegram.ext import Application, CommandHandler, MessageHandler, filters, ContextTypes
load_dotenv()
BASE_DIR = Path(__file__).parent.parent
CONFIG_DIR = BASE_DIR / 'config'
DATA_DIR = BASE_DIR / 'data'
LOG_DIR = BASE_DIR / 'logs'
LOG_DIR.mkdir(exist_ok=True)
log_handler = RotatingFileHandler(
LOG_DIR / 'scheduler.log',
maxBytes=5 * 1024 * 1024,
backupCount=3,
encoding='utf-8',
)
logging.basicConfig(
level=logging.INFO,
format='%(asctime)s [%(levelname)s] %(message)s',
handlers=[log_handler, logging.StreamHandler()],
)
logger = logging.getLogger(__name__)
TELEGRAM_BOT_TOKEN = os.getenv('TELEGRAM_BOT_TOKEN', '')
TELEGRAM_CHAT_ID = os.getenv('TELEGRAM_CHAT_ID', '')
IMAGE_MODE = os.getenv('IMAGE_MODE', 'manual').lower()
# request 모드에서 이미지 대기 시 사용하는 상태 변수
# {chat_id: prompt_id} — 다음에 받은 이미지를 어느 프롬프트에 연결할지 기억
_awaiting_image: dict[int, str] = {}
_publish_enabled = True
def load_schedule() -> dict:
with open(CONFIG_DIR / 'schedule.json', 'r', encoding='utf-8') as f:
return json.load(f)
# ─── 스케줄 작업 ──────────────────────────────────────
def job_collector():
logger.info("[스케줄] 수집봇 시작")
try:
sys.path.insert(0, str(BASE_DIR / 'bots'))
import collector_bot
collector_bot.run()
except Exception as e:
logger.error(f"수집봇 오류: {e}")
def job_ai_writer():
logger.info("[스케줄] AI 글 작성 트리거")
if not _publish_enabled:
logger.info("발행 중단 상태 — 건너뜀")
return
try:
_trigger_openclaw_writer()
except Exception as e:
logger.error(f"AI 글 작성 트리거 오류: {e}")
def _trigger_openclaw_writer():
topics_dir = DATA_DIR / 'topics'
drafts_dir = DATA_DIR / 'drafts'
drafts_dir.mkdir(exist_ok=True)
today = datetime.now().strftime('%Y%m%d')
topic_files = sorted(topics_dir.glob(f'{today}_*.json'))
if not topic_files:
logger.info("오늘 처리할 글감 없음")
return
for topic_file in topic_files[:3]:
draft_check = drafts_dir / topic_file.name
if draft_check.exists():
continue
topic_data = json.loads(topic_file.read_text(encoding='utf-8'))
logger.info(f"글 작성 요청: {topic_data.get('topic', '')}")
_call_openclaw(topic_data, draft_check)
def _call_openclaw(topic_data: dict, output_path: Path):
logger.info(f"OpenClaw 호출 (플레이스홀더): {topic_data.get('topic', '')}")
# OpenClaw 연동 완료 후 아래 주석 해제:
# import subprocess
# result = subprocess.run(
# ['openclaw', 'run', 'blog-writer', '--input', json.dumps(topic_data)],
# capture_output=True, text=True
# )
# output = result.stdout
topic_data['_pending_openclaw'] = True
output_path.write_text(json.dumps(topic_data, ensure_ascii=False, indent=2), encoding='utf-8')
def job_publish(slot: int):
if not _publish_enabled:
logger.info(f"[스케줄] 발행 중단 — 슬롯 {slot} 건너뜀")
return
logger.info(f"[스케줄] 발행봇 (슬롯 {slot})")
try:
_publish_next()
except Exception as e:
logger.error(f"발행봇 오류: {e}")
def _publish_next():
drafts_dir = DATA_DIR / 'drafts'
drafts_dir.mkdir(exist_ok=True)
for draft_file in sorted(drafts_dir.glob('*.json')):
try:
article = json.loads(draft_file.read_text(encoding='utf-8'))
if article.get('_pending_openclaw'):
continue
sys.path.insert(0, str(BASE_DIR / 'bots'))
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
article['_body_is_html'] = True
publisher_bot.publish(article)
draft_file.unlink(missing_ok=True)
break
except Exception as e:
logger.error(f"드래프트 처리 오류 ({draft_file.name}): {e}")
def job_analytics_daily():
logger.info("[스케줄] 분석봇 일일 리포트")
try:
sys.path.insert(0, str(BASE_DIR / 'bots'))
import analytics_bot
analytics_bot.daily_report()
except Exception as e:
logger.error(f"분석봇 오류: {e}")
def job_analytics_weekly():
logger.info("[스케줄] 분석봇 주간 리포트")
try:
sys.path.insert(0, str(BASE_DIR / 'bots'))
import analytics_bot
analytics_bot.weekly_report()
except Exception as e:
logger.error(f"분석봇 주간 리포트 오류: {e}")
def job_image_prompt_batch():
"""request 모드 전용 — 매주 월요일 10:00 프롬프트 배치 전송"""
if IMAGE_MODE != 'request':
return
logger.info("[스케줄] 이미지 프롬프트 배치 전송")
try:
sys.path.insert(0, str(BASE_DIR / 'bots'))
import image_bot
image_bot.send_prompt_batch()
except Exception as e:
logger.error(f"이미지 배치 오류: {e}")
# ─── Telegram 명령 핸들러 ────────────────────────────
async def cmd_status(update: Update, context: ContextTypes.DEFAULT_TYPE):
status = "🟢 발행 활성" if _publish_enabled else "🔴 발행 중단"
mode_label = {'manual': '수동', 'request': '요청', 'auto': '자동'}.get(IMAGE_MODE, IMAGE_MODE)
await update.message.reply_text(
f"블로그 엔진 상태: {status}\n이미지 모드: {mode_label} ({IMAGE_MODE})"
)
async def cmd_stop_publish(update: Update, context: ContextTypes.DEFAULT_TYPE):
global _publish_enabled
_publish_enabled = False
await update.message.reply_text("🔴 발행이 중단되었습니다.")
async def cmd_resume_publish(update: Update, context: ContextTypes.DEFAULT_TYPE):
global _publish_enabled
_publish_enabled = True
await update.message.reply_text("🟢 발행이 재개되었습니다.")
async def cmd_show_topics(update: Update, context: ContextTypes.DEFAULT_TYPE):
topics_dir = DATA_DIR / 'topics'
today = datetime.now().strftime('%Y%m%d')
files = sorted(topics_dir.glob(f'{today}_*.json'))
if not files:
await update.message.reply_text("오늘 수집된 글감이 없습니다.")
return
lines = [f"📋 오늘 수집된 글감 ({len(files)}개):"]
for f in files[:10]:
try:
data = json.loads(f.read_text(encoding='utf-8'))
lines.append(f" [{data.get('quality_score',0)}점][{data.get('corner','')}] {data.get('topic','')[:50]}")
except Exception:
pass
await update.message.reply_text('\n'.join(lines))
async def cmd_pending(update: Update, context: ContextTypes.DEFAULT_TYPE):
sys.path.insert(0, str(BASE_DIR / 'bots'))
import publisher_bot
pending = publisher_bot.get_pending_list()
if not pending:
await update.message.reply_text("수동 검토 대기 글이 없습니다.")
return
lines = [f"🔍 수동 검토 대기 ({len(pending)}개):"]
for i, item in enumerate(pending[:5], 1):
lines.append(f" {i}. [{item.get('corner','')}] {item.get('title','')[:50]}")
lines.append(f" 사유: {item.get('pending_reason','')}")
lines.append("\n/approve [번호] /reject [번호]")
await update.message.reply_text('\n'.join(lines))
async def cmd_approve(update: Update, context: ContextTypes.DEFAULT_TYPE):
sys.path.insert(0, str(BASE_DIR / 'bots'))
import publisher_bot
pending = publisher_bot.get_pending_list()
if not pending:
await update.message.reply_text("대기 글이 없습니다.")
return
args = context.args
idx = int(args[0]) - 1 if args and args[0].isdigit() else 0
if not (0 <= idx < len(pending)):
await update.message.reply_text("잘못된 번호입니다.")
return
success = publisher_bot.approve_pending(pending[idx].get('_filepath', ''))
await update.message.reply_text(
f"✅ 승인 완료: {pending[idx].get('title','')}" if success else "❌ 발행 실패. 로그 확인."
)
async def cmd_reject(update: Update, context: ContextTypes.DEFAULT_TYPE):
sys.path.insert(0, str(BASE_DIR / 'bots'))
import publisher_bot
pending = publisher_bot.get_pending_list()
if not pending:
await update.message.reply_text("대기 글이 없습니다.")
return
args = context.args
idx = int(args[0]) - 1 if args and args[0].isdigit() else 0
if not (0 <= idx < len(pending)):
await update.message.reply_text("잘못된 번호입니다.")
return
publisher_bot.reject_pending(pending[idx].get('_filepath', ''))
await update.message.reply_text(f"🗑 거부 완료: {pending[idx].get('title','')}")
async def cmd_report(update: Update, context: ContextTypes.DEFAULT_TYPE):
await update.message.reply_text("주간 리포트 생성 중...")
sys.path.insert(0, str(BASE_DIR / 'bots'))
import analytics_bot
analytics_bot.weekly_report()
# ─── 이미지 관련 명령 (request 모드) ────────────────
async def cmd_images(update: Update, context: ContextTypes.DEFAULT_TYPE):
"""대기 중인 이미지 프롬프트 목록 표시"""
sys.path.insert(0, str(BASE_DIR / 'bots'))
import image_bot
pending = image_bot.get_pending_prompts('pending')
selected = image_bot.get_pending_prompts('selected')
done = image_bot.get_pending_prompts('done')
if not pending and not selected:
await update.message.reply_text(
f"🎨 대기 중인 이미지 요청이 없습니다.\n"
f"완료된 이미지: {len(done)}\n\n"
f"/imgbatch — 지금 바로 배치 전송 요청"
)
return
lines = [f"🎨 이미지 제작 현황\n"]
if pending:
lines.append(f"⏳ 대기 ({len(pending)}건):")
for p in pending:
lines.append(f" #{p['id']} {p['topic'][:40]}")
if selected:
lines.append(f"\n🔄 진행 중 ({len(selected)}건):")
for p in selected:
lines.append(f" #{p['id']} {p['topic'][:40]}")
lines.append(f"\n✅ 완료: {len(done)}")
lines.append(
f"\n/imgpick [번호] — 프롬프트 받기\n"
f"/imgbatch — 전체 목록 재전송"
)
await update.message.reply_text('\n'.join(lines))
async def cmd_imgpick(update: Update, context: ContextTypes.DEFAULT_TYPE):
"""특정 번호 프롬프트 선택 → 전체 프롬프트 전송 + 이미지 대기 상태 진입"""
sys.path.insert(0, str(BASE_DIR / 'bots'))
import image_bot
args = context.args
if not args or not args[0].isdigit():
await update.message.reply_text("사용법: /imgpick [번호]\n예) /imgpick 3")
return
prompt_id = args[0]
prompt = image_bot.get_prompt_by_id(prompt_id)
if not prompt:
await update.message.reply_text(f"#{prompt_id} 번 프롬프트를 찾을 수 없습니다.\n/images 로 목록 확인")
return
if prompt['status'] == 'done':
await update.message.reply_text(f"#{prompt_id} 는 이미 완료된 항목입니다.")
return
# 단일 프롬프트 전송 (Telegram 메시지 길이 제한 고려해 분리 전송)
image_bot.send_single_prompt(prompt_id)
# 이미지 대기 상태 등록
chat_id = update.message.chat_id
_awaiting_image[chat_id] = prompt_id
logger.info(f"이미지 대기 등록: chat={chat_id}, prompt=#{prompt_id}")
async def cmd_imgbatch(update: Update, context: ContextTypes.DEFAULT_TYPE):
"""전체 대기 프롬프트 배치 전송 (수동 트리거)"""
sys.path.insert(0, str(BASE_DIR / 'bots'))
import image_bot
image_bot.send_prompt_batch()
await update.message.reply_text("📤 프롬프트 배치 전송 완료.")
async def cmd_imgcancel(update: Update, context: ContextTypes.DEFAULT_TYPE):
"""이미지 대기 상태 취소"""
chat_id = update.message.chat_id
if chat_id in _awaiting_image:
pid = _awaiting_image.pop(chat_id)
await update.message.reply_text(f"❌ #{pid} 이미지 대기 취소.")
else:
await update.message.reply_text("현재 대기 중인 이미지 요청이 없습니다.")
# ─── 이미지/파일 수신 핸들러 ─────────────────────────
async def _receive_image(update: Update, context: ContextTypes.DEFAULT_TYPE,
file_getter, caption: str):
"""공통 이미지 수신 처리 (photo / document)"""
sys.path.insert(0, str(BASE_DIR / 'bots'))
import image_bot
chat_id = update.message.chat_id
# 프롬프트 ID 결정: 대기 상태 > 캡션 파싱 > 없음
prompt_id = _awaiting_image.get(chat_id)
if not prompt_id and caption:
# 캡션에 #번호 형식이 있으면 추출
m = __import__('re').search(r'#(\d+)', caption)
if m:
prompt_id = m.group(1)
if not prompt_id:
await update.message.reply_text(
"⚠ 어느 주제의 이미지인지 알 수 없습니다.\n\n"
"방법 1: /imgpick [번호] 로 먼저 선택 후 이미지 전송\n"
"방법 2: 이미지 캡션에 #번호 입력 (예: #3)\n\n"
"/images — 현재 대기 목록 확인"
)
return
# Telegram에서 파일 다운로드
try:
tg_file = await file_getter()
file_bytes = (await tg_file.download_as_bytearray())
except Exception as e:
await update.message.reply_text(f"❌ 파일 다운로드 실패: {e}")
return
# 저장 및 프롬프트 완료 처리
image_path = image_bot.save_image_from_telegram(bytes(file_bytes), prompt_id)
if not image_path:
await update.message.reply_text(f"❌ 저장 실패. #{prompt_id} 번이 존재하는지 확인하세요.")
return
# 대기 상태 해제
_awaiting_image.pop(chat_id, None)
prompt = image_bot.get_prompt_by_id(prompt_id)
topic = prompt['topic'] if prompt else ''
await update.message.reply_text(
f"✅ <b>이미지 저장 완료!</b>\n\n"
f"#{prompt_id} {topic}\n"
f"경로: <code>{image_path}</code>\n\n"
f"이 이미지는 해당 만평 글 발행 시 자동으로 사용됩니다.",
parse_mode='HTML',
)
logger.info(f"이미지 수령 완료: #{prompt_id}{image_path}")
async def handle_photo(update: Update, context: ContextTypes.DEFAULT_TYPE):
"""Telegram 사진 수신"""
caption = update.message.caption or ''
photo = update.message.photo[-1] # 가장 큰 해상도
await _receive_image(
update, context,
file_getter=lambda: context.bot.get_file(photo.file_id),
caption=caption,
)
async def handle_document(update: Update, context: ContextTypes.DEFAULT_TYPE):
"""Telegram 파일(문서) 수신 — 고해상도 이미지 전송 시"""
doc = update.message.document
mime = doc.mime_type or ''
if not mime.startswith('image/'):
return # 이미지 파일만 처리
caption = update.message.caption or ''
await _receive_image(
update, context,
file_getter=lambda: context.bot.get_file(doc.file_id),
caption=caption,
)
# ─── 텍스트 명령 ─────────────────────────────────────
async def handle_text(update: Update, context: ContextTypes.DEFAULT_TYPE):
text = update.message.text.strip()
cmd_map = {
'발행 중단': cmd_stop_publish,
'발행 재개': cmd_resume_publish,
'오늘 수집된 글감 보여줘': cmd_show_topics,
'이번 주 리포트': cmd_report,
'대기 중인 글 보여줘': cmd_pending,
'이미지 목록': cmd_images,
}
if text in cmd_map:
await cmd_map[text](update, context)
else:
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 — 봇 상태"
)
# ─── 스케줄러 설정 + 메인 ─────────────────────────────
def setup_scheduler() -> AsyncIOScheduler:
scheduler = AsyncIOScheduler(timezone='Asia/Seoul')
schedule_cfg = load_schedule()
job_map = {
'collector': job_collector,
'ai_writer': job_ai_writer,
'publish_1': lambda: job_publish(1),
'publish_2': lambda: job_publish(2),
'publish_3': lambda: job_publish(3),
'analytics': job_analytics_daily,
}
for job in schedule_cfg.get('jobs', []):
fn = job_map.get(job['id'])
if fn:
scheduler.add_job(fn, 'cron', hour=job['hour'], minute=job['minute'], id=job['id'])
# 고정 스케줄
scheduler.add_job(job_analytics_weekly, 'cron',
day_of_week='sun', hour=22, minute=30, id='weekly_report')
# request 모드: 매주 월요일 10:00 이미지 프롬프트 배치 전송
if IMAGE_MODE == 'request':
scheduler.add_job(job_image_prompt_batch, 'cron',
day_of_week='mon', hour=10, minute=0, id='image_batch')
logger.info("이미지 request 모드: 매주 월요일 10:00 배치 전송 등록")
logger.info("스케줄러 설정 완료")
return scheduler
async def main():
logger.info("=== 블로그 엔진 스케줄러 시작 ===")
scheduler = setup_scheduler()
scheduler.start()
if TELEGRAM_BOT_TOKEN:
app = Application.builder().token(TELEGRAM_BOT_TOKEN).build()
# 발행 관련
app.add_handler(CommandHandler('status', cmd_status))
app.add_handler(CommandHandler('approve', cmd_approve))
app.add_handler(CommandHandler('reject', cmd_reject))
app.add_handler(CommandHandler('pending', cmd_pending))
app.add_handler(CommandHandler('report', cmd_report))
app.add_handler(CommandHandler('topics', cmd_show_topics))
# 이미지 관련 (request / manual 공통 사용 가능)
app.add_handler(CommandHandler('images', cmd_images))
app.add_handler(CommandHandler('imgpick', cmd_imgpick))
app.add_handler(CommandHandler('imgbatch', cmd_imgbatch))
app.add_handler(CommandHandler('imgcancel', cmd_imgcancel))
# 이미지 파일 수신
app.add_handler(MessageHandler(filters.PHOTO, handle_photo))
app.add_handler(MessageHandler(filters.Document.IMAGE, handle_document))
# 텍스트 명령
app.add_handler(MessageHandler(filters.TEXT & ~filters.COMMAND, handle_text))
logger.info("Telegram 봇 시작")
await app.initialize()
await app.start()
await app.updater.start_polling(drop_pending_updates=True)
try:
while True:
await asyncio.sleep(3600)
except (KeyboardInterrupt, SystemExit):
logger.info("종료 신호 수신")
finally:
await app.updater.stop()
await app.stop()
await app.shutdown()
scheduler.shutdown()
else:
logger.warning("TELEGRAM_BOT_TOKEN 없음 — 스케줄러만 실행")
try:
while True:
await asyncio.sleep(3600)
except (KeyboardInterrupt, SystemExit):
scheduler.shutdown()
logger.info("=== 블로그 엔진 스케줄러 종료 ===")
if __name__ == '__main__':
asyncio.run(main())

View File

@@ -0,0 +1,33 @@
{
"fixed_links": [
{
"keyword": "ChatGPT Plus",
"url": "https://chat.openai.com",
"label": "ChatGPT Plus 바로가기",
"type": "external"
},
{
"keyword": "Claude Pro",
"url": "https://claude.ai",
"label": "Claude Pro 바로가기",
"type": "external"
}
],
"coupang_category_map": {
"마이크": "mic",
"웹캠": "webcam",
"키보드": "keyboard",
"마우스": "mouse",
"모니터": "monitor",
"노트북": "laptop",
"이어폰": "earphone",
"헤드셋": "headset",
"외장하드": "external-hdd",
"USB허브": "usb-hub",
"책상": "desk",
"의자": "chair",
"서적": "book",
"스피커": "speaker"
},
"disclaimer_text": "이 포스팅은 쿠팡 파트너스 활동의 일환으로, 이에 따른 일정액의 수수료를 제공받습니다."
}

14
config/blogs.json Normal file
View File

@@ -0,0 +1,14 @@
{
"blogs": [
{
"id": "main",
"blog_id": "${BLOG_MAIN_ID}",
"name": "테크인사이더",
"persona": "tech_insider",
"domain": "",
"active": true,
"phase": 1,
"labels": ["쉬운세상", "숨은보물", "바이브리포트", "팩트체크", "한컷"]
}
]
}

72
config/quality_rules.json Normal file
View File

@@ -0,0 +1,72 @@
{
"min_score": 70,
"scoring": {
"korean_relevance": {
"max": 30,
"description": "한국 독자 관련성",
"keywords": ["한국", "국내", "한글", "카카오", "네이버", "쿠팡", "삼성", "LG", "현대", "기아", "배달", "토스", "당근", "야놀자"]
},
"freshness": {
"max": 20,
"description": "트렌드 신선도",
"hours_full_score": 24,
"hours_zero_score": 168
},
"search_demand": {
"max": 20,
"description": "검색 수요 (Google Trends 상대값)"
},
"source_trust": {
"max": 15,
"description": "출처 신뢰도",
"levels": {
"high": 15,
"medium": 8,
"low": 3
},
"high_sources": ["github.com", "official blog", "공식", "press release"],
"low_sources": ["twitter.com", "x.com", "reddit.com", "개인"]
},
"monetization": {
"max": 15,
"description": "수익 연결 가능성",
"keywords": ["도구", "앱", "서비스", "제품", "장비", "구독", "할인", "추천"]
}
},
"discard_rules": [
{
"id": "no_korean_relevance",
"description": "한국 독자와 무관한 주제",
"condition": "korean_relevance_score == 0"
},
{
"id": "unverified_source",
"description": "출처 불명/미확인 사례",
"condition": "source_trust_level == 'unknown'"
},
{
"id": "duplicate_topic",
"description": "이미 발행한 주제와 유사도 80% 이상",
"similarity_threshold": 0.8
},
{
"id": "stale_trend",
"description": "7일 이상 지난 트렌드 (에버그린 제외)",
"max_age_days": 7,
"except_evergreen": true
},
{
"id": "promotional",
"description": "광고성/홍보성이 명확한 원문",
"keywords": ["광고", "홍보", "스폰서", "협찬", "AD", "sponsored"]
},
{
"id": "clickbait",
"description": "클릭베이트성 주제",
"patterns": ["충격", "경악", "난리", "ㅋㅋ", "ㅠㅠ", "대박", "레전드", "역대급"]
}
],
"evergreen_keywords": [
"가이드", "방법", "사용법", "입문", "튜토리얼", "기초", "완전정복", "총정리"
]
}

View File

@@ -0,0 +1,18 @@
{
"crypto_keywords": [
"스캠", "사기", "폰지", "러그풀", "소송", "코인",
"비트코인", "이더리움", "암호화폐", "가상화폐"
],
"criticism_keywords": [
"고소", "피해", "논란", "비리", "내부고발", "고발"
],
"investment_keywords": [
"수익 보장", "확실한 수익", "반드시 오른다", "무조건", "투자 권유"
],
"legal_keywords": [
"불법", "위법", "처벌", "벌금", "징역", "기소"
],
"always_manual_review": ["팩트체크"],
"min_sources_required": 2,
"min_quality_score_for_auto": 75
}

40
config/schedule.json Normal file
View File

@@ -0,0 +1,40 @@
{
"jobs": [
{
"id": "collector",
"hour": 7,
"minute": 0,
"description": "수집봇 실행"
},
{
"id": "ai_writer",
"hour": 8,
"minute": 0,
"description": "AI 글 작성 트리거"
},
{
"id": "publish_1",
"hour": 9,
"minute": 0,
"description": "첫 번째 글 발행"
},
{
"id": "publish_2",
"hour": 12,
"minute": 0,
"description": "두 번째 글 발행"
},
{
"id": "publish_3",
"hour": 15,
"minute": 0,
"description": "세 번째 글 발행 (있을 경우)"
},
{
"id": "analytics",
"hour": 22,
"minute": 0,
"description": "분석봇 일일 리포트"
}
]
}

49
config/sources.json Normal file
View File

@@ -0,0 +1,49 @@
{
"rss_feeds": [
{
"name": "GeekNews",
"url": "https://feeds.feedburner.com/geeknews-feed",
"category": "tech",
"trust_level": "high"
},
{
"name": "ZDNet Korea",
"url": "https://www.zdnet.co.kr/rss/rss.php",
"category": "tech",
"trust_level": "high"
},
{
"name": "Yonhap IT",
"url": "https://www.yna.co.kr/rss/it.xml",
"category": "tech",
"trust_level": "high"
},
{
"name": "Bloter",
"url": "https://www.bloter.net/feed",
"category": "tech",
"trust_level": "high"
}
],
"x_keywords": [
"바이브코딩",
"vibe coding",
"AI 자동화",
"Claude 사용",
"ChatGPT 활용",
"비개발자 앱",
"노코드 AI"
],
"github_trending": {
"url": "https://github.com/trending",
"languages": ["", "python", "javascript"],
"since": "daily"
},
"hacker_news": {
"url": "https://hacker-news.firebaseio.com/v0/topstories.json",
"top_n": 30
},
"product_hunt": {
"rss_url": "https://www.producthunt.com/feed"
}
}

12
requirements.txt Normal file
View File

@@ -0,0 +1,12 @@
pytrends
google-api-python-client
google-auth-oauthlib
google-auth-httplib2
python-dotenv
apscheduler
requests
beautifulsoup4
feedparser
markdown
python-telegram-bot==20.7
lxml

59
scripts/get_token.py Normal file
View File

@@ -0,0 +1,59 @@
"""
Google OAuth2 토큰 발급 스크립트
실행: python scripts/get_token.py
결과: credentials.json 필요, token.json 생성, refresh_token 출력
"""
import json
import os
import sys
from google_auth_oauthlib.flow import InstalledAppFlow
from google.oauth2.credentials import Credentials
from google.auth.transport.requests import Request
SCOPES = [
'https://www.googleapis.com/auth/blogger',
'https://www.googleapis.com/auth/webmasters',
]
BASE_DIR = os.path.dirname(os.path.dirname(os.path.abspath(__file__)))
TOKEN_PATH = os.path.join(BASE_DIR, 'token.json')
CREDENTIALS_PATH = os.path.join(BASE_DIR, 'credentials.json')
def main():
if not os.path.exists(CREDENTIALS_PATH):
print(f"[ERROR] credentials.json 파일이 없습니다: {CREDENTIALS_PATH}")
print("Google Cloud Console에서 OAuth 클라이언트 ID를 생성하고")
print("credentials.json 을 C:\\blog-engine\\ 에 저장하세요.")
sys.exit(1)
creds = None
if os.path.exists(TOKEN_PATH):
creds = Credentials.from_authorized_user_file(TOKEN_PATH, SCOPES)
if not creds or not creds.valid:
if creds and creds.expired and creds.refresh_token:
creds.refresh(Request())
print("[OK] 기존 토큰 갱신 완료")
else:
flow = InstalledAppFlow.from_client_secrets_file(CREDENTIALS_PATH, SCOPES)
creds = flow.run_local_server(port=0)
print("[OK] 새 토큰 발급 완료")
with open(TOKEN_PATH, 'w') as token_file:
token_file.write(creds.to_json())
token_data = json.loads(creds.to_json())
refresh_token = token_data.get('refresh_token', '')
print("\n" + "=" * 50)
print("토큰 발급 성공!")
print("=" * 50)
print(f"\nREFRESH_TOKEN:\n{refresh_token}")
print(f"\n이 값을 .env 파일의 GOOGLE_REFRESH_TOKEN 에 붙여넣으세요.")
print(f"\ntoken.json 저장 위치: {TOKEN_PATH}")
if __name__ == '__main__':
main()

64
scripts/setup.bat Normal file
View File

@@ -0,0 +1,64 @@
@echo off
echo ========================================
echo Blog Engine Setup
echo ========================================
REM Python venv 생성
python -m venv venv
if errorlevel 1 (
echo [ERROR] Python venv 생성 실패. Python 3.11 이상이 설치되어 있는지 확인하세요.
pause
exit /b 1
)
REM 패키지 설치
call venv\Scripts\activate
pip install --upgrade pip
pip install -r requirements.txt
if errorlevel 1 (
echo [ERROR] 패키지 설치 실패.
pause
exit /b 1
)
REM .env 파일 복사 (없을 경우)
if not exist .env (
copy .env.example .env
echo [OK] .env 파일 생성됨. API 키를 입력해주세요: .env
)
REM data 폴더 생성
if not exist data\topics mkdir data\topics
if not exist data\collected mkdir data\collected
if not exist data\discarded mkdir data\discarded
if not exist data\pending_review mkdir data\pending_review
if not exist data\published mkdir data\published
if not exist data\analytics mkdir data\analytics
if not exist data\images mkdir data\images
if not exist data\drafts mkdir data\drafts
if not exist logs mkdir logs
REM Windows 작업 스케줄러에 scheduler.py 등록
set SCRIPT_PATH=%~dp0bots\scheduler.py
set PYTHON_PATH=%~dp0venv\Scripts\pythonw.exe
schtasks /query /tn "BlogEngine" >nul 2>&1
if errorlevel 1 (
schtasks /create /tn "BlogEngine" /tr "\"%PYTHON_PATH%\" \"%SCRIPT_PATH%\"" /sc onlogon /rl highest /f
echo [OK] Windows 작업 스케줄러에 BlogEngine 등록 완료
) else (
echo [INFO] BlogEngine 작업이 이미 등록되어 있습니다.
)
echo.
echo ========================================
echo Setup 완료!
echo ========================================
echo.
echo 다음 단계:
echo 1. .env 파일을 열고 API 키를 모두 입력하세요
echo 2. scripts\get_token.py 를 실행해서 Google OAuth 토큰을 발급받으세요
echo 3. config\blogs.json 에서 BLOG_MAIN_ID 를 실제 블로그 ID로 변경하세요
echo 4. python bots\scheduler.py 로 스케줄러를 시작하세요
echo.
pause