commit 15eb007b5a9b792966b9573985eea37f14c42e34 Author: sinmb79 Date: Wed Mar 25 06:54:43 2026 +0900 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 diff --git a/.env.example b/.env.example new file mode 100644 index 0000000..e4cef9c --- /dev/null +++ b/.env.example @@ -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= diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..b52b257 --- /dev/null +++ b/.gitignore @@ -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 diff --git a/README.md b/README.md new file mode 100644 index 0000000..dbb1fea --- /dev/null +++ b/README.md @@ -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.04–0.08 비용 발생 (ChatGPT Pro 구독과 별도). + +--- + +## 콘텐츠 코너 구성 + +| 코너 | 컨셉 | 발행 빈도 | +|------|------|-----------| +| **쉬운 세상** | AI/테크를 누구나 따라할 수 있게 | 주 2–3회 | +| **숨은 보물** | 모르면 손해인 무료 도구 발굴 | 주 2–3회 | +| **바이브 리포트** | 비개발자가 AI로 만든 실제 사례 | 주 1–2회 | +| **팩트체크** | 과대광고/거짓 주장 검증 (수동 승인 필수) | 주 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 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만원+/월 | + +--- + +## 자주 묻는 질문 + +**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 — 자유롭게 사용, 수정, 배포 가능합니다. diff --git a/blog-engine-final-masterplan-v2.txt b/blog-engine-final-masterplan-v2.txt new file mode 100644 index 0000000..d66d625 --- /dev/null +++ b/blog-engine-final-masterplan-v2.txt @@ -0,0 +1,1139 @@ +================================================================================ + 블로그 자동 수익 엔진 — 최종 마스터플랜 v2 + 리뷰 반영 수정본 +================================================================================ + + 작성일: 2026-03-24 + 버전: v2 (리뷰 5개 항목 수정 반영) + 상태: 최종 확정 — Claude Code 전달 가능 + + 이전 문서: 전부 이 문서로 대체됨 + - blog-engine-masterplan.txt (초안) + - blog-engine-masterplan-update.txt (서브에이전트 구조) + - blog-engine-final-masterplan.txt (v1) + + v1 → v2 수정 내역: + [수정1] 블로그 4개 동시 → 1개 시작 → 단계적 확장 + [수정2] 팩트체크 코너 안전장치 강화 + [수정3] 만평 코너 → Phase 1에서 주 1회 실험으로 축소 + [수정4] 수집봇에 폐기 규칙 + 품질 점수 시스템 추가 + [수정5] 구현 지시서 모호 표현 정리 + + +================================================================================ + PART 1: 전략 요약 +================================================================================ + + +── 1. 핵심 컨셉 ────────────────────────────────────────── + + "AI 시대의 1인 미디어" + + 단순한 AI 글 생성 블로그가 아니라, + 고유한 시각과 개성을 가진 독립 미디어를 만든다. + + - 트렌드를 다루되, 우리만의 해석과 시각으로 + - AI는 글쓰기만, 나머지는 효율적인 봇으로 + - 국내 시장 먼저, 안정화 후 글로벌 확장 + - 비용 최소화, 수익 극대화 + + +── 2. 전략 원칙 ────────────────────────────────────────── + + (1) 국내(한국어) 우선 + - 영문 시장은 AI 생성 글이 이미 레드오션 + - 한국어는 상대적으로 경쟁 낮음 + 쿠팡 파트너스 활용 가능 + - 시스템 검증 후 글로벌 확장 + + (2) AI는 글쓰기에만 사용 + - 트렌드 수집, 발행, 링크 삽입, 분석 → 봇(Python) + - 글 작성 → OpenClaw 서브에이전트 (ChatGPT Pro) + - 봇이 더 빠르고, 안정적이고, 비용 0원 + + (3) 양보다 질 + - 하루 2-3개. 월 60-90개 수준. + - 5대 콘텐츠 코너로 차별화된 시각 + - 트렌드 + 개성의 조합 + + (4) 작게 시작, 데이터로 확장 [수정1] + - Phase 1: 메인 블로그 1개에 집중 + - 데이터(색인률, CTR, 체류시간)를 보고 확장 판단 + - 처음부터 4개가 아니라 1 → 2 → 4 순서 + + (5) 비용 최소화 + - OpenAI API 비용: 0원 (ChatGPT Pro 구독 활용) + - 호스팅: 0원 (Google Blogger) + - 총 추가 월 비용: ~5,000원 + + +── 3. 비용 구조 ────────────────────────────────────────── + + [이미 지불 중 — 변동 없음] + ChatGPT Pro 구독 $200/월 (OpenClaw + 글쓰기용) + + [추가 비용] + 미니PC 전기세 ~3,000-5,000원/월 + 커스텀 도메인 ~13,000-20,000원/년 (블로그당) + ─────────────────────────────────────────── + Phase 1 추가 총액 ~4,000-6,000원/월 (도메인 1개 기준) + + +── 4. 수익 구조 ────────────────────────────────────────── + + [1차 수익] Google AdSense + - 블로그 광고 클릭당 수익 + - IT/AI 니치 CPC: 클릭당 300-1,500원 + - 현실적 Phase 1 목표: 월 0-5만원 + + [2차 수익] 쿠팡 파트너스 + - 상품 추천 링크 → 구매 시 수수료 3% + - Phase 1에서는 IT 관련 제품만 + - 현실적 Phase 1 목표: 월 0-5만원 + + [3차 수익] 어필리에이트 (Phase 3+) + - 크립토 거래소, SaaS 도구 등 + - 블로그 분리 후 추가 + + [4차 수익] 콘텐츠 확장 (Phase 4+) + - 뉴스레터, 전자책 등 + + ※ Phase 1(Month 1-3)에서는 수익 0원을 기본 가정. + 수익이 나면 좋은 거고, 안 나도 정상. + 이 시기는 "검색 자산 축적 기간"이라고 봐야 함. + + +================================================================================ + PART 2: 시스템 아키텍처 +================================================================================ + + +── 5. 전체 구조도 ──────────────────────────────────────── + + ┌─────────────────────────────────────────────────────┐ + │ MINI PC (24시간) │ + │ │ + │ ┌───────────────────────────────────────────────┐ │ + │ │ 봇 레이어 (Python — AI 없음) │ │ + │ │ │ │ + │ │ [수집봇] 트렌드/도구/사례 수집 + 품질 필터링 │ │ + │ │ [발행봇] Blogger API 자동 발행 │ │ + │ │ [링크봇] 쿠팡/어필리에이트 링크 삽입 │ │ + │ │ [분석봇] Search Console/AdSense 데이터 수집 │ │ + │ │ [이미지봇] 만평 이미지 생성 트리거 │ │ + │ │ [스케줄러] cron 기반 시간 관리 │ │ + │ └───────────────┬───────────────────────────────┘ │ + │ │ 주제+데이터 전달 │ + │ ▼ │ + │ ┌───────────────────────────────────────────────┐ │ + │ │ AI 레이어 (OpenClaw — 글쓰기만) │ │ + │ │ │ │ + │ │ 메인 에이전트 → 서브에이전트: blog-writer │ │ + │ │ Model: GPT-5.4 (ChatGPT Pro) │ │ + │ │ │ │ + │ │ 입력: 주제 + 수집 데이터 + 페르소나/코너 │ │ + │ │ 출력: 완성된 글 (제목+본문+메타) │ │ + │ └───────────────┬───────────────────────────────┘ │ + │ │ 완성된 글 반환 │ + │ ▼ │ + │ ┌───────────────────────────────────────────────┐ │ + │ │ 봇 레이어 (후처리) │ │ + │ │ │ │ + │ │ [안전장치] 팩트체크/크립토 글 수동검토 판별 │ │ + │ │ HTML 포맷팅 → 링크 삽입 → 발행 → 알림 │ │ + │ └───────────────────────────────────────────────┘ │ + └─────────────────────────────────────────────────────┘ + + +── 6. 봇 레이어 상세 ──────────────────────────────────── + + [수집봇] collector_bot.py + + 역할: 글감이 될 데이터를 자동으로 모아오고, + 나쁜 글감을 걸러낸다. [수정4] + + 수집 소스: + - Google Trends (pytrends): 실시간 트렌딩 키워드 (한국) + - GitHub Trending: 뜨는 오픈소스 프로젝트/도구 + - Product Hunt: 신규 출시 도구/앱 + - Hacker News API (상위 30개): 기술 커뮤니티 인기 글 + - RSS 피드: config/sources.json에 등록된 블로그/뉴스 + - X(트위터) 검색: 바이브코딩 사례, AI 이슈 (키워드 기반) + + ★ 품질 점수 시스템 (0-100점) [수정4] + + 수집된 글감마다 점수를 자동 계산합니다. + 70점 이상만 글감 큐에 들어가고, 나머지는 폐기됩니다. + + 점수 계산 기준: + +30 한국 독자 관련성 (한국어 키워드, 한국 서비스 관련) + +20 트렌드 신선도 (24시간 이내 = 만점, 7일 초과 = 0점) + +20 검색 수요 (Google Trends 상대 검색량) + +15 출처 신뢰도 (공식 블로그/GitHub = 높음, 개인 SNS = 낮음) + +15 수익 연결 가능성 (관련 제품/서비스 존재 여부) + + ★ 폐기 규칙 (아래 중 하나라도 해당되면 즉시 폐기) [수정4] + + - 한국 독자와 무관한 주제 (해외 로컬 뉴스 등) + - 출처 불명/미확인 사례 + - 이미 발행한 주제와 유사도 80% 이상 (제목 기준) + - 7일 이상 지난 트렌드 (에버그린 주제 제외) + - 광고성/홍보성이 명확한 원문 + - 클릭베이트성 주제 (자극적이지만 실질 가치 없음) + + 출력 형태: + { + "corner": "숨은보물", + "topic": "회의록 자동 요약 도구 — 무료 오픈소스", + "source_url": "https://github.com/...", + "quality_score": 85, + "trending_since": "12시간 전", + "related_keywords": ["회의록", "자동화", "무료 도구"], + "coupang_keywords": ["마이크", "웹캠", "사무용품"], + "sources": [ + {"url": "...", "title": "...", "date": "..."}, + {"url": "...", "title": "...", "date": "..."} + ] + } + + 실행 주기: 매일 07:00 + + + [발행봇] publisher_bot.py + + 역할: AI가 만든 글을 Blogger에 자동 발행. + + 하는 일: + 1. 마크다운 → HTML 변환 (Python markdown 라이브러리) + 2. 목차 자동 생성 (H2, H3 기반) + 3. AdSense 플레이스홀더 삽입 + - 두 번째 H2 뒤 + - 결론 섹션 앞 + 4. Schema.org Article JSON-LD 마크업 추가 + 5. Blogger API v3로 발행 + - POST /blogger/v3/blogs/{blogId}/posts + - OAuth2 인증 (refresh_token 자동 갱신) + 6. 발행 후 Google Search Console API로 URL 제출 [수정5] + - 방법: Search Console URL Inspection API 사용 + - IndexNow는 Blogger에서 직접 지원하지 않으므로 사용하지 않음 + - 대안: sitemap.xml 자동 갱신 (Blogger 내장) + + Search Console API indexing.url.publish 호출 + 7. 발행 이력 data/published/ 에 JSON으로 로그 + 8. Telegram 알림 전송 + + ★ 안전장치: 발행 전 검토 판별 [수정2] + + 아래 조건에 해당하면 자동 발행하지 않고, + "수동 검토 대기" 상태로 전환 → Telegram으로 알림. + 사용자가 Telegram에서 "승인" 명령을 보내면 발행. + + 수동 검토 대기 조건: + - 코너가 "팩트체크"인 글 전체 + - 글 본문에 다음 키워드 포함 시: + 암호화폐 관련: 스캠, 사기, 폰지, 러그풀, 소송 + 기업/인물 비판: [특정 기업명], 고소, 피해, 논란 + 투자 관련: 수익 보장, 확실한, 반드시 오른다 + 법률 관련: 불법, 위법, 처벌 + - 출처(source)가 2개 미만인 글 [수정2] + - 품질 점수가 75점 미만인 글감으로 작성된 글 + + + [링크봇] linker_bot.py + + 역할: 글 본문에 수익 링크를 자동 삽입. + + 하는 일: + 1. 글감 패키지의 coupang_keywords에서 키워드 추출 + 2. 쿠팡 파트너스 API로 상품 검색 → 링크 생성 + - Access Key + Secret Key로 인증 + - HMAC 서명 방식 (쿠팡 API 문서 참조) + 3. config/affiliate_links.json에서 추가 링크 매칭 + 4. 삽입 위치: + - 제품/도구 언급 바로 아래 + - 결론/추천 섹션 + 5. 쿠팡 필수 문구 자동 추가: + "이 포스팅은 쿠팡 파트너스 활동의 일환으로, + 이에 따른 일정액의 수수료를 제공받습니다." + + + [분석봇] analytics_bot.py + + 역할: 블로그 성과 데이터를 수집하고 리포트 생성. + + ★ 핵심 지표 (우선순위 순서) [수정5] + + 1. 색인률: 발행한 글 중 구글에 색인된 비율 + - Search Console API → 전체 글 수 대비 색인된 수 + - 초반에 가장 중요한 지표. 색인 안 되면 모든 게 의미 없음 + + 2. 검색 CTR: 검색 결과에 노출됐을 때 클릭하는 비율 + - Search Console API → 노출수 대비 클릭수 + - CTR이 낮으면 제목/메타 설명 개선 필요 + + 3. 발행 후 14일 성과: 글 발행 후 2주간 누적 지표 + - 조회수, 검색 유입수, 평균 노출 순위 + - "14일 지나도 유입 0이면 해당 주제 유형 축소" 규칙 + + 4. 어필리에이트 클릭률: 글 내 쿠팡/어필 링크 클릭 비율 + - 쿠팡 파트너스 대시보드 데이터 + - 클릭률 높은 글 유형 → 더 많이 생성 + + 5. 체류시간: 독자가 글에 머무는 평균 시간 + - Google Analytics (설치 시) 또는 Blogger 내장 통계 + - 체류시간 긴 글 = 양질의 콘텐츠 → 비슷한 주제 확대 + + 리포트 출력: + - 일일: Telegram 알림 (발행 결과 + 기본 지표) + - 주간: 상세 분석 (코너별 성과, 주제 유형별 비교) + - 주간 리포트 결과를 수집봇에 피드백 → 다음 주 글감 조정 + + + [이미지봇] image_bot.py + + 역할: 만평 코너용 이미지 생성. + + 하는 일: + 1. 만평 코너의 글 주제를 받음 + 2. 만평 프롬프트를 구성 (시사만평 스타일 지시) + 3. OpenAI Images API 호출 [수정5] + - 엔드포인트: POST https://api.openai.com/v1/images/generations + - 모델: dall-e-3 + - 인증: OpenAI API Key 사용 + - 비용: ChatGPT Pro 구독과 별도임을 인지 + → Phase 1에서는 무료 대안 우선 검토: + (a) ChatGPT 웹에서 수동 생성 후 다운로드 + (b) 오픈소스 이미지 생성 (Stable Diffusion 로컬) + (c) Canva 무료 템플릿으로 대체 + → API 비용이 부담되면 (a)로 운영 + 4. 이미지 다운로드 → data/images/ 저장 + + 실행 빈도: 주 1회 (Phase 1) [수정3] + + + [스케줄러] scheduler.py + + 역할: 모든 봇의 실행 시간 관리. + 라이브러리: APScheduler (Python, 무료) + Windows 자동 실행: 작업 스케줄러(Task Scheduler)에 등록 + + ※ Windows 작업 스케줄러 등록 방법은 setup.bat에서 자동 처리. + 수동 등록 시: 작업 스케줄러 → 작업 만들기 → + 트리거: "시작할 때" → 동작: pythonw scheduler.py + + +── 7. AI 레이어 상세 ──────────────────────────────────── + + 구조: + OpenClaw 메인 에이전트 (기존 설정 유지) + └── blog-writer 서브에이전트 (새로 생성) + + blog-writer의 역할: + - 수집봇이 넘긴 "글감 패키지"를 받음 + - 지정된 페르소나 + 코너 설정을 적용 + - 글 작성 (제목 + 본문 + 메타 설명 + 태그) + - 완성된 글을 반환 + + blog-writer가 하지 않는 것: + - 트렌드 검색 (수집봇 담당) + - HTML 변환 (발행봇 담당) + - 링크 삽입 (링크봇 담당) + - 이미지 생성 (이미지봇 담당) + - 발행 (발행봇 담당) + - 발행 여부 판단 (안전장치 담당) + + 비용: 0원 (ChatGPT Pro 구독에 포함) + + +── 8. 봇 vs AI 역할 분담 요약 ─────────────────────────── + + 작업 담당 이유 + ───────────────────────────────────────────────── + 트렌드 키워드 수집 수집봇 API 호출 + GitHub/뉴스 크롤링 수집봇 웹 스크래핑 + 글감 품질 점수 계산 수집봇 규칙 기반 계산 + 글감 폐기 판단 수집봇 규칙 기반 필터링 + 글 작성 AI 창의성 필요 + 만평 프롬프트 구성 AI 창의성 필요 + HTML 변환 발행봇 템플릿 변환 + 쿠팡 링크 삽입 링크봇 키워드 매칭 + 안전장치 판별 발행봇 키워드 감지 (규칙) + Blogger API 발행 발행봇 API 호출 + Search Console 제출 발행봇 API 호출 + 성과 데이터 수집 분석봇 API 호출 + 집계 + Telegram 알림 스케줄러 메시지 전송 + 스케줄 관리 스케줄러 시간 체크 + + +================================================================================ + PART 3: 콘텐츠 전략 +================================================================================ + + +── 9. 5대 콘텐츠 코너 ─────────────────────────────────── + + ★ Phase 1 운영 구분 [수정3] + + Phase 1 주력 (Day 1부터): + 코너 1: 쉬운 세상 + 코너 2: 숨은 보물 + 코너 3: 바이브 리포트 + + Phase 1 제한 운영: + 코너 4: 팩트체크 — 수동 검토 필수, 주 1회 이하 [수정2] + 코너 5: 한 컷(만평) — 주 1회 실험 [수정3] + + Phase 2 정식 운영 (Month 3+, 시스템 안정화 후): + 코너 4, 5를 정식 코너로 확대 + + + ┌─────────────────────────────────────────────────┐ + │ 코너 1: 쉬운 세상 (Easy Guide) [Phase 1 주력]│ + ├─────────────────────────────────────────────────┤ + │ │ + │ 컨셉: "하나에서 열까지, 누구나 따라할 수 있게" │ + │ │ + │ AI/테크 주제를 비전문가도 이해하고 │ + │ 바로 실행할 수 있도록 상세히 풀어쓴다. │ + │ │ + │ 특징: │ + │ - 모든 전문 용어에 괄호 안 쉬운 설명 포함 │ + │ - "이것만 따라하세요" 톤 │ + │ - 단계별 구체적 절차 포함 │ + │ │ + │ 예시 글: │ + │ "ChatGPT 처음 쓰는 사람을 위한 완전 가이드" │ + │ "GitHub 뭔지도 모르는 사람이 코드 실행하는 법" │ + │ "AI 자동화, 진짜 나도 할 수 있을까? — 예/아니오│ + │ 판단 가이드" │ + │ │ + │ 발행 빈도: 주 2-3회 │ + │ 수익화: AdSense (정보성 글 체류시간 높음) │ + │ 검색 자산 가치: ★★★★★ (에버그린) │ + └─────────────────────────────────────────────────┘ + + ┌─────────────────────────────────────────────────┐ + │ 코너 2: 숨은 보물 (Hidden Gems) [Phase 1 주력]│ + ├─────────────────────────────────────────────────┤ + │ │ + │ 컨셉: "세상의 정보격차를 줄여드립니다" │ + │ │ + │ 일반인은 모르지만 유용한 무료 도구, 프로젝트, │ + │ 웹사이트를 발굴해서 쉽게 설명한다. │ + │ │ + │ 특징: │ + │ - 수집봇이 GitHub Trending, Product Hunt에서 │ + │ 자동으로 "일반인에게 유용한 무료 도구" 탐지 │ + │ - "이런 게 무료라고?!" 반응 유도 │ + │ - 설치/사용법까지 상세 가이드 │ + │ │ + │ 예시 글: │ + │ "이 무료 앱 하나면 회의록이 자동 정리됩니다" │ + │ "포토샵 대신 쓸 수 있는 무료 도구 5가지" │ + │ "당신이 몰랐던 구글의 숨겨진 무료 기능 7가지" │ + │ │ + │ 발행 빈도: 주 2-3회 │ + │ 수익화: AdSense + 쿠팡 (관련 장비/서적) │ + │ 검색 자산 가치: ★★★★★ (에버그린) │ + └─────────────────────────────────────────────────┘ + + ┌─────────────────────────────────────────────────┐ + │ 코너 3: 바이브 리포트 (Vibe Report) [Phase 1 주력]│ + ├─────────────────────────────────────────────────┤ + │ │ + │ 컨셉: "비개발자가 AI로 만든 놀라운 것들" │ + │ │ + │ 코딩 모르는 사람들이 AI를 활용해서 만든 │ + │ 실제 사례를 발굴하고 상세히 리포트한다. │ + │ │ + │ 특징: │ + │ - 수집봇이 X, Reddit에서 사례 탐지 │ + │ - "이 사람은 어떻게 했나" 분석 + 교훈 │ + │ - 따라해볼 수 있는 구체적 단계 포함 │ + │ - 커뮤니티적 성격 → 충성 독자 형성 │ + │ │ + │ 예시 글: │ + │ "50대 회사원이 Claude로 사내 앱을 만들었다" │ + │ "비개발자의 GitHub Actions 자동화 도전기" │ + │ "주부가 만든 가계부 앱 — 어떻게 가능했을까" │ + │ │ + │ 발행 빈도: 주 1-2회 │ + │ 수익화: AdSense + SaaS 어필리에이트 (향후) │ + │ 검색 자산 가치: ★★★★ (사례 고유성) │ + └─────────────────────────────────────────────────┘ + + ┌─────────────────────────────────────────────────┐ + │ 코너 4: 팩트체크 (Fact Check) [Phase 1 제한]│ + ├─────────────────────────────────────────────────┤ + │ │ + │ 컨셉: "의심하고, 질문하고, 진실을 찾는다" │ + │ │ + │ AI/테크/경제 분야의 과대광고, 거짓 주장을 │ + │ 팩트 기반으로 검증한다. │ + │ │ + │ ★★★ 안전장치 (필수) ★★★ [수정2] + │ │ + │ 이 코너는 자동화 시스템에서 가장 위험합니다. │ + │ 반드시 아래 규칙을 적용합니다. │ + │ │ + │ 규칙 1: 출처 최소 2개 이상 없으면 글 생성 금지 │ + │ - 수집봇이 source 필드에 URL 2개 이상 첨부 │ + │ - 출처 없는 글감은 이 코너에 배정 금지 │ + │ │ + │ 규칙 2: 자동 발행 금지 │ + │ - 팩트체크 글은 항상 "수동 검토 대기" 상태 │ + │ - Telegram으로 사용자에게 미리보기 전송 │ + │ - 사용자가 "승인" 명령 → 발행 │ + │ - 사용자가 "거부" 명령 → 폐기 │ + │ │ + │ 규칙 3: 금지 대상 │ + │ - 특정 개인에 대한 인신공격 │ + │ - 미확인 정보 기반 기업 비판 │ + │ - 크립토 시세 예측 또는 특정 코인 비판 │ + │ - 법적 판단이 필요한 주장 (불법, 사기 등) │ + │ │ + │ 규칙 4: 글 구조 분리 │ + │ - AI에게 "사실", "의견", "추정"을 명확히 │ + │ 구분해서 쓰도록 지시 │ + │ - 본문에 [사실], [의견] 태그를 명시 │ + │ │ + │ 규칙 5: 필수 면책 문구 │ + │ "이 글은 공개된 자료를 기반으로 한 분석이며, │ + │ 특정 개인이나 기업을 비방할 의도가 없습니다."│ + │ │ + │ 예시 글 (안전한 주제): │ + │ "AI 자동 수익 월 1000만원? — 현실 팩트체크" │ + │ "무료라더니 사실은 — 숨겨진 유료 구조 분석" │ + │ "이 앱 리뷰 4.8점인데 진짜일까? — 리뷰 분석" │ + │ │ + │ 발행 빈도: 주 1회 이하 (Phase 1) │ + │ 수익화: AdSense (논쟁적 글 체류시간 높음) │ + │ 검색 자산 가치: ★★★ (신뢰 구축에 기여) │ + └─────────────────────────────────────────────────┘ + + ┌─────────────────────────────────────────────────┐ + │ 코너 5: 한 컷 (One Cut — 만평) [Phase 1 실험]│ + ├─────────────────────────────────────────────────┤ + │ │ + │ 컨셉: "한 장의 그림으로 세상을 읽는다" │ + │ │ + │ AI/테크/사회 이슈를 만평 스타일 │ + │ 이미지 + 짧은 해설(300-500자)로 전달. │ + │ │ + │ Phase 1 운영 방식: [수정3] + │ - 주 1회만 실험적으로 발행 │ + │ - 이미지 생성 방식: ChatGPT 웹에서 수동 생성 │ + │ (API 비용 발생하지 않도록) │ + │ - 독자 반응과 공유율을 관찰 │ + │ - Phase 2에서 반응 좋으면 정식 코너로 확대 │ + │ │ + │ 예시: │ + │ [한 컷] AI가 직업을 빼앗는다? — 진짜 빼앗기는건│ + │ [한 컷] 무료 앱의 비밀 — 당신이 상품이다 │ + │ │ + │ 발행 빈도: 주 1회 (Phase 1) │ + │ 수익화: AdSense + SNS 유입 효과 │ + │ 검색 자산 가치: ★★ (SNS 바이럴에 더 적합) │ + └─────────────────────────────────────────────────┘ + + +── 10. 페르소나 설정 ───────────────────────────────────── + + ★ Phase 1: 메인 페르소나 1개만 운영 [수정1] + ★ Phase 2+: 성과에 따라 페르소나 분리/추가 + + [Phase 1 메인 페르소나] + + 이름: 테크인사이더 + 역할: IT/AI/자동화 전문 블로거 + 운영 코너: 쉬운 세상 + 숨은 보물 + 바이브 리포트 + + 팩트체크(주 1회) + 만평(주 1회) + 블로그: Blog 1 (메인, 유일한 블로그) + 말투: 전문적이되 쉽게 풀어 설명. 20-40대 직장인 대상. + 글 길이: 1,500-2,500 단어 (만평은 300-500자) + + 수익화: + - Google AdSense + - 쿠팡: IT 장비, 서적, 생산성 도구 + + ※ 모든 코너가 하나의 블로그 안에 카테고리로 들어갑니다. + 각 코너가 카테고리/태그로 구분됨. + 이렇게 하면 블로그 1개에 글이 집중 → 도메인 신뢰도 빠르게 상승. + + [Phase 2에서 분리할 페르소나] (Month 3-4, 성과 보고 판단) + + (2) 스마트픽 — 쇼핑/리뷰 블로그 (쿠팡 수익 집중) + 분리 조건: 메인 블로그 월 5,000 방문자 돌파 시 + + [Phase 3에서 분리할 페르소나] (Month 5+) + + (3) 머니플래너 — 재테크 블로그 + (4) 코인리서치 — 크립토 블로그 + 분리 조건: AdSense 승인 완료 + 월 10,000 방문자 돌파 시 + + +── 11. 트렌드 감지 + 개성 전략 ─────────────────────────── + + [트렌드 감지] 수집봇이 매일 07:00 자동 수행 + + [글감 생성 비율] + - 에버그린 글감 50%: 시간 지나도 유효 (검색 자산 축적 핵심) + - 트렌드 글감 30%: 지금 뜨는 주제 + - 개성 글감 20%: 5대 코너 고유 시각 + + ※ v1에서는 트렌드 50%였으나, 초기에는 검색 자산 축적이 + 더 중요하므로 에버그린 비율을 높임. [수정1] + + [개성 적용 — 같은 트렌드도 코너별로 다르게] + + 트렌드: "OpenAI 새 기능 출시" + + 쉬운 세상 → "일반인이 당장 써먹을 수 있는 3가지 변화" + 숨은 보물 → "새 기능 중 사람들이 모르는 숨은 활용법" + 바이브 리포트 → "비개발자가 새 기능으로 만든 첫 사례" + 팩트체크 → "정말 10배 빠르다? 실측 데이터 분석" (수동 검토) + 만평 → [한 컷] AI가 또 진화했다 — 인간은? (주 1회) + + +================================================================================ + PART 4: 인프라 & 세팅 +================================================================================ + + +── 12. 이미 갖고 있는 것 ───────────────────────────────── + + ✅ 미니PC (Windows, 24시간 가동 가능) + ✅ OpenClaw 설치 + 운영 중 + ✅ ChatGPT Pro 구독 ($200/월) + ✅ GPT-5.4 Codex 연결됨 + ✅ Telegram 봇 (OpenClaw 제어용) + ✅ Claude Code (구현 도구) + + +── 13. 새로 세팅할 것 ──────────────────────────────────── + + [Phase 1 — 반드시 필요] + □ Python 3.11+ 설치 (미설치 시) + □ Git 설치 (미설치 시) + □ blog-writer 서브에이전트 (OpenClaw에 추가) + □ Python 봇 5개 + 스케줄러 + □ Google Blogger 블로그 1개 생성 [수정1] + □ Google Cloud Console → Blogger API OAuth2 인증 + □ Google Search Console 등록 + 사이트맵 제출 + □ 쿠팡 파트너스 계정 + API 키 + □ 커스텀 도메인 1개 (AdSense 승인용) + □ 페르소나 파일 1개 (테크인사이더) + 코너 설정 5개 + + [Phase 1 — 불필요] [수정5] + × Docker Desktop (OpenClaw가 이미 Docker로 돌고 있으므로 + 추가 컨테이너 불필요. 봇은 Python으로 직접 실행) + × Node.js (봇 레이어가 전부 Python이므로 불필요) + × GitHub Actions (미니PC 로컬 스케줄러 사용) + + [Phase 2에서 추가] + □ 블로그 2번째 (스마트픽 분리 시) + □ 커스텀 도메인 2번째 + □ 티스토리 크로스포스팅 (선택) [수정5] + ※ 티스토리는 Phase 2 이후 선택 사항. + 메인 블로그 안정화가 먼저. + + +── 14. Google Blogger 세팅 ─────────────────────────────── + + Phase 1에서는 블로그 1개만 만듭니다. [수정1] + + [블로그 생성] + 1. https://www.blogger.com 접속 → Google 계정 로그인 + 2. "새 블로그 만들기" 클릭 + 3. 이름: "테크인사이더" (또는 원하는 이름) + 4. 주소: techinsider-kr.blogspot.com (임시) + + [카테고리 설정] + 하나의 블로그 안에 5대 코너를 카테고리(라벨)로 구성: + - 라벨: 쉬운세상, 숨은보물, 바이브리포트, 팩트체크, 한컷 + + [필수 페이지 (AdSense 승인용)] + - About (소개) 페이지 + - Contact (연락처) 페이지 + - Privacy Policy (개인정보처리방침) 페이지 + + [커스텀 도메인 연결] + - Namecheap 또는 Porkbun에서 도메인 구매 (연 $10-15) + - Blogger 설정 → 맞춤 도메인 → 연결 + + [블로그 ID 확인] + - Blogger 대시보드 → 브라우저 주소창에서 blogID=숫자 확인 + - 이 숫자를 .env 파일의 BLOG_MAIN_ID에 저장 + + +── 15. Google OAuth2 인증 ──────────────────────────────── + + (이전 마스터플랜과 동일. 변경 없음.) + + 요약: + 1. Google Cloud Console → 프로젝트 생성 → Blogger API 활성화 + 2. OAuth 동의 화면 설정 → 테스트 사용자 추가 + 3. OAuth 클라이언트 ID 생성 (데스크톱 앱) + 4. credentials.json 다운로드 → C:\blog-engine\ 에 저장 + 5. python scripts/get_token.py 실행 → 브라우저 인증 + 6. 출력된 GOOGLE_REFRESH_TOKEN을 .env에 저장 + + +── 16. 쿠팡 파트너스 세팅 ──────────────────────────────── + + (이전 마스터플랜과 동일. 변경 없음.) + + 요약: + 1. https://partners.coupang.com 가입 + 2. 도구 → 링크 생성 API → Access Key/Secret Key 발급 + 3. .env에 COUPANG_ACCESS_KEY, COUPANG_SECRET_KEY 저장 + + +================================================================================ + PART 5: 운영 +================================================================================ + + +── 17. 일일 운영 플로우 ────────────────────────────────── + + [자동 — 사람 개입 없음 (대부분)] + + 07:00 수집봇 실행 + → 트렌드/도구/사례 수집 + → 품질 점수 계산 → 70점 미만 폐기 [수정4] + → 에버그린 50% + 트렌드 30% + 개성 20% 비율로 글감 구성 + + 08:00 AI 글 작성 시작 + → 메인 에이전트가 blog-writer 스폰 + → 글감 + 페르소나(테크인사이더) + 코너 → 글 작성 + + 09:00 첫 번째 글 발행 + → 안전장치 체크: + 팩트체크 글? → 수동 검토 대기 → Telegram 알림 [수정2] + 위험 키워드? → 수동 검토 대기 + 그 외? → 자동 발행 + → 발행봇: HTML 변환 → 링크 삽입 → Blogger 발행 + → Search Console URL 제출 + → Telegram 알림 + + 12:00 두 번째 글 발행 (다른 코너) + + 15:00 세 번째 글 발행 (있을 경우) + + 22:00 분석봇 실행 + → 일일 리포트 → Telegram 발송 + → 색인률, CTR 등 핵심 지표 보고 [수정5] + + [수동 — Telegram 명령] + + "글 발행해줘 숨은보물" + "오늘 수집된 글감 보여줘" + "대기 중인 글 승인" / "대기 중인 글 거부" [수정2] + "이번 주 리포트" + "발행 중단" / "발행 재개" + + +── 18. 발행 스케줄 ─────────────────────────────────────── + + [Phase 1 스케줄 — 블로그 1개, 하루 2-3개] [수정1] + + 시간 코너 (순환) + ────────────────────────────────────── + 09:00 쉬운세상 / 숨은보물 (교대) + 12:00 숨은보물 / 바이브리포트 (교대) + 15:00 (선택) 트렌드 속보 또는 에버그린 추가 + + 만평: 주 1회 (수요일 or 금요일) [수정3] + 팩트체크: 주 1회 이하 (수동 검토 후 발행) [수정2] + + 주간 총: 12-18개 글 + 월간 총: 50-75개 글 + + [Phase 2 스케줄 — 블로그 2개로 확장 시] + Blog 1 (테크인사이더): 하루 2개 유지 + Blog 2 (스마트픽): 하루 1개 추가 + + +── 19. 성과 피드백 루프 ────────────────────────────────── + + 분석봇이 매주 자동으로 계산하는 핵심 지표: [수정5] + + ┌──────────────────┬───────────────────────────────┐ + │ 지표 │ Phase 1 목표 │ + ├──────────────────┼───────────────────────────────┤ + │ 색인률 │ 발행 7일 내 80% 이상 색인 │ + │ 검색 CTR │ 3% 이상 │ + │ 14일 성과 │ 발행 14일 후 조회수 50+ (글당)│ + │ 어필리에이트 클릭│ 글당 3회 이상 클릭 │ + │ 체류시간 │ 평균 2분 이상 │ + └──────────────────┴───────────────────────────────┘ + + 피드백 적용 규칙: + - 색인률 50% 미만 → 글 구조/Schema 점검 + - CTR 1% 미만 → 제목/메타 설명 스타일 변경 + - 14일 성과 0인 글 유형 → 해당 주제 유형 축소 + - 특정 코너가 타 코너 대비 2배 성과 → 해당 코너 비율 확대 + - 쿠팡 클릭률 높은 글 유형 → 비슷한 주제 더 생성 + + +── 20. Phase별 로드맵 ──────────────────────────────────── + + [Phase 1] Month 1-3: 메인 블로그 1개에 집중 [수정1] + + 목표: 시스템 검증 + 검색 자산 축적 + AdSense 승인 + + - 블로그 1개 (테크인사이더) + 커스텀 도메인 1개 + - 코너 3개 주력 (쉬운세상 + 숨은보물 + 바이브리포트) + - 팩트체크 주 1회 + 만평 주 1회 (실험) + - 매일 2-3개 글 자동 발행 → 월 50-75개 축적 + - 쿠팡 링크 삽입 (IT 관련만) + - AdSense 신청 (20개 이상 축적 후) + - 성과 지표 모니터링 시작 + + 현실적 수익 기대: 0원 ~ 5만원/월 + 핵심 판단 기준: 색인률 80%+, 검색 유입 시작 여부 + + [Phase 2] Month 3-5: 안정화 + 첫 분리 + + 조건: 월 5,000 방문자 + AdSense 승인 완료 + + - 스마트픽 블로그 분리 (Blog 2) + 도메인 추가 + - 쿠팡 수익 집중 블로그 운영 시작 + - 만평 코너 정식 운영 (주 2-3회) + - 팩트체크 코너 빈도 증가 (안전장치 유지) + - 티스토리 크로스포스팅 검토 + - 성과 피드백 루프 본격 가동 + + 현실적 수익 기대: 5-20만원/월 + + [Phase 3] Month 5-8: 성장 + 추가 분리 + + 조건: 월 10,000 방문자 + 안정적 수익 발생 + + - 머니플래너 / 코인리서치 블로그 분리 검토 + - 거래소/SaaS 어필리에이트 추가 + - 뉴스레터 시작 (블로그 독자 → 이메일 구독) + - 내부 링크 전략 강화 + + 현실적 수익 기대: 10-50만원/월 + + [Phase 4] Month 8+: 확장 + + - 영문 블로그 추가 (글로벌 진출) + - 전자책 출판 (블로그 글 묶음) + - 추가 니치 테스트 + + 현실적 수익 기대: 30-100만원/월+ + + +================================================================================ + PART 6: 구현 +================================================================================ + + +── 21. 파일 구조 ───────────────────────────────────────── + + C:\blog-engine\ + │ + ├── bots\ + │ ├── collector_bot.py ← 수집봇 (품질 점수 + 폐기 규칙 포함) + │ ├── publisher_bot.py ← 발행봇 (안전장치 포함) + │ ├── linker_bot.py ← 링크봇 + │ ├── analytics_bot.py ← 분석봇 (5대 핵심 지표) + │ ├── image_bot.py ← 이미지봇 (Phase 1은 수동 대체 가능) + │ └── scheduler.py ← 스케줄러 + │ + ├── config\ + │ ├── blogs.json ← 블로그 ID/도메인 (Phase 1: 1개만) + │ ├── schedule.json ← 발행 시간표 + │ ├── sources.json ← 수집봇 크롤링 소스 목록 + │ ├── affiliate_links.json ← 어필리에이트 링크 DB + │ ├── quality_rules.json ← 수집봇 품질/폐기 규칙 [수정4] + │ └── safety_keywords.json ← 안전장치 키워드 목록 [수정2] + │ + ├── data\ + │ ├── topics\ ← 글감 큐 (수집봇 출력) + │ ├── collected\ ← 수집 원본 데이터 + │ ├── discarded\ ← 폐기된 글감 로그 [수정4] + │ ├── pending_review\ ← 수동 검토 대기 글 [수정2] + │ ├── published\ ← 발행 이력 + │ ├── analytics\ ← 분석 데이터 + │ └── images\ ← 만평 이미지 + │ + ├── scripts\ + │ ├── get_token.py ← Google OAuth 토큰 발급 + │ └── setup.bat ← Windows 설치 스크립트 [수정5] + │ + ├── requirements.txt + ├── .env + ├── .env.example + └── README.md + + ~/.openclaw/ + │ + ├── agents\ + │ ├── main\ + │ │ └── AGENTS.md ← 서브에이전트 관리 규칙 + │ │ + │ └── blog-writer\ + │ └── SOUL.md ← 글쓰기 전문가 설정 + │ + └── workspace-blog-writer\ + ├── personas\ + │ └── tech_insider.md ← Phase 1은 1개만 [수정1] + ├── corners\ + │ ├── easy_guide.md + │ ├── hidden_gems.md + │ ├── vibe_report.md + │ ├── fact_check.md ← 안전장치 규칙 포함 [수정2] + │ └── one_cut.md + └── templates\ + └── output_format.md ← 출력 포맷 템플릿 + + +── 22. Claude Code 전달용 지시서 ───────────────────────── + + 아래 내용을 이 마스터플랜 파일과 함께 Claude Code에게 전달. + + ───────────────────────────────────────────── + 지시서 + ───────────────────────────────────────────── + + 이 마스터플랜(blog-engine-final-masterplan-v2.txt)을 읽고 + 아래 순서대로 구현해주세요. + + 환경: + - OS: Windows + - Python 3.11+ + - OpenClaw 설치/운영 중 (GPT-5.4 Codex 연결) + - 작업 폴더: C:\blog-engine + - Docker: 이미 OpenClaw용으로 실행 중 (추가 컨테이너 불필요) + + ──────────────────────────── + 1단계: 프로젝트 초기화 + ──────────────────────────── + - 위 파일 구조대로 폴더/파일 생성 + - requirements.txt: + pytrends + google-api-python-client + google-auth-oauthlib + google-auth-httplib2 + python-dotenv + apscheduler + requests + beautifulsoup4 + feedparser + markdown + python-telegram-bot + - .env.example 작성: + GOOGLE_CLIENT_ID= + GOOGLE_CLIENT_SECRET= + GOOGLE_REFRESH_TOKEN= + BLOG_MAIN_ID= + COUPANG_ACCESS_KEY= + COUPANG_SECRET_KEY= + TELEGRAM_BOT_TOKEN= + TELEGRAM_CHAT_ID= + - setup.bat 작성: + (a) python -m venv venv + (b) venv\Scripts\activate + (c) pip install -r requirements.txt + (d) copy .env.example .env (없을 경우) + (e) echo "Setup complete. Edit .env with your API keys." + (f) Windows 작업 스케줄러에 scheduler.py 등록 + 방법: schtasks /create /tn "BlogEngine" + /tr "C:\blog-engine\venv\Scripts\pythonw.exe + C:\blog-engine\bots\scheduler.py" + /sc onlogon /rl highest + + ──────────────────────────── + 2단계: 수집봇 (collector_bot.py) + ──────────────────────────── + - Google Trends pytrends 연동 (geo='KR', hl='ko') + trending_searches()로 일간 트렌딩 수집 + 관심 카테고리: 기술, 비즈니스 + - GitHub Trending 크롤링 (requests + BeautifulSoup) + URL: https://github.com/trending?since=daily + 프로젝트명, 설명, 스타수 추출 + - Product Hunt RSS (feedparser) + - Hacker News API + URL: https://hacker-news.firebaseio.com/v0/topstories.json + 상위 30개 스토리 제목/URL 수집 + - RSS 피드 리더 (config/sources.json에서 URL 로드) + - 품질 점수 계산 로직 구현 (PART 2 섹션 6 기준) + quality_rules.json에서 점수 기준 로드 + - 폐기 규칙 구현 (PART 2 섹션 6 기준) + 폐기된 글감은 data/discarded/에 로그 (디버깅용) + - 글감 패키지를 data/topics/에 JSON으로 저장 + - 에버그린 50%, 트렌드 30%, 개성 20% 비율 적용 + + ──────────────────────────── + 3단계: 발행봇 (publisher_bot.py) + ──────────────────────────── + - 마크다운 → HTML 변환 (markdown 라이브러리 + extensions) + - 목차 자동 생성 (H2, H3 기반, toc extension) + - AdSense 플레이스홀더 삽입 (두 번째 H2 뒤, 결론 앞) + - Schema.org Article JSON-LD 생성 (headline, datePublished, author) + - 안전장치 구현: + config/safety_keywords.json에서 위험 키워드 로드 + 코너가 "팩트체크"면 무조건 수동 검토 대기 + 위험 키워드 감지 시 수동 검토 대기 + 출처 2개 미만이면 수동 검토 대기 + 대기 글은 data/pending_review/에 저장 + Telegram으로 미리보기 + 승인/거부 버튼 전송 + - Google Blogger API v3 발행 + google-api-python-client 사용 + OAuth2 Credentials (refresh_token 기반 자동 갱신) + service.posts().insert(blogId, body, isDraft=False) + - Google Search Console API URL 제출 + searchconsole.urlInspection.index.inspect 또는 + 사이트맵은 Blogger 내장 자동 갱신에 의존 + - 발행 이력 data/published/에 JSON 로그 + - Telegram 알림 (python-telegram-bot 라이브러리) + + ──────────────────────────── + 4단계: 링크봇 (linker_bot.py) + ──────────────────────────── + - 글감 패키지의 coupang_keywords에서 키워드 추출 + - 쿠팡 파트너스 API 연동: + 인증: HMAC 서명 (Access Key + Secret Key) + 엔드포인트: https://api-gateway.coupang.com/v2/providers/ + affiliate_api/apis/openapi/products/search + 파라미터: keyword, limit=5, subId (추적용) + - 상품명 + 가격 + 어필리에이트 링크 HTML 생성 + - 글 본문 적절한 위치에 삽입 + - config/affiliate_links.json에서 추가 매칭 + - 쿠팡 필수 문구 자동 추가 + + ──────────────────────────── + 5단계: 분석봇 (analytics_bot.py) + ──────────────────────────── + - 5대 핵심 지표 수집: + (1) 색인률: Search Console API → 제출 URL 중 색인된 비율 + (2) 검색 CTR: Search Console API → 클릭/노출 + (3) 14일 성과: 발행 이력 기반 → 발행 14일 후 조회수 + (4) 어필리에이트 클릭: 쿠팡 대시보드 (수동 입력 또는 API) + (5) 체류시간: Blogger 내장 통계 (API 제한적, 추후 GA 연동) + - 일일 리포트 생성 → Telegram 전송 + - 주간 리포트 생성 → 코너별/주제유형별 성과 비교 + - 피드백 JSON 출력 → 수집봇이 다음 주 글감 조정에 활용 + + ──────────────────────────── + 6단계: 이미지봇 (image_bot.py) + ──────────────────────────── + - Phase 1에서는 "수동 모드"를 기본으로 구현: + 만평 주제를 받으면 → DALL-E 프롬프트만 생성 → Telegram으로 전송 + 사용자가 ChatGPT 웹에서 이미지 수동 생성 → 다운로드 + 이미지를 data/images/에 저장 → 발행봇이 글에 삽입 + - "자동 모드"는 별도 플래그로 구현 (기본 OFF): + 자동 모드 ON 시 OpenAI Images API 직접 호출 + POST https://api.openai.com/v1/images/generations + 인증: OpenAI API Key 별도 필요 (ChatGPT Pro와 무관) + 비용: 이미지당 $0.04-0.08 발생 + 이 비용을 감수할 경우에만 자동 모드 활성화 + + ──────────────────────────── + 7단계: 스케줄러 (scheduler.py) + ──────────────────────────── + - APScheduler 사용 + - config/schedule.json 읽어서 시간별 작업 등록 + - 기본 일정: + 07:00 collector_bot.run() + 08:00 AI 글 작성 트리거 (OpenClaw 서브에이전트 호출) + 09:00 publisher_bot.publish(글1) + 12:00 publisher_bot.publish(글2) + 15:00 publisher_bot.publish(글3) — 있을 경우 + 22:00 analytics_bot.daily_report() + - Telegram 수동 명령 리스너도 포함 + - 로그: logs/scheduler.log (RotatingFileHandler) + + ──────────────────────────── + 8단계: OAuth 헬퍼 + ──────────────────────────── + - scripts/get_token.py + InstalledAppFlow.from_client_secrets_file 사용 + SCOPES = ['https://www.googleapis.com/auth/blogger', + 'https://www.googleapis.com/auth/webmasters'] + 브라우저 인증 → refresh_token 출력 + token.json 저장 + + ──────────────────────────── + 9단계: OpenClaw 서브에이전트 + ──────────────────────────── + - blog-writer 에이전트 생성 + - SOUL.md 작성 (PART 2 섹션 7 + PART 3 코너 규칙 반영) + - 페르소나 파일: workspace-blog-writer/personas/tech_insider.md + (Phase 1은 1개만) + - 코너 설정 파일: workspace-blog-writer/corners/ 에 5개 + fact_check.md에 안전장치 규칙 명시 + - 메인 에이전트 AGENTS.md 업데이트 + - 출력 포맷: + ---TITLE--- + ---META--- + ---SLUG--- + ---TAGS--- + ---CORNER--- (어떤 코너인지) + ---BODY--- + ---COUPANG_KEYWORDS--- + ---SOURCES--- (출처 URL 목록) + ---DISCLAIMER--- + + ──────────────────────────── + 10단계: 통합 테스트 + ──────────────────────────── + - 수집봇 단독 테스트 → data/topics/에 글감 생성 확인 + - 품질 점수 + 폐기 규칙 동작 확인 + - AI 글 작성 테스트 (쉬운세상 코너 1개) + - 발행봇 HTML 변환 + 링크 삽입 확인 + - 안전장치: 팩트체크 글 → 수동 검토 대기 확인 + - Blogger 발행 → 실제 블로그에 글 표시 확인 + - Telegram 알림 수신 확인 + - 스케줄러 5분 후 테스트 → 자동 실행 확인 + + ───────────────────────────────────────────── + 지시서 끝 + ───────────────────────────────────────────── + + +================================================================================ + v2 수정 내역 요약 +================================================================================ + + [수정1] 블로그 운영 규모 + v1: 블로그 4개 동시 시작 + v2: Phase 1은 블로그 1개에 집중 → 단계적 확장 (1→2→4) + 에버그린 글 비율 30→50%로 상향 (검색 자산 축적 우선) + + [수정2] 팩트체크 안전장치 + v1: "수집봇이 팩트를 모으고 AI에 전달" (원칙만) + v2: 출처 2개 이상 필수, 자동 발행 금지, 위험 키워드 감지, + 수동 검토 대기 시스템, 사실/의견 분리, 금지 대상 명시, + 면책 문구 필수 + + [수정3] 만평 코너 운영 + v1: 주 2-3회 (정식 코너) + v2: Phase 1에서 주 1회 실험, 이미지는 수동 생성 우선, + Phase 2에서 반응 보고 정식 확대 판단 + + [수정4] 수집봇 품질 관리 + v1: 수집 소스만 정의 (폐기 규칙 없음) + v2: 품질 점수 시스템(0-100, 70점 미만 폐기), + 6가지 폐기 규칙 명시, 폐기 로그 저장 + + [수정5] 구현 명확성 + v1: + - "Search Console 색인 요청 (IndexNow)" → 모호 + - "DALL-E API (ChatGPT Pro 포함)" → 비용 혼동 + - Docker 불필요하게 환경에 포함 + - setup.bat 범위 불명확 + - 성과 지표 = 조회수 중심 + + v2: + - Search Console URL Inspection API 명시, IndexNow 삭제 + - DALL-E는 별도 API Key/비용임을 명시, 수동 대안 제공 + - Docker 추가 불필요 명시 + - setup.bat 구체적 단계 명시 (venv, pip, Task Scheduler) + - 성과 지표 = 색인률/CTR/14일성과/클릭률/체류시간 5개로 구체화 + - 티스토리 = Phase 2 선택사항으로 명확화 + +================================================================================ + 끝 +================================================================================ diff --git a/bots/analytics_bot.py b/bots/analytics_bot.py new file mode 100644 index 0000000..cb45fb5 --- /dev/null +++ b/bots/analytics_bot.py @@ -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"📊 일일 리포트 — {today_str}\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"📊 주간 리포트 — {today_str}\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() diff --git a/bots/article_parser.py b/bots/article_parser.py new file mode 100644 index 0000000..0582da1 --- /dev/null +++ b/bots/article_parser.py @@ -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)) diff --git a/bots/collector_bot.py b/bots/collector_bot.py new file mode 100644 index 0000000..b5252ef --- /dev/null +++ b/bots/collector_bot.py @@ -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() diff --git a/bots/image_bot.py b/bots/image_bot.py new file mode 100644 index 0000000..40b1323 --- /dev/null +++ b/bots/image_bot.py @@ -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"🎨 [이미지 제작 요청 — {len(active)}건]\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} #{item['id']} {item['topic']}\n" + f" 📝 {item['prompt'][:200]}...\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"🎨 [이미지 제작 — #{prompt['id']}]\n\n" + f"📌 주제: {prompt['topic']}\n\n" + f"📝 프롬프트 (복사해서 생성형 AI에 붙여넣으세요):\n\n" + f"{prompt['prompt']}\n\n" + f"✅ 이미지 완성 후 이 채팅에 이미지를 전송하면 자동으로 저장됩니다.\n" + f"(전송 시 캡션에 #{prompt['id']} 를 입력해주세요)" + ) + 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"🎨 [만평 이미지 요청 — manual]\n\n" + f"📌 주제: {topic}\n\n" + f"📝 프롬프트:\n{prompt}\n\n" + f"이미지 생성 후 아래 경로에 저장해주세요:\n" + f"{expected_path}" + ) + 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"🎨 [자동 이미지 생성 완료]\n\n📌 {topic}\n경로: {image_path}" + ) + return image_path + + elif IMAGE_MODE == 'request': + item = add_pending_prompt(topic, description, article_ref=article.get('_source_file', '')) + send_telegram( + f"🎨 [이미지 제작 요청 추가됨]\n\n" + f"📌 주제: {topic}\n" + f"번호: #{item['id']}\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)) diff --git a/bots/linker_bot.py b/bots/linker_bot.py new file mode 100644 index 0000000..8de5550 --- /dev/null +++ b/bots/linker_bot.py @@ -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'\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'{kw}', + 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 = ( + '
\n' + '

