Compare commits
19 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
b34bc1f582 | ||
|
|
6ae4325994 | ||
|
|
bb785545f0 | ||
|
|
bc4f6f3f6a | ||
|
|
8aa03232e8 | ||
|
|
9c2bf7c1ce | ||
|
|
f4532840cf | ||
|
|
b9e1c4fb45 | ||
|
|
11b3a60be8 | ||
|
|
a2a82084ba | ||
|
|
302f892c5d | ||
|
|
22949d0602 | ||
|
|
da9e557c64 | ||
|
|
0bf38dd2f4 | ||
|
|
c8c9c592cf | ||
|
|
98cd620f23 | ||
|
|
5e8e10e2fa | ||
|
|
af0e2fca8a | ||
|
|
11852bf48c |
@@ -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
|
||||||
|
|||||||
4
.github/workflows/deploy.yml
vendored
4
.github/workflows/deploy.yml
vendored
@@ -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
217
CLAUDE.md
@@ -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/일)
|
||||||
|
|||||||
33
README.md
33
README.md
@@ -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)를 참고하세요.
|
||||||
|
|||||||
@@ -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"}
|
||||||
|
|||||||
109
app/discord.py
109
app/discord.py
@@ -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()
|
|
||||||
41
app/main.py
41
app/main.py
@@ -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")
|
||||||
|
|
||||||
|
title = req.title or "제목 없음"
|
||||||
|
|
||||||
|
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)
|
video_id = extract_video_id(req.video_url)
|
||||||
transcript = fetch_transcript(video_id)
|
transcript = fetch_transcript(video_id)
|
||||||
title = req.title or video_id
|
|
||||||
summary = summarize(transcript, title)
|
summary = summarize(transcript, title)
|
||||||
await send_to_discord(title, req.video_url, summary)
|
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),
|
||||||
|
}
|
||||||
|
|
||||||
return {"status": "ok", "title": title, "summary_length": len(summary)}
|
# 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")
|
||||||
|
|||||||
@@ -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": ""}
|
||||||
|
|||||||
@@ -1,9 +1,21 @@
|
|||||||
|
import os
|
||||||
|
import shutil
|
||||||
|
import tempfile
|
||||||
|
|
||||||
import httpx
|
import httpx
|
||||||
import yt_dlp
|
import yt_dlp
|
||||||
|
|
||||||
|
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:
|
||||||
@@ -17,16 +29,30 @@ def fetch_transcript(video_id: str) -> str:
|
|||||||
|
|
||||||
ydl_opts = {
|
ydl_opts = {
|
||||||
"skip_download": True,
|
"skip_download": True,
|
||||||
"writeautomaticsub": True,
|
|
||||||
"subtitleslangs": ["ko", "en"],
|
|
||||||
"subtitlesformat": "json3",
|
|
||||||
"quiet": True,
|
"quiet": True,
|
||||||
"no_warnings": True,
|
"no_warnings": True,
|
||||||
"cookiefile": "/app/cookies.txt",
|
"socket_timeout": 30,
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if os.path.isfile(COOKIES_SRC):
|
||||||
|
tmp = tempfile.NamedTemporaryFile(delete=False, suffix=".txt")
|
||||||
|
shutil.copy2(COOKIES_SRC, tmp.name)
|
||||||
|
ydl_opts["cookiefile"] = tmp.name
|
||||||
|
|
||||||
|
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:
|
||||||
|
if "cookiefile" in ydl_opts:
|
||||||
|
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
|
||||||
@@ -42,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()
|
||||||
|
|
||||||
|
|||||||
@@ -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 인증용 시크릿 (미설정 시 인증 생략) |
|
||||||
|
|||||||
@@ -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`
|
|
||||||
|
|||||||
@@ -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/일)
|
||||||
|
|||||||
@@ -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가 필수로 요구하므로 더미값을 환경변수로 전달해야 합니다.
|
||||||
|
|||||||
@@ -1,137 +0,0 @@
|
|||||||
"""discord.py 유닛 테스트 — 파싱, 비디오 ID 추출, 임베드 구성."""
|
|
||||||
|
|
||||||
from unittest.mock import AsyncMock, patch
|
|
||||||
|
|
||||||
import pytest
|
|
||||||
|
|
||||||
from app.discord import _extract_video_id, _parse_summary, send_to_discord
|
|
||||||
|
|
||||||
|
|
||||||
# ── _extract_video_id ──
|
|
||||||
|
|
||||||
|
|
||||||
@pytest.mark.parametrize(
|
|
||||||
"url, expected",
|
|
||||||
[
|
|
||||||
("https://youtu.be/abc123", "abc123"),
|
|
||||||
("https://www.youtube.com/watch?v=xyz789", "xyz789"),
|
|
||||||
("https://www.youtube.com/watch?v=xyz789&t=10", "xyz789"),
|
|
||||||
("https://youtu.be/abc123?si=something", "abc123"),
|
|
||||||
("https://example.com/no-video", None),
|
|
||||||
],
|
|
||||||
)
|
|
||||||
def test_extract_video_id(url: str, expected: str | None):
|
|
||||||
assert _extract_video_id(url) == expected
|
|
||||||
|
|
||||||
|
|
||||||
# ── _parse_summary ──
|
|
||||||
|
|
||||||
|
|
||||||
SAMPLE_SUMMARY_BOLD = """\
|
|
||||||
- **한줄 요약**: 한국 경제가 위기에 처했다.
|
|
||||||
|
|
||||||
- **주요 내용**:
|
|
||||||
- GDP 성장률 하락
|
|
||||||
- 수출 감소
|
|
||||||
- 환율 불안정
|
|
||||||
|
|
||||||
- **결론/시사점**: 정부의 적극적 대응이 필요하다.
|
|
||||||
"""
|
|
||||||
|
|
||||||
SAMPLE_SUMMARY_HEADING = """\
|
|
||||||
## 한줄 요약
|
|
||||||
한국 경제가 위기에 처했다.
|
|
||||||
|
|
||||||
## 주요 내용
|
|
||||||
- GDP 성장률 하락
|
|
||||||
- 수출 감소
|
|
||||||
- 환율 불안정
|
|
||||||
|
|
||||||
## 결론/시사점
|
|
||||||
정부의 적극적 대응이 필요하다.
|
|
||||||
"""
|
|
||||||
|
|
||||||
|
|
||||||
def test_parse_summary_bold_format():
|
|
||||||
result = _parse_summary(SAMPLE_SUMMARY_BOLD)
|
|
||||||
assert "한줄요약" in result
|
|
||||||
assert "한국 경제가 위기에 처했다." in result["한줄요약"]
|
|
||||||
assert "주요내용" in result
|
|
||||||
assert "GDP 성장률 하락" in result["주요내용"]
|
|
||||||
assert "결론/시사점" in result or "결론시사점" in result
|
|
||||||
|
|
||||||
|
|
||||||
def test_parse_summary_heading_format():
|
|
||||||
result = _parse_summary(SAMPLE_SUMMARY_HEADING)
|
|
||||||
assert "한줄요약" in result
|
|
||||||
assert "한국 경제가 위기에 처했다." in result["한줄요약"]
|
|
||||||
assert "주요내용" in result
|
|
||||||
assert "GDP 성장률 하락" in result["주요내용"]
|
|
||||||
|
|
||||||
|
|
||||||
def test_parse_summary_empty():
|
|
||||||
result = _parse_summary("이건 파싱 안 되는 텍스트")
|
|
||||||
assert result == {}
|
|
||||||
|
|
||||||
|
|
||||||
# ── send_to_discord ──
|
|
||||||
|
|
||||||
|
|
||||||
@pytest.mark.asyncio
|
|
||||||
async def test_send_to_discord_embed_structure():
|
|
||||||
"""send_to_discord가 올바른 임베드 구조로 웹훅을 호출하는지 확인."""
|
|
||||||
mock_response = AsyncMock()
|
|
||||||
mock_response.raise_for_status = lambda: None
|
|
||||||
|
|
||||||
with patch("app.discord.httpx.AsyncClient") as mock_client_cls:
|
|
||||||
mock_client = AsyncMock()
|
|
||||||
mock_client.post.return_value = mock_response
|
|
||||||
mock_client_cls.return_value.__aenter__ = AsyncMock(return_value=mock_client)
|
|
||||||
mock_client_cls.return_value.__aexit__ = AsyncMock(return_value=False)
|
|
||||||
|
|
||||||
await send_to_discord(
|
|
||||||
title="테스트 영상",
|
|
||||||
video_url="https://youtu.be/abc123",
|
|
||||||
summary=SAMPLE_SUMMARY_BOLD,
|
|
||||||
)
|
|
||||||
|
|
||||||
mock_client.post.assert_called_once()
|
|
||||||
payload = mock_client.post.call_args[1]["json"]
|
|
||||||
|
|
||||||
embed = payload["embeds"][0]
|
|
||||||
assert "📰 테스트 영상" == embed["title"]
|
|
||||||
assert embed["url"] == "https://youtu.be/abc123"
|
|
||||||
assert embed["thumbnail"]["url"] == "https://img.youtube.com/vi/abc123/hqdefault.jpg"
|
|
||||||
assert embed["footer"]["text"] == "YouTube 뉴스 요약 봇"
|
|
||||||
assert "timestamp" in embed
|
|
||||||
|
|
||||||
# 필드 확인
|
|
||||||
field_names = [f["name"] for f in embed["fields"]]
|
|
||||||
assert "📋 주요 내용" in field_names
|
|
||||||
assert "🎯 결론 / 시사점" in field_names
|
|
||||||
assert "🔗 원본 영상" in field_names
|
|
||||||
|
|
||||||
# description에 한줄 요약 포함
|
|
||||||
assert "한국 경제가 위기에 처했다" in embed["description"]
|
|
||||||
|
|
||||||
|
|
||||||
@pytest.mark.asyncio
|
|
||||||
async def test_send_to_discord_fallback_on_unparsable():
|
|
||||||
"""파싱 실패 시 전체 summary가 description에 들어가는지 확인."""
|
|
||||||
mock_response = AsyncMock()
|
|
||||||
mock_response.raise_for_status = lambda: None
|
|
||||||
|
|
||||||
with patch("app.discord.httpx.AsyncClient") as mock_client_cls:
|
|
||||||
mock_client = AsyncMock()
|
|
||||||
mock_client.post.return_value = mock_response
|
|
||||||
mock_client_cls.return_value.__aenter__ = AsyncMock(return_value=mock_client)
|
|
||||||
mock_client_cls.return_value.__aexit__ = AsyncMock(return_value=False)
|
|
||||||
|
|
||||||
raw = "이건 섹션 없는 요약 텍스트입니다."
|
|
||||||
await send_to_discord("테스트", "https://youtu.be/abc", raw)
|
|
||||||
|
|
||||||
payload = mock_client.post.call_args[1]["json"]
|
|
||||||
embed = payload["embeds"][0]
|
|
||||||
assert embed["description"] == raw
|
|
||||||
assert len(embed["fields"]) == 1
|
|
||||||
assert embed["fields"][0]["name"] == "🔗 원본 영상"
|
|
||||||
Reference in New Issue
Block a user