242 lines
8.9 KiB
Markdown
242 lines
8.9 KiB
Markdown
# CLAUDE.md
|
||
|
||
This file provides guidance to Claude Code (claude.ai/code) when working with code in this repository.
|
||
|
||
## 프로젝트 개요
|
||
|
||
YouTube 뉴스/경제 채널의 새 영상을 감지하면 자막을 추출하고 Claude API로 요약하는 봇.
|
||
- 영상 감지: n8n (YouTube Data API v3 `playlistItems.list`)
|
||
- 요약 처리: FastAPI 앱 (이 레포)
|
||
- Discord 전송: n8n (FastAPI 응답을 받아서 Discord 웹훅으로 전송)
|
||
- 배포: Docker Hub → OCI 서버에서 docker-compose pull
|
||
|
||
## 빌드 & 실행
|
||
|
||
```bash
|
||
# 로컬 개발
|
||
pip install -r requirements.txt
|
||
uvicorn app.main:app --reload
|
||
|
||
# Docker
|
||
docker build -t nkey/news-summary-bot .
|
||
docker compose up
|
||
```
|
||
|
||
## 아키텍처
|
||
|
||
n8n(YouTube API로 새 영상 감지) → `POST /api/news/summarize` → 자막 추출 → Claude 요약 → JSON 응답 → n8n이 Discord 웹훅 전송
|
||
|
||
- `app/main.py` — FastAPI 엔드포인트 (`/summarize`, `/health`) — Nginx가 `/api/news/` prefix를 strip
|
||
- `app/transcript.py` — YouTube 자막 추출 (`yt-dlp` + 쿠키 인증)
|
||
- `app/summarizer.py` — Claude Sonnet 4.6으로 요약 생성
|
||
- `app/config.py` — 환경변수 설정 (pydantic-settings)
|
||
|
||
### API 응답 형식
|
||
|
||
```json
|
||
// 성공
|
||
{"video_url": "...", "title": "...", "channel_name": "...", "status": "ok", "oneliner": "한줄 요약", "main_points": "• 포인트1\n• 포인트2", "conclusion": "결론", "summary": "**한줄 요약**\n\n• 포인트1\n• 포인트2\n\n> 결론"}
|
||
|
||
// 스킵 (라이브/쇼츠)
|
||
{"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`을 합친 Discord embed용 통합 필드.
|
||
|
||
## 환경변수
|
||
|
||
`ANTHROPIC_API_KEY` 필수. `API_SECRET`은 선택(n8n → FastAPI 인증용).
|
||
|
||
## 쿠키 인증 (YouTube 봇 감지 우회)
|
||
|
||
OCI 등 클라우드 서버에서 YouTube 자막 추출 시 봇 감지 차단을 우회하기 위해 쿠키 파일이 필요.
|
||
- 브라우저 확장(Get cookies.txt LOCALLY 등)으로 YouTube 쿠키를 `cookies.txt`로 export
|
||
- 서버의 `compose.apps.yml`에서 `./news-summary-bot/cookies.txt:/app/cookies.txt:ro`로 마운트
|
||
- 쿠키 만료 시(6개월~1년) 재export 필요 → 에러 발생 시 쿠키 갱신 확인
|
||
|
||
## n8n 워크플로우
|
||
|
||
### 전체 흐름
|
||
|
||
```
|
||
Schedule Trigger (매 정시) ─→ 머니코믹스 playlistItems ─→ Edit Fields (channel_name: 머니코믹스) ──┐
|
||
└→ 슈카월드 playlistItems ─→ Edit Fields (channel_name: 슈카월드) ──┤
|
||
↓
|
||
Merge (Append)
|
||
↓
|
||
Code (새 영상만 필터링)
|
||
↓
|
||
HTTP Request (POST /api/news/summarize)
|
||
↓
|
||
Switch (status 분기)
|
||
├→ ok → Discord 요약 전송
|
||
├→ skipped → No Operation
|
||
└→ error → Discord 에러 알림
|
||
```
|
||
|
||
### 노드별 설정
|
||
|
||
#### 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
|
||
{
|
||
"items": [
|
||
{
|
||
"snippet": {
|
||
"publishedAt": "2026-03-25T06:00:00Z",
|
||
"title": "영상 제목",
|
||
"resourceId": { "videoId": "abc123" }
|
||
}
|
||
}
|
||
]
|
||
}
|
||
```
|
||
|
||
#### 3. Edit Fields (채널별 각 1개)
|
||
- **Type**: Edit Fields (Set)
|
||
- **Mode**: Manual Mapping
|
||
- **Fields to Set**: `channel_name` (String) = `머니코믹스` 또는 `슈카월드`
|
||
- **Include Other Input Fields**: All (기존 데이터 유지)
|
||
|
||
#### 4. Merge
|
||
- **Type**: Merge
|
||
- **Mode**: Append
|
||
|
||
#### 5. Code (새 영상 필터링)
|
||
- **Type**: Code
|
||
- **Language**: JavaScript
|
||
- **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.video_url, inline: false }
|
||
],
|
||
thumbnail: {
|
||
url: "https://img.youtube.com/vi/" + $json.video_url.split("v=")[1] + "/hqdefault.jpg"
|
||
},
|
||
footer: {
|
||
text: "YouTube 뉴스 요약 봇 • " + $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/일)
|