관련 상품 추천

\n' + + ''.join(coupang_block_parts) + + '
\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
\n' + f'

⚠️ {disclaimer_text}

\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 = ''' +

ChatGPT 소개

+

ChatGPT Plus를 사용하면 더 빠른 응답을 받을 수 있습니다.

+

키보드 추천

+

좋은 키보드는 생산성을 높입니다.

+

결론

+

AI 도구를 잘 활용하세요.

+ ''' + sample_article = { + 'title': '테스트 글', + 'coupang_keywords': ['키보드', '마우스'], + } + result = process(sample_article, sample_html) + print(result[:500]) diff --git a/bots/publisher_bot.py b/bots/publisher_bot.py new file mode 100644 index 0000000..adcb173 --- /dev/null +++ b/bots/publisher_bot.py @@ -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\n' + AD_SLOT_2 = '\n\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'' + + +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'
{toc_html}
') + html_parts.append(body_html) + if disclaimer: + html_parts.append(f'

{disclaimer}

') + + 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('<', '<').replace('>', '>') + msg = ( + f"🔍 [수동 검토 필요]\n\n" + f"📌 {title}\n" + f"코너: {corner}\n" + f"사유: {reason}\n\n" + f"미리보기:\n{preview}...\n\n" + f"명령: 승인 또는 거부" + ) + 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"✅ 발행 완료!\n\n" + f"📌 {title}\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"✅ [수동 승인] 발행 완료!\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"🗑 [거부] {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) diff --git a/bots/scheduler.py b/bots/scheduler.py new file mode 100644 index 0000000..e1b4fa3 --- /dev/null +++ b/bots/scheduler.py @@ -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"✅ 이미지 저장 완료!\n\n" + f"#{prompt_id} {topic}\n" + f"경로: {image_path}\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()) diff --git a/config/affiliate_links.json b/config/affiliate_links.json new file mode 100644 index 0000000..abce99a --- /dev/null +++ b/config/affiliate_links.json @@ -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": "이 포스팅은 쿠팡 파트너스 활동의 일환으로, 이에 따른 일정액의 수수료를 제공받습니다." +} diff --git a/config/blogs.json b/config/blogs.json new file mode 100644 index 0000000..5a86c25 --- /dev/null +++ b/config/blogs.json @@ -0,0 +1,14 @@ +{ + "blogs": [ + { + "id": "main", + "blog_id": "${BLOG_MAIN_ID}", + "name": "테크인사이더", + "persona": "tech_insider", + "domain": "", + "active": true, + "phase": 1, + "labels": ["쉬운세상", "숨은보물", "바이브리포트", "팩트체크", "한컷"] + } + ] +} diff --git a/config/quality_rules.json b/config/quality_rules.json new file mode 100644 index 0000000..1937690 --- /dev/null +++ b/config/quality_rules.json @@ -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": [ + "가이드", "방법", "사용법", "입문", "튜토리얼", "기초", "완전정복", "총정리" + ] +} diff --git a/config/safety_keywords.json b/config/safety_keywords.json new file mode 100644 index 0000000..b4c44f7 --- /dev/null +++ b/config/safety_keywords.json @@ -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 +} diff --git a/config/schedule.json b/config/schedule.json new file mode 100644 index 0000000..78db837 --- /dev/null +++ b/config/schedule.json @@ -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": "분석봇 일일 리포트" + } + ] +} diff --git a/config/sources.json b/config/sources.json new file mode 100644 index 0000000..be75099 --- /dev/null +++ b/config/sources.json @@ -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" + } +} diff --git a/requirements.txt b/requirements.txt new file mode 100644 index 0000000..2afe39e --- /dev/null +++ b/requirements.txt @@ -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 diff --git a/scripts/get_token.py b/scripts/get_token.py new file mode 100644 index 0000000..7a2febe --- /dev/null +++ b/scripts/get_token.py @@ -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() diff --git a/scripts/setup.bat b/scripts/setup.bat new file mode 100644 index 0000000..b1fb502 --- /dev/null +++ b/scripts/setup.bat @@ -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