diff --git a/CLAUDE.md b/CLAUDE.md index 1bc69a7..2c46681 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -4,9 +4,10 @@ This file provides guidance to Claude Code (claude.ai/code) when working with co ## 프로젝트 개요 -YouTube 뉴스/경제 채널의 새 영상을 감지하면 자막을 추출하고 Claude API로 요약한 뒤 Discord로 전송하는 봇. -- 영상 감지: n8n RSS 트리거 (외부) +YouTube 뉴스/경제 채널의 새 영상을 감지하면 자막을 추출하고 Claude API로 요약하는 봇. +- 영상 감지: n8n (YouTube Data API v3 `playlistItems.list`) - 요약 처리: FastAPI 앱 (이 레포) +- Discord 전송: n8n (FastAPI 응답을 받아서 Discord 웹훅으로 전송) - 배포: Docker Hub → OCI 서버에서 docker-compose pull ## 빌드 & 실행 @@ -23,39 +24,192 @@ 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/transcript.py` — YouTube 자막 추출 (`yt-dlp` + 쿠키 인증) - `app/summarizer.py` — Claude Sonnet 4.6으로 요약 생성 -- `app/discord.py` — Discord 웹훅 전송 - `app/config.py` — 환경변수 설정 (pydantic-settings) +### API 응답 형식 + +```json +// 성공 +{"status": "ok", "title": "...", "summary": "요약 텍스트"} + +// 스킵 (라이브/쇼츠) +{"status": "skipped", "title": "...", "reason": "라이브/예정 영상은 요약 대상이 아닙니다"} + +// 에러 +{"status": "error", "title": "...", "error_type": "ValueError", "error_message": "..."} +``` + ## 환경변수 -`ANTHROPIC_API_KEY`, `DISCORD_WEBHOOK_URL` 필수. `API_SECRET`은 선택(n8n → FastAPI 인증용). +`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 필요 → 500 에러 발생 시 쿠키 갱신 확인 +- 쿠키 만료 시(6개월~1년) 재export 필요 → 에러 발생 시 쿠키 갱신 확인 ## n8n 워크플로우 +### 전체 흐름 + ``` -RSS Feed Trigger (채널A) ──┐ - ├→ Merge → HTTP Request (POST /api/news/summarize) -RSS Feed Trigger (채널B) ──┘ +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 에러 알림 ``` -- **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` +- **Query Parameters**: + - `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 -{"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 +- 최근 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` +- **Body Content Type**: JSON +- **Body**: +```json +{ + "video_url": "{{ $json.video_url }}", + "title": "{{ $json.title }}" +} +``` +- **Headers** (API_SECRET 사용 시): `X-Api-Secret: <시크릿값>` +- **Options**: "Always Output Data" 켜기 (에러 시에도 다음 노드로 전달) + +#### 7. Switch (응답 분기) +- **Type**: Switch +- **Field**: `{{ $json.status }}` +- **Rules**: + - `ok` → Discord 요약 전송 + - `skipped` → No Operation (무시) + - `error` → Discord 에러 알림 + +#### 8. Discord 요약 전송 (status=ok) +- **Type**: HTTP Request +- **Method**: POST +- **URL**: Discord 웹훅 URL +- **Body Content Type**: JSON +- **Body**: +```json +{ + "embeds": [{ + "title": "📰 [{{ $json.channel_name }}] {{ $json.title }}", + "url": "{{ $json.video_url }}", + "description": "{{ $json.summary }}", + "color": 2829105, + "thumbnail": { + "url": "https://img.youtube.com/vi/{{ $json.video_url.split('v=')[1] }}/hqdefault.jpg" + }, + "footer": { "text": "YouTube 뉴스 요약 봇 • {{ $json.channel_name }}" } + }] +} +``` + +#### 9. Discord 에러 알림 (status=error) +- **Type**: HTTP Request +- **Method**: POST +- **URL**: Discord 웹훅 URL +- **Body Content Type**: JSON +- **Body**: +```json +{ + "embeds": [{ + "title": "❌ [{{ $json.channel_name }}] 뉴스 요약 실패", + "color": 15548997, + "fields": [ + { "name": "영상 제목", "value": "{{ $json.title }}", "inline": false }, + { "name": "에러 타입", "value": "`{{ $json.error_type }}`", "inline": true }, + { "name": "에러 내용", "value": "```\n{{ $json.error_message }}\n```", "inline": false } + ], + "footer": { "text": "YouTube 뉴스 요약 봇 - 에러 알림" } + }] +} +``` + +### 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/일) diff --git a/app/config.py b/app/config.py index 1f556d2..f0066c8 100644 --- a/app/config.py +++ b/app/config.py @@ -3,7 +3,6 @@ from pydantic_settings import BaseSettings class Settings(BaseSettings): anthropic_api_key: str - discord_webhook_url: str api_secret: str = "" model_config = {"env_file": ".env", "extra": "ignore"} diff --git a/app/discord.py b/app/discord.py deleted file mode 100644 index 87b71bc..0000000 --- a/app/discord.py +++ /dev/null @@ -1,146 +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, channel_name: 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"📰 [{channel_name}] {title}" if channel_name else f"📰 {title}" - - embed = { - "title": embed_title, - "url": video_url, - "description": description, - "color": 0x2B2D31, - "fields": fields, - "footer": { - "text": f"YouTube 뉴스 요약 봇 • {channel_name}" if channel_name else "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(timeout=30.0) as client: - resp = await client.post(settings.discord_webhook_url, json=payload) - resp.raise_for_status() - - -async def send_error_to_discord( - title: str, video_url: str, error: Exception, channel_name: str = "" -) -> None: - """에러 발생 시 Discord 웹훅으로 에러 내용 전송.""" - error_type = type(error).__name__ - error_msg = str(error)[:1024] - - error_title = f"❌ [{channel_name}] 뉴스 요약 실패" if channel_name else "❌ 뉴스 요약 실패" - - fields = [] - if channel_name: - fields.append({"name": "채널", "value": channel_name, "inline": True}) - fields.extend([ - {"name": "영상 제목", "value": title or "(제목 없음)", "inline": False}, - {"name": "영상 URL", "value": video_url, "inline": False}, - {"name": "에러 타입", "value": f"`{error_type}`", "inline": True}, - {"name": "에러 내용", "value": f"```\n{error_msg}\n```", "inline": False}, - ]) - - embed = { - "title": error_title, - "color": 0xED4245, - "fields": fields, - "footer": {"text": f"YouTube 뉴스 요약 봇 - 에러 알림 • {channel_name}" if channel_name else "YouTube 뉴스 요약 봇 - 에러 알림"}, - "timestamp": datetime.now(timezone.utc).isoformat(), - } - - payload = {"embeds": [embed]} - - async with httpx.AsyncClient(timeout=30.0) as client: - await client.post(settings.discord_webhook_url, json=payload) diff --git a/app/main.py b/app/main.py index 82e4fb1..57dbc47 100644 --- a/app/main.py +++ b/app/main.py @@ -2,7 +2,6 @@ from fastapi import FastAPI, Header, HTTPException from pydantic import BaseModel from app.config import settings -from app.discord import send_error_to_discord, send_to_discord from app.summarizer import summarize from app.transcript import SkipVideo, extract_video_id, fetch_transcript @@ -12,7 +11,6 @@ app = FastAPI(title="News Summary Bot") class SummarizeRequest(BaseModel): video_url: str title: str = "" - channel_name: str = "" @app.post("/summarize") @@ -24,20 +22,22 @@ async def summarize_video( raise HTTPException(status_code=401, detail="Unauthorized") title = req.title or "제목 없음" - channel_name = req.channel_name or "" try: video_id = extract_video_id(req.video_url) transcript = fetch_transcript(video_id) summary = summarize(transcript, title) - await send_to_discord(title, req.video_url, summary, channel_name) except SkipVideo as e: return {"status": "skipped", "title": title, "reason": str(e)} except Exception as e: - await send_error_to_discord(title, req.video_url, e, channel_name) - raise HTTPException(status_code=500, detail=str(e)) + return { + "status": "error", + "title": title, + "error_type": type(e).__name__, + "error_message": str(e), + } - return {"status": "ok", "title": title, "summary_length": len(summary)} + return {"status": "ok", "title": title, "summary": summary} @app.get("/health")