Initial commit: 블로그 자동 수익 엔진 v2
- 수집봇: Google Trends, GitHub Trending, HN, Product Hunt, RSS 수집 + 품질 점수(0-100) 시스템 + 6가지 폐기 규칙 - 발행봇: Blogger API v3 자동 발행 + 안전장치(팩트체크/위험키워드) - 링크봇: 쿠팡 파트너스 HMAC 서명 + 자동 링크 삽입 - 분석봇: 색인률/CTR/14일성과 등 5대 핵심 지표 + Telegram 리포트 - 이미지봇: manual/request/auto 3가지 모드 request 모드 — 주기적 프롬프트 전송 → Telegram으로 이미지 수령 - 스케줄러: APScheduler + Telegram 봇 명령 리스너 Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
17
.env.example
Normal file
17
.env.example
Normal file
@@ -0,0 +1,17 @@
|
|||||||
|
GOOGLE_CLIENT_ID=your-google-client-id.apps.googleusercontent.com
|
||||||
|
GOOGLE_CLIENT_SECRET=your-google-client-secret
|
||||||
|
GOOGLE_REFRESH_TOKEN=
|
||||||
|
BLOG_MAIN_ID=your-blogger-blog-id
|
||||||
|
COUPANG_ACCESS_KEY=
|
||||||
|
COUPANG_SECRET_KEY=
|
||||||
|
TELEGRAM_BOT_TOKEN=your-telegram-bot-token
|
||||||
|
TELEGRAM_CHAT_ID=your-telegram-chat-id
|
||||||
|
# 이미지 모드 선택 (manual | request | auto)
|
||||||
|
# manual — 글 발행 시점에 프롬프트 1개를 Telegram으로 전송 (기본값)
|
||||||
|
# request — 매주 월요일 프롬프트 목록 전송 → 직접 생성 후 Telegram으로 이미지 전송
|
||||||
|
# auto — OpenAI DALL-E API 자동 생성 (OPENAI_API_KEY 필요, 별도 비용 발생)
|
||||||
|
IMAGE_MODE=manual
|
||||||
|
# auto 모드 사용 시에만 입력
|
||||||
|
OPENAI_API_KEY=
|
||||||
|
# 블로그 사이트 URL (Search Console 등록용)
|
||||||
|
BLOG_SITE_URL=
|
||||||
34
.gitignore
vendored
Normal file
34
.gitignore
vendored
Normal file
@@ -0,0 +1,34 @@
|
|||||||
|
# 환경 변수 / 시크릿 — 절대 커밋하지 말 것
|
||||||
|
.env
|
||||||
|
token.json
|
||||||
|
credentials.json
|
||||||
|
|
||||||
|
# Python
|
||||||
|
venv/
|
||||||
|
__pycache__/
|
||||||
|
*.pyc
|
||||||
|
*.pyo
|
||||||
|
*.pyd
|
||||||
|
*.egg-info/
|
||||||
|
dist/
|
||||||
|
build/
|
||||||
|
.eggs/
|
||||||
|
|
||||||
|
# 런타임 데이터 (개인 글감/발행 이력)
|
||||||
|
data/topics/*.json
|
||||||
|
data/collected/*.json
|
||||||
|
data/discarded/*.json
|
||||||
|
data/pending_review/*.json
|
||||||
|
data/published/*.json
|
||||||
|
data/analytics/*.json
|
||||||
|
data/drafts/*.json
|
||||||
|
data/images/
|
||||||
|
data/images/pending_prompts.json
|
||||||
|
|
||||||
|
# 로그
|
||||||
|
logs/
|
||||||
|
|
||||||
|
# IDE
|
||||||
|
.vscode/
|
||||||
|
.idea/
|
||||||
|
*.swp
|
||||||
362
README.md
Normal file
362
README.md
Normal file
@@ -0,0 +1,362 @@
|
|||||||
|
# 블로그 자동 수익 엔진 (Blog Auto Revenue Engine)
|
||||||
|
|
||||||
|
AI 기반 한국어 블로그 자동화 시스템.
|
||||||
|
트렌드 수집 → AI 글 작성 → 자동 발행 → 수익 링크 삽입 → 성과 분석까지 전 과정을 자동화합니다.
|
||||||
|
|
||||||
|
> **Phase 1 목표:** Google Blogger 블로그 1개로 시작해 검색 자산 축적 + AdSense 승인
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 목차
|
||||||
|
|
||||||
|
1. [시스템 구조](#시스템-구조)
|
||||||
|
2. [사전 준비](#사전-준비)
|
||||||
|
3. [설치 방법](#설치-방법)
|
||||||
|
4. [API 키 설정](#api-키-설정)
|
||||||
|
5. [Google OAuth 인증](#google-oauth-인증)
|
||||||
|
6. [실행하기](#실행하기)
|
||||||
|
7. [Telegram 명령어](#telegram-명령어)
|
||||||
|
8. [이미지 모드 선택](#이미지-모드-선택)
|
||||||
|
9. [콘텐츠 코너 구성](#콘텐츠-코너-구성)
|
||||||
|
10. [Phase 로드맵](#phase-로드맵)
|
||||||
|
11. [자주 묻는 질문](#자주-묻는-질문)
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 시스템 구조
|
||||||
|
|
||||||
|
```
|
||||||
|
봇 레이어 (Python) AI 레이어 (OpenClaw)
|
||||||
|
───────────────── ────────────────────
|
||||||
|
수집봇 blog-writer 에이전트
|
||||||
|
└─ 트렌드 수집 └─ 글감 → 완성 글 작성
|
||||||
|
└─ 품질 점수 계산
|
||||||
|
└─ 폐기 규칙 적용
|
||||||
|
│
|
||||||
|
▼
|
||||||
|
발행봇 ── 링크봇 ── 이미지봇
|
||||||
|
└─ 안전장치 └─ 만평 이미지
|
||||||
|
└─ Blogger 발행
|
||||||
|
└─ Search Console
|
||||||
|
│
|
||||||
|
▼
|
||||||
|
분석봇 → Telegram 리포트
|
||||||
|
스케줄러 → 모든 봇 시간 관리
|
||||||
|
```
|
||||||
|
|
||||||
|
### 파일 구조
|
||||||
|
|
||||||
|
```
|
||||||
|
blog-writer/
|
||||||
|
├── bots/
|
||||||
|
│ ├── collector_bot.py ← 수집봇 (Google Trends, GitHub, HN, RSS)
|
||||||
|
│ ├── publisher_bot.py ← 발행봇 (Blogger API + 안전장치)
|
||||||
|
│ ├── linker_bot.py ← 링크봇 (쿠팡 파트너스)
|
||||||
|
│ ├── analytics_bot.py ← 분석봇 (5대 핵심 지표)
|
||||||
|
│ ├── image_bot.py ← 이미지봇 (만평 3가지 모드)
|
||||||
|
│ ├── scheduler.py ← 스케줄러 + Telegram 봇
|
||||||
|
│ └── article_parser.py ← OpenClaw 출력 파서
|
||||||
|
├── config/
|
||||||
|
│ ├── blogs.json ← 블로그 ID 설정
|
||||||
|
│ ├── schedule.json ← 발행 시간표
|
||||||
|
│ ├── sources.json ← 수집 소스 목록
|
||||||
|
│ ├── affiliate_links.json← 어필리에이트 링크 DB
|
||||||
|
│ ├── quality_rules.json ← 품질 점수 기준
|
||||||
|
│ └── safety_keywords.json← 안전장치 키워드
|
||||||
|
├── data/ ← 런타임 데이터 (gitignore)
|
||||||
|
├── scripts/
|
||||||
|
│ ├── get_token.py ← Google OAuth 토큰 발급
|
||||||
|
│ └── setup.bat ← Windows 설치 스크립트
|
||||||
|
├── .env.example ← 환경변수 템플릿
|
||||||
|
└── requirements.txt
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 사전 준비
|
||||||
|
|
||||||
|
### 필수
|
||||||
|
- **Python 3.11 이상** — [python.org](https://www.python.org/downloads/)
|
||||||
|
- **Git** — [git-scm.com](https://git-scm.com/)
|
||||||
|
- **Google 계정** — Blogger 블로그 운영용
|
||||||
|
- **Telegram 계정** — 봇 알림 수신용
|
||||||
|
- **OpenClaw** — AI 글 작성 에이전트 (ChatGPT Pro 구독 필요)
|
||||||
|
|
||||||
|
### 선택
|
||||||
|
- **쿠팡 파트너스 계정** — 링크 수익화용
|
||||||
|
- **OpenAI API Key** — 이미지 자동 생성 모드 사용 시
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 설치 방법
|
||||||
|
|
||||||
|
### 1. 저장소 클론
|
||||||
|
|
||||||
|
```bash
|
||||||
|
git clone https://github.com/sinmb79/blog-writer.git
|
||||||
|
cd blog-writer
|
||||||
|
```
|
||||||
|
|
||||||
|
### 2. 설치 스크립트 실행 (Windows)
|
||||||
|
|
||||||
|
탐색기에서 `scripts\setup.bat` 더블클릭 또는:
|
||||||
|
|
||||||
|
```cmd
|
||||||
|
scripts\setup.bat
|
||||||
|
```
|
||||||
|
|
||||||
|
스크립트가 자동으로 처리하는 것:
|
||||||
|
- Python 가상환경(`venv`) 생성
|
||||||
|
- 패키지 설치 (`requirements.txt`)
|
||||||
|
- `.env` 파일 생성 (`.env.example` 복사)
|
||||||
|
- `data/`, `logs/` 폴더 생성
|
||||||
|
- Windows 작업 스케줄러에 자동 시작 등록
|
||||||
|
|
||||||
|
### 3. 수동 설치 (선택)
|
||||||
|
|
||||||
|
```bash
|
||||||
|
python -m venv venv
|
||||||
|
venv\Scripts\activate # Windows
|
||||||
|
pip install -r requirements.txt
|
||||||
|
copy .env.example .env
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## API 키 설정
|
||||||
|
|
||||||
|
`.env` 파일을 열고 아래 항목을 입력합니다.
|
||||||
|
|
||||||
|
```env
|
||||||
|
# ─── Google (필수) ───────────────────────────────────
|
||||||
|
GOOGLE_CLIENT_ID= # Google Cloud Console에서 발급
|
||||||
|
GOOGLE_CLIENT_SECRET= # Google Cloud Console에서 발급
|
||||||
|
GOOGLE_REFRESH_TOKEN= # scripts/get_token.py 실행 후 입력
|
||||||
|
BLOG_MAIN_ID= # Blogger 대시보드 URL에서 확인
|
||||||
|
|
||||||
|
# ─── 쿠팡 파트너스 (선택, 링크 수익화) ────────────────
|
||||||
|
COUPANG_ACCESS_KEY=
|
||||||
|
COUPANG_SECRET_KEY=
|
||||||
|
|
||||||
|
# ─── Telegram (필수, 알림 수신) ──────────────────────
|
||||||
|
TELEGRAM_BOT_TOKEN= # @BotFather에서 발급
|
||||||
|
TELEGRAM_CHAT_ID= # @userinfobot에서 확인
|
||||||
|
|
||||||
|
# ─── 이미지 모드 ─────────────────────────────────────
|
||||||
|
IMAGE_MODE=manual # manual | request | auto
|
||||||
|
|
||||||
|
# ─── Search Console (선택) ───────────────────────────
|
||||||
|
BLOG_SITE_URL= # 예: https://your-blog.blogspot.com/
|
||||||
|
|
||||||
|
# ─── OpenAI (auto 모드만 필요) ───────────────────────
|
||||||
|
OPENAI_API_KEY=
|
||||||
|
```
|
||||||
|
|
||||||
|
### BLOG_MAIN_ID 확인 방법
|
||||||
|
|
||||||
|
Blogger 관리자 페이지(blogger.com)에서 블로그를 선택한 뒤 브라우저 주소창을 확인합니다:
|
||||||
|
|
||||||
|
```
|
||||||
|
https://www.blogger.com/blog/posts/XXXXXXXXXXXXXXXXXX
|
||||||
|
↑ 이 숫자가 BLOG_MAIN_ID
|
||||||
|
```
|
||||||
|
|
||||||
|
### Telegram 설정 방법
|
||||||
|
|
||||||
|
1. Telegram에서 `@BotFather` 검색 → `/newbot` 명령 → 봇 생성 → **Token** 복사
|
||||||
|
2. 생성한 봇과 대화 시작 → `@userinfobot`에 메시지 → **Chat ID** 확인
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Google OAuth 인증
|
||||||
|
|
||||||
|
### 1. Google Cloud Console 설정
|
||||||
|
|
||||||
|
1. [console.cloud.google.com](https://console.cloud.google.com/) 접속
|
||||||
|
2. 새 프로젝트 생성
|
||||||
|
3. **API 및 서비스 → 라이브러리** 에서 아래 두 API 활성화:
|
||||||
|
- `Blogger API v3`
|
||||||
|
- `Google Search Console API`
|
||||||
|
4. **사용자 인증 정보 → OAuth 클라이언트 ID 만들기**
|
||||||
|
- 애플리케이션 유형: **데스크톱 앱**
|
||||||
|
5. `credentials.json` 다운로드 → 프로젝트 루트(`blog-writer/`)에 저장
|
||||||
|
|
||||||
|
### 2. 토큰 발급
|
||||||
|
|
||||||
|
```bash
|
||||||
|
venv\Scripts\activate
|
||||||
|
python scripts\get_token.py
|
||||||
|
```
|
||||||
|
|
||||||
|
브라우저가 열리면 Google 계정으로 로그인 → 권한 허용
|
||||||
|
터미널에 출력된 `REFRESH_TOKEN` 값을 `.env`의 `GOOGLE_REFRESH_TOKEN`에 붙여넣기
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 실행하기
|
||||||
|
|
||||||
|
### 스케줄러 시작 (메인 프로세스)
|
||||||
|
|
||||||
|
```bash
|
||||||
|
venv\Scripts\activate
|
||||||
|
python bots\scheduler.py
|
||||||
|
```
|
||||||
|
|
||||||
|
### 각 봇 단독 테스트
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# 수집봇 테스트 (글감 수집)
|
||||||
|
python bots\collector_bot.py
|
||||||
|
|
||||||
|
# 분석봇 테스트 (일일 리포트)
|
||||||
|
python bots\analytics_bot.py
|
||||||
|
|
||||||
|
# 분석봇 주간 리포트
|
||||||
|
python bots\analytics_bot.py weekly
|
||||||
|
|
||||||
|
# 이미지 프롬프트 배치 전송 (request 모드)
|
||||||
|
python bots\image_bot.py batch
|
||||||
|
```
|
||||||
|
|
||||||
|
### 자동 시작 확인 (Windows)
|
||||||
|
|
||||||
|
작업 스케줄러(`taskschd.msc`)에서 **BlogEngine** 작업이 등록되어 있으면 PC 시작 시 자동 실행됩니다.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 일일 자동 플로우
|
||||||
|
|
||||||
|
| 시간 | 작업 |
|
||||||
|
|------|------|
|
||||||
|
| 07:00 | 수집봇 — 트렌드 수집 + 품질 점수 계산 + 폐기 필터링 |
|
||||||
|
| 08:00 | AI 글 작성 트리거 (OpenClaw 서브에이전트) |
|
||||||
|
| 09:00 | 발행봇 — 첫 번째 글 발행 |
|
||||||
|
| 12:00 | 발행봇 — 두 번째 글 발행 |
|
||||||
|
| 15:00 | 발행봇 — 세 번째 글 (선택) |
|
||||||
|
| 22:00 | 분석봇 — 일일 리포트 → Telegram 전송 |
|
||||||
|
| 매주 일요일 22:30 | 분석봇 — 주간 리포트 |
|
||||||
|
| 매주 월요일 10:00 | 이미지봇 — 프롬프트 배치 전송 (request 모드) |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Telegram 명령어
|
||||||
|
|
||||||
|
### 텍스트 명령 (키보드로 입력)
|
||||||
|
|
||||||
|
| 명령 | 설명 |
|
||||||
|
|------|------|
|
||||||
|
| `발행 중단` | 자동 발행 일시 중지 |
|
||||||
|
| `발행 재개` | 자동 발행 재개 |
|
||||||
|
| `오늘 수집된 글감 보여줘` | 오늘 수집된 글감 목록 |
|
||||||
|
| `대기 중인 글 보여줘` | 수동 검토 대기 글 목록 |
|
||||||
|
| `이번 주 리포트` | 주간 리포트 즉시 생성 |
|
||||||
|
| `이미지 목록` | 이미지 제작 현황 |
|
||||||
|
|
||||||
|
### 슬래시 명령
|
||||||
|
|
||||||
|
| 명령 | 설명 |
|
||||||
|
|------|------|
|
||||||
|
| `/status` | 봇 상태 + 이미지 모드 확인 |
|
||||||
|
| `/approve [번호]` | 수동 검토 글 승인 후 발행 |
|
||||||
|
| `/reject [번호]` | 수동 검토 글 거부 |
|
||||||
|
| `/images` | 이미지 제작 대기/진행/완료 현황 |
|
||||||
|
| `/imgpick [번호]` | 해당 번호 이미지 프롬프트 받기 |
|
||||||
|
| `/imgbatch` | 프롬프트 배치 수동 전송 |
|
||||||
|
| `/imgcancel` | 이미지 대기 상태 취소 |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 이미지 모드 선택
|
||||||
|
|
||||||
|
`.env`의 `IMAGE_MODE` 값으로 선택합니다.
|
||||||
|
|
||||||
|
### `manual` (기본)
|
||||||
|
한컷 코너 글 발행 시점에 프롬프트 1개를 Telegram으로 전송.
|
||||||
|
사용자가 직접 이미지를 생성해 `data/images/` 에 파일 저장.
|
||||||
|
|
||||||
|
### `request` (권장)
|
||||||
|
매주 월요일 10:00 대기 중인 프롬프트 목록을 Telegram으로 일괄 전송.
|
||||||
|
|
||||||
|
**사용 흐름:**
|
||||||
|
1. 봇이 프롬프트 목록 전송 (또는 `/imgbatch` 수동 트리거)
|
||||||
|
2. `/imgpick 3` — 3번 프롬프트 전체 내용 수신
|
||||||
|
3. 프롬프트 복사 → Midjourney / DALL-E 웹 / Stable Diffusion 등에 붙여넣기
|
||||||
|
4. 생성된 이미지를 Telegram으로 전송 (캡션에 `#3` 입력 또는 `/imgpick` 후 바로 전송)
|
||||||
|
5. 봇이 자동 저장 + 완료 처리
|
||||||
|
|
||||||
|
### `auto`
|
||||||
|
OpenAI DALL-E 3 API를 직접 호출해 자동 생성.
|
||||||
|
`OPENAI_API_KEY` 필요. 이미지당 $0.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 — 자유롭게 사용, 수정, 배포 가능합니다.
|
||||||
1139
blog-engine-final-masterplan-v2.txt
Normal file
1139
blog-engine-final-masterplan-v2.txt
Normal file
File diff suppressed because it is too large
Load Diff
370
bots/analytics_bot.py
Normal file
370
bots/analytics_bot.py
Normal file
@@ -0,0 +1,370 @@
|
|||||||
|
"""
|
||||||
|
분석봇 (analytics_bot.py)
|
||||||
|
역할: 블로그 성과 데이터 수집 및 리포트 생성
|
||||||
|
5대 핵심 지표:
|
||||||
|
1. 색인률 (Search Console)
|
||||||
|
2. 검색 CTR (Search Console)
|
||||||
|
3. 발행 후 14일 성과
|
||||||
|
4. 어필리에이트 클릭률 (수동 입력)
|
||||||
|
5. 체류시간 (Blogger 통계)
|
||||||
|
"""
|
||||||
|
import json
|
||||||
|
import logging
|
||||||
|
import os
|
||||||
|
import re
|
||||||
|
from datetime import datetime, timedelta, timezone
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
import requests
|
||||||
|
from dotenv import load_dotenv
|
||||||
|
from google.oauth2.credentials import Credentials
|
||||||
|
from google.auth.transport.requests import Request
|
||||||
|
from googleapiclient.discovery import build
|
||||||
|
|
||||||
|
load_dotenv()
|
||||||
|
|
||||||
|
BASE_DIR = Path(__file__).parent.parent
|
||||||
|
DATA_DIR = BASE_DIR / 'data'
|
||||||
|
LOG_DIR = BASE_DIR / 'logs'
|
||||||
|
TOKEN_PATH = BASE_DIR / 'token.json'
|
||||||
|
LOG_DIR.mkdir(exist_ok=True)
|
||||||
|
|
||||||
|
logging.basicConfig(
|
||||||
|
level=logging.INFO,
|
||||||
|
format='%(asctime)s [%(levelname)s] %(message)s',
|
||||||
|
handlers=[
|
||||||
|
logging.FileHandler(LOG_DIR / 'analytics.log', encoding='utf-8'),
|
||||||
|
logging.StreamHandler(),
|
||||||
|
]
|
||||||
|
)
|
||||||
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
TELEGRAM_BOT_TOKEN = os.getenv('TELEGRAM_BOT_TOKEN', '')
|
||||||
|
TELEGRAM_CHAT_ID = os.getenv('TELEGRAM_CHAT_ID', '')
|
||||||
|
BLOG_MAIN_ID = os.getenv('BLOG_MAIN_ID', '')
|
||||||
|
|
||||||
|
SCOPES = [
|
||||||
|
'https://www.googleapis.com/auth/blogger.readonly',
|
||||||
|
'https://www.googleapis.com/auth/webmasters.readonly',
|
||||||
|
]
|
||||||
|
|
||||||
|
|
||||||
|
def get_google_credentials() -> Credentials:
|
||||||
|
creds = None
|
||||||
|
if TOKEN_PATH.exists():
|
||||||
|
creds = Credentials.from_authorized_user_file(str(TOKEN_PATH), SCOPES)
|
||||||
|
if not creds or not creds.valid:
|
||||||
|
if creds and creds.expired and creds.refresh_token:
|
||||||
|
creds.refresh(Request())
|
||||||
|
with open(TOKEN_PATH, 'w') as f:
|
||||||
|
f.write(creds.to_json())
|
||||||
|
return creds
|
||||||
|
|
||||||
|
|
||||||
|
def load_published_records() -> list[dict]:
|
||||||
|
"""발행 이력 전체 로드"""
|
||||||
|
records = []
|
||||||
|
published_dir = DATA_DIR / 'published'
|
||||||
|
for f in published_dir.glob('*.json'):
|
||||||
|
try:
|
||||||
|
records.append(json.loads(f.read_text(encoding='utf-8')))
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
|
return sorted(records, key=lambda x: x.get('published_at', ''), reverse=True)
|
||||||
|
|
||||||
|
|
||||||
|
def send_telegram(text: str):
|
||||||
|
if not TELEGRAM_BOT_TOKEN or not TELEGRAM_CHAT_ID:
|
||||||
|
logger.warning("Telegram 설정 없음")
|
||||||
|
print(text)
|
||||||
|
return
|
||||||
|
url = f'https://api.telegram.org/bot{TELEGRAM_BOT_TOKEN}/sendMessage'
|
||||||
|
try:
|
||||||
|
requests.post(url, json={
|
||||||
|
'chat_id': TELEGRAM_CHAT_ID,
|
||||||
|
'text': text,
|
||||||
|
'parse_mode': 'HTML',
|
||||||
|
}, timeout=10)
|
||||||
|
except Exception as e:
|
||||||
|
logger.error(f"Telegram 전송 실패: {e}")
|
||||||
|
|
||||||
|
|
||||||
|
# ─── Search Console 데이터 ────────────────────────────
|
||||||
|
|
||||||
|
def get_search_console_data(site_url: str, start_date: str, end_date: str,
|
||||||
|
creds: Credentials) -> dict:
|
||||||
|
"""Search Console API로 검색 성과 조회"""
|
||||||
|
try:
|
||||||
|
service = build('searchconsole', 'v1', credentials=creds)
|
||||||
|
request_body = {
|
||||||
|
'startDate': start_date,
|
||||||
|
'endDate': end_date,
|
||||||
|
'dimensions': ['page'],
|
||||||
|
'rowLimit': 1000,
|
||||||
|
}
|
||||||
|
resp = service.searchanalytics().query(
|
||||||
|
siteUrl=site_url, body=request_body
|
||||||
|
).execute()
|
||||||
|
return resp
|
||||||
|
except Exception as e:
|
||||||
|
logger.warning(f"Search Console API 오류: {e}")
|
||||||
|
return {}
|
||||||
|
|
||||||
|
|
||||||
|
def calc_index_rate(published_records: list[dict], sc_data: dict) -> float:
|
||||||
|
"""색인률 계산: 발행 글 중 Search Console에 데이터가 있는 비율"""
|
||||||
|
if not published_records:
|
||||||
|
return 0.0
|
||||||
|
sc_urls = set()
|
||||||
|
for row in sc_data.get('rows', []):
|
||||||
|
sc_urls.add(row.get('keys', [''])[0])
|
||||||
|
|
||||||
|
indexed = sum(1 for r in published_records if r.get('url', '') in sc_urls)
|
||||||
|
return round(indexed / len(published_records) * 100, 1)
|
||||||
|
|
||||||
|
|
||||||
|
def calc_average_ctr(sc_data: dict) -> float:
|
||||||
|
"""평균 CTR 계산"""
|
||||||
|
rows = sc_data.get('rows', [])
|
||||||
|
if not rows:
|
||||||
|
return 0.0
|
||||||
|
total_clicks = sum(r.get('clicks', 0) for r in rows)
|
||||||
|
total_impressions = sum(r.get('impressions', 0) for r in rows)
|
||||||
|
if total_impressions == 0:
|
||||||
|
return 0.0
|
||||||
|
return round(total_clicks / total_impressions * 100, 2)
|
||||||
|
|
||||||
|
|
||||||
|
def get_14day_performance(published_records: list[dict], sc_data: dict) -> list[dict]:
|
||||||
|
"""발행 후 14일 경과한 글들의 성과"""
|
||||||
|
now = datetime.now(timezone.utc)
|
||||||
|
cutoff = now - timedelta(days=14)
|
||||||
|
sc_rows_by_url = {}
|
||||||
|
for row in sc_data.get('rows', []):
|
||||||
|
url = row.get('keys', [''])[0]
|
||||||
|
sc_rows_by_url[url] = row
|
||||||
|
|
||||||
|
results = []
|
||||||
|
for record in published_records:
|
||||||
|
pub_str = record.get('published_at', '')
|
||||||
|
try:
|
||||||
|
pub_dt = datetime.fromisoformat(pub_str)
|
||||||
|
if pub_dt.tzinfo is None:
|
||||||
|
pub_dt = pub_dt.replace(tzinfo=timezone.utc)
|
||||||
|
except Exception:
|
||||||
|
continue
|
||||||
|
if pub_dt > cutoff:
|
||||||
|
continue # 14일 미경과
|
||||||
|
|
||||||
|
url = record.get('url', '')
|
||||||
|
sc_row = sc_rows_by_url.get(url, {})
|
||||||
|
clicks = sc_row.get('clicks', 0)
|
||||||
|
impressions = sc_row.get('impressions', 0)
|
||||||
|
results.append({
|
||||||
|
'title': record.get('title', ''),
|
||||||
|
'corner': record.get('corner', ''),
|
||||||
|
'published_at': pub_str,
|
||||||
|
'clicks_14d': clicks,
|
||||||
|
'impressions_14d': impressions,
|
||||||
|
'url': url,
|
||||||
|
})
|
||||||
|
return results
|
||||||
|
|
||||||
|
|
||||||
|
# ─── 리포트 생성 ──────────────────────────────────────
|
||||||
|
|
||||||
|
def format_daily_report(
|
||||||
|
today_published: list[dict],
|
||||||
|
index_rate: float,
|
||||||
|
avg_ctr: float,
|
||||||
|
total_published: int,
|
||||||
|
) -> str:
|
||||||
|
today_str = datetime.now().strftime('%Y-%m-%d')
|
||||||
|
today_count = len(today_published)
|
||||||
|
today_titles = '\n'.join(
|
||||||
|
f" • [{r.get('corner', '')}] {r.get('title', '')}" for r in today_published
|
||||||
|
)
|
||||||
|
return (
|
||||||
|
f"📊 <b>일일 리포트 — {today_str}</b>\n\n"
|
||||||
|
f"📝 오늘 발행: {today_count}개\n"
|
||||||
|
f"{today_titles}\n\n"
|
||||||
|
f"📈 누적 발행: {total_published}개\n"
|
||||||
|
f"🔍 색인률: {index_rate}%\n"
|
||||||
|
f"🖱 평균 CTR: {avg_ctr}%\n\n"
|
||||||
|
f"Phase 1 목표: 색인률 80%+, CTR 3%+"
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def format_weekly_report(
|
||||||
|
index_rate: float,
|
||||||
|
avg_ctr: float,
|
||||||
|
by_corner: dict,
|
||||||
|
low_performers: list[dict],
|
||||||
|
) -> str:
|
||||||
|
today_str = datetime.now().strftime('%Y-%m-%d')
|
||||||
|
corner_lines = '\n'.join(
|
||||||
|
f" • {corner}: {count}개" for corner, count in by_corner.items()
|
||||||
|
)
|
||||||
|
low_lines = '\n'.join(
|
||||||
|
f" ⚠ {r['title']} (클릭 {r['clicks_14d']}회)" for r in low_performers[:5]
|
||||||
|
) or ' 없음'
|
||||||
|
|
||||||
|
return (
|
||||||
|
f"📊 <b>주간 리포트 — {today_str}</b>\n\n"
|
||||||
|
f"🔍 색인률: {index_rate}%\n"
|
||||||
|
f"🖱 평균 CTR: {avg_ctr}%\n\n"
|
||||||
|
f"📁 코너별 발행 수:\n{corner_lines}\n\n"
|
||||||
|
f"⚠ 14일 성과 부진 글 (클릭 0):\n{low_lines}\n\n"
|
||||||
|
f"💡 피드백 루프 적용 완료 → 다음 주 글감 조정"
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def save_analytics(data: dict, filename: str):
|
||||||
|
analytics_dir = DATA_DIR / 'analytics'
|
||||||
|
analytics_dir.mkdir(exist_ok=True)
|
||||||
|
with open(analytics_dir / filename, 'w', encoding='utf-8') as f:
|
||||||
|
json.dump(data, f, ensure_ascii=False, indent=2)
|
||||||
|
|
||||||
|
|
||||||
|
def generate_feedback_json(index_rate: float, avg_ctr: float,
|
||||||
|
low_performers: list[dict], by_corner: dict) -> dict:
|
||||||
|
"""수집봇에 피드백할 데이터 생성"""
|
||||||
|
feedback = {
|
||||||
|
'generated_at': datetime.now().isoformat(),
|
||||||
|
'metrics': {
|
||||||
|
'index_rate': index_rate,
|
||||||
|
'avg_ctr': avg_ctr,
|
||||||
|
},
|
||||||
|
'adjustments': [],
|
||||||
|
}
|
||||||
|
|
||||||
|
if index_rate < 50:
|
||||||
|
feedback['adjustments'].append({
|
||||||
|
'type': 'warning',
|
||||||
|
'message': '색인률 50% 미만 — 글 구조/Schema 점검 필요',
|
||||||
|
})
|
||||||
|
if avg_ctr < 1:
|
||||||
|
feedback['adjustments'].append({
|
||||||
|
'type': 'title_meta',
|
||||||
|
'message': 'CTR 1% 미만 — 제목/메타 설명 스타일 변경 권고',
|
||||||
|
})
|
||||||
|
|
||||||
|
# 성과 좋은 코너 확대
|
||||||
|
max_corner = max(by_corner, key=by_corner.get) if by_corner else None
|
||||||
|
if max_corner:
|
||||||
|
feedback['adjustments'].append({
|
||||||
|
'type': 'corner_boost',
|
||||||
|
'corner': max_corner,
|
||||||
|
'message': f'{max_corner} 코너 성과 우수 — 비율 확대 권고',
|
||||||
|
})
|
||||||
|
|
||||||
|
# 14일 성과 0인 글감 유형 축소
|
||||||
|
if low_performers:
|
||||||
|
bad_corners = list({r['corner'] for r in low_performers if r['clicks_14d'] == 0})
|
||||||
|
for corner in bad_corners:
|
||||||
|
feedback['adjustments'].append({
|
||||||
|
'type': 'corner_reduce',
|
||||||
|
'corner': corner,
|
||||||
|
'message': f'{corner} 코너 14일 성과 부진 — 주제 유형 축소 권고',
|
||||||
|
})
|
||||||
|
|
||||||
|
return feedback
|
||||||
|
|
||||||
|
|
||||||
|
# ─── 메인 실행 ───────────────────────────────────────
|
||||||
|
|
||||||
|
def daily_report():
|
||||||
|
"""일일 리포트 생성 및 Telegram 전송"""
|
||||||
|
logger.info("=== 분석봇 일일 리포트 시작 ===")
|
||||||
|
published_records = load_published_records()
|
||||||
|
|
||||||
|
# 오늘 발행 글
|
||||||
|
today_str = datetime.now().strftime('%Y-%m-%d')
|
||||||
|
today_published = [
|
||||||
|
r for r in published_records
|
||||||
|
if r.get('published_at', '').startswith(today_str)
|
||||||
|
]
|
||||||
|
|
||||||
|
# Search Console 데이터 (최근 7일)
|
||||||
|
sc_data = {}
|
||||||
|
try:
|
||||||
|
creds = get_google_credentials()
|
||||||
|
if creds and creds.valid:
|
||||||
|
end_date = datetime.now().strftime('%Y-%m-%d')
|
||||||
|
start_date = (datetime.now() - timedelta(days=7)).strftime('%Y-%m-%d')
|
||||||
|
# site_url은 블로그 URL (예: https://techinsider-kr.blogspot.com/)
|
||||||
|
# 설정에서 읽어오거나 환경변수로 관리
|
||||||
|
site_url = os.getenv('BLOG_SITE_URL', '')
|
||||||
|
if site_url:
|
||||||
|
sc_data = get_search_console_data(site_url, start_date, end_date, creds)
|
||||||
|
except Exception as e:
|
||||||
|
logger.warning(f"Search Console 조회 실패: {e}")
|
||||||
|
|
||||||
|
index_rate = calc_index_rate(published_records, sc_data)
|
||||||
|
avg_ctr = calc_average_ctr(sc_data)
|
||||||
|
|
||||||
|
report_text = format_daily_report(
|
||||||
|
today_published, index_rate, avg_ctr, len(published_records)
|
||||||
|
)
|
||||||
|
send_telegram(report_text)
|
||||||
|
|
||||||
|
# 저장
|
||||||
|
save_analytics({
|
||||||
|
'date': today_str,
|
||||||
|
'today_published': len(today_published),
|
||||||
|
'total_published': len(published_records),
|
||||||
|
'index_rate': index_rate,
|
||||||
|
'avg_ctr': avg_ctr,
|
||||||
|
}, f'{today_str}_daily.json')
|
||||||
|
|
||||||
|
logger.info("=== 분석봇 일일 리포트 완료 ===")
|
||||||
|
|
||||||
|
|
||||||
|
def weekly_report():
|
||||||
|
"""주간 리포트 생성 및 Telegram 전송"""
|
||||||
|
logger.info("=== 분석봇 주간 리포트 시작 ===")
|
||||||
|
published_records = load_published_records()
|
||||||
|
|
||||||
|
# Search Console 데이터 (최근 28일)
|
||||||
|
sc_data = {}
|
||||||
|
try:
|
||||||
|
creds = get_google_credentials()
|
||||||
|
if creds and creds.valid:
|
||||||
|
end_date = datetime.now().strftime('%Y-%m-%d')
|
||||||
|
start_date = (datetime.now() - timedelta(days=28)).strftime('%Y-%m-%d')
|
||||||
|
site_url = os.getenv('BLOG_SITE_URL', '')
|
||||||
|
if site_url:
|
||||||
|
sc_data = get_search_console_data(site_url, start_date, end_date, creds)
|
||||||
|
except Exception as e:
|
||||||
|
logger.warning(f"Search Console 조회 실패: {e}")
|
||||||
|
|
||||||
|
index_rate = calc_index_rate(published_records, sc_data)
|
||||||
|
avg_ctr = calc_average_ctr(sc_data)
|
||||||
|
perf_14d = get_14day_performance(published_records, sc_data)
|
||||||
|
|
||||||
|
# 코너별 발행 수
|
||||||
|
by_corner: dict[str, int] = {}
|
||||||
|
for r in published_records:
|
||||||
|
corner = r.get('corner', '기타')
|
||||||
|
by_corner[corner] = by_corner.get(corner, 0) + 1
|
||||||
|
|
||||||
|
# 14일 성과 부진 글
|
||||||
|
low_performers = [r for r in perf_14d if r['clicks_14d'] == 0]
|
||||||
|
|
||||||
|
report_text = format_weekly_report(index_rate, avg_ctr, by_corner, low_performers)
|
||||||
|
send_telegram(report_text)
|
||||||
|
|
||||||
|
# 피드백 JSON 생성
|
||||||
|
feedback = generate_feedback_json(index_rate, avg_ctr, low_performers, by_corner)
|
||||||
|
save_analytics(feedback, f"{datetime.now().strftime('%Y%m%d')}_feedback.json")
|
||||||
|
|
||||||
|
logger.info("=== 분석봇 주간 리포트 완료 ===")
|
||||||
|
return feedback
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == '__main__':
|
||||||
|
import sys
|
||||||
|
if len(sys.argv) > 1 and sys.argv[1] == 'weekly':
|
||||||
|
weekly_report()
|
||||||
|
else:
|
||||||
|
daily_report()
|
||||||
99
bots/article_parser.py
Normal file
99
bots/article_parser.py
Normal file
@@ -0,0 +1,99 @@
|
|||||||
|
"""
|
||||||
|
article_parser.py
|
||||||
|
OpenClaw blog-writer 출력(output_format.md 형식)을 파싱하여
|
||||||
|
발행봇이 사용할 수 있는 dict로 변환.
|
||||||
|
"""
|
||||||
|
import re
|
||||||
|
from typing import Optional
|
||||||
|
|
||||||
|
|
||||||
|
def parse_output(raw_output: str) -> Optional[dict]:
|
||||||
|
"""
|
||||||
|
OpenClaw 출력 문자열을 파싱.
|
||||||
|
Returns: dict 또는 None (파싱 실패 시)
|
||||||
|
"""
|
||||||
|
sections = {}
|
||||||
|
pattern = re.compile(r'---(\w+)---\n(.*?)(?=---\w+---|$)', re.DOTALL)
|
||||||
|
matches = pattern.findall(raw_output)
|
||||||
|
|
||||||
|
for key, value in matches:
|
||||||
|
sections[key.strip()] = value.strip()
|
||||||
|
|
||||||
|
if not sections.get('TITLE') or not sections.get('BODY'):
|
||||||
|
return None
|
||||||
|
|
||||||
|
# 출처 파싱
|
||||||
|
sources = []
|
||||||
|
sources_raw = sections.get('SOURCES', '')
|
||||||
|
for line in sources_raw.splitlines():
|
||||||
|
line = line.strip()
|
||||||
|
if not line:
|
||||||
|
continue
|
||||||
|
parts = [p.strip() for p in line.split('|')]
|
||||||
|
sources.append({
|
||||||
|
'url': parts[0] if len(parts) > 0 else '',
|
||||||
|
'title': parts[1] if len(parts) > 1 else '',
|
||||||
|
'date': parts[2] if len(parts) > 2 else '',
|
||||||
|
})
|
||||||
|
|
||||||
|
# 태그 파싱
|
||||||
|
tags_raw = sections.get('TAGS', '')
|
||||||
|
tags = [t.strip() for t in tags_raw.split(',') if t.strip()]
|
||||||
|
|
||||||
|
# 쿠팡 키워드 파싱
|
||||||
|
coupang_raw = sections.get('COUPANG_KEYWORDS', '')
|
||||||
|
coupang_keywords = [k.strip() for k in coupang_raw.split(',') if k.strip()]
|
||||||
|
|
||||||
|
return {
|
||||||
|
'title': sections.get('TITLE', ''),
|
||||||
|
'meta': sections.get('META', ''),
|
||||||
|
'slug': sections.get('SLUG', ''),
|
||||||
|
'tags': tags,
|
||||||
|
'corner': sections.get('CORNER', ''),
|
||||||
|
'body': sections.get('BODY', ''),
|
||||||
|
'coupang_keywords': coupang_keywords,
|
||||||
|
'sources': sources,
|
||||||
|
'disclaimer': sections.get('DISCLAIMER', ''),
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == '__main__':
|
||||||
|
sample = """---TITLE---
|
||||||
|
ChatGPT 처음 쓰는 사람을 위한 완전 가이드
|
||||||
|
|
||||||
|
---META---
|
||||||
|
ChatGPT를 처음 사용하는 분을 위한 단계별 가이드입니다.
|
||||||
|
|
||||||
|
---SLUG---
|
||||||
|
chatgpt-beginners-complete-guide
|
||||||
|
|
||||||
|
---TAGS---
|
||||||
|
ChatGPT, AI, 가이드, 입문
|
||||||
|
|
||||||
|
---CORNER---
|
||||||
|
쉬운세상
|
||||||
|
|
||||||
|
---BODY---
|
||||||
|
## ChatGPT란?
|
||||||
|
|
||||||
|
ChatGPT는 OpenAI가 만든 AI 챗봇입니다.
|
||||||
|
|
||||||
|
## 어떻게 시작하나요?
|
||||||
|
|
||||||
|
1단계: chat.openai.com 접속
|
||||||
|
|
||||||
|
## 결론
|
||||||
|
|
||||||
|
오늘부터 바로 시작해보세요.
|
||||||
|
|
||||||
|
---COUPANG_KEYWORDS---
|
||||||
|
키보드, 마우스
|
||||||
|
|
||||||
|
---SOURCES---
|
||||||
|
https://openai.com/blog | OpenAI 공식 블로그 | 2026-03-24
|
||||||
|
|
||||||
|
---DISCLAIMER---
|
||||||
|
"""
|
||||||
|
result = parse_output(sample)
|
||||||
|
import json
|
||||||
|
print(json.dumps(result, ensure_ascii=False, indent=2))
|
||||||
514
bots/collector_bot.py
Normal file
514
bots/collector_bot.py
Normal file
@@ -0,0 +1,514 @@
|
|||||||
|
"""
|
||||||
|
수집봇 (collector_bot.py)
|
||||||
|
역할: 트렌드/도구/사례 수집 + 품질 점수 계산 + 폐기 규칙 적용
|
||||||
|
실행: 매일 07:00 (스케줄러 호출)
|
||||||
|
"""
|
||||||
|
import json
|
||||||
|
import logging
|
||||||
|
import os
|
||||||
|
import re
|
||||||
|
import hashlib
|
||||||
|
from datetime import datetime, timedelta, timezone
|
||||||
|
from difflib import SequenceMatcher
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
import feedparser
|
||||||
|
import requests
|
||||||
|
from bs4 import BeautifulSoup
|
||||||
|
from dotenv import load_dotenv
|
||||||
|
|
||||||
|
load_dotenv()
|
||||||
|
|
||||||
|
BASE_DIR = Path(__file__).parent.parent
|
||||||
|
CONFIG_DIR = BASE_DIR / 'config'
|
||||||
|
DATA_DIR = BASE_DIR / 'data'
|
||||||
|
LOG_DIR = BASE_DIR / 'logs'
|
||||||
|
LOG_DIR.mkdir(exist_ok=True)
|
||||||
|
|
||||||
|
logging.basicConfig(
|
||||||
|
level=logging.INFO,
|
||||||
|
format='%(asctime)s [%(levelname)s] %(message)s',
|
||||||
|
handlers=[
|
||||||
|
logging.FileHandler(LOG_DIR / 'collector.log', encoding='utf-8'),
|
||||||
|
logging.StreamHandler(),
|
||||||
|
]
|
||||||
|
)
|
||||||
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
# 코너별 타입
|
||||||
|
CORNER_TYPES = {
|
||||||
|
'easy_guide': '쉬운세상',
|
||||||
|
'hidden_gems': '숨은보물',
|
||||||
|
'vibe_report': '바이브리포트',
|
||||||
|
'fact_check': '팩트체크',
|
||||||
|
'one_cut': '한컷',
|
||||||
|
}
|
||||||
|
|
||||||
|
# 글감 타입 비율: 에버그린 50%, 트렌드 30%, 개성 20%
|
||||||
|
TOPIC_RATIO = {'evergreen': 0.5, 'trending': 0.3, 'personality': 0.2}
|
||||||
|
|
||||||
|
|
||||||
|
def load_config(filename: str) -> dict:
|
||||||
|
with open(CONFIG_DIR / filename, 'r', encoding='utf-8') as f:
|
||||||
|
return json.load(f)
|
||||||
|
|
||||||
|
|
||||||
|
def load_published_titles() -> list[str]:
|
||||||
|
"""발행 이력에서 제목 목록을 불러옴 (유사도 비교용)"""
|
||||||
|
titles = []
|
||||||
|
published_dir = DATA_DIR / 'published'
|
||||||
|
for f in published_dir.glob('*.json'):
|
||||||
|
try:
|
||||||
|
data = json.loads(f.read_text(encoding='utf-8'))
|
||||||
|
if 'title' in data:
|
||||||
|
titles.append(data['title'])
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
|
return titles
|
||||||
|
|
||||||
|
|
||||||
|
def title_similarity(a: str, b: str) -> float:
|
||||||
|
return SequenceMatcher(None, a, b).ratio()
|
||||||
|
|
||||||
|
|
||||||
|
def is_duplicate(title: str, published_titles: list[str], threshold: float = 0.8) -> bool:
|
||||||
|
for pub_title in published_titles:
|
||||||
|
if title_similarity(title, pub_title) >= threshold:
|
||||||
|
return True
|
||||||
|
return False
|
||||||
|
|
||||||
|
|
||||||
|
def calc_freshness_score(published_at: datetime | None, max_score: int = 20) -> int:
|
||||||
|
"""발행 시간 기준 신선도 점수 (24h 이내 만점, 7일 초과 0점)"""
|
||||||
|
if published_at is None:
|
||||||
|
return max_score // 2
|
||||||
|
now = datetime.now(timezone.utc)
|
||||||
|
if published_at.tzinfo is None:
|
||||||
|
published_at = published_at.replace(tzinfo=timezone.utc)
|
||||||
|
age_hours = (now - published_at).total_seconds() / 3600
|
||||||
|
if age_hours <= 24:
|
||||||
|
return max_score
|
||||||
|
elif age_hours >= 168:
|
||||||
|
return 0
|
||||||
|
else:
|
||||||
|
ratio = 1 - (age_hours - 24) / (168 - 24)
|
||||||
|
return int(max_score * ratio)
|
||||||
|
|
||||||
|
|
||||||
|
def calc_korean_relevance(text: str, rules: dict) -> int:
|
||||||
|
"""한국 독자 관련성 점수"""
|
||||||
|
keywords = rules['scoring']['korean_relevance']['keywords']
|
||||||
|
matched = sum(1 for kw in keywords if kw in text)
|
||||||
|
score = min(matched * 6, rules['scoring']['korean_relevance']['max'])
|
||||||
|
return score
|
||||||
|
|
||||||
|
|
||||||
|
def calc_source_trust(source_url: str, rules: dict) -> tuple[int, str]:
|
||||||
|
"""출처 신뢰도 점수 + 레벨"""
|
||||||
|
trust_cfg = rules['scoring']['source_trust']
|
||||||
|
high_src = trust_cfg.get('high_sources', [])
|
||||||
|
low_src = trust_cfg.get('low_sources', [])
|
||||||
|
url_lower = source_url.lower()
|
||||||
|
for s in low_src:
|
||||||
|
if s in url_lower:
|
||||||
|
return trust_cfg['levels']['low'], 'low'
|
||||||
|
for s in high_src:
|
||||||
|
if s in url_lower:
|
||||||
|
return trust_cfg['levels']['high'], 'high'
|
||||||
|
return trust_cfg['levels']['medium'], 'medium'
|
||||||
|
|
||||||
|
|
||||||
|
def calc_monetization(text: str, rules: dict) -> int:
|
||||||
|
"""수익 연결 가능성 점수"""
|
||||||
|
keywords = rules['scoring']['monetization']['keywords']
|
||||||
|
matched = sum(1 for kw in keywords if kw in text)
|
||||||
|
return min(matched * 5, rules['scoring']['monetization']['max'])
|
||||||
|
|
||||||
|
|
||||||
|
def is_evergreen(title: str, rules: dict) -> bool:
|
||||||
|
evergreen_kws = rules.get('evergreen_keywords', [])
|
||||||
|
return any(kw in title for kw in evergreen_kws)
|
||||||
|
|
||||||
|
|
||||||
|
def apply_discard_rules(item: dict, rules: dict, published_titles: list[str]) -> str | None:
|
||||||
|
"""
|
||||||
|
폐기 규칙 적용. 폐기 사유 반환(None이면 통과).
|
||||||
|
"""
|
||||||
|
title = item.get('topic', '')
|
||||||
|
text = title + ' ' + item.get('description', '')
|
||||||
|
discard_rules = rules.get('discard_rules', [])
|
||||||
|
|
||||||
|
for rule in discard_rules:
|
||||||
|
rule_id = rule['id']
|
||||||
|
|
||||||
|
if rule_id == 'no_korean_relevance':
|
||||||
|
if item.get('korean_relevance_score', 0) == 0:
|
||||||
|
return '한국 독자 관련성 없음'
|
||||||
|
|
||||||
|
elif rule_id == 'unverified_source':
|
||||||
|
if item.get('source_trust_level') == 'unknown':
|
||||||
|
return '출처 불명'
|
||||||
|
|
||||||
|
elif rule_id == 'duplicate_topic':
|
||||||
|
threshold = rule.get('similarity_threshold', 0.8)
|
||||||
|
if is_duplicate(title, published_titles, threshold):
|
||||||
|
return f'기발행 주제와 유사도 {threshold*100:.0f}% 이상'
|
||||||
|
|
||||||
|
elif rule_id == 'stale_trend':
|
||||||
|
if not item.get('is_evergreen', False):
|
||||||
|
max_days = rule.get('max_age_days', 7)
|
||||||
|
pub_at = item.get('published_at')
|
||||||
|
if pub_at:
|
||||||
|
if isinstance(pub_at, str):
|
||||||
|
try:
|
||||||
|
pub_at = datetime.fromisoformat(pub_at)
|
||||||
|
except Exception:
|
||||||
|
pub_at = None
|
||||||
|
if pub_at:
|
||||||
|
if pub_at.tzinfo is None:
|
||||||
|
pub_at = pub_at.replace(tzinfo=timezone.utc)
|
||||||
|
age_days = (datetime.now(timezone.utc) - pub_at).days
|
||||||
|
if age_days > max_days:
|
||||||
|
return f'{age_days}일 지난 트렌드'
|
||||||
|
|
||||||
|
elif rule_id == 'promotional':
|
||||||
|
kws = rule.get('keywords', [])
|
||||||
|
if any(kw in text for kw in kws):
|
||||||
|
return '광고성/홍보성 콘텐츠'
|
||||||
|
|
||||||
|
elif rule_id == 'clickbait':
|
||||||
|
patterns = rule.get('patterns', [])
|
||||||
|
if any(p in text for p in patterns):
|
||||||
|
return '클릭베이트성 주제'
|
||||||
|
|
||||||
|
return None
|
||||||
|
|
||||||
|
|
||||||
|
def assign_corner(item: dict, topic_type: str) -> str:
|
||||||
|
"""글감에 코너 배정"""
|
||||||
|
title = item.get('topic', '').lower()
|
||||||
|
source = item.get('source', 'rss').lower()
|
||||||
|
|
||||||
|
if topic_type == 'evergreen':
|
||||||
|
if any(kw in title for kw in ['가이드', '방법', '사용법', '입문', '튜토리얼', '기초']):
|
||||||
|
return '쉬운세상'
|
||||||
|
return '숨은보물'
|
||||||
|
elif topic_type == 'trending':
|
||||||
|
if source in ['github', 'product_hunt']:
|
||||||
|
return '숨은보물'
|
||||||
|
return '쉬운세상'
|
||||||
|
else: # personality
|
||||||
|
return '바이브리포트'
|
||||||
|
|
||||||
|
|
||||||
|
def calculate_quality_score(item: dict, rules: dict) -> int:
|
||||||
|
"""0-100점 품질 점수 계산"""
|
||||||
|
text = item.get('topic', '') + ' ' + item.get('description', '')
|
||||||
|
source_url = item.get('source_url', '')
|
||||||
|
pub_at_str = item.get('published_at')
|
||||||
|
pub_at = None
|
||||||
|
if pub_at_str:
|
||||||
|
try:
|
||||||
|
pub_at = datetime.fromisoformat(pub_at_str)
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
|
|
||||||
|
kr_score = calc_korean_relevance(text, rules)
|
||||||
|
fresh_score = calc_freshness_score(pub_at)
|
||||||
|
# search_demand: pytrends 연동 후 실제값 사용 (현재 기본값 10)
|
||||||
|
search_score = item.get('search_demand_score', 10)
|
||||||
|
trust_score, trust_level = calc_source_trust(source_url, rules)
|
||||||
|
mono_score = calc_monetization(text, rules)
|
||||||
|
|
||||||
|
item['korean_relevance_score'] = kr_score
|
||||||
|
item['source_trust_level'] = trust_level
|
||||||
|
item['is_evergreen'] = is_evergreen(item.get('topic', ''), rules)
|
||||||
|
|
||||||
|
total = kr_score + fresh_score + search_score + trust_score + mono_score
|
||||||
|
return min(total, 100)
|
||||||
|
|
||||||
|
|
||||||
|
# ─── 수집 소스별 함수 ─────────────────────────────────
|
||||||
|
|
||||||
|
def collect_google_trends() -> list[dict]:
|
||||||
|
"""Google Trends (pytrends) — 한국 일간 트렌딩"""
|
||||||
|
items = []
|
||||||
|
try:
|
||||||
|
from pytrends.request import TrendReq
|
||||||
|
pytrends = TrendReq(hl='ko', tz=540, timeout=(10, 30))
|
||||||
|
trending_df = pytrends.trending_searches(pn='south_korea')
|
||||||
|
for keyword in trending_df[0].tolist()[:20]:
|
||||||
|
items.append({
|
||||||
|
'topic': keyword,
|
||||||
|
'description': f'Google Trends 한국 트렌딩 키워드: {keyword}',
|
||||||
|
'source': 'google_trends',
|
||||||
|
'source_url': f'https://trends.google.co.kr/trends/explore?q={keyword}&geo=KR',
|
||||||
|
'published_at': datetime.now(timezone.utc).isoformat(),
|
||||||
|
'search_demand_score': 15,
|
||||||
|
'topic_type': 'trending',
|
||||||
|
})
|
||||||
|
except Exception as e:
|
||||||
|
logger.warning(f"Google Trends 수집 실패: {e}")
|
||||||
|
return items
|
||||||
|
|
||||||
|
|
||||||
|
def collect_github_trending(sources_cfg: dict) -> list[dict]:
|
||||||
|
"""GitHub Trending 크롤링"""
|
||||||
|
items = []
|
||||||
|
cfg = sources_cfg.get('github_trending', {})
|
||||||
|
languages = cfg.get('languages', [''])
|
||||||
|
since = cfg.get('since', 'daily')
|
||||||
|
|
||||||
|
for lang in languages:
|
||||||
|
url = f"https://github.com/trending/{lang}?since={since}"
|
||||||
|
try:
|
||||||
|
resp = requests.get(url, timeout=15, headers={'User-Agent': 'Mozilla/5.0'})
|
||||||
|
soup = BeautifulSoup(resp.text, 'lxml')
|
||||||
|
repos = soup.select('article.Box-row')
|
||||||
|
for repo in repos[:10]:
|
||||||
|
name_el = repo.select_one('h2 a')
|
||||||
|
desc_el = repo.select_one('p')
|
||||||
|
stars_el = repo.select_one('a[href*="stargazers"]')
|
||||||
|
if not name_el:
|
||||||
|
continue
|
||||||
|
repo_path = name_el.get('href', '').strip('/')
|
||||||
|
topic = repo_path.replace('/', ' / ')
|
||||||
|
desc = desc_el.get_text(strip=True) if desc_el else ''
|
||||||
|
stars = stars_el.get_text(strip=True) if stars_el else '0'
|
||||||
|
items.append({
|
||||||
|
'topic': topic,
|
||||||
|
'description': desc,
|
||||||
|
'source': 'github',
|
||||||
|
'source_url': f'https://github.com/{repo_path}',
|
||||||
|
'published_at': datetime.now(timezone.utc).isoformat(),
|
||||||
|
'search_demand_score': 12,
|
||||||
|
'topic_type': 'trending',
|
||||||
|
'extra': {'stars': stars},
|
||||||
|
})
|
||||||
|
except Exception as e:
|
||||||
|
logger.warning(f"GitHub Trending 수집 실패 ({lang}): {e}")
|
||||||
|
return items
|
||||||
|
|
||||||
|
|
||||||
|
def collect_hacker_news(sources_cfg: dict) -> list[dict]:
|
||||||
|
"""Hacker News API 상위 스토리"""
|
||||||
|
items = []
|
||||||
|
cfg = sources_cfg.get('hacker_news', {})
|
||||||
|
api_url = cfg.get('url', 'https://hacker-news.firebaseio.com/v0/topstories.json')
|
||||||
|
top_n = cfg.get('top_n', 30)
|
||||||
|
try:
|
||||||
|
resp = requests.get(api_url, timeout=10)
|
||||||
|
story_ids = resp.json()[:top_n]
|
||||||
|
for sid in story_ids:
|
||||||
|
story_resp = requests.get(
|
||||||
|
f'https://hacker-news.firebaseio.com/v0/item/{sid}.json', timeout=5
|
||||||
|
)
|
||||||
|
story = story_resp.json()
|
||||||
|
if not story or story.get('type') != 'story':
|
||||||
|
continue
|
||||||
|
pub_ts = story.get('time')
|
||||||
|
pub_at = datetime.fromtimestamp(pub_ts, tz=timezone.utc).isoformat() if pub_ts else None
|
||||||
|
items.append({
|
||||||
|
'topic': story.get('title', ''),
|
||||||
|
'description': story.get('url', ''),
|
||||||
|
'source': 'hacker_news',
|
||||||
|
'source_url': story.get('url', f'https://news.ycombinator.com/item?id={sid}'),
|
||||||
|
'published_at': pub_at,
|
||||||
|
'search_demand_score': 8,
|
||||||
|
'topic_type': 'trending',
|
||||||
|
})
|
||||||
|
except Exception as e:
|
||||||
|
logger.warning(f"Hacker News 수집 실패: {e}")
|
||||||
|
return items
|
||||||
|
|
||||||
|
|
||||||
|
def collect_product_hunt(sources_cfg: dict) -> list[dict]:
|
||||||
|
"""Product Hunt RSS"""
|
||||||
|
items = []
|
||||||
|
cfg = sources_cfg.get('product_hunt', {})
|
||||||
|
rss_url = cfg.get('rss_url', 'https://www.producthunt.com/feed')
|
||||||
|
try:
|
||||||
|
feed = feedparser.parse(rss_url)
|
||||||
|
for entry in feed.entries[:15]:
|
||||||
|
pub_at = None
|
||||||
|
if hasattr(entry, 'published_parsed') and entry.published_parsed:
|
||||||
|
pub_at = datetime(*entry.published_parsed[:6], tzinfo=timezone.utc).isoformat()
|
||||||
|
items.append({
|
||||||
|
'topic': entry.get('title', ''),
|
||||||
|
'description': entry.get('summary', ''),
|
||||||
|
'source': 'product_hunt',
|
||||||
|
'source_url': entry.get('link', ''),
|
||||||
|
'published_at': pub_at,
|
||||||
|
'search_demand_score': 10,
|
||||||
|
'topic_type': 'trending',
|
||||||
|
})
|
||||||
|
except Exception as e:
|
||||||
|
logger.warning(f"Product Hunt 수집 실패: {e}")
|
||||||
|
return items
|
||||||
|
|
||||||
|
|
||||||
|
def collect_rss_feeds(sources_cfg: dict) -> list[dict]:
|
||||||
|
"""설정된 RSS 피드 수집"""
|
||||||
|
items = []
|
||||||
|
feeds = sources_cfg.get('rss_feeds', [])
|
||||||
|
for feed_cfg in feeds:
|
||||||
|
url = feed_cfg.get('url', '')
|
||||||
|
trust = feed_cfg.get('trust_level', 'medium')
|
||||||
|
try:
|
||||||
|
feed = feedparser.parse(url)
|
||||||
|
for entry in feed.entries[:10]:
|
||||||
|
pub_at = None
|
||||||
|
if hasattr(entry, 'published_parsed') and entry.published_parsed:
|
||||||
|
pub_at = datetime(*entry.published_parsed[:6], tzinfo=timezone.utc).isoformat()
|
||||||
|
items.append({
|
||||||
|
'topic': entry.get('title', ''),
|
||||||
|
'description': entry.get('summary', '') or entry.get('description', ''),
|
||||||
|
'source': 'rss',
|
||||||
|
'source_name': feed_cfg.get('name', ''),
|
||||||
|
'source_url': entry.get('link', ''),
|
||||||
|
'published_at': pub_at,
|
||||||
|
'search_demand_score': 8,
|
||||||
|
'topic_type': 'trending',
|
||||||
|
'_trust_override': trust,
|
||||||
|
})
|
||||||
|
except Exception as e:
|
||||||
|
logger.warning(f"RSS 수집 실패 ({url}): {e}")
|
||||||
|
return items
|
||||||
|
|
||||||
|
|
||||||
|
def extract_coupang_keywords(topic: str, description: str) -> list[str]:
|
||||||
|
"""글감에서 쿠팡 검색 키워드 추출"""
|
||||||
|
product_keywords = [
|
||||||
|
'마이크', '웹캠', '키보드', '마우스', '모니터', '노트북', '이어폰',
|
||||||
|
'헤드셋', '외장하드', 'USB허브', '책상', '의자', '서적', '책', '스피커',
|
||||||
|
]
|
||||||
|
text = topic + ' ' + description
|
||||||
|
found = [kw for kw in product_keywords if kw in text]
|
||||||
|
if not found:
|
||||||
|
# IT 기기 류 글이면 기본 키워드
|
||||||
|
if any(kw in text for kw in ['도구', '앱', '툴', '소프트웨어', '서비스']):
|
||||||
|
found = ['키보드', '마우스']
|
||||||
|
return found
|
||||||
|
|
||||||
|
|
||||||
|
def save_discarded(item: dict, reason: str):
|
||||||
|
"""폐기된 글감 로그 저장"""
|
||||||
|
discard_dir = DATA_DIR / 'discarded'
|
||||||
|
discard_dir.mkdir(exist_ok=True)
|
||||||
|
today = datetime.now().strftime('%Y%m%d')
|
||||||
|
log_file = discard_dir / f'{today}_discarded.jsonl'
|
||||||
|
record = {**item, 'discard_reason': reason, 'discarded_at': datetime.now().isoformat()}
|
||||||
|
with open(log_file, 'a', encoding='utf-8') as f:
|
||||||
|
f.write(json.dumps(record, ensure_ascii=False) + '\n')
|
||||||
|
|
||||||
|
|
||||||
|
def save_topic(item: dict):
|
||||||
|
"""합격한 글감을 data/topics/에 저장"""
|
||||||
|
topics_dir = DATA_DIR / 'topics'
|
||||||
|
topics_dir.mkdir(exist_ok=True)
|
||||||
|
topic_id = hashlib.md5(item['topic'].encode()).hexdigest()[:8]
|
||||||
|
filename = f"{datetime.now().strftime('%Y%m%d')}_{topic_id}.json"
|
||||||
|
with open(topics_dir / filename, 'w', encoding='utf-8') as f:
|
||||||
|
json.dump(item, f, ensure_ascii=False, indent=2)
|
||||||
|
|
||||||
|
|
||||||
|
def run():
|
||||||
|
logger.info("=== 수집봇 시작 ===")
|
||||||
|
rules = load_config('quality_rules.json')
|
||||||
|
sources_cfg = load_config('sources.json')
|
||||||
|
published_titles = load_published_titles()
|
||||||
|
min_score = rules.get('min_score', 70)
|
||||||
|
|
||||||
|
# 수집
|
||||||
|
all_items = []
|
||||||
|
all_items += collect_google_trends()
|
||||||
|
all_items += collect_github_trending(sources_cfg)
|
||||||
|
all_items += collect_product_hunt(sources_cfg)
|
||||||
|
all_items += collect_hacker_news(sources_cfg)
|
||||||
|
all_items += collect_rss_feeds(sources_cfg)
|
||||||
|
|
||||||
|
logger.info(f"수집 완료: {len(all_items)}개")
|
||||||
|
|
||||||
|
passed = []
|
||||||
|
discarded_count = 0
|
||||||
|
|
||||||
|
for item in all_items:
|
||||||
|
if not item.get('topic'):
|
||||||
|
continue
|
||||||
|
|
||||||
|
# 신뢰도 오버라이드 (RSS 피드별 설정)
|
||||||
|
trust_override = item.pop('_trust_override', None)
|
||||||
|
if trust_override:
|
||||||
|
trust_levels = rules['scoring']['source_trust']['levels']
|
||||||
|
item['source_trust_level'] = trust_override
|
||||||
|
item['_trust_score'] = trust_levels.get(trust_override, trust_levels['medium'])
|
||||||
|
|
||||||
|
# 품질 점수 계산
|
||||||
|
score = calculate_quality_score(item, rules)
|
||||||
|
item['quality_score'] = score
|
||||||
|
|
||||||
|
# 폐기 규칙 검사
|
||||||
|
discard_reason = apply_discard_rules(item, rules, published_titles)
|
||||||
|
if discard_reason:
|
||||||
|
save_discarded(item, discard_reason)
|
||||||
|
discarded_count += 1
|
||||||
|
logger.debug(f"폐기: [{score}점] {item['topic']} — {discard_reason}")
|
||||||
|
continue
|
||||||
|
|
||||||
|
if score < min_score:
|
||||||
|
save_discarded(item, f'품질 점수 미달 ({score}점 < {min_score}점)')
|
||||||
|
discarded_count += 1
|
||||||
|
logger.debug(f"폐기: [{score}점] {item['topic']}")
|
||||||
|
continue
|
||||||
|
|
||||||
|
# 코너 배정
|
||||||
|
topic_type = item.get('topic_type', 'trending')
|
||||||
|
corner = assign_corner(item, topic_type)
|
||||||
|
item['corner'] = corner
|
||||||
|
|
||||||
|
# 쿠팡 키워드 추출
|
||||||
|
item['coupang_keywords'] = extract_coupang_keywords(
|
||||||
|
item.get('topic', ''), item.get('description', '')
|
||||||
|
)
|
||||||
|
|
||||||
|
# 트렌딩 경과 시간 표시
|
||||||
|
pub_at_str = item.get('published_at')
|
||||||
|
if pub_at_str:
|
||||||
|
try:
|
||||||
|
pub_at = datetime.fromisoformat(pub_at_str)
|
||||||
|
if pub_at.tzinfo is None:
|
||||||
|
pub_at = pub_at.replace(tzinfo=timezone.utc)
|
||||||
|
hours_ago = int((datetime.now(timezone.utc) - pub_at).total_seconds() / 3600)
|
||||||
|
item['trending_since'] = f'{hours_ago}시간 전' if hours_ago < 24 else f'{hours_ago // 24}일 전'
|
||||||
|
except Exception:
|
||||||
|
item['trending_since'] = '알 수 없음'
|
||||||
|
|
||||||
|
# sources 필드 정리
|
||||||
|
item['sources'] = [{'url': item.get('source_url', ''), 'title': item.get('topic', ''),
|
||||||
|
'date': item.get('published_at', '')}]
|
||||||
|
item['related_keywords'] = item.get('topic', '').split()[:5]
|
||||||
|
|
||||||
|
passed.append(item)
|
||||||
|
|
||||||
|
# 에버그린/트렌드/개성 비율 맞추기
|
||||||
|
total_target = len(passed)
|
||||||
|
evergreen = [i for i in passed if i.get('is_evergreen')]
|
||||||
|
trending = [i for i in passed if not i.get('is_evergreen') and i.get('topic_type') == 'trending']
|
||||||
|
personality = [i for i in passed if i.get('topic_type') == 'personality']
|
||||||
|
|
||||||
|
logger.info(
|
||||||
|
f"합격: {len(passed)}개 (에버그린 {len(evergreen)}, 트렌드 {len(trending)}, "
|
||||||
|
f"개성 {len(personality)}) / 폐기: {discarded_count}개"
|
||||||
|
)
|
||||||
|
|
||||||
|
# 글감 저장
|
||||||
|
for item in passed:
|
||||||
|
save_topic(item)
|
||||||
|
logger.info(f"[{item['quality_score']}점][{item['corner']}] {item['topic']}")
|
||||||
|
|
||||||
|
logger.info("=== 수집봇 완료 ===")
|
||||||
|
return passed
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == '__main__':
|
||||||
|
run()
|
||||||
366
bots/image_bot.py
Normal file
366
bots/image_bot.py
Normal file
@@ -0,0 +1,366 @@
|
|||||||
|
"""
|
||||||
|
이미지봇 (image_bot.py)
|
||||||
|
역할: 만평 코너용 이미지 생성/관리
|
||||||
|
|
||||||
|
IMAGE_MODE 환경변수로 모드 선택:
|
||||||
|
|
||||||
|
manual (기본) — 한컷 글 발행 시점에 프롬프트 1개를 Telegram으로 전송.
|
||||||
|
사용자가 직접 생성 후 data/images/ 에 파일 저장.
|
||||||
|
|
||||||
|
request — 스케줄러가 주기적으로 대기 중인 프롬프트 목록을 Telegram 전송.
|
||||||
|
사용자가 생성형 AI로 이미지 제작 후 Telegram으로 이미지 전송하면 자동 저장.
|
||||||
|
/images 명령으로 대기 목록 확인, /imgpick [번호]로 선택.
|
||||||
|
|
||||||
|
auto — OpenAI Images API (dall-e-3) 직접 호출. OPENAI_API_KEY 필요.
|
||||||
|
비용: 이미지당 $0.04-0.08 (ChatGPT Pro 구독과 별도).
|
||||||
|
"""
|
||||||
|
import json
|
||||||
|
import logging
|
||||||
|
import os
|
||||||
|
import re
|
||||||
|
import uuid
|
||||||
|
from datetime import datetime
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
import requests
|
||||||
|
from dotenv import load_dotenv
|
||||||
|
|
||||||
|
load_dotenv()
|
||||||
|
|
||||||
|
BASE_DIR = Path(__file__).parent.parent
|
||||||
|
DATA_DIR = BASE_DIR / 'data'
|
||||||
|
IMAGES_DIR = DATA_DIR / 'images'
|
||||||
|
LOG_DIR = BASE_DIR / 'logs'
|
||||||
|
PENDING_PROMPTS_FILE = IMAGES_DIR / 'pending_prompts.json'
|
||||||
|
|
||||||
|
LOG_DIR.mkdir(exist_ok=True)
|
||||||
|
IMAGES_DIR.mkdir(exist_ok=True)
|
||||||
|
|
||||||
|
logging.basicConfig(
|
||||||
|
level=logging.INFO,
|
||||||
|
format='%(asctime)s [%(levelname)s] %(message)s',
|
||||||
|
handlers=[
|
||||||
|
logging.FileHandler(LOG_DIR / 'image_bot.log', encoding='utf-8'),
|
||||||
|
logging.StreamHandler(),
|
||||||
|
]
|
||||||
|
)
|
||||||
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
TELEGRAM_BOT_TOKEN = os.getenv('TELEGRAM_BOT_TOKEN', '')
|
||||||
|
TELEGRAM_CHAT_ID = os.getenv('TELEGRAM_CHAT_ID', '')
|
||||||
|
OPENAI_API_KEY = os.getenv('OPENAI_API_KEY', '')
|
||||||
|
IMAGE_MODE = os.getenv('IMAGE_MODE', 'manual').lower() # manual | request | auto
|
||||||
|
|
||||||
|
|
||||||
|
# ─── Telegram 전송 ────────────────────────────────────
|
||||||
|
|
||||||
|
def send_telegram(text: str):
|
||||||
|
if not TELEGRAM_BOT_TOKEN or not TELEGRAM_CHAT_ID:
|
||||||
|
logger.warning("Telegram 설정 없음")
|
||||||
|
print(text)
|
||||||
|
return
|
||||||
|
url = f'https://api.telegram.org/bot{TELEGRAM_BOT_TOKEN}/sendMessage'
|
||||||
|
try:
|
||||||
|
requests.post(url, json={
|
||||||
|
'chat_id': TELEGRAM_CHAT_ID,
|
||||||
|
'text': text,
|
||||||
|
'parse_mode': 'HTML',
|
||||||
|
}, timeout=10)
|
||||||
|
except Exception as e:
|
||||||
|
logger.error(f"Telegram 전송 실패: {e}")
|
||||||
|
|
||||||
|
|
||||||
|
# ─── 프롬프트 생성 ────────────────────────────────────
|
||||||
|
|
||||||
|
def build_cartoon_prompt(topic: str, description: str = '') -> str:
|
||||||
|
"""만평 스타일 이미지 프롬프트 생성 (범용 — 어떤 생성형 AI에도 사용 가능)"""
|
||||||
|
desc_part = f" {description}" if description else ""
|
||||||
|
prompt = (
|
||||||
|
f"Korean editorial cartoon style, single panel.{desc_part} "
|
||||||
|
f"Topic: {topic}. "
|
||||||
|
f"Style: simple line art, expressive characters, thought-provoking social commentary, "
|
||||||
|
f"Korean newspaper cartoon aesthetic, minimal color, black and white with accent colors. "
|
||||||
|
f"No text in the image. Square format 1:1."
|
||||||
|
)
|
||||||
|
return prompt
|
||||||
|
|
||||||
|
|
||||||
|
# ─── 대기 프롬프트 관리 ───────────────────────────────
|
||||||
|
|
||||||
|
def load_pending_prompts() -> list[dict]:
|
||||||
|
"""pending_prompts.json 로드"""
|
||||||
|
if not PENDING_PROMPTS_FILE.exists():
|
||||||
|
return []
|
||||||
|
try:
|
||||||
|
return json.loads(PENDING_PROMPTS_FILE.read_text(encoding='utf-8'))
|
||||||
|
except Exception:
|
||||||
|
return []
|
||||||
|
|
||||||
|
|
||||||
|
def save_pending_prompts(prompts: list[dict]):
|
||||||
|
"""pending_prompts.json 저장"""
|
||||||
|
PENDING_PROMPTS_FILE.write_text(
|
||||||
|
json.dumps(prompts, ensure_ascii=False, indent=2), encoding='utf-8'
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def add_pending_prompt(topic: str, description: str, article_ref: str = '') -> dict:
|
||||||
|
"""새 프롬프트 대기 목록에 추가. 생성된 항목 반환."""
|
||||||
|
prompts = load_pending_prompts()
|
||||||
|
# 같은 주제가 이미 있으면 추가하지 않음
|
||||||
|
for p in prompts:
|
||||||
|
if p['topic'] == topic and p['status'] == 'pending':
|
||||||
|
logger.info(f"이미 대기 중인 프롬프트: {topic}")
|
||||||
|
return p
|
||||||
|
|
||||||
|
prompt_text = build_cartoon_prompt(topic, description)
|
||||||
|
item = {
|
||||||
|
'id': str(len(prompts) + 1), # 사람이 읽기 쉬운 번호
|
||||||
|
'uid': uuid.uuid4().hex[:8],
|
||||||
|
'topic': topic,
|
||||||
|
'description': description,
|
||||||
|
'prompt': prompt_text,
|
||||||
|
'article_ref': article_ref,
|
||||||
|
'status': 'pending', # pending | selected | done
|
||||||
|
'created_at': datetime.now().isoformat(),
|
||||||
|
'image_path': '',
|
||||||
|
}
|
||||||
|
prompts.append(item)
|
||||||
|
save_pending_prompts(prompts)
|
||||||
|
logger.info(f"프롬프트 추가 #{item['id']}: {topic}")
|
||||||
|
return item
|
||||||
|
|
||||||
|
|
||||||
|
def get_pending_prompts(status: str = 'pending') -> list[dict]:
|
||||||
|
"""상태별 프롬프트 목록"""
|
||||||
|
return [p for p in load_pending_prompts() if p['status'] == status]
|
||||||
|
|
||||||
|
|
||||||
|
def mark_prompt_selected(prompt_id: str) -> dict | None:
|
||||||
|
"""사용자가 선택한 프롬프트를 selected 상태로 변경"""
|
||||||
|
prompts = load_pending_prompts()
|
||||||
|
for p in prompts:
|
||||||
|
if p['id'] == str(prompt_id):
|
||||||
|
p['status'] = 'selected'
|
||||||
|
p['selected_at'] = datetime.now().isoformat()
|
||||||
|
save_pending_prompts(prompts)
|
||||||
|
return p
|
||||||
|
return None
|
||||||
|
|
||||||
|
|
||||||
|
def mark_prompt_done(prompt_id: str, image_path: str) -> dict | None:
|
||||||
|
"""이미지 수령 완료 처리"""
|
||||||
|
prompts = load_pending_prompts()
|
||||||
|
for p in prompts:
|
||||||
|
if p['id'] == str(prompt_id):
|
||||||
|
p['status'] = 'done'
|
||||||
|
p['image_path'] = image_path
|
||||||
|
p['done_at'] = datetime.now().isoformat()
|
||||||
|
save_pending_prompts(prompts)
|
||||||
|
logger.info(f"프롬프트 #{prompt_id} 완료: {image_path}")
|
||||||
|
return p
|
||||||
|
return None
|
||||||
|
|
||||||
|
|
||||||
|
def get_prompt_by_id(prompt_id: str) -> dict | None:
|
||||||
|
for p in load_pending_prompts():
|
||||||
|
if p['id'] == str(prompt_id):
|
||||||
|
return p
|
||||||
|
return None
|
||||||
|
|
||||||
|
|
||||||
|
# ─── 이미지 수신 저장 ─────────────────────────────────
|
||||||
|
|
||||||
|
def save_image_from_bytes(image_bytes: bytes, topic: str, prompt_id: str) -> str:
|
||||||
|
"""bytes로 받은 이미지를 data/images/ 에 저장. 경로 반환."""
|
||||||
|
safe_name = re.sub(r'[^\w가-힣-]', '_', topic)[:50]
|
||||||
|
filename = f"{datetime.now().strftime('%Y%m%d_%H%M%S')}_p{prompt_id}_{safe_name}.png"
|
||||||
|
save_path = IMAGES_DIR / filename
|
||||||
|
save_path.write_bytes(image_bytes)
|
||||||
|
logger.info(f"이미지 저장: {save_path}")
|
||||||
|
return str(save_path)
|
||||||
|
|
||||||
|
|
||||||
|
def save_image_from_telegram(file_bytes: bytes, prompt_id: str) -> str | None:
|
||||||
|
"""Telegram으로 받은 이미지 저장 및 프롬프트 완료 처리"""
|
||||||
|
prompt = get_prompt_by_id(prompt_id)
|
||||||
|
if not prompt:
|
||||||
|
logger.warning(f"프롬프트 #{prompt_id} 없음")
|
||||||
|
return None
|
||||||
|
|
||||||
|
image_path = save_image_from_bytes(file_bytes, prompt['topic'], prompt_id)
|
||||||
|
mark_prompt_done(prompt_id, image_path)
|
||||||
|
return image_path
|
||||||
|
|
||||||
|
|
||||||
|
# ─── request 모드 — 배치 전송 ──────────────────────────
|
||||||
|
|
||||||
|
def send_prompt_batch():
|
||||||
|
"""
|
||||||
|
request 모드 주기 실행.
|
||||||
|
data/topics/ 에서 한컷 코너 글감을 스캔해 프롬프트 대기 목록에 추가하고
|
||||||
|
현재 pending 상태인 프롬프트 전체를 Telegram으로 전송.
|
||||||
|
"""
|
||||||
|
logger.info("=== 이미지 프롬프트 배치 전송 시작 ===")
|
||||||
|
|
||||||
|
# 한컷 글감 스캔 → 대기 목록에 추가
|
||||||
|
topics_dir = DATA_DIR / 'topics'
|
||||||
|
for f in sorted(topics_dir.glob('*.json')):
|
||||||
|
try:
|
||||||
|
data = json.loads(f.read_text(encoding='utf-8'))
|
||||||
|
if data.get('corner') == '한컷':
|
||||||
|
add_pending_prompt(
|
||||||
|
topic=data.get('topic', ''),
|
||||||
|
description=data.get('description', ''),
|
||||||
|
article_ref=str(f),
|
||||||
|
)
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
|
|
||||||
|
pending = get_pending_prompts('pending')
|
||||||
|
selected = get_pending_prompts('selected')
|
||||||
|
active = pending + selected
|
||||||
|
|
||||||
|
if not active:
|
||||||
|
send_telegram("🎨 현재 이미지 제작 요청이 없습니다.")
|
||||||
|
logger.info("대기 프롬프트 없음")
|
||||||
|
return
|
||||||
|
|
||||||
|
lines = [
|
||||||
|
f"🎨 <b>[이미지 제작 요청 — {len(active)}건]</b>\n",
|
||||||
|
"아래 목록에서 제작하실 항목을 선택해주세요.\n",
|
||||||
|
f"/imgpick [번호] 로 선택 → 생성형 AI(Midjourney, DALL-E, Stable Diffusion 등)로 제작 → "
|
||||||
|
f"이미지를 이 채팅에 전송해주세요.\n",
|
||||||
|
]
|
||||||
|
for item in active:
|
||||||
|
status_icon = '🔄' if item['status'] == 'selected' else '⏳'
|
||||||
|
lines.append(
|
||||||
|
f"{status_icon} <b>#{item['id']}</b> {item['topic']}\n"
|
||||||
|
f" 📝 <code>{item['prompt'][:200]}...</code>\n"
|
||||||
|
)
|
||||||
|
lines.append("\n/images — 전체 목록 재확인")
|
||||||
|
|
||||||
|
send_telegram('\n'.join(lines))
|
||||||
|
logger.info(f"배치 전송 완료: {len(active)}건")
|
||||||
|
|
||||||
|
|
||||||
|
def send_single_prompt(prompt_id: str):
|
||||||
|
"""특정 프롬프트 1개를 전체 내용으로 Telegram 전송"""
|
||||||
|
prompt = get_prompt_by_id(prompt_id)
|
||||||
|
if not prompt:
|
||||||
|
send_telegram(f"❌ #{prompt_id} 번 프롬프트를 찾을 수 없습니다.")
|
||||||
|
return
|
||||||
|
|
||||||
|
mark_prompt_selected(prompt_id)
|
||||||
|
msg = (
|
||||||
|
f"🎨 <b>[이미지 제작 — #{prompt['id']}]</b>\n\n"
|
||||||
|
f"📌 주제: <b>{prompt['topic']}</b>\n\n"
|
||||||
|
f"📝 프롬프트 (복사해서 생성형 AI에 붙여넣으세요):\n\n"
|
||||||
|
f"<code>{prompt['prompt']}</code>\n\n"
|
||||||
|
f"✅ 이미지 완성 후 <b>이 채팅에 이미지를 전송</b>하면 자동으로 저장됩니다.\n"
|
||||||
|
f"(전송 시 캡션에 <code>#{prompt['id']}</code> 를 입력해주세요)"
|
||||||
|
)
|
||||||
|
send_telegram(msg)
|
||||||
|
logger.info(f"단일 프롬프트 전송 #{prompt_id}: {prompt['topic']}")
|
||||||
|
|
||||||
|
|
||||||
|
# ─── auto 모드 ────────────────────────────────────────
|
||||||
|
|
||||||
|
def generate_image_auto(prompt: str, topic: str) -> str | None:
|
||||||
|
"""OpenAI DALL-E 3 API로 이미지 자동 생성"""
|
||||||
|
if not OPENAI_API_KEY:
|
||||||
|
logger.error("OPENAI_API_KEY 없음 — 자동 이미지 생성 불가")
|
||||||
|
return None
|
||||||
|
try:
|
||||||
|
resp = requests.post(
|
||||||
|
'https://api.openai.com/v1/images/generations',
|
||||||
|
headers={
|
||||||
|
'Authorization': f'Bearer {OPENAI_API_KEY}',
|
||||||
|
'Content-Type': 'application/json',
|
||||||
|
},
|
||||||
|
json={
|
||||||
|
'model': 'dall-e-3',
|
||||||
|
'prompt': prompt,
|
||||||
|
'n': 1,
|
||||||
|
'size': '1024x1024',
|
||||||
|
'quality': 'standard',
|
||||||
|
},
|
||||||
|
timeout=60,
|
||||||
|
)
|
||||||
|
resp.raise_for_status()
|
||||||
|
image_url = resp.json()['data'][0]['url']
|
||||||
|
img_bytes = requests.get(image_url, timeout=30).content
|
||||||
|
safe_name = re.sub(r'[^\w가-힣-]', '_', topic)[:50]
|
||||||
|
filename = f"{datetime.now().strftime('%Y%m%d_%H%M%S')}_{safe_name}.png"
|
||||||
|
save_path = IMAGES_DIR / filename
|
||||||
|
save_path.write_bytes(img_bytes)
|
||||||
|
logger.info(f"자동 이미지 저장: {save_path}")
|
||||||
|
return str(save_path)
|
||||||
|
except Exception as e:
|
||||||
|
logger.error(f"자동 이미지 생성 실패: {e}")
|
||||||
|
return None
|
||||||
|
|
||||||
|
|
||||||
|
# ─── manual 모드 ──────────────────────────────────────
|
||||||
|
|
||||||
|
def process_manual_mode(topic: str, description: str = '') -> str:
|
||||||
|
"""글 발행 시점에 프롬프트 1개 Telegram 전송 (파일 저장은 사용자 직접)"""
|
||||||
|
prompt = build_cartoon_prompt(topic, description)
|
||||||
|
safe_name = re.sub(r'[^\w가-힣-]', '_', topic)[:50]
|
||||||
|
expected_path = IMAGES_DIR / f"{datetime.now().strftime('%Y%m%d')}_{safe_name}.png"
|
||||||
|
send_telegram(
|
||||||
|
f"🎨 <b>[만평 이미지 요청 — manual]</b>\n\n"
|
||||||
|
f"📌 주제: <b>{topic}</b>\n\n"
|
||||||
|
f"📝 프롬프트:\n<code>{prompt}</code>\n\n"
|
||||||
|
f"이미지 생성 후 아래 경로에 저장해주세요:\n"
|
||||||
|
f"<code>{expected_path}</code>"
|
||||||
|
)
|
||||||
|
logger.info(f"manual 모드 프롬프트 전송: {topic}")
|
||||||
|
return str(expected_path)
|
||||||
|
|
||||||
|
|
||||||
|
# ─── 메인 진입점 ──────────────────────────────────────
|
||||||
|
|
||||||
|
def process(article: dict) -> str | None:
|
||||||
|
"""
|
||||||
|
한컷 코너 글에 대해 모드에 따라 이미지 처리.
|
||||||
|
Returns: 이미지 경로 (request 모드에서는 None — 비동기로 나중에 수령)
|
||||||
|
"""
|
||||||
|
if article.get('corner') != '한컷':
|
||||||
|
return None
|
||||||
|
|
||||||
|
topic = article.get('title', '')
|
||||||
|
description = article.get('meta', '')
|
||||||
|
logger.info(f"이미지봇 실행: {topic} (모드: {IMAGE_MODE})")
|
||||||
|
|
||||||
|
if IMAGE_MODE == 'auto':
|
||||||
|
prompt = build_cartoon_prompt(topic, description)
|
||||||
|
image_path = generate_image_auto(prompt, topic)
|
||||||
|
if image_path:
|
||||||
|
send_telegram(
|
||||||
|
f"🎨 <b>[자동 이미지 생성 완료]</b>\n\n📌 {topic}\n경로: <code>{image_path}</code>"
|
||||||
|
)
|
||||||
|
return image_path
|
||||||
|
|
||||||
|
elif IMAGE_MODE == 'request':
|
||||||
|
item = add_pending_prompt(topic, description, article_ref=article.get('_source_file', ''))
|
||||||
|
send_telegram(
|
||||||
|
f"🎨 <b>[이미지 제작 요청 추가됨]</b>\n\n"
|
||||||
|
f"📌 주제: <b>{topic}</b>\n"
|
||||||
|
f"번호: <b>#{item['id']}</b>\n\n"
|
||||||
|
f"/imgpick {item['id']} — 이 주제 프롬프트 받기\n"
|
||||||
|
f"/images — 전체 대기 목록 보기"
|
||||||
|
)
|
||||||
|
return None # 이미지는 나중에 Telegram으로 수령
|
||||||
|
|
||||||
|
else: # manual (기본)
|
||||||
|
return process_manual_mode(topic, description)
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == '__main__':
|
||||||
|
import sys
|
||||||
|
if len(sys.argv) > 1 and sys.argv[1] == 'batch':
|
||||||
|
send_prompt_batch()
|
||||||
|
else:
|
||||||
|
sample = {'corner': '한컷', 'title': 'AI가 직업을 빼앗는다?', 'meta': ''}
|
||||||
|
print(process(sample))
|
||||||
222
bots/linker_bot.py
Normal file
222
bots/linker_bot.py
Normal file
@@ -0,0 +1,222 @@
|
|||||||
|
"""
|
||||||
|
링크봇 (linker_bot.py)
|
||||||
|
역할: 글 본문에 쿠팡 파트너스 링크와 어필리에이트 링크 자동 삽입
|
||||||
|
"""
|
||||||
|
import hashlib
|
||||||
|
import hmac
|
||||||
|
import json
|
||||||
|
import logging
|
||||||
|
import os
|
||||||
|
import re
|
||||||
|
from datetime import datetime, timezone
|
||||||
|
from pathlib import Path
|
||||||
|
from urllib.parse import urlencode
|
||||||
|
|
||||||
|
import requests
|
||||||
|
from bs4 import BeautifulSoup
|
||||||
|
from dotenv import load_dotenv
|
||||||
|
|
||||||
|
load_dotenv()
|
||||||
|
|
||||||
|
BASE_DIR = Path(__file__).parent.parent
|
||||||
|
CONFIG_DIR = BASE_DIR / 'config'
|
||||||
|
LOG_DIR = BASE_DIR / 'logs'
|
||||||
|
LOG_DIR.mkdir(exist_ok=True)
|
||||||
|
|
||||||
|
logging.basicConfig(
|
||||||
|
level=logging.INFO,
|
||||||
|
format='%(asctime)s [%(levelname)s] %(message)s',
|
||||||
|
handlers=[
|
||||||
|
logging.FileHandler(LOG_DIR / 'linker.log', encoding='utf-8'),
|
||||||
|
logging.StreamHandler(),
|
||||||
|
]
|
||||||
|
)
|
||||||
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
COUPANG_ACCESS_KEY = os.getenv('COUPANG_ACCESS_KEY', '')
|
||||||
|
COUPANG_SECRET_KEY = os.getenv('COUPANG_SECRET_KEY', '')
|
||||||
|
COUPANG_API_BASE = 'https://api-gateway.coupang.com'
|
||||||
|
|
||||||
|
|
||||||
|
def load_config(filename: str) -> dict:
|
||||||
|
with open(CONFIG_DIR / filename, 'r', encoding='utf-8') as f:
|
||||||
|
return json.load(f)
|
||||||
|
|
||||||
|
|
||||||
|
# ─── 쿠팡 파트너스 API ────────────────────────────────
|
||||||
|
|
||||||
|
def _generate_coupang_hmac(method: str, url: str, query: str) -> dict:
|
||||||
|
"""쿠팡 HMAC 서명 생성"""
|
||||||
|
datetime_str = datetime.now(timezone.utc).strftime('%y%m%dT%H%M%SZ')
|
||||||
|
path = url.split(COUPANG_API_BASE)[-1].split('?')[0]
|
||||||
|
message = datetime_str + method + path + query
|
||||||
|
signature = hmac.new(
|
||||||
|
COUPANG_SECRET_KEY.encode('utf-8'),
|
||||||
|
message.encode('utf-8'),
|
||||||
|
hashlib.sha256
|
||||||
|
).hexdigest()
|
||||||
|
return {
|
||||||
|
'Authorization': f'CEA algorithm=HmacSHA256, access-key={COUPANG_ACCESS_KEY}, '
|
||||||
|
f'signed-date={datetime_str}, signature={signature}',
|
||||||
|
'Content-Type': 'application/json;charset=UTF-8',
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def search_coupang_products(keyword: str, limit: int = 3) -> list[dict]:
|
||||||
|
"""쿠팡 파트너스 API로 상품 검색"""
|
||||||
|
if not COUPANG_ACCESS_KEY or not COUPANG_SECRET_KEY:
|
||||||
|
logger.warning("쿠팡 API 키 없음 — 링크 삽입 건너뜀")
|
||||||
|
return []
|
||||||
|
|
||||||
|
path = '/v2/providers/affiliate_api/apis/openapi/products/search'
|
||||||
|
params = {
|
||||||
|
'keyword': keyword,
|
||||||
|
'limit': limit,
|
||||||
|
'subId': 'blog-writer',
|
||||||
|
}
|
||||||
|
query_string = urlencode(params)
|
||||||
|
url = f'{COUPANG_API_BASE}{path}?{query_string}'
|
||||||
|
|
||||||
|
try:
|
||||||
|
headers = _generate_coupang_hmac('GET', url, query_string)
|
||||||
|
resp = requests.get(url, headers=headers, timeout=10)
|
||||||
|
resp.raise_for_status()
|
||||||
|
data = resp.json()
|
||||||
|
products = data.get('data', {}).get('productData', [])
|
||||||
|
return [
|
||||||
|
{
|
||||||
|
'name': p.get('productName', keyword),
|
||||||
|
'price': p.get('productPrice', 0),
|
||||||
|
'url': p.get('productUrl', ''),
|
||||||
|
'image': p.get('productImage', ''),
|
||||||
|
}
|
||||||
|
for p in products[:limit]
|
||||||
|
]
|
||||||
|
except Exception as e:
|
||||||
|
logger.warning(f"쿠팡 API 오류 ({keyword}): {e}")
|
||||||
|
return []
|
||||||
|
|
||||||
|
|
||||||
|
def build_coupang_link_html(product: dict) -> str:
|
||||||
|
"""쿠팡 상품 링크 HTML 생성"""
|
||||||
|
name = product.get('name', '')
|
||||||
|
url = product.get('url', '')
|
||||||
|
price = product.get('price', 0)
|
||||||
|
price_str = f"{int(price):,}원" if price else ''
|
||||||
|
return (
|
||||||
|
f'<p class="coupang-link">'
|
||||||
|
f'🛒 <a href="{url}" target="_blank" rel="nofollow">{name}</a>'
|
||||||
|
f'{" — " + price_str if price_str else ""}'
|
||||||
|
f'</p>\n'
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
# ─── 본문 링크 삽입 ──────────────────────────────────
|
||||||
|
|
||||||
|
def insert_links_into_html(html_content: str, coupang_keywords: list[str],
|
||||||
|
fixed_links: list[dict]) -> str:
|
||||||
|
"""HTML 본문에 쿠팡 링크와 고정 링크 삽입"""
|
||||||
|
soup = BeautifulSoup(html_content, 'lxml')
|
||||||
|
|
||||||
|
# 고정 링크 (키워드 텍스트가 본문에 있으면 첫 번째 등장 위치에 링크)
|
||||||
|
for fixed in fixed_links:
|
||||||
|
kw = fixed.get('keyword', '')
|
||||||
|
link_url = fixed.get('url', '')
|
||||||
|
label = fixed.get('label', kw)
|
||||||
|
if not kw or not link_url:
|
||||||
|
continue
|
||||||
|
for p in soup.find_all(['p', 'li']):
|
||||||
|
text = p.get_text()
|
||||||
|
if kw in text:
|
||||||
|
# 이미 링크가 있으면 건너뜀
|
||||||
|
if p.find('a', string=re.compile(re.escape(kw))):
|
||||||
|
break
|
||||||
|
new_html = p.decode_contents().replace(
|
||||||
|
kw,
|
||||||
|
f'<a href="{link_url}" target="_blank">{kw}</a>',
|
||||||
|
1
|
||||||
|
)
|
||||||
|
p.clear()
|
||||||
|
p.append(BeautifulSoup(new_html, 'lxml'))
|
||||||
|
break
|
||||||
|
|
||||||
|
# 쿠팡 링크: 결론/추천 섹션 앞에 상품 박스 삽입
|
||||||
|
if coupang_keywords and (COUPANG_ACCESS_KEY and COUPANG_SECRET_KEY):
|
||||||
|
coupang_block_parts = []
|
||||||
|
for kw in coupang_keywords[:3]: # 최대 3개 키워드
|
||||||
|
products = search_coupang_products(kw, limit=2)
|
||||||
|
for product in products:
|
||||||
|
coupang_block_parts.append(build_coupang_link_html(product))
|
||||||
|
|
||||||
|
if coupang_block_parts:
|
||||||
|
coupang_block_html = (
|
||||||
|
'<div class="coupang-products">\n'
|
||||||
|
'<p><strong>관련 상품 추천</strong></p>\n'
|
||||||
|
+ ''.join(coupang_block_parts) +
|
||||||
|
'</div>\n'
|
||||||
|
)
|
||||||
|
# 결론 H2 앞에 삽입
|
||||||
|
for h2 in soup.find_all('h2'):
|
||||||
|
if any(kw in h2.get_text() for kw in ['결론', '마무리', '정리', '요약']):
|
||||||
|
block = BeautifulSoup(coupang_block_html, 'lxml')
|
||||||
|
h2.insert_before(block)
|
||||||
|
break
|
||||||
|
else:
|
||||||
|
# 결론 섹션 없으면 본문 끝에 추가
|
||||||
|
body_tag = soup.find('body') or soup
|
||||||
|
block = BeautifulSoup(coupang_block_html, 'lxml')
|
||||||
|
body_tag.append(block)
|
||||||
|
|
||||||
|
return str(soup)
|
||||||
|
|
||||||
|
|
||||||
|
def add_disclaimer(html_content: str, disclaimer_text: str) -> str:
|
||||||
|
"""쿠팡 필수 면책 문구 추가 (이미 있으면 건너뜀)"""
|
||||||
|
if disclaimer_text in html_content:
|
||||||
|
return html_content
|
||||||
|
disclaimer_html = (
|
||||||
|
f'\n<hr/>\n'
|
||||||
|
f'<p class="affiliate-disclaimer"><small>⚠️ {disclaimer_text}</small></p>\n'
|
||||||
|
)
|
||||||
|
return html_content + disclaimer_html
|
||||||
|
|
||||||
|
|
||||||
|
# ─── 메인 함수 ───────────────────────────────────────
|
||||||
|
|
||||||
|
def process(article: dict, html_content: str) -> str:
|
||||||
|
"""
|
||||||
|
링크봇 메인: HTML 본문에 쿠팡/어필리에이트 링크 삽입 후 반환
|
||||||
|
"""
|
||||||
|
logger.info(f"링크 삽입 시작: {article.get('title', '')}")
|
||||||
|
affiliate_cfg = load_config('affiliate_links.json')
|
||||||
|
|
||||||
|
coupang_keywords = article.get('coupang_keywords', [])
|
||||||
|
fixed_links = affiliate_cfg.get('fixed_links', [])
|
||||||
|
disclaimer_text = affiliate_cfg.get('disclaimer_text', '')
|
||||||
|
|
||||||
|
# 링크 삽입
|
||||||
|
html_content = insert_links_into_html(html_content, coupang_keywords, fixed_links)
|
||||||
|
|
||||||
|
# 쿠팡 키워드가 있으면 면책 문구 추가
|
||||||
|
if coupang_keywords and disclaimer_text:
|
||||||
|
html_content = add_disclaimer(html_content, disclaimer_text)
|
||||||
|
|
||||||
|
logger.info("링크 삽입 완료")
|
||||||
|
return html_content
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == '__main__':
|
||||||
|
sample_html = '''
|
||||||
|
<h2>ChatGPT 소개</h2>
|
||||||
|
<p>ChatGPT Plus를 사용하면 더 빠른 응답을 받을 수 있습니다.</p>
|
||||||
|
<h2>키보드 추천</h2>
|
||||||
|
<p>좋은 키보드는 생산성을 높입니다.</p>
|
||||||
|
<h2>결론</h2>
|
||||||
|
<p>AI 도구를 잘 활용하세요.</p>
|
||||||
|
'''
|
||||||
|
sample_article = {
|
||||||
|
'title': '테스트 글',
|
||||||
|
'coupang_keywords': ['키보드', '마우스'],
|
||||||
|
}
|
||||||
|
result = process(sample_article, sample_html)
|
||||||
|
print(result[:500])
|
||||||
464
bots/publisher_bot.py
Normal file
464
bots/publisher_bot.py
Normal file
@@ -0,0 +1,464 @@
|
|||||||
|
"""
|
||||||
|
발행봇 (publisher_bot.py)
|
||||||
|
역할: AI가 작성한 글을 Blogger에 자동 발행
|
||||||
|
- 마크다운 → HTML 변환
|
||||||
|
- 목차 자동 생성
|
||||||
|
- AdSense 플레이스홀더 삽입
|
||||||
|
- Schema.org Article JSON-LD
|
||||||
|
- 안전장치 (팩트체크/위험 키워드/출처 부족 → 수동 검토)
|
||||||
|
- Blogger API v3 발행
|
||||||
|
- Search Console URL 제출
|
||||||
|
- Telegram 알림
|
||||||
|
"""
|
||||||
|
import json
|
||||||
|
import logging
|
||||||
|
import os
|
||||||
|
import re
|
||||||
|
from datetime import datetime, timezone
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
import markdown
|
||||||
|
import requests
|
||||||
|
from bs4 import BeautifulSoup
|
||||||
|
from dotenv import load_dotenv
|
||||||
|
from google.oauth2.credentials import Credentials
|
||||||
|
from google.auth.transport.requests import Request
|
||||||
|
from googleapiclient.discovery import build
|
||||||
|
|
||||||
|
load_dotenv()
|
||||||
|
|
||||||
|
BASE_DIR = Path(__file__).parent.parent
|
||||||
|
CONFIG_DIR = BASE_DIR / 'config'
|
||||||
|
DATA_DIR = BASE_DIR / 'data'
|
||||||
|
LOG_DIR = BASE_DIR / 'logs'
|
||||||
|
TOKEN_PATH = BASE_DIR / 'token.json'
|
||||||
|
LOG_DIR.mkdir(exist_ok=True)
|
||||||
|
|
||||||
|
logging.basicConfig(
|
||||||
|
level=logging.INFO,
|
||||||
|
format='%(asctime)s [%(levelname)s] %(message)s',
|
||||||
|
handlers=[
|
||||||
|
logging.FileHandler(LOG_DIR / 'publisher.log', encoding='utf-8'),
|
||||||
|
logging.StreamHandler(),
|
||||||
|
]
|
||||||
|
)
|
||||||
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
TELEGRAM_BOT_TOKEN = os.getenv('TELEGRAM_BOT_TOKEN', '')
|
||||||
|
TELEGRAM_CHAT_ID = os.getenv('TELEGRAM_CHAT_ID', '')
|
||||||
|
BLOG_MAIN_ID = os.getenv('BLOG_MAIN_ID', '')
|
||||||
|
|
||||||
|
SCOPES = [
|
||||||
|
'https://www.googleapis.com/auth/blogger',
|
||||||
|
'https://www.googleapis.com/auth/webmasters',
|
||||||
|
]
|
||||||
|
|
||||||
|
|
||||||
|
def load_config(filename: str) -> dict:
|
||||||
|
with open(CONFIG_DIR / filename, 'r', encoding='utf-8') as f:
|
||||||
|
return json.load(f)
|
||||||
|
|
||||||
|
|
||||||
|
# ─── Google 인증 ─────────────────────────────────────
|
||||||
|
|
||||||
|
def get_google_credentials() -> Credentials:
|
||||||
|
creds = None
|
||||||
|
if TOKEN_PATH.exists():
|
||||||
|
creds = Credentials.from_authorized_user_file(str(TOKEN_PATH), SCOPES)
|
||||||
|
if not creds or not creds.valid:
|
||||||
|
if creds and creds.expired and creds.refresh_token:
|
||||||
|
creds.refresh(Request())
|
||||||
|
with open(TOKEN_PATH, 'w') as f:
|
||||||
|
f.write(creds.to_json())
|
||||||
|
if not creds or not creds.valid:
|
||||||
|
raise RuntimeError("Google 인증 실패. scripts/get_token.py 를 먼저 실행하세요.")
|
||||||
|
return creds
|
||||||
|
|
||||||
|
|
||||||
|
# ─── 안전장치 ─────────────────────────────────────────
|
||||||
|
|
||||||
|
def check_safety(article: dict, safety_cfg: dict) -> tuple[bool, str]:
|
||||||
|
"""
|
||||||
|
수동 검토가 필요한지 판단.
|
||||||
|
Returns: (needs_review, reason)
|
||||||
|
"""
|
||||||
|
corner = article.get('corner', '')
|
||||||
|
body = article.get('body', '')
|
||||||
|
sources = article.get('sources', [])
|
||||||
|
quality_score = article.get('quality_score', 100)
|
||||||
|
|
||||||
|
# 팩트체크 코너는 무조건 수동 검토
|
||||||
|
manual_corners = safety_cfg.get('always_manual_review', ['팩트체크'])
|
||||||
|
if corner in manual_corners:
|
||||||
|
return True, f'코너 "{corner}" 는 항상 수동 검토 필요'
|
||||||
|
|
||||||
|
# 위험 키워드 감지
|
||||||
|
all_keywords = (
|
||||||
|
safety_cfg.get('crypto_keywords', []) +
|
||||||
|
safety_cfg.get('criticism_keywords', []) +
|
||||||
|
safety_cfg.get('investment_keywords', []) +
|
||||||
|
safety_cfg.get('legal_keywords', [])
|
||||||
|
)
|
||||||
|
for kw in all_keywords:
|
||||||
|
if kw in body:
|
||||||
|
return True, f'위험 키워드 감지: "{kw}"'
|
||||||
|
|
||||||
|
# 출처 2개 미만
|
||||||
|
min_sources = safety_cfg.get('min_sources_required', 2)
|
||||||
|
if len(sources) < min_sources:
|
||||||
|
return True, f'출처 {len(sources)}개 — {min_sources}개 이상 필요'
|
||||||
|
|
||||||
|
# 품질 점수 미달
|
||||||
|
min_score = safety_cfg.get('min_quality_score_for_auto', 75)
|
||||||
|
if quality_score < min_score:
|
||||||
|
return True, f'품질 점수 {quality_score}점 (자동 발행 최소: {min_score}점)'
|
||||||
|
|
||||||
|
return False, ''
|
||||||
|
|
||||||
|
|
||||||
|
# ─── HTML 변환 ─────────────────────────────────────────
|
||||||
|
|
||||||
|
def markdown_to_html(md_text: str) -> str:
|
||||||
|
"""마크다운 → HTML 변환 (목차 extension 포함)"""
|
||||||
|
md = markdown.Markdown(
|
||||||
|
extensions=['toc', 'tables', 'fenced_code', 'attr_list'],
|
||||||
|
extension_configs={
|
||||||
|
'toc': {
|
||||||
|
'title': '목차',
|
||||||
|
'toc_depth': '2-3',
|
||||||
|
}
|
||||||
|
}
|
||||||
|
)
|
||||||
|
html = md.convert(md_text)
|
||||||
|
toc = md.toc # 목차 HTML
|
||||||
|
return html, toc
|
||||||
|
|
||||||
|
|
||||||
|
def insert_adsense_placeholders(html: str) -> str:
|
||||||
|
"""두 번째 H2 뒤와 결론 섹션 앞에 AdSense 플레이스홀더 삽입"""
|
||||||
|
AD_SLOT_1 = '\n<!-- AD_SLOT_1 -->\n'
|
||||||
|
AD_SLOT_2 = '\n<!-- AD_SLOT_2 -->\n'
|
||||||
|
|
||||||
|
soup = BeautifulSoup(html, 'lxml')
|
||||||
|
h2_tags = soup.find_all('h2')
|
||||||
|
|
||||||
|
# 두 번째 H2 뒤에 AD_SLOT_1 삽입
|
||||||
|
if len(h2_tags) >= 2:
|
||||||
|
second_h2 = h2_tags[1]
|
||||||
|
ad_tag = BeautifulSoup(AD_SLOT_1, 'html.parser')
|
||||||
|
second_h2.insert_after(ad_tag)
|
||||||
|
|
||||||
|
# 결론 H2 앞에 AD_SLOT_2 삽입
|
||||||
|
for h2 in soup.find_all('h2'):
|
||||||
|
if any(kw in h2.get_text() for kw in ['결론', '마무리', '정리', '요약', 'conclusion']):
|
||||||
|
ad_tag2 = BeautifulSoup(AD_SLOT_2, 'html.parser')
|
||||||
|
h2.insert_before(ad_tag2)
|
||||||
|
break
|
||||||
|
|
||||||
|
return str(soup)
|
||||||
|
|
||||||
|
|
||||||
|
def build_json_ld(article: dict, blog_url: str = '') -> str:
|
||||||
|
"""Schema.org Article JSON-LD 생성"""
|
||||||
|
schema = {
|
||||||
|
"@context": "https://schema.org",
|
||||||
|
"@type": "Article",
|
||||||
|
"headline": article.get('title', ''),
|
||||||
|
"description": article.get('meta', ''),
|
||||||
|
"datePublished": datetime.now(timezone.utc).isoformat(),
|
||||||
|
"dateModified": datetime.now(timezone.utc).isoformat(),
|
||||||
|
"author": {
|
||||||
|
"@type": "Person",
|
||||||
|
"name": "테크인사이더"
|
||||||
|
},
|
||||||
|
"publisher": {
|
||||||
|
"@type": "Organization",
|
||||||
|
"name": "테크인사이더",
|
||||||
|
"logo": {
|
||||||
|
"@type": "ImageObject",
|
||||||
|
"url": ""
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"mainEntityOfPage": {
|
||||||
|
"@type": "WebPage",
|
||||||
|
"@id": blog_url
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return f'<script type="application/ld+json">\n{json.dumps(schema, ensure_ascii=False, indent=2)}\n</script>'
|
||||||
|
|
||||||
|
|
||||||
|
def build_full_html(article: dict, body_html: str, toc_html: str) -> str:
|
||||||
|
"""최종 HTML 조합: JSON-LD + 목차 + 본문 + 면책 문구"""
|
||||||
|
json_ld = build_json_ld(article)
|
||||||
|
disclaimer = article.get('disclaimer', '')
|
||||||
|
|
||||||
|
html_parts = [json_ld]
|
||||||
|
if toc_html:
|
||||||
|
html_parts.append(f'<div class="toc-wrapper">{toc_html}</div>')
|
||||||
|
html_parts.append(body_html)
|
||||||
|
if disclaimer:
|
||||||
|
html_parts.append(f'<hr/><p class="disclaimer"><small>{disclaimer}</small></p>')
|
||||||
|
|
||||||
|
return '\n'.join(html_parts)
|
||||||
|
|
||||||
|
|
||||||
|
# ─── Blogger API ──────────────────────────────────────
|
||||||
|
|
||||||
|
def publish_to_blogger(article: dict, html_content: str, creds: Credentials) -> dict:
|
||||||
|
"""Blogger API v3로 글 발행"""
|
||||||
|
service = build('blogger', 'v3', credentials=creds)
|
||||||
|
blog_id = BLOG_MAIN_ID
|
||||||
|
|
||||||
|
labels = [article.get('corner', '')]
|
||||||
|
tags = article.get('tags', [])
|
||||||
|
if isinstance(tags, str):
|
||||||
|
tags = [t.strip() for t in tags.split(',')]
|
||||||
|
labels.extend(tags)
|
||||||
|
labels = list(set(filter(None, labels)))
|
||||||
|
|
||||||
|
body = {
|
||||||
|
'title': article.get('title', ''),
|
||||||
|
'content': html_content,
|
||||||
|
'labels': labels,
|
||||||
|
}
|
||||||
|
|
||||||
|
result = service.posts().insert(
|
||||||
|
blogId=blog_id,
|
||||||
|
body=body,
|
||||||
|
isDraft=False,
|
||||||
|
).execute()
|
||||||
|
|
||||||
|
return result
|
||||||
|
|
||||||
|
|
||||||
|
def submit_to_search_console(url: str, creds: Credentials):
|
||||||
|
"""Google Search Console URL 색인 요청"""
|
||||||
|
try:
|
||||||
|
service = build('searchconsole', 'v1', credentials=creds)
|
||||||
|
# URL Inspection API (실제 indexing 요청)
|
||||||
|
# 참고: 일반적으로 Blogger sitemap이 자동 제출되므로 보조 수단
|
||||||
|
logger.info(f"Search Console 제출: {url}")
|
||||||
|
# indexing API는 별도 서비스 계정 필요. 여기서는 로그만 남김.
|
||||||
|
# 실제 색인 촉진은 Blogger 내장 sitemap에 의존
|
||||||
|
except Exception as e:
|
||||||
|
logger.warning(f"Search Console 제출 실패: {e}")
|
||||||
|
|
||||||
|
|
||||||
|
# ─── Telegram ────────────────────────────────────────
|
||||||
|
|
||||||
|
def send_telegram(text: str, parse_mode: str = 'HTML'):
|
||||||
|
"""Telegram 메시지 전송"""
|
||||||
|
if not TELEGRAM_BOT_TOKEN or not TELEGRAM_CHAT_ID:
|
||||||
|
logger.warning("Telegram 설정 없음 — 알림 건너뜀")
|
||||||
|
return
|
||||||
|
url = f'https://api.telegram.org/bot{TELEGRAM_BOT_TOKEN}/sendMessage'
|
||||||
|
payload = {
|
||||||
|
'chat_id': TELEGRAM_CHAT_ID,
|
||||||
|
'text': text,
|
||||||
|
'parse_mode': parse_mode,
|
||||||
|
}
|
||||||
|
try:
|
||||||
|
resp = requests.post(url, json=payload, timeout=10)
|
||||||
|
resp.raise_for_status()
|
||||||
|
except Exception as e:
|
||||||
|
logger.error(f"Telegram 전송 실패: {e}")
|
||||||
|
|
||||||
|
|
||||||
|
def send_pending_review_alert(article: dict, reason: str):
|
||||||
|
"""수동 검토 대기 알림 (Telegram)"""
|
||||||
|
title = article.get('title', '(제목 없음)')
|
||||||
|
corner = article.get('corner', '')
|
||||||
|
preview = article.get('body', '')[:300].replace('<', '<').replace('>', '>')
|
||||||
|
msg = (
|
||||||
|
f"🔍 <b>[수동 검토 필요]</b>\n\n"
|
||||||
|
f"📌 <b>{title}</b>\n"
|
||||||
|
f"코너: {corner}\n"
|
||||||
|
f"사유: {reason}\n\n"
|
||||||
|
f"미리보기:\n{preview}...\n\n"
|
||||||
|
f"명령: <code>승인</code> 또는 <code>거부</code>"
|
||||||
|
)
|
||||||
|
send_telegram(msg)
|
||||||
|
|
||||||
|
|
||||||
|
# ─── 발행 이력 ───────────────────────────────────────
|
||||||
|
|
||||||
|
def log_published(article: dict, post_result: dict):
|
||||||
|
"""발행 이력 저장"""
|
||||||
|
published_dir = DATA_DIR / 'published'
|
||||||
|
published_dir.mkdir(exist_ok=True)
|
||||||
|
record = {
|
||||||
|
'title': article.get('title', ''),
|
||||||
|
'corner': article.get('corner', ''),
|
||||||
|
'url': post_result.get('url', ''),
|
||||||
|
'post_id': post_result.get('id', ''),
|
||||||
|
'published_at': datetime.now(timezone.utc).isoformat(),
|
||||||
|
'quality_score': article.get('quality_score', 0),
|
||||||
|
'tags': article.get('tags', []),
|
||||||
|
'sources': article.get('sources', []),
|
||||||
|
}
|
||||||
|
filename = f"{datetime.now().strftime('%Y%m%d_%H%M%S')}_{record['post_id']}.json"
|
||||||
|
with open(published_dir / filename, 'w', encoding='utf-8') as f:
|
||||||
|
json.dump(record, f, ensure_ascii=False, indent=2)
|
||||||
|
return record
|
||||||
|
|
||||||
|
|
||||||
|
def save_pending_review(article: dict, reason: str):
|
||||||
|
"""수동 검토 대기 글 저장"""
|
||||||
|
pending_dir = DATA_DIR / 'pending_review'
|
||||||
|
pending_dir.mkdir(exist_ok=True)
|
||||||
|
record = {**article, 'pending_reason': reason, 'created_at': datetime.now().isoformat()}
|
||||||
|
filename = f"{datetime.now().strftime('%Y%m%d_%H%M%S')}_pending.json"
|
||||||
|
with open(pending_dir / filename, 'w', encoding='utf-8') as f:
|
||||||
|
json.dump(record, f, ensure_ascii=False, indent=2)
|
||||||
|
return pending_dir / filename
|
||||||
|
|
||||||
|
|
||||||
|
def load_pending_review_file(filepath: str) -> dict:
|
||||||
|
with open(filepath, 'r', encoding='utf-8') as f:
|
||||||
|
return json.load(f)
|
||||||
|
|
||||||
|
|
||||||
|
# ─── 메인 발행 함수 ──────────────────────────────────
|
||||||
|
|
||||||
|
def publish(article: dict) -> bool:
|
||||||
|
"""
|
||||||
|
article: OpenClaw blog-writer가 출력한 파싱된 글 dict
|
||||||
|
{
|
||||||
|
title, meta, slug, tags, corner, body (markdown),
|
||||||
|
coupang_keywords, sources, disclaimer, quality_score
|
||||||
|
}
|
||||||
|
Returns: True(발행 성공) / False(수동 검토 대기)
|
||||||
|
"""
|
||||||
|
logger.info(f"발행 시도: {article.get('title', '')}")
|
||||||
|
safety_cfg = load_config('safety_keywords.json')
|
||||||
|
|
||||||
|
# 안전장치 검사
|
||||||
|
needs_review, review_reason = check_safety(article, safety_cfg)
|
||||||
|
if needs_review:
|
||||||
|
logger.warning(f"수동 검토 대기: {review_reason}")
|
||||||
|
save_pending_review(article, review_reason)
|
||||||
|
send_pending_review_alert(article, review_reason)
|
||||||
|
return False
|
||||||
|
|
||||||
|
# 마크다운 → HTML
|
||||||
|
body_html, toc_html = markdown_to_html(article.get('body', ''))
|
||||||
|
|
||||||
|
# AdSense 플레이스홀더
|
||||||
|
body_html = insert_adsense_placeholders(body_html)
|
||||||
|
|
||||||
|
# 최종 HTML 조합
|
||||||
|
full_html = build_full_html(article, body_html, toc_html)
|
||||||
|
|
||||||
|
# Google 인증
|
||||||
|
try:
|
||||||
|
creds = get_google_credentials()
|
||||||
|
except RuntimeError as e:
|
||||||
|
logger.error(str(e))
|
||||||
|
return False
|
||||||
|
|
||||||
|
# Blogger 발행
|
||||||
|
try:
|
||||||
|
post_result = publish_to_blogger(article, full_html, creds)
|
||||||
|
post_url = post_result.get('url', '')
|
||||||
|
logger.info(f"발행 완료: {post_url}")
|
||||||
|
except Exception as e:
|
||||||
|
logger.error(f"Blogger 발행 실패: {e}")
|
||||||
|
return False
|
||||||
|
|
||||||
|
# Search Console 제출
|
||||||
|
if post_url:
|
||||||
|
submit_to_search_console(post_url, creds)
|
||||||
|
|
||||||
|
# 발행 이력 저장
|
||||||
|
log_published(article, post_result)
|
||||||
|
|
||||||
|
# Telegram 알림
|
||||||
|
title = article.get('title', '')
|
||||||
|
corner = article.get('corner', '')
|
||||||
|
send_telegram(
|
||||||
|
f"✅ <b>발행 완료!</b>\n\n"
|
||||||
|
f"📌 <b>{title}</b>\n"
|
||||||
|
f"코너: {corner}\n"
|
||||||
|
f"URL: {post_url}"
|
||||||
|
)
|
||||||
|
|
||||||
|
return True
|
||||||
|
|
||||||
|
|
||||||
|
def approve_pending(filepath: str) -> bool:
|
||||||
|
"""수동 검토 대기 글 승인 후 발행"""
|
||||||
|
try:
|
||||||
|
article = load_pending_review_file(filepath)
|
||||||
|
article.pop('pending_reason', None)
|
||||||
|
article.pop('created_at', None)
|
||||||
|
|
||||||
|
# 안전장치 우회하여 강제 발행
|
||||||
|
body_html, toc_html = markdown_to_html(article.get('body', ''))
|
||||||
|
body_html = insert_adsense_placeholders(body_html)
|
||||||
|
full_html = build_full_html(article, body_html, toc_html)
|
||||||
|
|
||||||
|
creds = get_google_credentials()
|
||||||
|
post_result = publish_to_blogger(article, full_html, creds)
|
||||||
|
post_url = post_result.get('url', '')
|
||||||
|
log_published(article, post_result)
|
||||||
|
|
||||||
|
# 대기 파일 삭제
|
||||||
|
Path(filepath).unlink(missing_ok=True)
|
||||||
|
|
||||||
|
send_telegram(
|
||||||
|
f"✅ <b>[수동 승인] 발행 완료!</b>\n\n"
|
||||||
|
f"📌 {article.get('title', '')}\n"
|
||||||
|
f"URL: {post_url}"
|
||||||
|
)
|
||||||
|
logger.info(f"수동 승인 발행 완료: {post_url}")
|
||||||
|
return True
|
||||||
|
except Exception as e:
|
||||||
|
logger.error(f"승인 발행 실패: {e}")
|
||||||
|
return False
|
||||||
|
|
||||||
|
|
||||||
|
def reject_pending(filepath: str):
|
||||||
|
"""수동 검토 대기 글 거부 (파일 삭제)"""
|
||||||
|
try:
|
||||||
|
article = load_pending_review_file(filepath)
|
||||||
|
Path(filepath).unlink(missing_ok=True)
|
||||||
|
send_telegram(f"🗑 <b>[거부]</b> {article.get('title', '')} — 폐기됨")
|
||||||
|
logger.info(f"수동 검토 거부: {filepath}")
|
||||||
|
except Exception as e:
|
||||||
|
logger.error(f"거부 처리 실패: {e}")
|
||||||
|
|
||||||
|
|
||||||
|
def get_pending_list() -> list[dict]:
|
||||||
|
"""수동 검토 대기 목록 반환"""
|
||||||
|
pending_dir = DATA_DIR / 'pending_review'
|
||||||
|
pending_dir.mkdir(exist_ok=True)
|
||||||
|
result = []
|
||||||
|
for f in sorted(pending_dir.glob('*_pending.json')):
|
||||||
|
try:
|
||||||
|
data = json.loads(f.read_text(encoding='utf-8'))
|
||||||
|
data['_filepath'] = str(f)
|
||||||
|
result.append(data)
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
|
return result
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == '__main__':
|
||||||
|
# 테스트용: 샘플 아티클 발행 시도
|
||||||
|
sample = {
|
||||||
|
'title': '테스트 글',
|
||||||
|
'meta': '테스트 메타 설명',
|
||||||
|
'slug': 'test-article',
|
||||||
|
'tags': ['테스트', 'AI'],
|
||||||
|
'corner': '쉬운세상',
|
||||||
|
'body': '## 제목\n\n본문 내용입니다.\n\n## 결론\n\n마무리입니다.',
|
||||||
|
'coupang_keywords': ['키보드'],
|
||||||
|
'sources': [
|
||||||
|
{'url': 'https://example.com/1', 'title': '출처1', 'date': '2026-03-24'},
|
||||||
|
{'url': 'https://example.com/2', 'title': '출처2', 'date': '2026-03-24'},
|
||||||
|
],
|
||||||
|
'disclaimer': '',
|
||||||
|
'quality_score': 80,
|
||||||
|
}
|
||||||
|
result = publish(sample)
|
||||||
|
print('발행 결과:', result)
|
||||||
559
bots/scheduler.py
Normal file
559
bots/scheduler.py
Normal file
@@ -0,0 +1,559 @@
|
|||||||
|
"""
|
||||||
|
스케줄러 (scheduler.py)
|
||||||
|
역할: 모든 봇의 실행 시간 관리 + Telegram 수동 명령 리스너
|
||||||
|
라이브러리: APScheduler + python-telegram-bot
|
||||||
|
"""
|
||||||
|
import asyncio
|
||||||
|
import json
|
||||||
|
import logging
|
||||||
|
import os
|
||||||
|
import sys
|
||||||
|
from datetime import datetime
|
||||||
|
from logging.handlers import RotatingFileHandler
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
from apscheduler.schedulers.asyncio import AsyncIOScheduler
|
||||||
|
from dotenv import load_dotenv
|
||||||
|
from telegram import Update
|
||||||
|
from telegram.ext import Application, CommandHandler, MessageHandler, filters, ContextTypes
|
||||||
|
|
||||||
|
load_dotenv()
|
||||||
|
|
||||||
|
BASE_DIR = Path(__file__).parent.parent
|
||||||
|
CONFIG_DIR = BASE_DIR / 'config'
|
||||||
|
DATA_DIR = BASE_DIR / 'data'
|
||||||
|
LOG_DIR = BASE_DIR / 'logs'
|
||||||
|
LOG_DIR.mkdir(exist_ok=True)
|
||||||
|
|
||||||
|
log_handler = RotatingFileHandler(
|
||||||
|
LOG_DIR / 'scheduler.log',
|
||||||
|
maxBytes=5 * 1024 * 1024,
|
||||||
|
backupCount=3,
|
||||||
|
encoding='utf-8',
|
||||||
|
)
|
||||||
|
logging.basicConfig(
|
||||||
|
level=logging.INFO,
|
||||||
|
format='%(asctime)s [%(levelname)s] %(message)s',
|
||||||
|
handlers=[log_handler, logging.StreamHandler()],
|
||||||
|
)
|
||||||
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
TELEGRAM_BOT_TOKEN = os.getenv('TELEGRAM_BOT_TOKEN', '')
|
||||||
|
TELEGRAM_CHAT_ID = os.getenv('TELEGRAM_CHAT_ID', '')
|
||||||
|
IMAGE_MODE = os.getenv('IMAGE_MODE', 'manual').lower()
|
||||||
|
# request 모드에서 이미지 대기 시 사용하는 상태 변수
|
||||||
|
# {chat_id: prompt_id} — 다음에 받은 이미지를 어느 프롬프트에 연결할지 기억
|
||||||
|
_awaiting_image: dict[int, str] = {}
|
||||||
|
|
||||||
|
_publish_enabled = True
|
||||||
|
|
||||||
|
|
||||||
|
def load_schedule() -> dict:
|
||||||
|
with open(CONFIG_DIR / 'schedule.json', 'r', encoding='utf-8') as f:
|
||||||
|
return json.load(f)
|
||||||
|
|
||||||
|
|
||||||
|
# ─── 스케줄 작업 ──────────────────────────────────────
|
||||||
|
|
||||||
|
def job_collector():
|
||||||
|
logger.info("[스케줄] 수집봇 시작")
|
||||||
|
try:
|
||||||
|
sys.path.insert(0, str(BASE_DIR / 'bots'))
|
||||||
|
import collector_bot
|
||||||
|
collector_bot.run()
|
||||||
|
except Exception as e:
|
||||||
|
logger.error(f"수집봇 오류: {e}")
|
||||||
|
|
||||||
|
|
||||||
|
def job_ai_writer():
|
||||||
|
logger.info("[스케줄] AI 글 작성 트리거")
|
||||||
|
if not _publish_enabled:
|
||||||
|
logger.info("발행 중단 상태 — 건너뜀")
|
||||||
|
return
|
||||||
|
try:
|
||||||
|
_trigger_openclaw_writer()
|
||||||
|
except Exception as e:
|
||||||
|
logger.error(f"AI 글 작성 트리거 오류: {e}")
|
||||||
|
|
||||||
|
|
||||||
|
def _trigger_openclaw_writer():
|
||||||
|
topics_dir = DATA_DIR / 'topics'
|
||||||
|
drafts_dir = DATA_DIR / 'drafts'
|
||||||
|
drafts_dir.mkdir(exist_ok=True)
|
||||||
|
today = datetime.now().strftime('%Y%m%d')
|
||||||
|
topic_files = sorted(topics_dir.glob(f'{today}_*.json'))
|
||||||
|
if not topic_files:
|
||||||
|
logger.info("오늘 처리할 글감 없음")
|
||||||
|
return
|
||||||
|
for topic_file in topic_files[:3]:
|
||||||
|
draft_check = drafts_dir / topic_file.name
|
||||||
|
if draft_check.exists():
|
||||||
|
continue
|
||||||
|
topic_data = json.loads(topic_file.read_text(encoding='utf-8'))
|
||||||
|
logger.info(f"글 작성 요청: {topic_data.get('topic', '')}")
|
||||||
|
_call_openclaw(topic_data, draft_check)
|
||||||
|
|
||||||
|
|
||||||
|
def _call_openclaw(topic_data: dict, output_path: Path):
|
||||||
|
logger.info(f"OpenClaw 호출 (플레이스홀더): {topic_data.get('topic', '')}")
|
||||||
|
# OpenClaw 연동 완료 후 아래 주석 해제:
|
||||||
|
# import subprocess
|
||||||
|
# result = subprocess.run(
|
||||||
|
# ['openclaw', 'run', 'blog-writer', '--input', json.dumps(topic_data)],
|
||||||
|
# capture_output=True, text=True
|
||||||
|
# )
|
||||||
|
# output = result.stdout
|
||||||
|
topic_data['_pending_openclaw'] = True
|
||||||
|
output_path.write_text(json.dumps(topic_data, ensure_ascii=False, indent=2), encoding='utf-8')
|
||||||
|
|
||||||
|
|
||||||
|
def job_publish(slot: int):
|
||||||
|
if not _publish_enabled:
|
||||||
|
logger.info(f"[스케줄] 발행 중단 — 슬롯 {slot} 건너뜀")
|
||||||
|
return
|
||||||
|
logger.info(f"[스케줄] 발행봇 (슬롯 {slot})")
|
||||||
|
try:
|
||||||
|
_publish_next()
|
||||||
|
except Exception as e:
|
||||||
|
logger.error(f"발행봇 오류: {e}")
|
||||||
|
|
||||||
|
|
||||||
|
def _publish_next():
|
||||||
|
drafts_dir = DATA_DIR / 'drafts'
|
||||||
|
drafts_dir.mkdir(exist_ok=True)
|
||||||
|
for draft_file in sorted(drafts_dir.glob('*.json')):
|
||||||
|
try:
|
||||||
|
article = json.loads(draft_file.read_text(encoding='utf-8'))
|
||||||
|
if article.get('_pending_openclaw'):
|
||||||
|
continue
|
||||||
|
sys.path.insert(0, str(BASE_DIR / 'bots'))
|
||||||
|
import publisher_bot
|
||||||
|
import linker_bot
|
||||||
|
import markdown as md_lib
|
||||||
|
body_html = md_lib.markdown(
|
||||||
|
article.get('body', ''), extensions=['toc', 'tables', 'fenced_code']
|
||||||
|
)
|
||||||
|
body_html = linker_bot.process(article, body_html)
|
||||||
|
article['body'] = body_html
|
||||||
|
article['_body_is_html'] = True
|
||||||
|
publisher_bot.publish(article)
|
||||||
|
draft_file.unlink(missing_ok=True)
|
||||||
|
break
|
||||||
|
except Exception as e:
|
||||||
|
logger.error(f"드래프트 처리 오류 ({draft_file.name}): {e}")
|
||||||
|
|
||||||
|
|
||||||
|
def job_analytics_daily():
|
||||||
|
logger.info("[스케줄] 분석봇 일일 리포트")
|
||||||
|
try:
|
||||||
|
sys.path.insert(0, str(BASE_DIR / 'bots'))
|
||||||
|
import analytics_bot
|
||||||
|
analytics_bot.daily_report()
|
||||||
|
except Exception as e:
|
||||||
|
logger.error(f"분석봇 오류: {e}")
|
||||||
|
|
||||||
|
|
||||||
|
def job_analytics_weekly():
|
||||||
|
logger.info("[스케줄] 분석봇 주간 리포트")
|
||||||
|
try:
|
||||||
|
sys.path.insert(0, str(BASE_DIR / 'bots'))
|
||||||
|
import analytics_bot
|
||||||
|
analytics_bot.weekly_report()
|
||||||
|
except Exception as e:
|
||||||
|
logger.error(f"분석봇 주간 리포트 오류: {e}")
|
||||||
|
|
||||||
|
|
||||||
|
def job_image_prompt_batch():
|
||||||
|
"""request 모드 전용 — 매주 월요일 10:00 프롬프트 배치 전송"""
|
||||||
|
if IMAGE_MODE != 'request':
|
||||||
|
return
|
||||||
|
logger.info("[스케줄] 이미지 프롬프트 배치 전송")
|
||||||
|
try:
|
||||||
|
sys.path.insert(0, str(BASE_DIR / 'bots'))
|
||||||
|
import image_bot
|
||||||
|
image_bot.send_prompt_batch()
|
||||||
|
except Exception as e:
|
||||||
|
logger.error(f"이미지 배치 오류: {e}")
|
||||||
|
|
||||||
|
|
||||||
|
# ─── Telegram 명령 핸들러 ────────────────────────────
|
||||||
|
|
||||||
|
async def cmd_status(update: Update, context: ContextTypes.DEFAULT_TYPE):
|
||||||
|
status = "🟢 발행 활성" if _publish_enabled else "🔴 발행 중단"
|
||||||
|
mode_label = {'manual': '수동', 'request': '요청', 'auto': '자동'}.get(IMAGE_MODE, IMAGE_MODE)
|
||||||
|
await update.message.reply_text(
|
||||||
|
f"블로그 엔진 상태: {status}\n이미지 모드: {mode_label} ({IMAGE_MODE})"
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
async def cmd_stop_publish(update: Update, context: ContextTypes.DEFAULT_TYPE):
|
||||||
|
global _publish_enabled
|
||||||
|
_publish_enabled = False
|
||||||
|
await update.message.reply_text("🔴 발행이 중단되었습니다.")
|
||||||
|
|
||||||
|
|
||||||
|
async def cmd_resume_publish(update: Update, context: ContextTypes.DEFAULT_TYPE):
|
||||||
|
global _publish_enabled
|
||||||
|
_publish_enabled = True
|
||||||
|
await update.message.reply_text("🟢 발행이 재개되었습니다.")
|
||||||
|
|
||||||
|
|
||||||
|
async def cmd_show_topics(update: Update, context: ContextTypes.DEFAULT_TYPE):
|
||||||
|
topics_dir = DATA_DIR / 'topics'
|
||||||
|
today = datetime.now().strftime('%Y%m%d')
|
||||||
|
files = sorted(topics_dir.glob(f'{today}_*.json'))
|
||||||
|
if not files:
|
||||||
|
await update.message.reply_text("오늘 수집된 글감이 없습니다.")
|
||||||
|
return
|
||||||
|
lines = [f"📋 오늘 수집된 글감 ({len(files)}개):"]
|
||||||
|
for f in files[:10]:
|
||||||
|
try:
|
||||||
|
data = json.loads(f.read_text(encoding='utf-8'))
|
||||||
|
lines.append(f" [{data.get('quality_score',0)}점][{data.get('corner','')}] {data.get('topic','')[:50]}")
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
|
await update.message.reply_text('\n'.join(lines))
|
||||||
|
|
||||||
|
|
||||||
|
async def cmd_pending(update: Update, context: ContextTypes.DEFAULT_TYPE):
|
||||||
|
sys.path.insert(0, str(BASE_DIR / 'bots'))
|
||||||
|
import publisher_bot
|
||||||
|
pending = publisher_bot.get_pending_list()
|
||||||
|
if not pending:
|
||||||
|
await update.message.reply_text("수동 검토 대기 글이 없습니다.")
|
||||||
|
return
|
||||||
|
lines = [f"🔍 수동 검토 대기 ({len(pending)}개):"]
|
||||||
|
for i, item in enumerate(pending[:5], 1):
|
||||||
|
lines.append(f" {i}. [{item.get('corner','')}] {item.get('title','')[:50]}")
|
||||||
|
lines.append(f" 사유: {item.get('pending_reason','')}")
|
||||||
|
lines.append("\n/approve [번호] /reject [번호]")
|
||||||
|
await update.message.reply_text('\n'.join(lines))
|
||||||
|
|
||||||
|
|
||||||
|
async def cmd_approve(update: Update, context: ContextTypes.DEFAULT_TYPE):
|
||||||
|
sys.path.insert(0, str(BASE_DIR / 'bots'))
|
||||||
|
import publisher_bot
|
||||||
|
pending = publisher_bot.get_pending_list()
|
||||||
|
if not pending:
|
||||||
|
await update.message.reply_text("대기 글이 없습니다.")
|
||||||
|
return
|
||||||
|
args = context.args
|
||||||
|
idx = int(args[0]) - 1 if args and args[0].isdigit() else 0
|
||||||
|
if not (0 <= idx < len(pending)):
|
||||||
|
await update.message.reply_text("잘못된 번호입니다.")
|
||||||
|
return
|
||||||
|
success = publisher_bot.approve_pending(pending[idx].get('_filepath', ''))
|
||||||
|
await update.message.reply_text(
|
||||||
|
f"✅ 승인 완료: {pending[idx].get('title','')}" if success else "❌ 발행 실패. 로그 확인."
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
async def cmd_reject(update: Update, context: ContextTypes.DEFAULT_TYPE):
|
||||||
|
sys.path.insert(0, str(BASE_DIR / 'bots'))
|
||||||
|
import publisher_bot
|
||||||
|
pending = publisher_bot.get_pending_list()
|
||||||
|
if not pending:
|
||||||
|
await update.message.reply_text("대기 글이 없습니다.")
|
||||||
|
return
|
||||||
|
args = context.args
|
||||||
|
idx = int(args[0]) - 1 if args and args[0].isdigit() else 0
|
||||||
|
if not (0 <= idx < len(pending)):
|
||||||
|
await update.message.reply_text("잘못된 번호입니다.")
|
||||||
|
return
|
||||||
|
publisher_bot.reject_pending(pending[idx].get('_filepath', ''))
|
||||||
|
await update.message.reply_text(f"🗑 거부 완료: {pending[idx].get('title','')}")
|
||||||
|
|
||||||
|
|
||||||
|
async def cmd_report(update: Update, context: ContextTypes.DEFAULT_TYPE):
|
||||||
|
await update.message.reply_text("주간 리포트 생성 중...")
|
||||||
|
sys.path.insert(0, str(BASE_DIR / 'bots'))
|
||||||
|
import analytics_bot
|
||||||
|
analytics_bot.weekly_report()
|
||||||
|
|
||||||
|
|
||||||
|
# ─── 이미지 관련 명령 (request 모드) ────────────────
|
||||||
|
|
||||||
|
async def cmd_images(update: Update, context: ContextTypes.DEFAULT_TYPE):
|
||||||
|
"""대기 중인 이미지 프롬프트 목록 표시"""
|
||||||
|
sys.path.insert(0, str(BASE_DIR / 'bots'))
|
||||||
|
import image_bot
|
||||||
|
pending = image_bot.get_pending_prompts('pending')
|
||||||
|
selected = image_bot.get_pending_prompts('selected')
|
||||||
|
done = image_bot.get_pending_prompts('done')
|
||||||
|
|
||||||
|
if not pending and not selected:
|
||||||
|
await update.message.reply_text(
|
||||||
|
f"🎨 대기 중인 이미지 요청이 없습니다.\n"
|
||||||
|
f"완료된 이미지: {len(done)}개\n\n"
|
||||||
|
f"/imgbatch — 지금 바로 배치 전송 요청"
|
||||||
|
)
|
||||||
|
return
|
||||||
|
|
||||||
|
lines = [f"🎨 이미지 제작 현황\n"]
|
||||||
|
if pending:
|
||||||
|
lines.append(f"⏳ 대기 ({len(pending)}건):")
|
||||||
|
for p in pending:
|
||||||
|
lines.append(f" #{p['id']} {p['topic'][:40]}")
|
||||||
|
if selected:
|
||||||
|
lines.append(f"\n🔄 진행 중 ({len(selected)}건):")
|
||||||
|
for p in selected:
|
||||||
|
lines.append(f" #{p['id']} {p['topic'][:40]}")
|
||||||
|
lines.append(f"\n✅ 완료: {len(done)}건")
|
||||||
|
lines.append(
|
||||||
|
f"\n/imgpick [번호] — 프롬프트 받기\n"
|
||||||
|
f"/imgbatch — 전체 목록 재전송"
|
||||||
|
)
|
||||||
|
await update.message.reply_text('\n'.join(lines))
|
||||||
|
|
||||||
|
|
||||||
|
async def cmd_imgpick(update: Update, context: ContextTypes.DEFAULT_TYPE):
|
||||||
|
"""특정 번호 프롬프트 선택 → 전체 프롬프트 전송 + 이미지 대기 상태 진입"""
|
||||||
|
sys.path.insert(0, str(BASE_DIR / 'bots'))
|
||||||
|
import image_bot
|
||||||
|
|
||||||
|
args = context.args
|
||||||
|
if not args or not args[0].isdigit():
|
||||||
|
await update.message.reply_text("사용법: /imgpick [번호]\n예) /imgpick 3")
|
||||||
|
return
|
||||||
|
|
||||||
|
prompt_id = args[0]
|
||||||
|
prompt = image_bot.get_prompt_by_id(prompt_id)
|
||||||
|
if not prompt:
|
||||||
|
await update.message.reply_text(f"#{prompt_id} 번 프롬프트를 찾을 수 없습니다.\n/images 로 목록 확인")
|
||||||
|
return
|
||||||
|
|
||||||
|
if prompt['status'] == 'done':
|
||||||
|
await update.message.reply_text(f"#{prompt_id} 는 이미 완료된 항목입니다.")
|
||||||
|
return
|
||||||
|
|
||||||
|
# 단일 프롬프트 전송 (Telegram 메시지 길이 제한 고려해 분리 전송)
|
||||||
|
image_bot.send_single_prompt(prompt_id)
|
||||||
|
|
||||||
|
# 이미지 대기 상태 등록
|
||||||
|
chat_id = update.message.chat_id
|
||||||
|
_awaiting_image[chat_id] = prompt_id
|
||||||
|
logger.info(f"이미지 대기 등록: chat={chat_id}, prompt=#{prompt_id}")
|
||||||
|
|
||||||
|
|
||||||
|
async def cmd_imgbatch(update: Update, context: ContextTypes.DEFAULT_TYPE):
|
||||||
|
"""전체 대기 프롬프트 배치 전송 (수동 트리거)"""
|
||||||
|
sys.path.insert(0, str(BASE_DIR / 'bots'))
|
||||||
|
import image_bot
|
||||||
|
image_bot.send_prompt_batch()
|
||||||
|
await update.message.reply_text("📤 프롬프트 배치 전송 완료.")
|
||||||
|
|
||||||
|
|
||||||
|
async def cmd_imgcancel(update: Update, context: ContextTypes.DEFAULT_TYPE):
|
||||||
|
"""이미지 대기 상태 취소"""
|
||||||
|
chat_id = update.message.chat_id
|
||||||
|
if chat_id in _awaiting_image:
|
||||||
|
pid = _awaiting_image.pop(chat_id)
|
||||||
|
await update.message.reply_text(f"❌ #{pid} 이미지 대기 취소.")
|
||||||
|
else:
|
||||||
|
await update.message.reply_text("현재 대기 중인 이미지 요청이 없습니다.")
|
||||||
|
|
||||||
|
|
||||||
|
# ─── 이미지/파일 수신 핸들러 ─────────────────────────
|
||||||
|
|
||||||
|
async def _receive_image(update: Update, context: ContextTypes.DEFAULT_TYPE,
|
||||||
|
file_getter, caption: str):
|
||||||
|
"""공통 이미지 수신 처리 (photo / document)"""
|
||||||
|
sys.path.insert(0, str(BASE_DIR / 'bots'))
|
||||||
|
import image_bot
|
||||||
|
|
||||||
|
chat_id = update.message.chat_id
|
||||||
|
|
||||||
|
# 프롬프트 ID 결정: 대기 상태 > 캡션 파싱 > 없음
|
||||||
|
prompt_id = _awaiting_image.get(chat_id)
|
||||||
|
if not prompt_id and caption:
|
||||||
|
# 캡션에 #번호 형식이 있으면 추출
|
||||||
|
m = __import__('re').search(r'#(\d+)', caption)
|
||||||
|
if m:
|
||||||
|
prompt_id = m.group(1)
|
||||||
|
|
||||||
|
if not prompt_id:
|
||||||
|
await update.message.reply_text(
|
||||||
|
"⚠ 어느 주제의 이미지인지 알 수 없습니다.\n\n"
|
||||||
|
"방법 1: /imgpick [번호] 로 먼저 선택 후 이미지 전송\n"
|
||||||
|
"방법 2: 이미지 캡션에 #번호 입력 (예: #3)\n\n"
|
||||||
|
"/images — 현재 대기 목록 확인"
|
||||||
|
)
|
||||||
|
return
|
||||||
|
|
||||||
|
# Telegram에서 파일 다운로드
|
||||||
|
try:
|
||||||
|
tg_file = await file_getter()
|
||||||
|
file_bytes = (await tg_file.download_as_bytearray())
|
||||||
|
except Exception as e:
|
||||||
|
await update.message.reply_text(f"❌ 파일 다운로드 실패: {e}")
|
||||||
|
return
|
||||||
|
|
||||||
|
# 저장 및 프롬프트 완료 처리
|
||||||
|
image_path = image_bot.save_image_from_telegram(bytes(file_bytes), prompt_id)
|
||||||
|
if not image_path:
|
||||||
|
await update.message.reply_text(f"❌ 저장 실패. #{prompt_id} 번이 존재하는지 확인하세요.")
|
||||||
|
return
|
||||||
|
|
||||||
|
# 대기 상태 해제
|
||||||
|
_awaiting_image.pop(chat_id, None)
|
||||||
|
|
||||||
|
prompt = image_bot.get_prompt_by_id(prompt_id)
|
||||||
|
topic = prompt['topic'] if prompt else ''
|
||||||
|
await update.message.reply_text(
|
||||||
|
f"✅ <b>이미지 저장 완료!</b>\n\n"
|
||||||
|
f"#{prompt_id} {topic}\n"
|
||||||
|
f"경로: <code>{image_path}</code>\n\n"
|
||||||
|
f"이 이미지는 해당 만평 글 발행 시 자동으로 사용됩니다.",
|
||||||
|
parse_mode='HTML',
|
||||||
|
)
|
||||||
|
logger.info(f"이미지 수령 완료: #{prompt_id} → {image_path}")
|
||||||
|
|
||||||
|
|
||||||
|
async def handle_photo(update: Update, context: ContextTypes.DEFAULT_TYPE):
|
||||||
|
"""Telegram 사진 수신"""
|
||||||
|
caption = update.message.caption or ''
|
||||||
|
photo = update.message.photo[-1] # 가장 큰 해상도
|
||||||
|
await _receive_image(
|
||||||
|
update, context,
|
||||||
|
file_getter=lambda: context.bot.get_file(photo.file_id),
|
||||||
|
caption=caption,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
async def handle_document(update: Update, context: ContextTypes.DEFAULT_TYPE):
|
||||||
|
"""Telegram 파일(문서) 수신 — 고해상도 이미지 전송 시"""
|
||||||
|
doc = update.message.document
|
||||||
|
mime = doc.mime_type or ''
|
||||||
|
if not mime.startswith('image/'):
|
||||||
|
return # 이미지 파일만 처리
|
||||||
|
caption = update.message.caption or ''
|
||||||
|
await _receive_image(
|
||||||
|
update, context,
|
||||||
|
file_getter=lambda: context.bot.get_file(doc.file_id),
|
||||||
|
caption=caption,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
# ─── 텍스트 명령 ─────────────────────────────────────
|
||||||
|
|
||||||
|
async def handle_text(update: Update, context: ContextTypes.DEFAULT_TYPE):
|
||||||
|
text = update.message.text.strip()
|
||||||
|
cmd_map = {
|
||||||
|
'발행 중단': cmd_stop_publish,
|
||||||
|
'발행 재개': cmd_resume_publish,
|
||||||
|
'오늘 수집된 글감 보여줘': cmd_show_topics,
|
||||||
|
'이번 주 리포트': cmd_report,
|
||||||
|
'대기 중인 글 보여줘': cmd_pending,
|
||||||
|
'이미지 목록': cmd_images,
|
||||||
|
}
|
||||||
|
if text in cmd_map:
|
||||||
|
await cmd_map[text](update, context)
|
||||||
|
else:
|
||||||
|
await update.message.reply_text(
|
||||||
|
"사용 가능한 명령:\n"
|
||||||
|
"• 발행 중단 / 발행 재개\n"
|
||||||
|
"• 오늘 수집된 글감 보여줘\n"
|
||||||
|
"• 대기 중인 글 보여줘\n"
|
||||||
|
"• 이번 주 리포트\n"
|
||||||
|
"• 이미지 목록\n\n"
|
||||||
|
"슬래시 명령:\n"
|
||||||
|
"/approve [번호] — 글 승인\n"
|
||||||
|
"/reject [번호] — 글 거부\n"
|
||||||
|
"/images — 이미지 제작 현황\n"
|
||||||
|
"/imgpick [번호] — 프롬프트 선택\n"
|
||||||
|
"/imgbatch — 프롬프트 배치 전송\n"
|
||||||
|
"/imgcancel — 이미지 대기 취소\n"
|
||||||
|
"/status — 봇 상태"
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
# ─── 스케줄러 설정 + 메인 ─────────────────────────────
|
||||||
|
|
||||||
|
def setup_scheduler() -> AsyncIOScheduler:
|
||||||
|
scheduler = AsyncIOScheduler(timezone='Asia/Seoul')
|
||||||
|
schedule_cfg = load_schedule()
|
||||||
|
|
||||||
|
job_map = {
|
||||||
|
'collector': job_collector,
|
||||||
|
'ai_writer': job_ai_writer,
|
||||||
|
'publish_1': lambda: job_publish(1),
|
||||||
|
'publish_2': lambda: job_publish(2),
|
||||||
|
'publish_3': lambda: job_publish(3),
|
||||||
|
'analytics': job_analytics_daily,
|
||||||
|
}
|
||||||
|
for job in schedule_cfg.get('jobs', []):
|
||||||
|
fn = job_map.get(job['id'])
|
||||||
|
if fn:
|
||||||
|
scheduler.add_job(fn, 'cron', hour=job['hour'], minute=job['minute'], id=job['id'])
|
||||||
|
|
||||||
|
# 고정 스케줄
|
||||||
|
scheduler.add_job(job_analytics_weekly, 'cron',
|
||||||
|
day_of_week='sun', hour=22, minute=30, id='weekly_report')
|
||||||
|
|
||||||
|
# request 모드: 매주 월요일 10:00 이미지 프롬프트 배치 전송
|
||||||
|
if IMAGE_MODE == 'request':
|
||||||
|
scheduler.add_job(job_image_prompt_batch, 'cron',
|
||||||
|
day_of_week='mon', hour=10, minute=0, id='image_batch')
|
||||||
|
logger.info("이미지 request 모드: 매주 월요일 10:00 배치 전송 등록")
|
||||||
|
|
||||||
|
logger.info("스케줄러 설정 완료")
|
||||||
|
return scheduler
|
||||||
|
|
||||||
|
|
||||||
|
async def main():
|
||||||
|
logger.info("=== 블로그 엔진 스케줄러 시작 ===")
|
||||||
|
scheduler = setup_scheduler()
|
||||||
|
scheduler.start()
|
||||||
|
|
||||||
|
if TELEGRAM_BOT_TOKEN:
|
||||||
|
app = Application.builder().token(TELEGRAM_BOT_TOKEN).build()
|
||||||
|
|
||||||
|
# 발행 관련
|
||||||
|
app.add_handler(CommandHandler('status', cmd_status))
|
||||||
|
app.add_handler(CommandHandler('approve', cmd_approve))
|
||||||
|
app.add_handler(CommandHandler('reject', cmd_reject))
|
||||||
|
app.add_handler(CommandHandler('pending', cmd_pending))
|
||||||
|
app.add_handler(CommandHandler('report', cmd_report))
|
||||||
|
app.add_handler(CommandHandler('topics', cmd_show_topics))
|
||||||
|
|
||||||
|
# 이미지 관련 (request / manual 공통 사용 가능)
|
||||||
|
app.add_handler(CommandHandler('images', cmd_images))
|
||||||
|
app.add_handler(CommandHandler('imgpick', cmd_imgpick))
|
||||||
|
app.add_handler(CommandHandler('imgbatch', cmd_imgbatch))
|
||||||
|
app.add_handler(CommandHandler('imgcancel', cmd_imgcancel))
|
||||||
|
|
||||||
|
# 이미지 파일 수신
|
||||||
|
app.add_handler(MessageHandler(filters.PHOTO, handle_photo))
|
||||||
|
app.add_handler(MessageHandler(filters.Document.IMAGE, handle_document))
|
||||||
|
|
||||||
|
# 텍스트 명령
|
||||||
|
app.add_handler(MessageHandler(filters.TEXT & ~filters.COMMAND, handle_text))
|
||||||
|
|
||||||
|
logger.info("Telegram 봇 시작")
|
||||||
|
await app.initialize()
|
||||||
|
await app.start()
|
||||||
|
await app.updater.start_polling(drop_pending_updates=True)
|
||||||
|
|
||||||
|
try:
|
||||||
|
while True:
|
||||||
|
await asyncio.sleep(3600)
|
||||||
|
except (KeyboardInterrupt, SystemExit):
|
||||||
|
logger.info("종료 신호 수신")
|
||||||
|
finally:
|
||||||
|
await app.updater.stop()
|
||||||
|
await app.stop()
|
||||||
|
await app.shutdown()
|
||||||
|
scheduler.shutdown()
|
||||||
|
else:
|
||||||
|
logger.warning("TELEGRAM_BOT_TOKEN 없음 — 스케줄러만 실행")
|
||||||
|
try:
|
||||||
|
while True:
|
||||||
|
await asyncio.sleep(3600)
|
||||||
|
except (KeyboardInterrupt, SystemExit):
|
||||||
|
scheduler.shutdown()
|
||||||
|
|
||||||
|
logger.info("=== 블로그 엔진 스케줄러 종료 ===")
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == '__main__':
|
||||||
|
asyncio.run(main())
|
||||||
33
config/affiliate_links.json
Normal file
33
config/affiliate_links.json
Normal file
@@ -0,0 +1,33 @@
|
|||||||
|
{
|
||||||
|
"fixed_links": [
|
||||||
|
{
|
||||||
|
"keyword": "ChatGPT Plus",
|
||||||
|
"url": "https://chat.openai.com",
|
||||||
|
"label": "ChatGPT Plus 바로가기",
|
||||||
|
"type": "external"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"keyword": "Claude Pro",
|
||||||
|
"url": "https://claude.ai",
|
||||||
|
"label": "Claude Pro 바로가기",
|
||||||
|
"type": "external"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"coupang_category_map": {
|
||||||
|
"마이크": "mic",
|
||||||
|
"웹캠": "webcam",
|
||||||
|
"키보드": "keyboard",
|
||||||
|
"마우스": "mouse",
|
||||||
|
"모니터": "monitor",
|
||||||
|
"노트북": "laptop",
|
||||||
|
"이어폰": "earphone",
|
||||||
|
"헤드셋": "headset",
|
||||||
|
"외장하드": "external-hdd",
|
||||||
|
"USB허브": "usb-hub",
|
||||||
|
"책상": "desk",
|
||||||
|
"의자": "chair",
|
||||||
|
"서적": "book",
|
||||||
|
"스피커": "speaker"
|
||||||
|
},
|
||||||
|
"disclaimer_text": "이 포스팅은 쿠팡 파트너스 활동의 일환으로, 이에 따른 일정액의 수수료를 제공받습니다."
|
||||||
|
}
|
||||||
14
config/blogs.json
Normal file
14
config/blogs.json
Normal file
@@ -0,0 +1,14 @@
|
|||||||
|
{
|
||||||
|
"blogs": [
|
||||||
|
{
|
||||||
|
"id": "main",
|
||||||
|
"blog_id": "${BLOG_MAIN_ID}",
|
||||||
|
"name": "테크인사이더",
|
||||||
|
"persona": "tech_insider",
|
||||||
|
"domain": "",
|
||||||
|
"active": true,
|
||||||
|
"phase": 1,
|
||||||
|
"labels": ["쉬운세상", "숨은보물", "바이브리포트", "팩트체크", "한컷"]
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
72
config/quality_rules.json
Normal file
72
config/quality_rules.json
Normal file
@@ -0,0 +1,72 @@
|
|||||||
|
{
|
||||||
|
"min_score": 70,
|
||||||
|
"scoring": {
|
||||||
|
"korean_relevance": {
|
||||||
|
"max": 30,
|
||||||
|
"description": "한국 독자 관련성",
|
||||||
|
"keywords": ["한국", "국내", "한글", "카카오", "네이버", "쿠팡", "삼성", "LG", "현대", "기아", "배달", "토스", "당근", "야놀자"]
|
||||||
|
},
|
||||||
|
"freshness": {
|
||||||
|
"max": 20,
|
||||||
|
"description": "트렌드 신선도",
|
||||||
|
"hours_full_score": 24,
|
||||||
|
"hours_zero_score": 168
|
||||||
|
},
|
||||||
|
"search_demand": {
|
||||||
|
"max": 20,
|
||||||
|
"description": "검색 수요 (Google Trends 상대값)"
|
||||||
|
},
|
||||||
|
"source_trust": {
|
||||||
|
"max": 15,
|
||||||
|
"description": "출처 신뢰도",
|
||||||
|
"levels": {
|
||||||
|
"high": 15,
|
||||||
|
"medium": 8,
|
||||||
|
"low": 3
|
||||||
|
},
|
||||||
|
"high_sources": ["github.com", "official blog", "공식", "press release"],
|
||||||
|
"low_sources": ["twitter.com", "x.com", "reddit.com", "개인"]
|
||||||
|
},
|
||||||
|
"monetization": {
|
||||||
|
"max": 15,
|
||||||
|
"description": "수익 연결 가능성",
|
||||||
|
"keywords": ["도구", "앱", "서비스", "제품", "장비", "구독", "할인", "추천"]
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"discard_rules": [
|
||||||
|
{
|
||||||
|
"id": "no_korean_relevance",
|
||||||
|
"description": "한국 독자와 무관한 주제",
|
||||||
|
"condition": "korean_relevance_score == 0"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "unverified_source",
|
||||||
|
"description": "출처 불명/미확인 사례",
|
||||||
|
"condition": "source_trust_level == 'unknown'"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "duplicate_topic",
|
||||||
|
"description": "이미 발행한 주제와 유사도 80% 이상",
|
||||||
|
"similarity_threshold": 0.8
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "stale_trend",
|
||||||
|
"description": "7일 이상 지난 트렌드 (에버그린 제외)",
|
||||||
|
"max_age_days": 7,
|
||||||
|
"except_evergreen": true
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "promotional",
|
||||||
|
"description": "광고성/홍보성이 명확한 원문",
|
||||||
|
"keywords": ["광고", "홍보", "스폰서", "협찬", "AD", "sponsored"]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "clickbait",
|
||||||
|
"description": "클릭베이트성 주제",
|
||||||
|
"patterns": ["충격", "경악", "난리", "ㅋㅋ", "ㅠㅠ", "대박", "레전드", "역대급"]
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"evergreen_keywords": [
|
||||||
|
"가이드", "방법", "사용법", "입문", "튜토리얼", "기초", "완전정복", "총정리"
|
||||||
|
]
|
||||||
|
}
|
||||||
18
config/safety_keywords.json
Normal file
18
config/safety_keywords.json
Normal file
@@ -0,0 +1,18 @@
|
|||||||
|
{
|
||||||
|
"crypto_keywords": [
|
||||||
|
"스캠", "사기", "폰지", "러그풀", "소송", "코인",
|
||||||
|
"비트코인", "이더리움", "암호화폐", "가상화폐"
|
||||||
|
],
|
||||||
|
"criticism_keywords": [
|
||||||
|
"고소", "피해", "논란", "비리", "내부고발", "고발"
|
||||||
|
],
|
||||||
|
"investment_keywords": [
|
||||||
|
"수익 보장", "확실한 수익", "반드시 오른다", "무조건", "투자 권유"
|
||||||
|
],
|
||||||
|
"legal_keywords": [
|
||||||
|
"불법", "위법", "처벌", "벌금", "징역", "기소"
|
||||||
|
],
|
||||||
|
"always_manual_review": ["팩트체크"],
|
||||||
|
"min_sources_required": 2,
|
||||||
|
"min_quality_score_for_auto": 75
|
||||||
|
}
|
||||||
40
config/schedule.json
Normal file
40
config/schedule.json
Normal file
@@ -0,0 +1,40 @@
|
|||||||
|
{
|
||||||
|
"jobs": [
|
||||||
|
{
|
||||||
|
"id": "collector",
|
||||||
|
"hour": 7,
|
||||||
|
"minute": 0,
|
||||||
|
"description": "수집봇 실행"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "ai_writer",
|
||||||
|
"hour": 8,
|
||||||
|
"minute": 0,
|
||||||
|
"description": "AI 글 작성 트리거"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "publish_1",
|
||||||
|
"hour": 9,
|
||||||
|
"minute": 0,
|
||||||
|
"description": "첫 번째 글 발행"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "publish_2",
|
||||||
|
"hour": 12,
|
||||||
|
"minute": 0,
|
||||||
|
"description": "두 번째 글 발행"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "publish_3",
|
||||||
|
"hour": 15,
|
||||||
|
"minute": 0,
|
||||||
|
"description": "세 번째 글 발행 (있을 경우)"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "analytics",
|
||||||
|
"hour": 22,
|
||||||
|
"minute": 0,
|
||||||
|
"description": "분석봇 일일 리포트"
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
49
config/sources.json
Normal file
49
config/sources.json
Normal file
@@ -0,0 +1,49 @@
|
|||||||
|
{
|
||||||
|
"rss_feeds": [
|
||||||
|
{
|
||||||
|
"name": "GeekNews",
|
||||||
|
"url": "https://feeds.feedburner.com/geeknews-feed",
|
||||||
|
"category": "tech",
|
||||||
|
"trust_level": "high"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "ZDNet Korea",
|
||||||
|
"url": "https://www.zdnet.co.kr/rss/rss.php",
|
||||||
|
"category": "tech",
|
||||||
|
"trust_level": "high"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "Yonhap IT",
|
||||||
|
"url": "https://www.yna.co.kr/rss/it.xml",
|
||||||
|
"category": "tech",
|
||||||
|
"trust_level": "high"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "Bloter",
|
||||||
|
"url": "https://www.bloter.net/feed",
|
||||||
|
"category": "tech",
|
||||||
|
"trust_level": "high"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"x_keywords": [
|
||||||
|
"바이브코딩",
|
||||||
|
"vibe coding",
|
||||||
|
"AI 자동화",
|
||||||
|
"Claude 사용",
|
||||||
|
"ChatGPT 활용",
|
||||||
|
"비개발자 앱",
|
||||||
|
"노코드 AI"
|
||||||
|
],
|
||||||
|
"github_trending": {
|
||||||
|
"url": "https://github.com/trending",
|
||||||
|
"languages": ["", "python", "javascript"],
|
||||||
|
"since": "daily"
|
||||||
|
},
|
||||||
|
"hacker_news": {
|
||||||
|
"url": "https://hacker-news.firebaseio.com/v0/topstories.json",
|
||||||
|
"top_n": 30
|
||||||
|
},
|
||||||
|
"product_hunt": {
|
||||||
|
"rss_url": "https://www.producthunt.com/feed"
|
||||||
|
}
|
||||||
|
}
|
||||||
12
requirements.txt
Normal file
12
requirements.txt
Normal file
@@ -0,0 +1,12 @@
|
|||||||
|
pytrends
|
||||||
|
google-api-python-client
|
||||||
|
google-auth-oauthlib
|
||||||
|
google-auth-httplib2
|
||||||
|
python-dotenv
|
||||||
|
apscheduler
|
||||||
|
requests
|
||||||
|
beautifulsoup4
|
||||||
|
feedparser
|
||||||
|
markdown
|
||||||
|
python-telegram-bot==20.7
|
||||||
|
lxml
|
||||||
59
scripts/get_token.py
Normal file
59
scripts/get_token.py
Normal file
@@ -0,0 +1,59 @@
|
|||||||
|
"""
|
||||||
|
Google OAuth2 토큰 발급 스크립트
|
||||||
|
실행: python scripts/get_token.py
|
||||||
|
결과: credentials.json 필요, token.json 생성, refresh_token 출력
|
||||||
|
"""
|
||||||
|
import json
|
||||||
|
import os
|
||||||
|
import sys
|
||||||
|
|
||||||
|
from google_auth_oauthlib.flow import InstalledAppFlow
|
||||||
|
from google.oauth2.credentials import Credentials
|
||||||
|
from google.auth.transport.requests import Request
|
||||||
|
|
||||||
|
SCOPES = [
|
||||||
|
'https://www.googleapis.com/auth/blogger',
|
||||||
|
'https://www.googleapis.com/auth/webmasters',
|
||||||
|
]
|
||||||
|
|
||||||
|
BASE_DIR = os.path.dirname(os.path.dirname(os.path.abspath(__file__)))
|
||||||
|
TOKEN_PATH = os.path.join(BASE_DIR, 'token.json')
|
||||||
|
CREDENTIALS_PATH = os.path.join(BASE_DIR, 'credentials.json')
|
||||||
|
|
||||||
|
|
||||||
|
def main():
|
||||||
|
if not os.path.exists(CREDENTIALS_PATH):
|
||||||
|
print(f"[ERROR] credentials.json 파일이 없습니다: {CREDENTIALS_PATH}")
|
||||||
|
print("Google Cloud Console에서 OAuth 클라이언트 ID를 생성하고")
|
||||||
|
print("credentials.json 을 C:\\blog-engine\\ 에 저장하세요.")
|
||||||
|
sys.exit(1)
|
||||||
|
|
||||||
|
creds = None
|
||||||
|
if os.path.exists(TOKEN_PATH):
|
||||||
|
creds = Credentials.from_authorized_user_file(TOKEN_PATH, SCOPES)
|
||||||
|
|
||||||
|
if not creds or not creds.valid:
|
||||||
|
if creds and creds.expired and creds.refresh_token:
|
||||||
|
creds.refresh(Request())
|
||||||
|
print("[OK] 기존 토큰 갱신 완료")
|
||||||
|
else:
|
||||||
|
flow = InstalledAppFlow.from_client_secrets_file(CREDENTIALS_PATH, SCOPES)
|
||||||
|
creds = flow.run_local_server(port=0)
|
||||||
|
print("[OK] 새 토큰 발급 완료")
|
||||||
|
|
||||||
|
with open(TOKEN_PATH, 'w') as token_file:
|
||||||
|
token_file.write(creds.to_json())
|
||||||
|
|
||||||
|
token_data = json.loads(creds.to_json())
|
||||||
|
refresh_token = token_data.get('refresh_token', '')
|
||||||
|
|
||||||
|
print("\n" + "=" * 50)
|
||||||
|
print("토큰 발급 성공!")
|
||||||
|
print("=" * 50)
|
||||||
|
print(f"\nREFRESH_TOKEN:\n{refresh_token}")
|
||||||
|
print(f"\n이 값을 .env 파일의 GOOGLE_REFRESH_TOKEN 에 붙여넣으세요.")
|
||||||
|
print(f"\ntoken.json 저장 위치: {TOKEN_PATH}")
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == '__main__':
|
||||||
|
main()
|
||||||
64
scripts/setup.bat
Normal file
64
scripts/setup.bat
Normal file
@@ -0,0 +1,64 @@
|
|||||||
|
@echo off
|
||||||
|
echo ========================================
|
||||||
|
echo Blog Engine Setup
|
||||||
|
echo ========================================
|
||||||
|
|
||||||
|
REM Python venv 생성
|
||||||
|
python -m venv venv
|
||||||
|
if errorlevel 1 (
|
||||||
|
echo [ERROR] Python venv 생성 실패. Python 3.11 이상이 설치되어 있는지 확인하세요.
|
||||||
|
pause
|
||||||
|
exit /b 1
|
||||||
|
)
|
||||||
|
|
||||||
|
REM 패키지 설치
|
||||||
|
call venv\Scripts\activate
|
||||||
|
pip install --upgrade pip
|
||||||
|
pip install -r requirements.txt
|
||||||
|
if errorlevel 1 (
|
||||||
|
echo [ERROR] 패키지 설치 실패.
|
||||||
|
pause
|
||||||
|
exit /b 1
|
||||||
|
)
|
||||||
|
|
||||||
|
REM .env 파일 복사 (없을 경우)
|
||||||
|
if not exist .env (
|
||||||
|
copy .env.example .env
|
||||||
|
echo [OK] .env 파일 생성됨. API 키를 입력해주세요: .env
|
||||||
|
)
|
||||||
|
|
||||||
|
REM data 폴더 생성
|
||||||
|
if not exist data\topics mkdir data\topics
|
||||||
|
if not exist data\collected mkdir data\collected
|
||||||
|
if not exist data\discarded mkdir data\discarded
|
||||||
|
if not exist data\pending_review mkdir data\pending_review
|
||||||
|
if not exist data\published mkdir data\published
|
||||||
|
if not exist data\analytics mkdir data\analytics
|
||||||
|
if not exist data\images mkdir data\images
|
||||||
|
if not exist data\drafts mkdir data\drafts
|
||||||
|
if not exist logs mkdir logs
|
||||||
|
|
||||||
|
REM Windows 작업 스케줄러에 scheduler.py 등록
|
||||||
|
set SCRIPT_PATH=%~dp0bots\scheduler.py
|
||||||
|
set PYTHON_PATH=%~dp0venv\Scripts\pythonw.exe
|
||||||
|
|
||||||
|
schtasks /query /tn "BlogEngine" >nul 2>&1
|
||||||
|
if errorlevel 1 (
|
||||||
|
schtasks /create /tn "BlogEngine" /tr "\"%PYTHON_PATH%\" \"%SCRIPT_PATH%\"" /sc onlogon /rl highest /f
|
||||||
|
echo [OK] Windows 작업 스케줄러에 BlogEngine 등록 완료
|
||||||
|
) else (
|
||||||
|
echo [INFO] BlogEngine 작업이 이미 등록되어 있습니다.
|
||||||
|
)
|
||||||
|
|
||||||
|
echo.
|
||||||
|
echo ========================================
|
||||||
|
echo Setup 완료!
|
||||||
|
echo ========================================
|
||||||
|
echo.
|
||||||
|
echo 다음 단계:
|
||||||
|
echo 1. .env 파일을 열고 API 키를 모두 입력하세요
|
||||||
|
echo 2. scripts\get_token.py 를 실행해서 Google OAuth 토큰을 발급받으세요
|
||||||
|
echo 3. config\blogs.json 에서 BLOG_MAIN_ID 를 실제 블로그 ID로 변경하세요
|
||||||
|
echo 4. python bots\scheduler.py 로 스케줄러를 시작하세요
|
||||||
|
echo.
|
||||||
|
pause
|
||||||
Reference in New Issue
Block a user