From a2a82084ba327848037939c46c07056e87735eb6 Mon Sep 17 00:00:00 2001 From: sm4640 Date: Wed, 25 Mar 2026 15:18:39 +0900 Subject: [PATCH] =?UTF-8?q?Docs:=20[3.0.0]=20=EC=A0=84=EC=B2=B4=20?= =?UTF-8?q?=EB=AC=B8=EC=84=9C=20=EC=97=85=EB=8D=B0=EC=9D=B4=ED=8A=B8=20?= =?UTF-8?q?=E2=80=94=20YouTube=20Data=20API=20+=20n8n=20Discord=20?= =?UTF-8?q?=EC=A0=84=EC=86=A1=20=EA=B5=AC=EC=A1=B0=20=EB=B0=98=EC=98=81?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 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 --- .env.example | 1 - README.md | 33 +++--- docs/development.md | 31 +++-- docs/n8n-setup.md | 257 +++++++++++++++++++++++++++++++++--------- docs/operations.md | 55 ++++----- docs/testing.md | 19 +--- tests/test_discord.py | 137 ---------------------- 7 files changed, 260 insertions(+), 273 deletions(-) delete mode 100644 tests/test_discord.py diff --git a/.env.example b/.env.example index 8a2bdf8..4d879f6 100644 --- a/.env.example +++ b/.env.example @@ -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 diff --git a/README.md b/README.md index c035e77..a710e17 100644 --- a/README.md +++ b/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)를 참고하세요. diff --git a/docs/development.md b/docs/development.md index 87a1f99..d5b3dee 100644 --- a/docs/development.md +++ b/docs/development.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 인증용 시크릿 (미설정 시 인증 생략) | diff --git a/docs/n8n-setup.md b/docs/n8n-setup.md index 478fe99..1f46b1b 100644 --- a/docs/n8n-setup.md +++ b/docs/n8n-setup.md @@ -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**에서 이 에러 워크플로우를 지정 diff --git a/docs/operations.md b/docs/operations.md index 7263da3..175753d 100644 --- a/docs/operations.md +++ b/docs/operations.md @@ -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/일) diff --git a/docs/testing.md b/docs/testing.md index 224950e..bafdcfe 100644 --- a/docs/testing.md +++ b/docs/testing.md @@ -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가 필수로 요구하므로 더미값을 환경변수로 전달해야 합니다. diff --git a/tests/test_discord.py b/tests/test_discord.py deleted file mode 100644 index ffbaf21..0000000 --- a/tests/test_discord.py +++ /dev/null @@ -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"] == "🔗 원본 영상"