18 Commits
2.0.4 ... main

Author SHA1 Message Date
sm4640
b34bc1f582 Feat: [4.0.0] 뉴스 요약에 주식 시장 영향 분석 기능 추가
All checks were successful
news-summary-bot-cicd / build_push_deploy (push) Successful in 11m18s
요약 프롬프트에 증권 애널리스트 역할을 통합하여 API 1회 호출로
시장 영향, 관련 섹터, 주목 종목, 전망을 함께 생성.
모든 필드는 단순 문자열로 유지하여 파싱 안정성 확보.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-28 15:30:22 +09:00
sm4640
6ae4325994 Docs: n8n Discord 노드 템플릿을 실제 사용 중인 코드로 복원
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-25 20:05:44 +09:00
sm4640
bb785545f0 Fix: [3.0.5] 요약 필드 빈 값 방지 — 프롬프트 규칙 + fallback 처리
All checks were successful
news-summary-bot-cicd / build_push_deploy (push) Successful in 9m23s
- 프롬프트에 세 필드 모두 비어있지 않은 값 필수 규칙 추가
- 코드에서 빈 문자열일 경우 "(내용 없음)" fallback 처리
- Discord embed field value 빈 문자열 에러 방지

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-25 20:03:30 +09:00
sm4640
bc4f6f3f6a Fix: [3.0.4] Discord embed 요약에 이모티콘 섹션 구분 추가
Some checks failed
news-summary-bot-cicd / build_push_deploy (push) Has been cancelled
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-25 19:59:40 +09:00
sm4640
8aa03232e8 Fix: [3.0.3] JSON 파싱 강화 + Discord 전송 안정화
Some checks failed
news-summary-bot-cicd / build_push_deploy (push) Has been cancelled
- summarizer: 코드펜스/잡텍스트 포함된 Claude 응답도 안정적으로 파싱
- summarizer: 프롬프트에 코드펜스 금지 명시
- main: Discord embed용 summary 통합 필드 추가
- docs: n8n Discord 노드를 JSON.stringify() 방식으로 변경 (줄바꿈/따옴표 이스케이프)

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-25 19:56:06 +09:00
sm4640
9c2bf7c1ce Fix: [3.0.2] 요약 응답 구조화 + Discord 임베드 디자인 개선
All checks were successful
news-summary-bot-cicd / build_push_deploy (push) Successful in 9m14s
- 요약을 JSON으로 구조화: oneliner, main_points, conclusion 분리
- Claude에게 JSON 형식으로만 응답하도록 프롬프트 변경
- n8n Discord 임베드: 섹션별 필드 분리, 이모지, 타임스탬프 추가
- JSON.stringify Expression으로 특수문자 이스케이프 처리
- 전체 문서 API 응답 형식 업데이트

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-25 17:18:31 +09:00
sm4640
f4532840cf Fix: [3.0.1] 응답에 video_url/channel_name 포함 + 요약 프롬프트 개선
All checks were successful
news-summary-bot-cicd / build_push_deploy (push) Successful in 15m25s
- API 응답에 video_url, channel_name을 항상 포함 (n8n Switch 이후 사용)
- channel_name 필드를 request body에서 받아 그대로 반환
- 요약 프롬프트: 구체적 수치/사례 포함, 메타 서술 금지, 시청자 액션 제시
- 문서 전체 API 응답 형식 업데이트

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-25 16:19:15 +09:00
sm4640
b9e1c4fb45 Docs: 에러 알림 구분 — n8n 워크플로우 에러 vs 봇 요약 처리 에러
- Error Trigger: 임베드 형식으로 변경, 실패 노드/실행 ID 포함, fallback 처리
- Switch error: footer를 "봇 요약 처리 에러"로 구분
- 에러 유형 비교 테이블 추가

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-25 16:00:11 +09:00
sm4640
11b3a60be8 Docs: n8n 노드 설정 상세화 — Mode, Send Body, Specify Body 등 누락 옵션 추가
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-25 15:54:33 +09:00
sm4640
a2a82084ba 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>
2026-03-25 15:18:39 +09:00
sm4640
302f892c5d Refactor: [3.0.0] Discord 전송을 n8n으로 이관, YouTube RSS → Data API 전환
Some checks failed
news-summary-bot-cicd / build_push_deploy (push) Has been cancelled
- FastAPI에서 Discord 직접 전송 제거, 요약 결과를 JSON으로 반환
- app/discord.py 삭제, DISCORD_WEBHOOK_URL 환경변수 제거
- 에러 시 500 대신 200 + status: error로 응답 (n8n에서 분기 처리)
- CLAUDE.md에 n8n 워크플로우 노드별 상세 설정 문서화

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-25 15:05:36 +09:00
sm4640
22949d0602 Fix: [2.0.11] 라이브 DownloadError 스킵 처리 및 채널명 표시
All checks were successful
news-summary-bot-cicd / build_push_deploy (push) Successful in 12m11s
- yt-dlp DownloadError에서 라이브/프리미어 감지 시 SkipVideo로 변환 (500 방지)
- channel_name 필드 추가로 Discord 알림에 채널명(머니코믹스/슈카월드) 표시

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-25 13:18:12 +09:00
sm4640
da9e557c64 Fix: [2.0.10] 라이브/쇼츠 스킵 및 네트워크 타임아웃 설정
All checks were successful
news-summary-bot-cicd / build_push_deploy (push) Successful in 18m49s
- 라이브/쇼츠 영상 감지 시 에러 대신 스킵 처리
- yt-dlp socket_timeout, httpx timeout, Discord webhook timeout 30초로 설정

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-24 21:09:32 +09:00
sm4640
0bf38dd2f4 Docs: [2.0.9] 트러블슈팅 가이드 상세화
- Nginx 404, n8n JSON 에러, Docker 쿠키 마운트 이슈 문서화
- YouTube 봇 감지 원인 및 대응 방법 정리
- 쿠키 갱신 절차에 심볼릭 링크 주의사항 추가

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-24 17:48:35 +09:00
sm4640
c8c9c592cf Fix: [2.0.8] process=False로 포맷 처리 건너뛰고 자막만 추출
All checks were successful
news-summary-bot-cicd / build_push_deploy (push) Successful in 15m23s
extract_info에 process=False 적용하여 포맷 선택 단계를 완전히 스킵.
자막 정보는 YouTube extractor에서 직접 반환되므로 포맷 처리 불필요.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-24 17:12:23 +09:00
sm4640
98cd620f23 Fix: [2.0.7] format 에러 해결 - ignore_no_formats_error 사용
All checks were successful
news-summary-bot-cicd / build_push_deploy (push) Successful in 11m7s
format: worst 대신 ignore_no_formats_error: True로 변경하여
포맷 관련 에러 없이 자막만 추출하도록 수정

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-24 16:54:33 +09:00
sm4640
5e8e10e2fa Fix: [2.0.6] Docker 빌드 시 --no-cache 추가
All checks were successful
news-summary-bot-cicd / build_push_deploy (push) Successful in 12m17s
buildx 캐시 참조 에러(no such job) 해결

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-24 16:32:34 +09:00
sm4640
af0e2fca8a Feat: [2.0.5] 에러 발생 시 Discord 알림 전송
Some checks failed
news-summary-bot-cicd / build_push_deploy (push) Failing after 3m42s
- 요약 처리 중 에러 발생 시 Discord에 에러 상세 내용 전송
- 에러 타입, 메시지, 영상 정보를 포함한 embed 형식

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-24 16:27:03 +09:00
14 changed files with 677 additions and 422 deletions

View File

@@ -1,4 +1,3 @@
ANTHROPIC_API_KEY=sk-ant-xxx ANTHROPIC_API_KEY=sk-ant-xxx
DISCORD_WEBHOOK_URL=https://discord.com/api/webhooks/xxx/xxx
API_SECRET=your-secret-here API_SECRET=your-secret-here
DOCKER_IMAGE=nkey/news-summary-bot:latest DOCKER_IMAGE=nkey/news-summary-bot:latest

View File

