Docs: [3.0.0] 전체 문서 업데이트 — YouTube Data API + n8n Discord 전송 구조 반영
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:
sm4640
2026-03-25 15:18:39 +09:00
parent 302f892c5d
commit a2a82084ba
7 changed files with 260 additions and 273 deletions

View File

@@ -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

View File

@@ -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)를 참고하세요.

View File

@@ -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 인증용 시크릿 (미설정 시 인증 생략) |

View File

@@ -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**에서 이 에러 워크플로우를 지정

View File

@@ -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/일)

View File

@@ -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가 필수로 요구하므로 더미값을 환경변수로 전달해야 합니다.

View File

@@ -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"] == "🔗 원본 영상"