Docs: [3.0.0] 전체 문서 업데이트 — YouTube Data API + n8n Discord 전송 구조 반영
All checks were successful
news-summary-bot-cicd / build_push_deploy (push) Successful in 25m15s
All checks were successful
news-summary-bot-cicd / build_push_deploy (push) Successful in 25m15s
- README: 아키텍처, 환경변수, API 응답 형식 업데이트 - n8n-setup: RSS → YouTube Data API playlistItems 전환, 노드별 상세 설정 - development: discord.py 제거 반영, API 응답 형식 추가 - operations: CI/CD 자동 배포 설명, DISCORD_WEBHOOK_URL 제거 - testing: DISCORD_WEBHOOK_URL 더미값 제거 - .env.example: DISCORD_WEBHOOK_URL 제거 - tests/test_discord.py 삭제 (모듈 삭제됨) Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
@@ -1,4 +1,3 @@
|
||||
ANTHROPIC_API_KEY=sk-ant-xxx
|
||||
DISCORD_WEBHOOK_URL=https://discord.com/api/webhooks/xxx/xxx
|
||||
API_SECRET=your-secret-here
|
||||
DOCKER_IMAGE=nkey/news-summary-bot:latest
|
||||
|
||||
33
README.md
33
README.md
@@ -1,11 +1,11 @@
|
||||
# News Summary Bot
|
||||
|
||||
YouTube 뉴스/경제 채널의 새 영상을 감지하면 자막을 추출하고, Claude API로 요약한 뒤 Discord로 전송하는 봇입니다.
|
||||
YouTube 뉴스/경제 채널의 새 영상을 감지하면 자막을 추출하고, Claude API로 요약하는 봇입니다.
|
||||
|
||||
## 아키텍처
|
||||
|
||||
```
|
||||
n8n (RSS 감지) → POST /api/news/summarize → 자막 추출 → Claude 요약 → Discord 웹훅
|
||||
n8n (YouTube Data API로 새 영상 감지) → POST /api/news/summarize → 자막 추출 → Claude 요약 → JSON 응답 → n8n이 Discord 웹훅 전송
|
||||
```
|
||||
|
||||
| 모듈 | 역할 |
|
||||
@@ -13,7 +13,6 @@ n8n (RSS 감지) → POST /api/news/summarize → 자막 추출 → Claude 요
|
||||
| `app/main.py` | FastAPI 엔드포인트 |
|
||||
| `app/transcript.py` | YouTube 자막 추출 (yt-dlp + 쿠키 인증) |
|
||||
| `app/summarizer.py` | Claude Sonnet 4.6으로 요약 생성 |
|
||||
| `app/discord.py` | Discord 웹훅 전송 |
|
||||
| `app/config.py` | 환경변수 설정 (pydantic-settings) |
|
||||
|
||||
## 빠른 시작
|
||||
@@ -28,7 +27,6 @@ cp .env.example .env
|
||||
| 변수 | 필수 | 설명 |
|
||||
|------|------|------|
|
||||
| `ANTHROPIC_API_KEY` | O | Claude API 키 |
|
||||
| `DISCORD_WEBHOOK_URL` | O | Discord 웹훅 URL |
|
||||
| `API_SECRET` | X | n8n → FastAPI 인증용 시크릿 |
|
||||
|
||||
### 로컬 개발
|
||||
@@ -53,13 +51,13 @@ OCI 등 클라우드 서버에서는 YouTube가 데이터센터 IP를 봇으로
|
||||
2. 서버에 `cookies.txt` 업로드
|
||||
3. `compose.apps.yml`에서 볼륨 마운트: `./news-summary-bot/cookies.txt:/app/cookies.txt:ro`
|
||||
|
||||
> 쿠키는 6개월~1년 후 만료됩니다. 자막 추출 500 에러 발생 시 쿠키 재export가 필요합니다.
|
||||
> 쿠키는 6개월~1년 후 만료됩니다. 자막 추출 에러 발생 시 쿠키 재export가 필요합니다.
|
||||
|
||||
## API
|
||||
|
||||
### `POST /api/news/summarize` (외부) / `POST /summarize` (내부)
|
||||
|
||||
영상 URL을 받아 자막 추출 → 요약 → Discord 전송을 수행합니다.
|
||||
영상 URL을 받아 자막 추출 → 요약을 수행하고 JSON으로 결과를 반환합니다. Discord 전송은 n8n에서 처리합니다.
|
||||
|
||||
**Request:**
|
||||
|
||||
@@ -75,11 +73,14 @@ OCI 등 클라우드 서버에서는 YouTube가 데이터센터 IP를 봇으로
|
||||
**Response:**
|
||||
|
||||
```json
|
||||
{
|
||||
"status": "ok",
|
||||
"title": "영상 제목",
|
||||
"summary_length": 1234
|
||||
}
|
||||
// 성공
|
||||
{"status": "ok", "title": "영상 제목", "summary": "요약 텍스트"}
|
||||
|
||||
// 스킵 (라이브/쇼츠)
|
||||
{"status": "skipped", "title": "영상 제목", "reason": "라이브/예정 영상은 요약 대상이 아닙니다"}
|
||||
|
||||
// 에러
|
||||
{"status": "error", "title": "영상 제목", "error_type": "ValueError", "error_message": "..."}
|
||||
```
|
||||
|
||||
### `GET /api/news/health` (외부) / `GET /health` (내부)
|
||||
@@ -90,14 +91,10 @@ OCI 등 클라우드 서버에서는 YouTube가 데이터센터 IP를 봇으로
|
||||
|
||||
## n8n 워크플로우
|
||||
|
||||
```
|
||||
RSS Feed Trigger (채널A) ──┐
|
||||
├→ Merge → HTTP Request (POST /api/news/summarize)
|
||||
RSS Feed Trigger (채널B) ──┘
|
||||
```
|
||||
n8n에서 YouTube Data API v3의 `playlistItems.list`로 채널별 새 영상을 감지하고, 각 영상마다 이 봇의 API를 호출합니다. 응답의 `status` 필드로 분기하여 Discord 웹훅으로 요약 또는 에러 알림을 전송합니다.
|
||||
|
||||
n8n에서 RSS Feed Trigger로 채널별 새 영상을 감지하고, 각 영상마다 이 봇의 API를 호출합니다. 자세한 설정은 [docs/n8n-setup.md](docs/n8n-setup.md)를 참고하세요.
|
||||
자세한 설정은 [docs/n8n-setup.md](docs/n8n-setup.md)를 참고하세요.
|
||||
|
||||
## 배포
|
||||
|
||||
Docker Hub에 이미지를 푸시하고 서버에서 `docker compose pull && docker compose up -d`로 배포합니다. 자세한 내용은 [docs/operations.md](docs/operations.md)를 참고하세요.
|
||||
CI/CD가 설정되어 있어 main 브랜치에 push하면 자동으로 Docker 이미지 빌드 → Docker Hub push → OCI 서버 배포가 진행됩니다. 자세한 내용은 [docs/operations.md](docs/operations.md)를 참고하세요.
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
# 개발 가이드
|
||||
|
||||
YouTube 뉴스 영상을 자동 요약하여 Discord로 전송하는 FastAPI 기반 봇의 개발 문서입니다.
|
||||
YouTube 뉴스 영상을 자동 요약하는 FastAPI 기반 봇의 개발 문서입니다.
|
||||
|
||||
## 프로젝트 구조
|
||||
|
||||
@@ -11,8 +11,7 @@ news-summary-bot/
|
||||
│ ├── main.py # FastAPI 엔드포인트 (/summarize, /health)
|
||||
│ ├── config.py # pydantic-settings 환경변수
|
||||
│ ├── transcript.py # yt-dlp + 쿠키로 자막 추출
|
||||
│ ├── summarizer.py # Claude Sonnet 4.6 요약
|
||||
│ └── discord.py # Discord 웹훅 전송
|
||||
│ └── summarizer.py # Claude Sonnet 4.6 요약
|
||||
├── Dockerfile
|
||||
├── docker-compose.yml
|
||||
├── requirements.txt
|
||||
@@ -42,12 +41,25 @@ uvicorn app.main:app --reload
|
||||
|
||||
### POST /summarize
|
||||
|
||||
YouTube 영상 URL을 받아 자막을 추출하고, Claude로 요약한 뒤 Discord 웹훅으로 전송합니다.
|
||||
YouTube 영상 URL을 받아 자막을 추출하고 Claude로 요약합니다. 결과를 JSON으로 반환하며, Discord 전송은 n8n에서 처리합니다.
|
||||
|
||||
- **인증**: `X-Api-Secret` 헤더에 시크릿 값 전달 (API_SECRET 환경변수가 설정된 경우 필수)
|
||||
- **요청 본문**:
|
||||
- `video_url` (string, 필수) — YouTube 영상 URL
|
||||
- `title` (string, 필수) — 영상 제목
|
||||
- `title` (string, 선택) — 영상 제목
|
||||
|
||||
**응답:**
|
||||
|
||||
```json
|
||||
// 성공
|
||||
{"status": "ok", "title": "...", "summary": "요약 텍스트"}
|
||||
|
||||
// 스킵 (라이브/쇼츠)
|
||||
{"status": "skipped", "title": "...", "reason": "..."}
|
||||
|
||||
// 에러
|
||||
{"status": "error", "title": "...", "error_type": "...", "error_message": "..."}
|
||||
```
|
||||
|
||||
### GET /health
|
||||
|
||||
@@ -73,7 +85,7 @@ curl -X POST http://localhost:8000/summarize \
|
||||
}'
|
||||
```
|
||||
|
||||
정상 처리 시 Discord 채널에 요약 메시지가 전송됩니다.
|
||||
정상 처리 시 요약 텍스트가 포함된 JSON 응답이 반환됩니다.
|
||||
|
||||
## 주요 모듈 설명
|
||||
|
||||
@@ -81,16 +93,14 @@ curl -X POST http://localhost:8000/summarize \
|
||||
|
||||
YouTube URL에서 video ID를 추출하고 `yt-dlp`를 사용하여 자막을 가져옵니다. 한국어(`ko`) 자막을 우선으로 시도하며, 없을 경우 영어(`en`) 자막을 fallback으로 사용합니다.
|
||||
|
||||
라이브/예정 영상과 쇼츠는 `SkipVideo` 예외를 발생시켜 스킵합니다. `yt-dlp`의 `DownloadError`에서 라이브 관련 메시지가 감지되면 자동으로 스킵 처리됩니다.
|
||||
|
||||
서버 환경에서는 YouTube 봇 감지를 우회하기 위해 `/app/cookies.txt` 쿠키 파일을 사용합니다. 로컬 개발 시에는 가정용 IP라 쿠키 없이도 동작합니다.
|
||||
|
||||
### summarizer.py
|
||||
|
||||
Anthropic의 **Claude Sonnet 4.6** 모델을 사용하여 자막 텍스트를 요약합니다. 시스템 프롬프트에 뉴스/경제 요약에 최적화된 포맷을 지정하여 일관된 형식의 요약을 생성합니다.
|
||||
|
||||
### discord.py
|
||||
|
||||
요약 결과를 **Discord Embed** 형식으로 구성하여 웹훅 URL로 전송합니다. 영상 제목, 요약 내용, 원본 링크 등을 포함합니다.
|
||||
|
||||
### config.py
|
||||
|
||||
`pydantic-settings`를 사용하여 `.env` 파일 또는 시스템 환경변수에서 설정값을 로드합니다. 필수 값이 누락되면 서버 시작 시 에러가 발생합니다.
|
||||
@@ -100,5 +110,4 @@ Anthropic의 **Claude Sonnet 4.6** 모델을 사용하여 자막 텍스트를
|
||||
| 변수 | 필수 | 설명 |
|
||||
|------|------|------|
|
||||
| `ANTHROPIC_API_KEY` | O | Anthropic API 키 |
|
||||
| `DISCORD_WEBHOOK_URL` | O | Discord 웹훅 URL |
|
||||
| `API_SECRET` | X | n8n → FastAPI 인증용 시크릿 (미설정 시 인증 생략) |
|
||||
|
||||
@@ -2,104 +2,249 @@
|
||||
|
||||
## 워크플로우 개요
|
||||
|
||||
이 워크플로우는 2개의 YouTube 채널(머니머니코믹스, 슈카월드)의 새 영상을 RSS Feed Trigger로 감지하고, FastAPI `/api/news/summarize` 엔드포인트를 호출하여 요약 후 Discord로 전송하는 자동화 파이프라인입니다.
|
||||
이 워크플로우는 2개의 YouTube 채널(머니코믹스, 슈카월드)의 새 영상을 YouTube Data API v3로 감지하고, FastAPI `/api/news/summarize` 엔드포인트를 호출하여 요약한 뒤, 응답 결과에 따라 Discord로 요약 또는 에러 알림을 전송하는 자동화 파이프라인입니다.
|
||||
|
||||
**전체 흐름:**
|
||||
|
||||
```
|
||||
RSS Feed Trigger (채널A) ──┐
|
||||
├→ Merge → HTTP Request (FastAPI) → 완료
|
||||
RSS Feed Trigger (채널B) ──┘ ↘ Error Trigger → Discord 알림
|
||||
Schedule Trigger (매 정시)
|
||||
├→ HTTP Request (머니코믹스 playlistItems) → Edit Fields (channel_name: 머니코믹스)
|
||||
└→ HTTP Request (슈카월드 playlistItems) → Edit Fields (channel_name: 슈카월드)
|
||||
↓
|
||||
Merge (Append)
|
||||
↓
|
||||
Code (새 영상만 필터링)
|
||||
↓
|
||||
HTTP Request (POST /api/news/summarize)
|
||||
↓
|
||||
Switch (status 분기)
|
||||
├→ ok → Discord 요약 전송
|
||||
├→ skipped → No Operation
|
||||
└→ error → Discord 에러 알림
|
||||
```
|
||||
|
||||
## 사전 준비: YouTube Data API 키 발급
|
||||
|
||||
1. [Google Cloud Console](https://console.cloud.google.com/) → 프로젝트 생성/선택
|
||||
2. "YouTube Data API v3" 검색 → **사용 설정**
|
||||
3. **사용자 인증 정보 → API 키** 생성
|
||||
4. (권장) 키 제한 설정:
|
||||
- **API 제한**: YouTube Data API v3만 허용
|
||||
- **애플리케이션 제한**: IP 주소 → n8n 서버 IP
|
||||
5. 일일 쿼터: 10,000 units (`playlistItems.list`는 1 unit/요청 → 2채널 × 24회 = **48 units/일**)
|
||||
|
||||
## 노드 구성 상세
|
||||
|
||||
### 1. RSS Feed Trigger 노드 (2개)
|
||||
|
||||
RSS Feed Trigger는 **중복 방지가 내장**되어 있어 이전에 처리한 항목을 기억하고, 새 영상만 반환합니다. Poll Time으로 체크 주기를 설정합니다.
|
||||
|
||||
**노드 A - 머니머니코믹스:**
|
||||
### 1. Schedule Trigger
|
||||
|
||||
| 설정 항목 | 값 |
|
||||
|---|---|
|
||||
| Feed URL | `https://www.youtube.com/feeds/videos.xml?channel_id=UCJo6G1u0e_-wS-JQn3T-zEw` |
|
||||
| Poll Time | Every Day / Hour: 10, 14, 20 (하루 3회 권장) |
|
||||
| Type | Schedule Trigger |
|
||||
| Rule | Every Hour |
|
||||
| Minute | 0 |
|
||||
|
||||
**노드 B - 슈카월드:**
|
||||
### 2. YouTube API — playlistItems (채널별 각 1개)
|
||||
|
||||
| 설정 항목 | 값 |
|
||||
|---|---|
|
||||
| Feed URL | `https://www.youtube.com/feeds/videos.xml?channel_id=UCsJ6RuBiTVWRX156FVbeaGg` |
|
||||
| Poll Time | Every Day / Hour: 10, 14, 20 (하루 3회 권장) |
|
||||
| Type | HTTP Request |
|
||||
| Method | GET |
|
||||
| URL | `https://www.googleapis.com/youtube/v3/playlistItems` |
|
||||
|
||||
> ⚠️ **첫 실행 주의:** RSS Feed Trigger를 처음 활성화하면 피드에 있는 모든 영상(최대 15개)을 새 영상으로 인식합니다. 워크플로우 활성화 전에 수동으로 한 번 테스트 실행하여 기존 항목을 처리 완료 상태로 만드세요.
|
||||
**Query Parameters:**
|
||||
|
||||
### 2. Merge 노드
|
||||
|
||||
- **타입:** Merge
|
||||
- **모드:** Append
|
||||
- 두 RSS Feed Trigger의 출력을 하나의 리스트로 합칩니다.
|
||||
- 합쳐진 리스트의 각 항목마다 다음 노드(HTTP Request)가 실행됩니다.
|
||||
|
||||
### 3. HTTP Request 노드
|
||||
|
||||
FastAPI 서버의 `/api/news/summarize` 엔드포인트를 호출합니다.
|
||||
|
||||
| 설정 항목 | 값 |
|
||||
| 파라미터 | 값 |
|
||||
|---|---|
|
||||
| Method | `POST` |
|
||||
| URL | `https://nkeystudy.site/api/news/summarize` |
|
||||
| Body Content Type | JSON |
|
||||
| Body | 아래 참조 |
|
||||
| `part` | `snippet` |
|
||||
| `playlistId` | 채널 업로드 목록 ID (아래 참고) |
|
||||
| `maxResults` | `5` |
|
||||
| `key` | YouTube Data API v3 키 |
|
||||
|
||||
**Body 설정:**
|
||||
**채널별 playlistId** (채널 ID의 `UC` → `UU`로 변환):
|
||||
|
||||
| 채널 | 채널 ID | playlistId (업로드 목록) |
|
||||
|---|---|---|
|
||||
| 머니코믹스 | `UCJo6G1u0e_-wS-JQn3T-zEw` | `UUJo6G1u0e_-wS-JQn3T-zEw` |
|
||||
| 슈카월드 | `UCsJ6RuBiTVWRX156FVbeaGg` | `UUsJ6RuBiTVWRX156FVbeaGg` |
|
||||
|
||||
**응답 예시:**
|
||||
|
||||
- **Specify Body:** `Using JSON` (Expression 모드)
|
||||
- **JSON:**
|
||||
```json
|
||||
{
|
||||
"video_url": "{{ $json.link }}",
|
||||
"items": [
|
||||
{
|
||||
"snippet": {
|
||||
"publishedAt": "2026-03-25T06:00:00Z",
|
||||
"title": "영상 제목",
|
||||
"resourceId": { "videoId": "abc123" }
|
||||
}
|
||||
}
|
||||
]
|
||||
}
|
||||
```
|
||||
|
||||
### 3. Edit Fields (채널별 각 1개)
|
||||
|
||||
| 설정 항목 | 값 |
|
||||
|---|---|
|
||||
| Type | Edit Fields (Set) |
|
||||
| Mode | Manual Mapping |
|
||||
| Fields to Set | `channel_name` (String) = `머니코믹스` 또는 `슈카월드` |
|
||||
| Include Other Input Fields | All (기존 데이터 유지) |
|
||||
|
||||
### 4. Merge
|
||||
|
||||
| 설정 항목 | 값 |
|
||||
|---|---|
|
||||
| Type | Merge |
|
||||
| Mode | Append |
|
||||
|
||||
### 5. Code (새 영상 필터링)
|
||||
|
||||
| 설정 항목 | 값 |
|
||||
|---|---|
|
||||
| Type | Code |
|
||||
| Language | JavaScript |
|
||||
|
||||
최근 1시간 이내 발행된 영상만 필터링합니다:
|
||||
|
||||
```javascript
|
||||
const oneHourAgo = new Date(Date.now() - 60 * 60 * 1000);
|
||||
|
||||
return $input.all().filter(item => {
|
||||
const items = item.json.items || [];
|
||||
return items.some(i => new Date(i.snippet.publishedAt) > oneHourAgo);
|
||||
}).flatMap(item => {
|
||||
const channelName = item.json.channel_name || '';
|
||||
return (item.json.items || [])
|
||||
.filter(i => new Date(i.snippet.publishedAt) > oneHourAgo)
|
||||
.map(i => ({
|
||||
json: {
|
||||
video_url: `https://www.youtube.com/watch?v=${i.snippet.resourceId.videoId}`,
|
||||
title: i.snippet.title,
|
||||
channel_name: channelName,
|
||||
}
|
||||
}));
|
||||
});
|
||||
```
|
||||
|
||||
> **참고:** Schedule Trigger가 매 정시 실행되므로, 1시간 이내 영상을 필터링하면 새 영상만 처리됩니다. 영상이 없으면 빈 배열이 반환되어 이후 노드가 실행되지 않습니다.
|
||||
|
||||
### 6. HTTP Request (요약 API 호출)
|
||||
|
||||
| 설정 항목 | 값 |
|
||||
|---|---|
|
||||
| Type | HTTP Request |
|
||||
| Method | POST |
|
||||
| URL | `https://<서버주소>/api/news/summarize` |
|
||||
| Body Content Type | JSON |
|
||||
| Send Body | ON |
|
||||
|
||||
**Body (Expression 모드):**
|
||||
|
||||
```json
|
||||
{
|
||||
"video_url": "{{ $json.video_url }}",
|
||||
"title": "{{ $json.title }}"
|
||||
}
|
||||
```
|
||||
|
||||
> **주의:** 영상 제목에 큰따옴표(`"`)가 포함된 경우 JSON이 깨질 수 있습니다. 반드시 **Expression 모드**를 사용하세요 (Fixed 모드에서는 특수문자 이스케이프가 안 됩니다).
|
||||
|
||||
**Headers:**
|
||||
**Headers** (API_SECRET 사용 시):
|
||||
|
||||
| 헤더 | 값 |
|
||||
|---|---|
|
||||
| `Content-Type` | `application/json` |
|
||||
| `X-Api-Secret` | 설정한 시크릿 값 (`.env`의 `API_SECRET`과 동일) |
|
||||
| `X-Api-Secret` | 설정한 시크릿 값 |
|
||||
|
||||
## 에러 처리
|
||||
**Options:**
|
||||
- **Always Output Data**: ON (에러 시에도 다음 노드로 전달)
|
||||
|
||||
### Error Trigger 워크플로우
|
||||
> **주의:** 영상 제목에 큰따옴표(`"`)가 포함된 경우 JSON이 깨질 수 있습니다. 반드시 **Expression 모드**를 사용하세요.
|
||||
|
||||
메인 워크플로우와 별도로 에러 처리 워크플로우를 생성합니다.
|
||||
|
||||
1. **Error Trigger** 노드를 추가합니다.
|
||||
2. **HTTP Request** 노드로 Discord Webhook을 호출합니다:
|
||||
### 7. Switch (응답 분기)
|
||||
|
||||
| 설정 항목 | 값 |
|
||||
|---|---|
|
||||
| Method | `POST` |
|
||||
| URL | Discord Webhook URL |
|
||||
| Type | Switch |
|
||||
| Field | `{{ $json.status }}` |
|
||||
|
||||
**Rules:**
|
||||
|
||||
| Rule | Value | Output |
|
||||
|---|---|---|
|
||||
| Rule 1 | `ok` | → Discord 요약 전송 |
|
||||
| Rule 2 | `skipped` | → No Operation (무시) |
|
||||
| Rule 3 | `error` | → Discord 에러 알림 |
|
||||
|
||||
### 8. Discord 요약 전송 (status=ok)
|
||||
|
||||
| 설정 항목 | 값 |
|
||||
|---|---|
|
||||
| Type | HTTP Request |
|
||||
| Method | POST |
|
||||
| URL | Discord 웹훅 URL |
|
||||
| Body Content Type | JSON |
|
||||
|
||||
**Body:**
|
||||
|
||||
```json
|
||||
{
|
||||
"embeds": [{
|
||||
"title": "📰 [{{ $json.channel_name }}] {{ $json.title }}",
|
||||
"url": "{{ $json.video_url }}",
|
||||
"description": "{{ $json.summary }}",
|
||||
"color": 2829105,
|
||||
"thumbnail": {
|
||||
"url": "https://img.youtube.com/vi/{{ $json.video_url.split('v=')[1] }}/hqdefault.jpg"
|
||||
},
|
||||
"footer": {
|
||||
"text": "YouTube 뉴스 요약 봇 • {{ $json.channel_name }}"
|
||||
}
|
||||
}]
|
||||
}
|
||||
```
|
||||
|
||||
### 9. Discord 에러 알림 (status=error)
|
||||
|
||||
| 설정 항목 | 값 |
|
||||
|---|---|
|
||||
| Type | HTTP Request |
|
||||
| Method | POST |
|
||||
| URL | Discord 웹훅 URL |
|
||||
| Body Content Type | JSON |
|
||||
|
||||
**Body:**
|
||||
|
||||
```json
|
||||
{
|
||||
"embeds": [{
|
||||
"title": "❌ [{{ $json.channel_name }}] 뉴스 요약 실패",
|
||||
"color": 15548997,
|
||||
"fields": [
|
||||
{ "name": "영상 제목", "value": "{{ $json.title }}", "inline": false },
|
||||
{ "name": "영상 URL", "value": "{{ $json.video_url }}", "inline": false },
|
||||
{ "name": "에러 타입", "value": "`{{ $json.error_type }}`", "inline": true },
|
||||
{ "name": "에러 내용", "value": "```\n{{ $json.error_message }}\n```", "inline": false }
|
||||
],
|
||||
"footer": {
|
||||
"text": "YouTube 뉴스 요약 봇 - 에러 알림"
|
||||
}
|
||||
}]
|
||||
}
|
||||
```
|
||||
|
||||
## 에러 처리
|
||||
|
||||
### Error Trigger 워크플로우 (선택)
|
||||
|
||||
메인 워크플로우 자체가 실패하는 경우를 대비해 별도 에러 워크플로우를 만들 수 있습니다.
|
||||
|
||||
1. 새 워크플로우 생성 → **Error Trigger** 노드 추가
|
||||
2. **HTTP Request** 노드로 Discord Webhook 호출:
|
||||
|
||||
```json
|
||||
{
|
||||
"content": "뉴스 요약 봇 에러 발생!\n워크플로우: {{ $json.workflow.name }}\n에러: {{ $json.execution.error.message }}"
|
||||
}
|
||||
```
|
||||
|
||||
3. 메인 워크플로우의 **Settings → Error Workflow**에서 이 에러 워크플로우를 지정합니다.
|
||||
|
||||
### HTTP Request 노드 자체 에러 처리
|
||||
|
||||
HTTP Request 노드 설정에서:
|
||||
|
||||
- **Continue On Fail:** `true`로 설정하면 하나의 영상 요약이 실패해도 나머지 영상은 계속 처리됩니다.
|
||||
- **Retry On Fail:** `true`, **Max Retries:** `2`, **Wait Between Retries (ms):** `3000`
|
||||
3. 메인 워크플로우의 **Settings → Error Workflow**에서 이 에러 워크플로우를 지정
|
||||
|
||||
@@ -2,33 +2,23 @@
|
||||
|
||||
YouTube 뉴스 요약 봇의 배포 및 운영 가이드.
|
||||
|
||||
**아키텍처:** n8n (RSS 트리거) → FastAPI 앱 (Docker 컨테이너) → Claude API → Discord 웹훅
|
||||
**아키텍처:** n8n (YouTube Data API로 새 영상 감지) → FastAPI 앱 (자막 추출 + Claude 요약) → JSON 응답 → n8n (Discord 웹훅 전송)
|
||||
|
||||
---
|
||||
|
||||
## 배포 절차
|
||||
## 배포
|
||||
|
||||
### 1. Docker 이미지 빌드 & Push
|
||||
### CI/CD (자동)
|
||||
|
||||
```bash
|
||||
docker build -t nkey/news-summary-bot:latest .
|
||||
docker push nkey/news-summary-bot:latest
|
||||
```
|
||||
main 브랜치에 push하면 GitHub Actions가 자동으로:
|
||||
1. Docker 이미지 빌드
|
||||
2. Docker Hub에 push
|
||||
3. OCI 서버에서 `docker compose pull && up -d`
|
||||
4. Discord에 배포 결과 알림
|
||||
|
||||
### 2. OCI 서버 설정
|
||||
커밋 메시지에 `[x.y.z]` 버전 태그가 있으면 해당 버전 태그로도 이미지가 push됩니다.
|
||||
|
||||
- `docker-compose.yml`과 `.env` 파일을 서버에 준비
|
||||
- `.env`에 아래 값 설정:
|
||||
- `ANTHROPIC_API_KEY` — Claude API 키
|
||||
- `DISCORD_WEBHOOK_URL` — Discord 웹훅 URL
|
||||
- `API_SECRET` — n8n에서 호출 시 인증용 시크릿
|
||||
- 컨테이너 실행:
|
||||
|
||||
```bash
|
||||
docker compose pull && docker compose up -d
|
||||
```
|
||||
|
||||
### 3. 업데이트 시
|
||||
### 수동 배포
|
||||
|
||||
```bash
|
||||
# 로컬에서
|
||||
@@ -36,9 +26,16 @@ docker build -t nkey/news-summary-bot:latest .
|
||||
docker push nkey/news-summary-bot:latest
|
||||
|
||||
# OCI 서버에서
|
||||
docker compose pull && docker compose up -d
|
||||
docker compose -p nkeys-apps -f /nkeysworld/compose.apps.yml pull news-summary-bot
|
||||
docker compose -p nkeys-apps -f /nkeysworld/compose.apps.yml up -d news-summary-bot
|
||||
```
|
||||
|
||||
### OCI 서버 환경변수
|
||||
|
||||
`.env`에 아래 값 설정:
|
||||
- `ANTHROPIC_API_KEY` — Claude API 키
|
||||
- `API_SECRET` — n8n에서 호출 시 인증용 시크릿
|
||||
|
||||
---
|
||||
|
||||
## 헬스체크
|
||||
@@ -63,14 +60,12 @@ docker compose logs -f news-summary-bot
|
||||
|
||||
| 증상 | 원인 | 해결 |
|
||||
|------|------|------|
|
||||
| 자막 추출 실패 (자막 없음) | 영상에 자막 없음 | 자동생성 자막이 없는 영상은 스킵됨 |
|
||||
| 자막 추출 실패 (자막 없음) | 영상에 자막 없음 | 자동생성 자막이 없는 영상은 status: error로 반환됨 |
|
||||
| 자막 추출 실패 (봇 감지) | YouTube 쿠키 만료 | 브라우저에서 쿠키 재export 후 서버에 업로드 (아래 쿠키 갱신 참고) |
|
||||
| Discord 전송 실패 | 웹훅 URL 만료 | Discord에서 웹훅 재생성 후 `.env` 업데이트 |
|
||||
| 401 Unauthorized | API_SECRET 불일치 | n8n 헤더와 `.env` 값 확인 |
|
||||
| Claude API 오류 | API 키 만료 또는 잔액 부족 | Anthropic 콘솔에서 확인 |
|
||||
| Discord embed 글자 수 초과 | 요약이 4096자 초과 | `summarizer.py`의 `max_tokens` 줄이기 |
|
||||
|
||||
> 에러 발생 시 FastAPI가 자동으로 Discord에 에러 상세 내용(에러 타입, 메시지, 영상 정보)을 전송합니다.
|
||||
> 에러 발생 시 FastAPI가 `status: error`로 응답하고, n8n이 Switch 노드에서 분기하여 Discord에 에러 알림을 전송합니다.
|
||||
|
||||
### Nginx 404 에러
|
||||
|
||||
@@ -79,13 +74,6 @@ Nginx가 `/api/news/` prefix를 strip하여 FastAPI로 전달합니다. FastAPI
|
||||
- 404가 발생하면 Nginx 설정에 `/api/news/` location 블록이 있는지 확인
|
||||
- FastAPI 라우트가 prefix 없이 `/summarize`, `/health`로 되어 있는지 확인
|
||||
|
||||
### n8n HTTP Request 에러
|
||||
|
||||
| 증상 | 원인 | 해결 |
|
||||
|------|------|------|
|
||||
| `JSON parameter needs to be valid JSON` | 영상 제목에 큰따옴표(`"`) 포함 시 JSON 깨짐 | Specify Body를 **Expression 모드**로 설정 (Fixed 모드 사용 금지) |
|
||||
| 404 Not Found | Nginx → FastAPI 프록시 미설정 또는 라우트 불일치 | Nginx 설정 및 FastAPI 라우트 확인 |
|
||||
|
||||
### Docker 쿠키 마운트 관련
|
||||
|
||||
| 증상 | 원인 | 해결 |
|
||||
@@ -109,7 +97,7 @@ YouTube는 OCI/AWS/GCP 등 **데이터센터 IP를 봇으로 감지**하여 자
|
||||
|
||||
YouTube는 클라우드 서버 IP를 봇으로 감지하여 자막 추출을 차단합니다. 이를 우회하기 위해 브라우저 쿠키를 사용하며, 약 6개월~1년 주기로 만료됩니다.
|
||||
|
||||
**만료 증상:** 자막 추출 시 500 에러 + 로그에 `Sign in to confirm you're not a bot` 메시지 + Discord 에러 알림
|
||||
**만료 증상:** 자막 추출 에러 + 로그에 `Sign in to confirm you're not a bot` 메시지 + Discord 에러 알림
|
||||
|
||||
**갱신 절차:**
|
||||
|
||||
@@ -146,3 +134,4 @@ YouTube는 클라우드 서버 IP를 봇으로 감지하여 자막 추출을 차
|
||||
- **Claude Sonnet 4.6:** Input $3/MTok, Output $15/MTok
|
||||
- 일 1~2건 기준 월 ~$3 이내
|
||||
- [Anthropic 콘솔](https://console.anthropic.com/)에서 usage 확인
|
||||
- **YouTube Data API v3:** 무료 (일일 10,000 units 쿼터, 현재 사용량 ~48 units/일)
|
||||
|
||||
@@ -10,25 +10,10 @@ pip install pytest pytest-asyncio
|
||||
|
||||
```bash
|
||||
# 환경변수 더미값 설정 (config.py 로드용)
|
||||
ANTHROPIC_API_KEY=test DISCORD_WEBHOOK_URL=https://test pytest -v
|
||||
ANTHROPIC_API_KEY=test pytest -v
|
||||
```
|
||||
|
||||
## 테스트 구조
|
||||
|
||||
### `tests/test_discord.py`
|
||||
|
||||
Discord 메시지 전송 모듈(`app/discord.py`)에 대한 유닛 테스트.
|
||||
|
||||
| 테스트 | 설명 |
|
||||
|--------|------|
|
||||
| `test_extract_video_id` | 다양한 YouTube URL 형식에서 비디오 ID를 올바르게 추출하는지 확인 (parametrize 5건) |
|
||||
| `test_parse_summary_bold_format` | `**한줄 요약**:` 볼드 형식의 요약 파싱 검증 |
|
||||
| `test_parse_summary_heading_format` | `## 한줄 요약` 헤딩 형식의 요약 파싱 검증 |
|
||||
| `test_parse_summary_empty` | 파싱 불가한 텍스트에 대해 빈 dict 반환 확인 |
|
||||
| `test_send_to_discord_embed_structure` | 임베드에 제목, URL, 썸네일, 필드, 푸터, 타임스탬프가 올바르게 구성되는지 확인 |
|
||||
| `test_send_to_discord_fallback_on_unparsable` | 파싱 실패 시 전체 텍스트가 description에 그대로 들어가는지 확인 |
|
||||
|
||||
## 참고
|
||||
|
||||
- 테스트는 외부 API를 호출하지 않습니다 (`httpx.AsyncClient`를 mock 처리).
|
||||
- `ANTHROPIC_API_KEY`, `DISCORD_WEBHOOK_URL`은 pydantic-settings가 필수로 요구하므로 더미값을 환경변수로 전달해야 합니다.
|
||||
- `ANTHROPIC_API_KEY`는 pydantic-settings가 필수로 요구하므로 더미값을 환경변수로 전달해야 합니다.
|
||||
|
||||
@@ -1,137 +0,0 @@
|
||||
"""discord.py 유닛 테스트 — 파싱, 비디오 ID 추출, 임베드 구성."""
|
||||
|
||||
from unittest.mock import AsyncMock, patch
|
||||
|
||||
import pytest
|
||||
|
||||
from app.discord import _extract_video_id, _parse_summary, send_to_discord
|
||||
|
||||
|
||||
# ── _extract_video_id ──
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
"url, expected",
|
||||
[
|
||||
("https://youtu.be/abc123", "abc123"),
|
||||
("https://www.youtube.com/watch?v=xyz789", "xyz789"),
|
||||
("https://www.youtube.com/watch?v=xyz789&t=10", "xyz789"),
|
||||
("https://youtu.be/abc123?si=something", "abc123"),
|
||||
("https://example.com/no-video", None),
|
||||
],
|
||||
)
|
||||
def test_extract_video_id(url: str, expected: str | None):
|
||||
assert _extract_video_id(url) == expected
|
||||
|
||||
|
||||
# ── _parse_summary ──
|
||||
|
||||
|
||||
SAMPLE_SUMMARY_BOLD = """\
|
||||
- **한줄 요약**: 한국 경제가 위기에 처했다.
|
||||
|
||||
- **주요 내용**:
|
||||
- GDP 성장률 하락
|
||||
- 수출 감소
|
||||
- 환율 불안정
|
||||
|
||||
- **결론/시사점**: 정부의 적극적 대응이 필요하다.
|
||||
"""
|
||||
|
||||
SAMPLE_SUMMARY_HEADING = """\
|
||||
## 한줄 요약
|
||||
한국 경제가 위기에 처했다.
|
||||
|
||||
## 주요 내용
|
||||
- GDP 성장률 하락
|
||||
- 수출 감소
|
||||
- 환율 불안정
|
||||
|
||||
## 결론/시사점
|
||||
정부의 적극적 대응이 필요하다.
|
||||
"""
|
||||
|
||||
|
||||
def test_parse_summary_bold_format():
|
||||
result = _parse_summary(SAMPLE_SUMMARY_BOLD)
|
||||
assert "한줄요약" in result
|
||||
assert "한국 경제가 위기에 처했다." in result["한줄요약"]
|
||||
assert "주요내용" in result
|
||||
assert "GDP 성장률 하락" in result["주요내용"]
|
||||
assert "결론/시사점" in result or "결론시사점" in result
|
||||
|
||||
|
||||
def test_parse_summary_heading_format():
|
||||
result = _parse_summary(SAMPLE_SUMMARY_HEADING)
|
||||
assert "한줄요약" in result
|
||||
assert "한국 경제가 위기에 처했다." in result["한줄요약"]
|
||||
assert "주요내용" in result
|
||||
assert "GDP 성장률 하락" in result["주요내용"]
|
||||
|
||||
|
||||
def test_parse_summary_empty():
|
||||
result = _parse_summary("이건 파싱 안 되는 텍스트")
|
||||
assert result == {}
|
||||
|
||||
|
||||
# ── send_to_discord ──
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_send_to_discord_embed_structure():
|
||||
"""send_to_discord가 올바른 임베드 구조로 웹훅을 호출하는지 확인."""
|
||||
mock_response = AsyncMock()
|
||||
mock_response.raise_for_status = lambda: None
|
||||
|
||||
with patch("app.discord.httpx.AsyncClient") as mock_client_cls:
|
||||
mock_client = AsyncMock()
|
||||
mock_client.post.return_value = mock_response
|
||||
mock_client_cls.return_value.__aenter__ = AsyncMock(return_value=mock_client)
|
||||
mock_client_cls.return_value.__aexit__ = AsyncMock(return_value=False)
|
||||
|
||||
await send_to_discord(
|
||||
title="테스트 영상",
|
||||
video_url="https://youtu.be/abc123",
|
||||
summary=SAMPLE_SUMMARY_BOLD,
|
||||
)
|
||||
|
||||
mock_client.post.assert_called_once()
|
||||
payload = mock_client.post.call_args[1]["json"]
|
||||
|
||||
embed = payload["embeds"][0]
|
||||
assert "📰 테스트 영상" == embed["title"]
|
||||
assert embed["url"] == "https://youtu.be/abc123"
|
||||
assert embed["thumbnail"]["url"] == "https://img.youtube.com/vi/abc123/hqdefault.jpg"
|
||||
assert embed["footer"]["text"] == "YouTube 뉴스 요약 봇"
|
||||
assert "timestamp" in embed
|
||||
|
||||
# 필드 확인
|
||||
field_names = [f["name"] for f in embed["fields"]]
|
||||
assert "📋 주요 내용" in field_names
|
||||
assert "🎯 결론 / 시사점" in field_names
|
||||
assert "🔗 원본 영상" in field_names
|
||||
|
||||
# description에 한줄 요약 포함
|
||||
assert "한국 경제가 위기에 처했다" in embed["description"]
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_send_to_discord_fallback_on_unparsable():
|
||||
"""파싱 실패 시 전체 summary가 description에 들어가는지 확인."""
|
||||
mock_response = AsyncMock()
|
||||
mock_response.raise_for_status = lambda: None
|
||||
|
||||
with patch("app.discord.httpx.AsyncClient") as mock_client_cls:
|
||||
mock_client = AsyncMock()
|
||||
mock_client.post.return_value = mock_response
|
||||
mock_client_cls.return_value.__aenter__ = AsyncMock(return_value=mock_client)
|
||||
mock_client_cls.return_value.__aexit__ = AsyncMock(return_value=False)
|
||||
|
||||
raw = "이건 섹션 없는 요약 텍스트입니다."
|
||||
await send_to_discord("테스트", "https://youtu.be/abc", raw)
|
||||
|
||||
payload = mock_client.post.call_args[1]["json"]
|
||||
embed = payload["embeds"][0]
|
||||
assert embed["description"] == raw
|
||||
assert len(embed["fields"]) == 1
|
||||
assert embed["fields"][0]["name"] == "🔗 원본 영상"
|
||||
Reference in New Issue
Block a user