@@ -90,10 +90,10 @@ jobs:
IMAGE="${DOCKERHUB_USER}/${IMAGE_NAME}:latest" IMAGE="${DOCKERHUB_USER}/${IMAGE_NAME}:latest"
if [ -n "${VERSION_TAG}" ]; then if [ -n "${VERSION_TAG}" ]; then
VERSIONED_IMAGE="${DOCKERHUB_USER}/${IMAGE_NAME}:${VERSION_TAG}" VERSIONED_IMAGE="${DOCKERHUB_USER}/${IMAGE_NAME}:${VERSION_TAG}"
docker build -t "${IMAGE}" -t "${VERSIONED_IMAGE}" . docker build --no-cache -t "${IMAGE}" -t "${VERSIONED_IMAGE}" .
docker push "${VERSIONED_IMAGE}" docker push "${VERSIONED_IMAGE}"
else else
docker build -t "${IMAGE}" . docker build --no-cache -t "${IMAGE}" .
fi fi
docker push "${IMAGE}" docker push "${IMAGE}"

217
CLAUDE.md
View File

@@ -4,9 +4,10 @@ This file provides guidance to Claude Code (claude.ai/code) when working with co
## 프로젝트 개요 ## 프로젝트 개요
YouTube 뉴스/경제 채널의 새 영상을 감지하면 자막을 추출하고 Claude API로 요약한 뒤 Discord로 전송하는 봇. YouTube 뉴스/경제 채널의 새 영상을 감지하면 자막을 추출하고 Claude API로 요약하는 봇.
- 영상 감지: n8n RSS 트리거 (외부) - 영상 감지: n8n (YouTube Data API v3 `playlistItems.list`)
- 요약 처리: FastAPI 앱 (이 레포) - 요약 처리: FastAPI 앱 (이 레포)
- Discord 전송: n8n (FastAPI 응답을 받아서 Discord 웹훅으로 전송)
- 배포: Docker Hub → OCI 서버에서 docker-compose pull - 배포: Docker Hub → OCI 서버에서 docker-compose pull
## 빌드 & 실행 ## 빌드 & 실행
@@ -23,39 +24,223 @@ docker compose up
## 아키텍처 ## 아키텍처
n8n(RSS 감지) → `POST /api/news/summarize` → 자막 추출 → Claude 요약 → Discord 웹훅 n8n(YouTube API로 새 영상 감지) → `POST /api/news/summarize` → 자막 추출 → Claude 요약 → JSON 응답 → n8n이 Discord 웹훅 전송
- `app/main.py` — FastAPI 엔드포인트 (`/summarize`, `/health`) — Nginx가 `/api/news/` prefix를 strip - `app/main.py` — FastAPI 엔드포인트 (`/summarize`, `/health`) — Nginx가 `/api/news/` prefix를 strip
- `app/transcript.py` — YouTube 자막 추출 (`yt-dlp` + 쿠키 인증) - `app/transcript.py` — YouTube 자막 추출 (`yt-dlp` + 쿠키 인증)
- `app/summarizer.py` — Claude Sonnet 4.6으로 요약 생성 - `app/summarizer.py` — Claude Sonnet 4.6으로 요약 + 주식 시장 영향 분석 생성
- `app/discord.py` — Discord 웹훅 전송
- `app/config.py` — 환경변수 설정 (pydantic-settings) - `app/config.py` — 환경변수 설정 (pydantic-settings)
### API 응답 형식
```json
// 성공
{"video_url": "...", "title": "...", "channel_name": "...", "status": "ok", "oneliner": "한줄 요약", "main_points": "• 포인트1\n• 포인트2", "conclusion": "결론", "market_impact": "시장 영향 요약", "sectors": "📈 섹터명 — 이유\n📉 섹터명 — 이유", "watchlist": "• 종목명(코드) — 이유\n• 종목명(코드) — 이유", "outlook": "전망 및 액션 포인트", "summary": "통합 요약 텍스트"}
// 스킵 (라이브/쇼츠)
{"video_url": "...", "title": "...", "channel_name": "...", "status": "skipped", "reason": "라이브/예정 영상은 요약 대상이 아닙니다"}
// 에러
{"video_url": "...", "title": "...", "channel_name": "...", "status": "error", "error_type": "ValueError", "error_message": "..."}
```
> `summary`는 `oneliner` + `main_points` + `conclusion` + `market_impact` + `sectors` + `watchlist` + `outlook`을 합친 Discord embed용 통합 필드.
> 주식 분석 필드(`market_impact`, `sectors`, `watchlist`, `outlook`)는 뉴스와 무관 시 "해당 뉴스는 주식 시장과 직접적 연관이 없습니다"로 fallback.
## 환경변수 ## 환경변수
`ANTHROPIC_API_KEY`, `DISCORD_WEBHOOK_URL` 필수. `API_SECRET`은 선택(n8n → FastAPI 인증용). `ANTHROPIC_API_KEY` 필수. `API_SECRET`은 선택(n8n → FastAPI 인증용).
## 쿠키 인증 (YouTube 봇 감지 우회) ## 쿠키 인증 (YouTube 봇 감지 우회)
OCI 등 클라우드 서버에서 YouTube 자막 추출 시 봇 감지 차단을 우회하기 위해 쿠키 파일이 필요. OCI 등 클라우드 서버에서 YouTube 자막 추출 시 봇 감지 차단을 우회하기 위해 쿠키 파일이 필요.
- 브라우저 확장(Get cookies.txt LOCALLY 등)으로 YouTube 쿠키를 `cookies.txt`로 export - 브라우저 확장(Get cookies.txt LOCALLY 등)으로 YouTube 쿠키를 `cookies.txt`로 export
- 서버의 `compose.apps.yml`에서 `./news-summary-bot/cookies.txt:/app/cookies.txt:ro`로 마운트 - 서버의 `compose.apps.yml`에서 `./news-summary-bot/cookies.txt:/app/cookies.txt:ro`로 마운트
- 쿠키 만료 시(6개월~1년) 재export 필요 → 500 에러 발생 시 쿠키 갱신 확인 - 쿠키 만료 시(6개월~1년) 재export 필요 → 에러 발생 시 쿠키 갱신 확인
## n8n 워크플로우 ## n8n 워크플로우
### 전체 흐름
``` ```
RSS Feed Trigger (채널A) ──┐ Schedule Trigger (매 정시) ─→ 머니코믹스 playlistItems ─→ Edit Fields (channel_name: 머니코믹스) ──┐
Merge → HTTP Request (POST /api/news/summarize) 슈카월드 playlistItems ─→ Edit Fields (channel_name: 슈카월드) ──┤
RSS Feed Trigger (채널B) ──┘
Merge (Append)
Code (새 영상만 필터링)
HTTP Request (POST /api/news/summarize)
Switch (status 분기)
├→ ok → Discord 요약 전송
├→ skipped → No Operation
└→ error → Discord 에러 알림
``` ```
- **RSS Feed Trigger**: 채널별 RSS URL로 새 영상만 감지 (중복 방지 내장, Poll Time으로 주기 설정) ### 노드별 설정
- **Merge**: 두 채널의 새 영상을 하나의 리스트로 합침
- **HTTP Request**: 각 영상마다 `POST <서버IP>/api/news/summarize` 호출
요청 바디: #### 1. Schedule Trigger
- **Type**: Schedule Trigger
- **Rule**: Every Hour, Minute=0
#### 2. YouTube API - playlistItems (채널별 각 1개)
- **Type**: HTTP Request
- **Method**: GET
- **URL**: `https://www.googleapis.com/youtube/v3/playlistItems`
- **Send Query Parameters**: ON
- **Specify Query Parameters**: Using Individual Fields
- **Query Parameters (Add Parameter로 추가)**:
- `part`: `snippet`
- `playlistId`: 채널 업로드 목록 ID (채널 ID의 `UC``UU`로 변환)
- `maxResults`: `5`
- `key`: YouTube Data API v3 키
- **채널별 playlistId**:
- 머니코믹스: `UUJo6G1u0e_-wS-JQn3T-zEw` (채널 ID: `UCJo6G1u0e_-wS-JQn3T-zEw`)
- 슈카월드: `UUsJ6RuBiTVWRX156FVbeaGg` (채널 ID: `UCsJ6RuBiTVWRX156FVbeaGg`)
- **응답 예시**:
```json ```json
{"video_url": "https://youtu.be/xxx", "title": "영상 제목"} {
"items": [
{
"snippet": {
"publishedAt": "2026-03-25T06:00:00Z",
"title": "영상 제목",
"resourceId": { "videoId": "abc123" }
}
}
]
}
``` ```
`API_SECRET` 설정 시 헤더에 `X-Api-Secret` 포함.
#### 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
- **Mode**: Run Once for All Items
- 최근 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,
}
}));
});
```
#### 6. HTTP Request (요약 API 호출)
- **Type**: HTTP Request
- **Method**: POST
- **URL**: `https://<서버주소>/api/news/summarize`
- **Send Body**: ON
- **Body Content Type**: JSON
- **Specify Body**: Using JSON
- **Body (Expression 모드)**:
```json
{
"video_url": "{{ $json.video_url }}",
"title": "{{ $json.title }}",
"channel_name": "{{ $json.channel_name }}"
}
```
- **Headers** (API_SECRET 사용 시): `X-Api-Secret: <시크릿값>`
- **Options**: "Always Output Data" 켜기 (에러 시에도 다음 노드로 전달)
#### 7. Switch (응답 분기)
- **Type**: Switch
- **Mode**: Rules
- **Data Type**: String
- **Value**: `{{ $json.status }}`
- **Rules**:
- Rule 1: Equal `ok` → Discord 요약 전송
- Rule 2: Equal `skipped` → No Operation (무시)
- Rule 3: Equal `error` → Discord 에러 알림
#### 8. Discord 요약 전송 (status=ok)
- **Type**: HTTP Request
- **Method**: POST
- **URL**: Discord 웹훅 URL
- **Send Body**: ON
- **Body Content Type**: JSON
- **Specify Body**: Using JSON
- **Body**: Expression 모드 (`=` 클릭)로 전환 후 아래 입력:
```javascript
={{ JSON.stringify({
embeds: [{
title: "📰 [" + $json.channel_name + "] " + $json.title,
url: $json.video_url,
description: "### 💡 " + $json.oneliner,
color: 2829105,
fields: [
{ name: "📋 주요 내용", value: $json.main_points, inline: false },
{ name: "🎯 결론 / 시사점", value: $json.conclusion, inline: false },
{ name: "📈 시장 영향", value: $json.market_impact, inline: false },
{ name: "🏭 관련 섹터", value: $json.sectors, inline: false },
{ name: "👀 주목 종목", value: $json.watchlist, inline: false },
{ name: "🔮 전망", value: $json.outlook, inline: false },
{ name: "🔗 원본 영상", value: $json.video_url, inline: false }
],
thumbnail: {
url: "https://img.youtube.com/vi/" + $json.video_url.split("v=")[1] + "/hqdefault.jpg"
},
footer: {
text: "⚠️ 투자 참고용 · 판단의 책임은 본인에게 있습니다 • " + $json.channel_name
},
timestamp: new Date().toISOString()
}]
}) }}
```
#### 9. Discord 에러 알림 (status=error)
- **Type**: HTTP Request
- **Method**: POST
- **URL**: Discord 웹훅 URL
- **Send Body**: ON
- **Body Content Type**: JSON
- **Specify Body**: Using JSON
- **Body**: Expression 모드 (`=` 클릭)로 전환 후 아래 입력:
```javascript
={{ JSON.stringify({
embeds: [{
title: "❌ [" + $json.channel_name + "] 뉴스 요약 실패",
color: 15548997,
fields: [
{ name: "영상 제목", value: $json.title, inline: false },
{ name: "에러 타입", value: "`" + $json.error_type + "`", inline: true },
{ name: "에러 내용", value: "```\n" + $json.error_message + "\n```", inline: false }
],
footer: {
text: "봇 요약 처리 에러 — FastAPI 응답"
}
}]
}) }}
```
### YouTube Data API 키 발급
1. [Google Cloud Console](https://console.cloud.google.com/) → 프로젝트 생성/선택
2. "YouTube Data API v3" 검색 → 사용 설정
3. 사용자 인증 정보 → API 키 생성
4. (선택) 키 제한: YouTube Data API v3만 허용, IP 제한 등
- 일일 쿼터: 10,000 units (`playlistItems.list`는 1 unit/요청 → 2채널 × 24회 = 48 units/일)

View File

@@ -1,11 +1,11 @@
# News Summary Bot # 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/main.py` | FastAPI 엔드포인트 |
| `app/transcript.py` | YouTube 자막 추출 (yt-dlp + 쿠키 인증) | | `app/transcript.py` | YouTube 자막 추출 (yt-dlp + 쿠키 인증) |
| `app/summarizer.py` | Claude Sonnet 4.6으로 요약 생성 | | `app/summarizer.py` | Claude Sonnet 4.6으로 요약 생성 |
| `app/discord.py` | Discord 웹훅 전송 |
| `app/config.py` | 환경변수 설정 (pydantic-settings) | | `app/config.py` | 환경변수 설정 (pydantic-settings) |
## 빠른 시작 ## 빠른 시작
@@ -28,7 +27,6 @@ cp .env.example .env
| 변수 | 필수 | 설명 | | 변수 | 필수 | 설명 |
|------|------|------| |------|------|------|
| `ANTHROPIC_API_KEY` | O | Claude API 키 | | `ANTHROPIC_API_KEY` | O | Claude API 키 |
| `DISCORD_WEBHOOK_URL` | O | Discord 웹훅 URL |
| `API_SECRET` | X | n8n → FastAPI 인증용 시크릿 | | `API_SECRET` | X | n8n → FastAPI 인증용 시크릿 |
### 로컬 개발 ### 로컬 개발
@@ -53,13 +51,13 @@ OCI 등 클라우드 서버에서는 YouTube가 데이터센터 IP를 봇으로
2. 서버에 `cookies.txt` 업로드 2. 서버에 `cookies.txt` 업로드
3. `compose.apps.yml`에서 볼륨 마운트: `./news-summary-bot/cookies.txt:/app/cookies.txt:ro` 3. `compose.apps.yml`에서 볼륨 마운트: `./news-summary-bot/cookies.txt:/app/cookies.txt:ro`
> 쿠키는 6개월~1년 후 만료됩니다. 자막 추출 500 에러 발생 시 쿠키 재export가 필요합니다. > 쿠키는 6개월~1년 후 만료됩니다. 자막 추출 에러 발생 시 쿠키 재export가 필요합니다.
## API ## API
### `POST /api/news/summarize` (외부) / `POST /summarize` (내부) ### `POST /api/news/summarize` (외부) / `POST /summarize` (내부)
영상 URL을 받아 자막 추출 → 요약 Discord 전송을 수행합니다. 영상 URL을 받아 자막 추출 → 요약을 수행하고 JSON으로 결과를 반환합니다. Discord 전송은 n8n에서 처리합니다.
**Request:** **Request:**
@@ -75,11 +73,14 @@ OCI 등 클라우드 서버에서는 YouTube가 데이터센터 IP를 봇으로
**Response:** **Response:**
```json ```json
{ // 성공
"status": "ok", {"video_url": "...", "title": "...", "channel_name": "...", "status": "ok", "oneliner": "한줄 요약", "main_points": "• 포인트1\n• 포인트2", "conclusion": "결론"}
"title": "영상 제목",
"summary_length": 1234 // 스킵 (라이브/쇼츠)
} {"video_url": "...", "title": "...", "channel_name": "...", "status": "skipped", "reason": "라이브/예정 영상은 요약 대상이 아닙니다"}
// 에러
{"video_url": "...", "title": "...", "channel_name": "...", "status": "error", "error_type": "ValueError", "error_message": "..."}
``` ```
### `GET /api/news/health` (외부) / `GET /health` (내부) ### `GET /api/news/health` (외부) / `GET /health` (내부)
@@ -90,14 +91,10 @@ OCI 등 클라우드 서버에서는 YouTube가 데이터센터 IP를 봇으로
## n8n 워크플로우 ## n8n 워크플로우
``` n8n에서 YouTube Data API v3의 `playlistItems.list`로 채널별 새 영상을 감지하고, 각 영상마다 이 봇의 API를 호출합니다. 응답의 `status` 필드로 분기하여 Discord 웹훅으로 요약 또는 에러 알림을 전송합니다.
RSS Feed Trigger (채널A) ──┐
├→ Merge → HTTP Request (POST /api/news/summarize)
RSS Feed Trigger (채널B) ──┘
```
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

@@ -3,7 +3,6 @@ from pydantic_settings import BaseSettings
class Settings(BaseSettings): class Settings(BaseSettings):
anthropic_api_key: str anthropic_api_key: str
discord_webhook_url: str
api_secret: str = "" api_secret: str = ""
model_config = {"env_file": ".env", "extra": "ignore"} model_config = {"env_file": ".env", "extra": "ignore"}

View File

@@ -1,109 +0,0 @@
import re
from datetime import datetime, timezone
import httpx
from app.config import settings
def _extract_video_id(video_url: str) -> str | None:
"""URL에서 YouTube 비디오 ID 추출."""
patterns = [
r"(?:youtu\.be/)([^?&]+)",
r"(?:v=)([^?&]+)",
]
for p in patterns:
m = re.search(p, video_url)
if m:
return m.group(1)
return None
def _parse_summary(summary: str) -> dict[str, str]:
"""요약 텍스트를 섹션별로 파싱."""
sections: dict[str, str] = {}
current_key = None
current_lines: list[str] = []
for line in summary.split("\n"):
# **한줄 요약**: ... 또는 ## 한줄 요약 형태 매칭
header_match = re.match(
r"^(?:##\s*|-\s*\*\*|\*\*)(한줄\s*요약|주요\s*내용|결론/?시사점)[:\*\s]*(.*)",
line,
)
if header_match:
if current_key:
sections[current_key] = "\n".join(current_lines).strip()
current_key = header_match.group(1).replace(" ", "")
rest = re.sub(r"^\*\*:?\s*", "", header_match.group(2)).strip()
current_lines = [rest] if rest else []
elif current_key is not None:
current_lines.append(line)
if current_key:
sections[current_key] = "\n".join(current_lines).strip()
return sections
async def send_to_discord(title: str, video_url: str, summary: str) -> None:
"""Discord 웹훅으로 요약 전송 (임베드 디자인)."""
video_id = _extract_video_id(video_url)
thumbnail_url = (
f"https://img.youtube.com/vi/{video_id}/hqdefault.jpg"
if video_id
else None
)
sections = _parse_summary(summary)
oneliner = sections.get("한줄요약", "")
main_points = sections.get("주요내용", "")
conclusion = sections.get("결론/시사점", sections.get("결론시사점", ""))
# 파싱 실패 시 전체 텍스트를 그대로 사용
if not oneliner and not main_points:
fields = [{"name": "🔗 원본 영상", "value": video_url, "inline": False}]
description = summary[:4096]
else:
description = f"### 💡 {oneliner}" if oneliner else ""
fields = []
if main_points:
fields.append({
"name": "📋 주요 내용",
"value": main_points[:1024],
"inline": False,
})
if conclusion:
fields.append({
"name": "🎯 결론 / 시사점",
"value": conclusion[:1024],
"inline": False,
})
fields.append({
"name": "🔗 원본 영상",
"value": video_url,
"inline": False,
})
embed = {
"title": f"📰 {title}",
"url": video_url,
"description": description,
"color": 0x2B2D31,
"fields": fields,
"footer": {
"text": "YouTube 뉴스 요약 봇",
"icon_url": "https://www.youtube.com/s/desktop/f5ced909/img/favicon_144x144.png",
},
"timestamp": datetime.now(timezone.utc).isoformat(),
}
if thumbnail_url:
embed["thumbnail"] = {"url": thumbnail_url}
payload = {"embeds": [embed]}
async with httpx.AsyncClient() as client:
resp = await client.post(settings.discord_webhook_url, json=payload)
resp.raise_for_status()

View File

@@ -2,9 +2,8 @@ from fastapi import FastAPI, Header, HTTPException
from pydantic import BaseModel from pydantic import BaseModel
from app.config import settings from app.config import settings
from app.discord import send_to_discord
from app.summarizer import summarize from app.summarizer import summarize
from app.transcript import extract_video_id, fetch_transcript from app.transcript import SkipVideo, extract_video_id, fetch_transcript
app = FastAPI(title="News Summary Bot") app = FastAPI(title="News Summary Bot")
@@ -12,6 +11,7 @@ app = FastAPI(title="News Summary Bot")
class SummarizeRequest(BaseModel): class SummarizeRequest(BaseModel):
video_url: str video_url: str
title: str = "" title: str = ""
channel_name: str = ""
@app.post("/summarize") @app.post("/summarize")
@@ -22,13 +22,44 @@ async def summarize_video(
if settings.api_secret and x_api_secret != settings.api_secret: if settings.api_secret and x_api_secret != settings.api_secret:
raise HTTPException(status_code=401, detail="Unauthorized") raise HTTPException(status_code=401, detail="Unauthorized")
video_id = extract_video_id(req.video_url) title = req.title or "제목 없음"
transcript = fetch_transcript(video_id)
title = req.title or video_id
summary = summarize(transcript, title)
await send_to_discord(title, req.video_url, summary)
return {"status": "ok", "title": title, "summary_length": len(summary)} channel_name = req.channel_name or ""
base = {"video_url": req.video_url, "title": title, "channel_name": channel_name}
try:
video_id = extract_video_id(req.video_url)
transcript = fetch_transcript(video_id)
summary = summarize(transcript, title)
except SkipVideo as e:
return {**base, "status": "skipped", "reason": str(e)}
except Exception as e:
return {
**base,
"status": "error",
"error_type": type(e).__name__,
"error_message": str(e),
}
# Discord embed description용 통합 요약
parts = []
if summary.get("oneliner"):
parts.append(f"💡 **{summary['oneliner']}**")
if summary.get("main_points"):
parts.append(f"\n📌 **주요 내용**\n{summary['main_points']}")
if summary.get("conclusion"):
parts.append(f"\n🎯 **결론**\n> {summary['conclusion']}")
if summary.get("market_impact"):
parts.append(f"\n📈 **시장 영향**\n{summary['market_impact']}")
if summary.get("sectors"):
parts.append(f"\n🏭 **관련 섹터**\n{summary['sectors']}")
if summary.get("watchlist"):
parts.append(f"\n👀 **주목 종목**\n{summary['watchlist']}")
if summary.get("outlook"):
parts.append(f"\n🔮 **전망**\n{summary['outlook']}")
summary["summary"] = "\n".join(parts)
return {**base, "status": "ok", **summary}
@app.get("/health") @app.get("/health")

View File

@@ -1,25 +1,46 @@
import json
import re
import anthropic import anthropic
from app.config import settings from app.config import settings
client = anthropic.Anthropic(api_key=settings.anthropic_api_key) client = anthropic.Anthropic(api_key=settings.anthropic_api_key)
SYSTEM_PROMPT = """너는 뉴스/경제 유튜브 영상 요약 전문가야. SYSTEM_PROMPT = """너는 뉴스/경제 유튜브 영상을 시청하고 핵심을 전달하는 요약 전문가이자, 주식 시장 영향을 분석하는 증권 애널리스트야.
영상 자막 텍스트를 받아서 아래 형식으로 요약해줘. 영상을 직접 다 본 사람처럼, 구체적인 수치·사례·맥락을 포함해서 요약하고, 투자자 관점에서 시장 영향도 분석해줘.
## 형식 ## 출력 형식
- **한줄 요약**: 영상의 핵심을 한 문장으로 반드시 아래 JSON만 출력해. 코드펜스(```)로 감싸지 마.
- **주요 내용**: 핵심 포인트를 3~7개 불릿으로 정리 {"oneliner": "영상의 핵심 메시지를 구체적으로 한 문장", "main_points": "• 핵심 포인트 1\\n• 핵심 포인트 2\\n• 핵심 포인트 3", "conclusion": "영상이 전달하려는 메시지와 시청자가 취할 수 있는 액션", "market_impact": "이 뉴스가 시장 전체에 미치는 영향 요약 (1~2문장)", "sectors": "📈 섹터명 — 이유\\n📉 섹터명 — 이유", "watchlist": "• 종목명(종목코드) — 주목 이유\\n• 종목명(종목코드) — 주목 이유", "outlook": "향후 1~2주 전망 및 투자자 액션 포인트"}
- **결론/시사점**: 영상이 전달하려는 메시지나 시사점
## 규칙 ## 출력 형식 상세 규칙
- 반드시 위 7개 필드를 가진 단일 JSON 객체만 출력. 다른 텍스트, 코드펜스, 설명 절대 금지
- 7개 필드 모두 반드시 비어있지 않은 문자열로 채울 것. 절대 빈 문자열("") 금지
- 모든 필드의 값은 문자열(string)이다. 배열([])이나 중첩 객체({})는 절대 사용 금지
- 줄바꿈은 반드시 \\n 이스케이프로 표현. 실제 줄바꿈 문자 금지
- 문자열 안에 큰따옴표(")가 필요하면 반드시 \\"로 이스케이프
## 요약 규칙
- 한국어로 작성 - 한국어로 작성
- 간결하고 명확하게 - main_points는 3~7개 불릿(•)으로 정리. 각 항목에 구체적인 수치, 종목명, 인물, 사건 등을 반드시 포함
- "~에 대해 이야기했다" 같은 메타 서술 금지. 내용 자체를 직접 전달
- 자막의 오타나 말더듬은 무시하고 의미 중심으로 정리 - 자막의 오타나 말더듬은 무시하고 의미 중심으로 정리
- 영상에서 언급된 구체적 수치(%, 금액, 날짜 등)가 있으면 반드시 포함
## 주식 분석 규칙
- sectors는 2~4개 항목. 각 항목 앞에 📈(상승) 또는 📉(하락) 이모지로 방향 표시. 항목 사이는 \\n으로 구분
- watchlist는 3~5개 종목. 한국 주식은 6자리 숫자(예: 005930), 미국 주식은 티커(예: AAPL). 형식: • 종목명(코드) — 이유
- 한국 주식(KRX) 우선, 관련 있으면 미국 주식도 포함
- 뉴스 내용과 직접 관련된 종목만 언급. 억지 연결 금지
- 뉴스가 주식과 전혀 관련 없으면: market_impact, sectors, watchlist, outlook에 "해당 뉴스는 주식 시장과 직접적 연관이 없습니다"라고 작성
## 예시 출력
{"oneliner": "미국 연준이 기준금리를 0.25%p 인하하며 3연속 인하 행진", "main_points": "• 연준 기준금리 5.25%→5.00%로 0.25%p 인하 결정\\n• 파월 의장, 인플레이션 2%대 안착 자신감 표명\\n• 고용 둔화 우려에 선제적 대응 성격\\n• 시장은 이미 반영했다는 분석과 추가 상승 여력 의견 공존", "conclusion": "금리 인하 사이클 진입이 확인되면서 성장주·부동산 등 금리 민감 섹터에 주목할 시점", "market_impact": "금리 인하로 유동성 확대 기대감이 커지며 성장주 중심의 반등 흐름이 예상된다", "sectors": "📈 반도체 — 성장주 밸류에이션 부담 완화로 수혜\\n📈 건설/부동산 — 금리 하락에 따른 주택 수요 회복 기대\\n📉 은행 — 예대마진 축소 우려", "watchlist": "• 삼성전자(005930) — 반도체 업황 회복 + 금리 인하 수혜\\n• SK하이닉스(000660) — HBM 수요 지속 + 유동성 장세 수혜\\n• 현대건설(000720) — 금리 인하 시 건설 경기 회복 수혜주\\n• SOFI(SOFI) — 미국 핀테크, 금리 인하로 대출 수요 증가 기대", "outlook": "3연속 인하로 완화 기조가 확실해진 만큼, 단기 차익 실현보다는 성장주 비중 확대가 유리한 구간. 다만 고용 지표 악화 시 경기침체 우려가 부각될 수 있으므로 다음 고용보고서 발표일 주시 필요"}
""" """
def summarize(transcript: str, title: str) -> str: def summarize(transcript: str, title: str) -> dict:
message = client.messages.create( message = client.messages.create(
model="claude-sonnet-4-20250514", model="claude-sonnet-4-20250514",
max_tokens=2048, max_tokens=2048,
@@ -31,4 +52,36 @@ def summarize(transcript: str, title: str) -> str:
} }
], ],
) )
return message.content[0].text raw = message.content[0].text
parsed = _parse_json_response(raw)
fallback = "(내용 없음)"
stock_fallback = "해당 뉴스는 주식 시장과 직접적 연관이 없습니다"
return {
"oneliner": parsed.get("oneliner") or fallback,
"main_points": parsed.get("main_points") or fallback,
"conclusion": parsed.get("conclusion") or fallback,
"market_impact": parsed.get("market_impact") or stock_fallback,
"sectors": parsed.get("sectors") or stock_fallback,
"watchlist": parsed.get("watchlist") or stock_fallback,
"outlook": parsed.get("outlook") or stock_fallback,
}
def _parse_json_response(raw: str) -> dict:
"""Claude 응답에서 JSON을 추출하여 파싱한다."""
# 1) 코드펜스 제거 (```json ... ``` 또는 ``` ... ```)
json_match = re.search(r"```(?:json)?\s*(.*?)\s*```", raw, re.DOTALL)
json_str = json_match.group(1) if json_match else raw.strip()
# 2) 첫 번째 { ~ 마지막 } 만 추출 (앞뒤 잡텍스트 제거)
brace_match = re.search(r"\{.*\}", json_str, re.DOTALL)
if brace_match:
json_str = brace_match.group(0)
try:
return json.loads(json_str)
except json.JSONDecodeError:
# 파싱 실패 시 전체 텍스트를 oneliner로
return {"oneliner": raw, "main_points": "", "conclusion": ""}

View File

@@ -8,8 +8,14 @@ import yt_dlp
COOKIES_SRC = "/app/cookies.txt" COOKIES_SRC = "/app/cookies.txt"
class SkipVideo(Exception):
"""라이브/쇼츠 등 요약 대상이 아닌 영상."""
def extract_video_id(url: str) -> str: def extract_video_id(url: str) -> str:
"""YouTube URL에서 video ID 추출.""" """YouTube URL에서 video ID 추출."""
if "/shorts/" in url:
raise SkipVideo("쇼츠 영상은 요약 대상이 아닙니다")
if "youtu.be/" in url: if "youtu.be/" in url:
return url.split("youtu.be/")[1].split("?")[0] return url.split("youtu.be/")[1].split("?")[0]
if "v=" in url: if "v=" in url:
@@ -23,12 +29,9 @@ def fetch_transcript(video_id: str) -> str:
ydl_opts = { ydl_opts = {
"skip_download": True, "skip_download": True,
"writeautomaticsub": True,
"subtitleslangs": ["ko", "en"],
"subtitlesformat": "json3",
"format": "worst",
"quiet": True, "quiet": True,
"no_warnings": True, "no_warnings": True,
"socket_timeout": 30,
} }
if os.path.isfile(COOKIES_SRC): if os.path.isfile(COOKIES_SRC):
@@ -38,11 +41,19 @@ def fetch_transcript(video_id: str) -> str:
try: try:
with yt_dlp.YoutubeDL(ydl_opts) as ydl: with yt_dlp.YoutubeDL(ydl_opts) as ydl:
info = ydl.extract_info(url, download=False) info = ydl.extract_info(url, ie_key="Youtube", download=False, process=False)
except yt_dlp.utils.DownloadError as e:
err_msg = str(e).lower()
if "live event" in err_msg or "is live" in err_msg or "premieres in" in err_msg:
raise SkipVideo("라이브/예정 영상은 요약 대상이 아닙니다")
raise
finally: finally:
if "cookiefile" in ydl_opts: if "cookiefile" in ydl_opts:
os.unlink(ydl_opts["cookiefile"]) os.unlink(ydl_opts["cookiefile"])
if info.get("is_live") or info.get("live_status") in ("is_live", "is_upcoming"):
raise SkipVideo("라이브/예정 영상은 요약 대상이 아닙니다")
subs = info.get("automatic_captions", {}) subs = info.get("automatic_captions", {})
lang = "ko" if "ko" in subs else "en" if "en" in subs else None lang = "ko" if "ko" in subs else "en" if "en" in subs else None
if not lang: if not lang:
@@ -57,7 +68,7 @@ def fetch_transcript(video_id: str) -> str:
if not sub_url: if not sub_url:
raise ValueError(f"json3 자막 포맷을 찾을 수 없습니다: {video_id}") raise ValueError(f"json3 자막 포맷을 찾을 수 없습니다: {video_id}")
resp = httpx.get(sub_url) resp = httpx.get(sub_url, timeout=30.0)
resp.raise_for_status() resp.raise_for_status()
data = resp.json() data = resp.json()

View File

@@ -1,6 +1,6 @@
# 개발 가이드 # 개발 가이드
YouTube 뉴스 영상을 자동 요약하여 Discord로 전송하는 FastAPI 기반 봇의 개발 문서입니다. YouTube 뉴스 영상을 자동 요약하는 FastAPI 기반 봇의 개발 문서입니다.
## 프로젝트 구조 ## 프로젝트 구조
@@ -11,8 +11,7 @@ news-summary-bot/
│ ├── main.py # FastAPI 엔드포인트 (/summarize, /health) │ ├── main.py # FastAPI 엔드포인트 (/summarize, /health)
│ ├── config.py # pydantic-settings 환경변수 │ ├── config.py # pydantic-settings 환경변수
│ ├── transcript.py # yt-dlp + 쿠키로 자막 추출 │ ├── transcript.py # yt-dlp + 쿠키로 자막 추출
── summarizer.py # Claude Sonnet 4.6 요약 ── summarizer.py # Claude Sonnet 4.6 요약
│ └── discord.py # Discord 웹훅 전송
├── Dockerfile ├── Dockerfile
├── docker-compose.yml ├── docker-compose.yml
├── requirements.txt ├── requirements.txt
@@ -42,12 +41,25 @@ uvicorn app.main:app --reload
### POST /summarize ### POST /summarize
YouTube 영상 URL을 받아 자막을 추출하고, Claude로 요약한 뒤 Discord 웹훅으로 전송합니다. YouTube 영상 URL을 받아 자막을 추출하고 Claude로 요약합니다. 결과를 JSON으로 반환하며, Discord 전송은 n8n에서 처리합니다.
- **인증**: `X-Api-Secret` 헤더에 시크릿 값 전달 (API_SECRET 환경변수가 설정된 경우 필수) - **인증**: `X-Api-Secret` 헤더에 시크릿 값 전달 (API_SECRET 환경변수가 설정된 경우 필수)
- **요청 본문**: - **요청 본문**:
- `video_url` (string, 필수) — YouTube 영상 URL - `video_url` (string, 필수) — YouTube 영상 URL
- `title` (string, 필수) — 영상 제목 - `title` (string, 선택) — 영상 제목
**응답:**
```json
// 성공
{"video_url": "...", "title": "...", "channel_name": "...", "status": "ok", "oneliner": "한줄 요약", "main_points": "• 포인트1\n• 포인트2", "conclusion": "결론"}
// 스킵 (라이브/쇼츠)
{"video_url": "...", "title": "...", "channel_name": "...", "status": "skipped", "reason": "..."}
// 에러
{"video_url": "...", "title": "...", "channel_name": "...", "status": "error", "error_type": "...", "error_message": "..."}
```
### GET /health ### 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으로 사용합니다. YouTube URL에서 video ID를 추출하고 `yt-dlp`를 사용하여 자막을 가져옵니다. 한국어(`ko`) 자막을 우선으로 시도하며, 없을 경우 영어(`en`) 자막을 fallback으로 사용합니다.
라이브/예정 영상과 쇼츠는 `SkipVideo` 예외를 발생시켜 스킵합니다. `yt-dlp``DownloadError`에서 라이브 관련 메시지가 감지되면 자동으로 스킵 처리됩니다.
서버 환경에서는 YouTube 봇 감지를 우회하기 위해 `/app/cookies.txt` 쿠키 파일을 사용합니다. 로컬 개발 시에는 가정용 IP라 쿠키 없이도 동작합니다. 서버 환경에서는 YouTube 봇 감지를 우회하기 위해 `/app/cookies.txt` 쿠키 파일을 사용합니다. 로컬 개발 시에는 가정용 IP라 쿠키 없이도 동작합니다.
### summarizer.py ### summarizer.py
Anthropic의 **Claude Sonnet 4.6** 모델을 사용하여 자막 텍스트를 요약합니다. 시스템 프롬프트에 뉴스/경제 요약에 최적화된 포맷을 지정하여 일관된 형식의 요약을 생성합니다. Anthropic의 **Claude Sonnet 4.6** 모델을 사용하여 자막 텍스트를 요약합니다. 시스템 프롬프트에 뉴스/경제 요약에 최적화된 포맷을 지정하여 일관된 형식의 요약을 생성합니다.
### discord.py
요약 결과를 **Discord Embed** 형식으로 구성하여 웹훅 URL로 전송합니다. 영상 제목, 요약 내용, 원본 링크 등을 포함합니다.
### config.py ### config.py
`pydantic-settings`를 사용하여 `.env` 파일 또는 시스템 환경변수에서 설정값을 로드합니다. 필수 값이 누락되면 서버 시작 시 에러가 발생합니다. `pydantic-settings`를 사용하여 `.env` 파일 또는 시스템 환경변수에서 설정값을 로드합니다. 필수 값이 누락되면 서버 시작 시 에러가 발생합니다.
@@ -100,5 +110,4 @@ Anthropic의 **Claude Sonnet 4.6** 모델을 사용하여 자막 텍스트를
| 변수 | 필수 | 설명 | | 변수 | 필수 | 설명 |
|------|------|------| |------|------|------|
| `ANTHROPIC_API_KEY` | O | Anthropic API 키 | | `ANTHROPIC_API_KEY` | O | Anthropic API 키 |
| `DISCORD_WEBHOOK_URL` | O | Discord 웹훅 URL |
| `API_SECRET` | X | n8n → FastAPI 인증용 시크릿 (미설정 시 인증 생략) | | `API_SECRET` | X | n8n → FastAPI 인증용 시크릿 (미설정 시 인증 생략) |

View File

@@ -2,104 +2,306 @@
## 워크플로우 개요 ## 워크플로우 개요
이 워크플로우는 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) ──┐ Schedule Trigger (매 정시)
├→ Merge → HTTP Request (FastAPI) → 완료 ├→ HTTP Request (머니코믹스 playlistItems) → Edit Fields (channel_name: 머니코믹스)
RSS Feed Trigger (채널B) ──┘ ↘ Error Trigger → Discord 알림 └→ 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개) ### 1. Schedule Trigger
RSS Feed Trigger는 **중복 방지가 내장**되어 있어 이전에 처리한 항목을 기억하고, 새 영상만 반환합니다. Poll Time으로 체크 주기를 설정합니다.
**노드 A - 머니머니코믹스:**
| 설정 항목 | 값 | | 설정 항목 | 값 |
|---|---| |---|---|
| Feed URL | `https://www.youtube.com/feeds/videos.xml?channel_id=UCJo6G1u0e_-wS-JQn3T-zEw` | | Type | Schedule Trigger |
| Poll Time | Every Day / Hour: 10, 14, 20 (하루 3회 권장) | | Rule | Every Hour |
| Minute | 0 |
**노드 B - 슈카월드:** ### 2. YouTube API — playlistItems (채널별 각 1개)
| 설정 항목 | 값 | | 설정 항목 | 값 |
|---|---| |---|---|
| Feed URL | `https://www.youtube.com/feeds/videos.xml?channel_id=UCsJ6RuBiTVWRX156FVbeaGg` | | Type | HTTP Request |
| Poll Time | Every Day / Hour: 10, 14, 20 (하루 3회 권장) | | Method | GET |
| URL | `https://www.googleapis.com/youtube/v3/playlistItems` |
| Send Query Parameters | ON |
| Specify Query Parameters | Using Individual Fields |
> ⚠️ **첫 실행 주의:** RSS Feed Trigger를 처음 활성화하면 피드에 있는 모든 영상(최대 15개)을 새 영상으로 인식합니다. 워크플로우 활성화 전에 수동으로 한 번 테스트 실행하여 기존 항목을 처리 완료 상태로 만드세요. **Query Parameters (Add Parameter로 추가):**
### 2. Merge 노드 | 파라미터 | 값 |
- **타입:** Merge
- **모드:** Append
- 두 RSS Feed Trigger의 출력을 하나의 리스트로 합칩니다.
- 합쳐진 리스트의 각 항목마다 다음 노드(HTTP Request)가 실행됩니다.
### 3. HTTP Request 노드
FastAPI 서버의 `/api/news/summarize` 엔드포인트를 호출합니다.
| 설정 항목 | 값 |
|---|---| |---|---|
| Method | `POST` | | `part` | `snippet` |
| URL | `https://nkeystudy.site/api/news/summarize` | | `playlistId` | 채널 업로드 목록 ID (아래 참고) |
| Body Content Type | JSON | | `maxResults` | `5` |
| Body | 아래 참조 | | `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 ```json
{ {
"video_url": "{{ $json.link }}", "items": [
"title": "{{ $json.title }}" {
"snippet": {
"publishedAt": "2026-03-25T06:00:00Z",
"title": "영상 제목",
"resourceId": { "videoId": "abc123" }
}
}
]
} }
``` ```
> **주의:** 영상 제목에 큰따옴표(`"`)가 포함된 경우 JSON이 깨질 수 있습니다. 반드시 **Expression 모드**를 사용하세요 (Fixed 모드에서는 특수문자 이스케이프가 안 됩니다). ### 3. Edit Fields (채널별 각 1개)
**Headers:** | 설정 항목 | 값 |
|---|---|
| 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 |
| Mode | Run Once for All Items |
최근 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` |
| Send Body | ON |
| Body Content Type | JSON |
| Specify Body | Using JSON |
**Body (Expression 모드):**
```json
{
"video_url": "{{ $json.video_url }}",
"title": "{{ $json.title }}",
"channel_name": "{{ $json.channel_name }}"
}
```
**Headers** (API_SECRET 사용 시):
| 헤더 | 값 | | 헤더 | 값 |
|---|---| |---|---|
| `Content-Type` | `application/json` | | `X-Api-Secret` | 설정한 시크릿 값 |
| `X-Api-Secret` | 설정한 시크릿 값 (`.env``API_SECRET`과 동일) |
## 에러 처리 **Options:**
- **Always Output Data**: ON (에러 시에도 다음 노드로 전달)
### Error Trigger 워크플로우 > **주의:** 영상 제목에 큰따옴표(`"`)가 포함된 경우 JSON이 깨질 수 있습니다. 반드시 **Expression 모드**를 사용하세요.
메인 워크플로우와 별도로 에러 처리 워크플로우를 생성합니다. ### 7. Switch (응답 분기)
1. **Error Trigger** 노드를 추가합니다.
2. **HTTP Request** 노드로 Discord Webhook을 호출합니다:
| 설정 항목 | 값 | | 설정 항목 | 값 |
|---|---| |---|---|
| Method | `POST` | | Type | Switch |
| URL | Discord Webhook URL | | Mode | Rules |
| Data Type | String |
| Value | `{{ $json.status }}` |
**Rules:**
| Rule | Operation | Value | Output |
|---|---|---|---|
| Rule 1 | Equal | `ok` | → Discord 요약 전송 |
| Rule 2 | Equal | `skipped` | → No Operation (무시) |
| Rule 3 | Equal | `error` | → Discord 에러 알림 |
### 8. Discord 요약 전송 (status=ok)
| 설정 항목 | 값 |
|---|---|
| Type | HTTP Request |
| Method | POST |
| URL | Discord 웹훅 URL |
| Send Body | ON |
| Body Content Type | JSON | | Body Content Type | JSON |
| Specify Body | Using JSON |
**Body:** **Body:**
Specify Body를 **Using JSON**으로 선택 후, JSON 입력란 우측의 **Expression** 토글을 켜고 아래 코드를 입력합니다. `JSON.stringify`를 사용해야 요약 텍스트의 특수문자(따옴표, 줄바꿈 등)가 자동 이스케이프됩니다.
```javascript
={{ JSON.stringify({
embeds: [{
title: "📰 [" + $json.channel_name + "] " + $json.title,
url: $json.video_url,
description: "### 💡 " + $json.oneliner,
color: 2829105,
fields: [
{ name: "📋 주요 내용", value: $json.main_points, inline: false },
{ name: "🎯 결론 / 시사점", value: $json.conclusion, inline: false },
{ name: "📈 시장 영향", value: $json.market_impact, inline: false },
{ name: "🏭 관련 섹터", value: $json.sectors, inline: false },
{ name: "👀 주목 종목", value: $json.watchlist, inline: false },
{ name: "🔮 전망", value: $json.outlook, inline: false },
{ name: "🔗 원본 영상", value: $json.video_url, inline: false }
],
thumbnail: {
url: "https://img.youtube.com/vi/" + $json.video_url.split("v=")[1] + "/hqdefault.jpg"
},
footer: {
text: "⚠️ 투자 참고용 · 판단의 책임은 본인에게 있습니다 • " + $json.channel_name
},
timestamp: new Date().toISOString()
}]
}) }}
```
### 9. Discord 에러 알림 (status=error)
| 설정 항목 | 값 |
|---|---|
| Type | HTTP Request |
| Method | POST |
| URL | Discord 웹훅 URL |
| Send Body | ON |
| Body Content Type | JSON |
| Specify Body | Using JSON (Expression 토글 ON) |
**Body:**
```javascript
={{ JSON.stringify({
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: "봇 요약 처리 에러 — FastAPI 응답"
},
timestamp: new Date().toISOString()
}]
}) }}
```
## 에러 처리
에러 알림은 두 종류로 구분됩니다:
| 구분 | 발생 위치 | 원인 예시 | 알림 제목 |
|---|---|---|---|
| **워크플로우 에러** | n8n 자체 | API 연결 불가, 노드 설정 오류, YouTube API 실패 | ⚠️ n8n 워크플로우 에러 |
| **요약 처리 에러** | FastAPI 봇 | 자막 없음, 쿠키 만료, Claude API 오류 | ❌ [채널명] 뉴스 요약 실패 |
### Error Trigger 워크플로우 (n8n 워크플로우 에러)
메인 워크플로우 자체가 실패하는 경우를 대비해 별도 에러 워크플로우를 만들 수 있습니다.
1. 새 워크플로우 생성 → **Error Trigger** 노드 추가
2. **HTTP Request** 노드로 Discord Webhook 호출:
| 설정 항목 | 값 |
|---|---|
| Type | HTTP Request |
| Method | POST |
| URL | Discord 웹훅 URL |
| Send Body | ON |
| Body Content Type | JSON |
| Specify Body | Using JSON |
**Body (Expression 모드):**
```json ```json
{ {
"content": "뉴스 요약 봇 에러 발생!\n워크플로우: {{ $json.workflow.name }}\n에러: {{ $json.execution.error.message }}" "embeds": [{
"title": "⚠️ n8n 워크플로우 에러",
"color": 16753920,
"description": "n8n 워크플로우 실행 중 에러가 발생했습니다. 봇 요약 처리가 아닌 **워크플로우 자체 문제**입니다.",
"fields": [
{ "name": "워크플로우", "value": "{{ $json.workflow.name }}", "inline": true },
{ "name": "실패 노드", "value": "{{ $json.execution.lastNodeExecuted }}", "inline": true },
{ "name": "에러 내용", "value": "```\n{{ $json.execution.error.message || '상세 내용 없음' }}\n```", "inline": false },
{ "name": "실행 ID", "value": "`{{ $json.execution.id }}`", "inline": true }
],
"footer": {
"text": "n8n Error Trigger — 워크플로우 에러"
}
}]
} }
``` ```
3. 메인 워크플로우의 **Settings → Error Workflow**에서 이 에러 워크플로우를 지정합니다. 3. 메인 워크플로우의 **Settings → Error Workflow**에서 이 에러 워크플로우를 지정
### HTTP Request 노드 자체 에러 처리 > **참고:** `$json.execution.error.message`가 빈 값일 수 있으므로 `|| '상세 내용 없음'`으로 fallback 처리합니다.
HTTP Request 노드 설정에서:
- **Continue On Fail:** `true`로 설정하면 하나의 영상 요약이 실패해도 나머지 영상은 계속 처리됩니다.
- **Retry On Fail:** `true`, **Max Retries:** `2`, **Wait Between Retries (ms):** `3000`

View File

@@ -2,33 +2,23 @@
YouTube 뉴스 요약 봇의 배포 및 운영 가이드. YouTube 뉴스 요약 봇의 배포 및 운영 가이드.
**아키텍처:** n8n (RSS 트리거) → FastAPI 앱 (Docker 컨테이너) → Claude API → Discord 웹훅 **아키텍처:** n8n (YouTube Data API로 새 영상 감지) → FastAPI 앱 (자막 추출 + Claude 요약) → JSON 응답 → n8n (Discord 웹훅 전송)
--- ---
## 배포 절차 ## 배포
### 1. Docker 이미지 빌드 & Push ### CI/CD (자동)
```bash main 브랜치에 push하면 GitHub Actions가 자동으로:
docker build -t nkey/news-summary-bot:latest . 1. Docker 이미지 빌드
docker push nkey/news-summary-bot:latest 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 ```bash
# 로컬에서 # 로컬에서
@@ -36,9 +26,16 @@ docker build -t nkey/news-summary-bot:latest .
docker push nkey/news-summary-bot:latest docker push nkey/news-summary-bot:latest
# OCI 서버에서 # 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에서 호출 시 인증용 시크릿
--- ---
## 헬스체크 ## 헬스체크
@@ -59,14 +56,40 @@ docker compose logs -f news-summary-bot
## 트러블슈팅 ## 트러블슈팅
### 일반 에러
| 증상 | 원인 | 해결 | | 증상 | 원인 | 해결 |
|------|------|------| |------|------|------|
| 자막 추출 실패 (자막 없음) | 영상에 자막 없음 | 자동생성 자막이 없는 영상은 스킵됨 | | 자막 추출 실패 (자막 없음) | 영상에 자막 없음 | 자동생성 자막이 없는 영상은 status: error로 반환됨 |
| 자막 추출 실패 (봇 감지) | YouTube 쿠키 만료 | 브라우저에서 쿠키 재export 후 서버에 업로드 (아래 쿠키 갱신 참고) | | 자막 추출 실패 (봇 감지) | YouTube 쿠키 만료 | 브라우저에서 쿠키 재export 후 서버에 업로드 (아래 쿠키 갱신 참고) |
| Discord 전송 실패 | 웹훅 URL 만료 | Discord에서 웹훅 재생성 후 `.env` 업데이트 |
| 401 Unauthorized | API_SECRET 불일치 | n8n 헤더와 `.env` 값 확인 | | 401 Unauthorized | API_SECRET 불일치 | n8n 헤더와 `.env` 값 확인 |
| Claude API 오류 | API 키 만료 또는 잔액 부족 | Anthropic 콘솔에서 확인 | | Claude API 오류 | API 키 만료 또는 잔액 부족 | Anthropic 콘솔에서 확인 |
| Discord embed 글자 수 초과 | 요약이 4096자 초과 | `summarizer.py``max_tokens` 줄이기 |
> 에러 발생 시 FastAPI가 `status: error`로 응답하고, n8n이 Switch 노드에서 분기하여 Discord에 에러 알림을 전송합니다.
### Nginx 404 에러
Nginx가 `/api/news/` prefix를 strip하여 FastAPI로 전달합니다. FastAPI 내부 라우트는 `/summarize`, `/health`이며, 외부에서는 `/api/news/summarize`, `/api/news/health`로 접근합니다.
- 404가 발생하면 Nginx 설정에 `/api/news/` location 블록이 있는지 확인
- FastAPI 라우트가 prefix 없이 `/summarize`, `/health`로 되어 있는지 확인
### Docker 쿠키 마운트 관련
| 증상 | 원인 | 해결 |
|------|------|------|
| `Is a directory: '/app/cookies.txt'` | 쿠키 파일이 없는 상태에서 컨테이너 생성 시 Docker가 디렉토리로 자동 생성 | `down` + `up`으로 컨테이너 완전 재생성 (`restart`로는 안 됨) |
| CI/CD 후 쿠키가 디렉토리로 변경됨 | `compose.apps.yml`이 심볼릭 링크일 때 상대경로 볼륨 마운트가 실제 파일 위치 기준으로 해석됨 | 볼륨 마운트에 **절대경로** 사용: `/home/ubuntu/nkeysworld/news-summary-bot/cookies.txt:/app/cookies.txt:ro` |
| `Read-only file system: '/app/cookies.txt'` | `:ro`로 마운트된 쿠키 파일에 yt-dlp가 쓰기 시도 | 코드에서 임시 파일에 복사 후 사용 (현재 적용됨) |
| `Requested format is not available` | yt-dlp가 영상 포맷 선택 단계에서 실패 | `extract_info()``process=False` 옵션으로 포맷 처리 건너뜀 (현재 적용됨) |
### YouTube 봇 감지 (클라우드 서버)
YouTube는 OCI/AWS/GCP 등 **데이터센터 IP를 봇으로 감지**하여 자막 추출을 차단합니다. 도메인 유무와 무관하게 요청 출처 IP 기반으로 판단합니다.
- 로컬(가정용 IP)에서는 쿠키 없이 동작
- 서버(데이터센터 IP)에서는 반드시 YouTube 쿠키 필요
- 로그에 `Sign in to confirm you're not a bot` 메시지가 나타나면 쿠키 만료
--- ---
@@ -74,21 +97,27 @@ docker compose logs -f news-summary-bot
YouTube는 클라우드 서버 IP를 봇으로 감지하여 자막 추출을 차단합니다. 이를 우회하기 위해 브라우저 쿠키를 사용하며, 약 6개월~1년 주기로 만료됩니다. YouTube는 클라우드 서버 IP를 봇으로 감지하여 자막 추출을 차단합니다. 이를 우회하기 위해 브라우저 쿠키를 사용하며, 약 6개월~1년 주기로 만료됩니다.
**만료 증상:** 자막 추출 시 500 에러 + 로그에 `Sign in to confirm you're not a bot` 메시지 **만료 증상:** 자막 추출 에러 + 로그에 `Sign in to confirm you're not a bot` 메시지 + Discord 에러 알림
**갱신 절차:** **갱신 절차:**
1. Chrome 확장 **Get cookies.txt LOCALLY**로 YouTube 쿠키 export 1. Chrome 확장 **Get cookies.txt LOCALLY**로 YouTube 쿠키 export (youtube.com에 로그인한 상태에서)
2. 서버에 업로드: 2. 서버에 업로드:
```bash ```bash
scp cookies.txt ubuntu@nkeystudy.site:~/nkeysworld/news-summary-bot/cookies.txt scp -i <SSH_KEY_PATH> ~/Downloads/cookies.txt ubuntu@nkeystudy.site:~/nkeysworld/news-summary-bot/cookies.txt
``` ```
3. 컨테이너 재시작: 3. 컨테이너 완전 재생성 (`restart`가 아닌 `down` + `up`):
```bash ```bash
docker compose -p nkeys-apps -f /nkeysworld/compose.apps.yml restart news-summary-bot docker compose -p nkeys-apps -f /nkeysworld/compose.apps.yml down news-summary-bot
docker compose -p nkeys-apps -f /nkeysworld/compose.apps.yml up -d news-summary-bot
```
4. 마운트 확인:
```bash
docker exec news-summary-bot head -3 /app/cookies.txt
``` ```
> `compose.apps.yml`에서 `./news-summary-bot/cookies.txt:/app/cookies.txt:ro`로 마운트되어 있어야 합니다. > `compose.apps.yml`에서 쿠키 볼륨은 **절대경로**로 마운트해야 합니다: `/home/ubuntu/nkeysworld/news-summary-bot/cookies.txt:/app/cookies.txt:ro`
> (심볼릭 링크된 compose 파일에서 상대경로 사용 시 경로 해석 오류 발생)
--- ---
@@ -105,3 +134,4 @@ YouTube는 클라우드 서버 IP를 봇으로 감지하여 자막 추출을 차
- **Claude Sonnet 4.6:** Input $3/MTok, Output $15/MTok - **Claude Sonnet 4.6:** Input $3/MTok, Output $15/MTok
- 일 1~2건 기준 월 ~$3 이내 - 일 1~2건 기준 월 ~$3 이내
- [Anthropic 콘솔](https://console.anthropic.com/)에서 usage 확인 - [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 ```bash
# 환경변수 더미값 설정 (config.py 로드용) # 환경변수 더미값 설정 (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 처리). - 테스트는 외부 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"] == "🔗 원본 영상"