From dc4656e4527983463db33a47bd7f0b6e8e88e72c Mon Sep 17 00:00:00 2001 From: sm4640 Date: Tue, 24 Mar 2026 12:19:54 +0900 Subject: [PATCH] =?UTF-8?q?Feat:=20[main]=20news-summary-bot=20=EC=99=84?= =?UTF-8?q?=EC=84=B1?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .dockerignore | 6 ++ .env.example | 4 + .github/workflows/deploy.yml | 136 ++++++++++++++++++++++++++++++++++ .gitignore | 4 + CLAUDE.md | 54 ++++++++++++++ Dockerfile | 12 +++ README.md | 91 +++++++++++++++++++++++ app/__init__.py | 0 app/config.py | 12 +++ app/discord.py | 109 ++++++++++++++++++++++++++++ app/main.py | 36 +++++++++ app/summarizer.py | 34 +++++++++ app/transcript.py | 55 ++++++++++++++ docker-compose.yml | 9 +++ docs/development.md | 102 ++++++++++++++++++++++++++ docs/n8n-setup.md | 101 ++++++++++++++++++++++++++ docs/operations.md | 84 +++++++++++++++++++++ docs/testing.md | 34 +++++++++ requirements.txt | 8 ++ tests/__init__.py | 0 tests/test_discord.py | 137 +++++++++++++++++++++++++++++++++++ 21 files changed, 1028 insertions(+) create mode 100644 .dockerignore create mode 100644 .env.example create mode 100644 .github/workflows/deploy.yml create mode 100644 .gitignore create mode 100644 CLAUDE.md create mode 100644 Dockerfile create mode 100644 README.md create mode 100644 app/__init__.py create mode 100644 app/config.py create mode 100644 app/discord.py create mode 100644 app/main.py create mode 100644 app/summarizer.py create mode 100644 app/transcript.py create mode 100644 docker-compose.yml create mode 100644 docs/development.md create mode 100644 docs/n8n-setup.md create mode 100644 docs/operations.md create mode 100644 docs/testing.md create mode 100644 requirements.txt create mode 100644 tests/__init__.py create mode 100644 tests/test_discord.py diff --git a/.dockerignore b/.dockerignore new file mode 100644 index 0000000..5b3b829 --- /dev/null +++ b/.dockerignore @@ -0,0 +1,6 @@ +.env +.env.* +!.env.example +__pycache__ +*.pyc +.git diff --git a/.env.example b/.env.example new file mode 100644 index 0000000..8a2bdf8 --- /dev/null +++ b/.env.example @@ -0,0 +1,4 @@ +ANTHROPIC_API_KEY=sk-ant-xxx +DISCORD_WEBHOOK_URL=https://discord.com/api/webhooks/xxx/xxx +API_SECRET=your-secret-here +DOCKER_IMAGE=nkey/news-summary-bot:latest diff --git a/.github/workflows/deploy.yml b/.github/workflows/deploy.yml new file mode 100644 index 0000000..57aed3b --- /dev/null +++ b/.github/workflows/deploy.yml @@ -0,0 +1,136 @@ +name: news-summary-bot-cicd + +on: + push: + branches: ["main"] + +jobs: + build_push_deploy: + runs-on: ubuntu-latest + steps: + - name: Setup SSH for Gitea + env: + SSH_PRIVATE_KEY: ${{ secrets.NKEY_SSH_PRIVATE_KEY }} + run: | + set -euo pipefail + + mkdir -p ~/.ssh + chmod 700 ~/.ssh + + echo "$SSH_PRIVATE_KEY" > ~/.ssh/id_ed25519 + chmod 600 ~/.ssh/id_ed25519 + + ssh-keyscan -p 2222 -t rsa,ed25519 nkeystudy.site >> ~/.ssh/known_hosts + chmod 644 ~/.ssh/known_hosts + + cat >> ~/.ssh/config <<'EOF' + Host nkey-gitea + HostName nkeystudy.site + User git + Port 2222 + IdentityFile ~/.ssh/id_ed25519 + IdentitiesOnly yes + EOF + chmod 600 ~/.ssh/config + + - name: Manual checkout via SSH + env: + REPO: ${{ github.repository }} + SHA: ${{ github.sha }} + run: | + set -euo pipefail + + git init . + git remote add origin "nkey-gitea:${REPO}.git" + git fetch --no-tags --prune --depth=1 origin "${SHA}" + git checkout -q FETCH_HEAD + + - name: Ensure docker compose available + run: | + set -euo pipefail + docker version + if ! docker compose version >/dev/null 2>&1; then + sudo apt-get update + sudo apt-get install -y docker-compose-plugin + fi + docker compose version + + - name: Docker login + env: + DOCKERHUB_USER: ${{ secrets.DOCKERHUB_USERNAME }} + DOCKERHUB_TOKEN: ${{ secrets.DOCKERHUB_TOKEN }} + run: | + set -euo pipefail + echo "${DOCKERHUB_TOKEN}" | docker login -u "${DOCKERHUB_USER}" --password-stdin + + - name: Extract image version from commit message + id: version + env: + COMMIT_MESSAGE: ${{ github.event.head_commit.message }} + run: | + set -euo pipefail + + VERSION_TAG="" + if printf '%s' "${COMMIT_MESSAGE}" | grep -Eq '\[[0-9]+\.[0-9]+\.[0-9]+\]'; then + VERSION_TAG="$(printf '%s' "${COMMIT_MESSAGE}" | sed -nE 's/.*\[([0-9]+\.[0-9]+\.[0-9]+)\].*/\1/p' | head -n1)" + fi + + echo "version_tag=${VERSION_TAG}" >> "$GITHUB_OUTPUT" + + - name: Build and push image + env: + DOCKERHUB_USER: ${{ secrets.DOCKERHUB_USERNAME }} + IMAGE_NAME: news-summary-bot + VERSION_TAG: ${{ steps.version.outputs.version_tag }} + run: | + set -euo pipefail + + IMAGE="${DOCKERHUB_USER}/${IMAGE_NAME}:latest" + if [ -n "${VERSION_TAG}" ]; then + VERSIONED_IMAGE="${DOCKERHUB_USER}/${IMAGE_NAME}:${VERSION_TAG}" + docker build -t "${IMAGE}" -t "${VERSIONED_IMAGE}" . + docker push "${VERSIONED_IMAGE}" + else + docker build -t "${IMAGE}" . + fi + + docker push "${IMAGE}" + + - name: Deploy on server (compose pull/up) + run: | + set -euo pipefail + 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 + docker image prune -f + + - name: Discord Notification + if: always() + env: + DISCORD_WEBHOOK: ${{ secrets.DISCORD_WEBHOOK }} + run: | + set -euo pipefail + if [ "${{ job.status }}" = "success" ]; then + STATUS="SUCCESS" + COLOR=3066993 + DESC="News summary bot build/push/deploy succeeded." + else + STATUS="FAILURE" + COLOR=15158332 + DESC="News summary bot build or deploy failed." + fi + + curl -X POST -H "Content-Type: application/json" \ + -d '{ + "embeds": [{ + "title": "News Summary Bot CI/CD - '"$STATUS"'", + "description": "'"$DESC"'", + "fields": [ + { "name": "Repo", "value": "${{ github.repository }}", "inline": true }, + { "name": "Commit", "value": "`${{ github.sha }}`", "inline": true }, + { "name": "Actor", "value": "${{ github.actor }}", "inline": true }, + { "name": "Image Version", "value": "`${{ steps.version.outputs.version_tag || 'latest only' }}`", "inline": true } + ], + "color": '"$COLOR"', + "timestamp": "'$(date -u +%Y-%m-%dT%H:%M:%SZ)'" + }] + }' "${DISCORD_WEBHOOK}" diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..939f18f --- /dev/null +++ b/.gitignore @@ -0,0 +1,4 @@ +.env +__pycache__/ +*.pyc +venv/ diff --git a/CLAUDE.md b/CLAUDE.md new file mode 100644 index 0000000..45dec83 --- /dev/null +++ b/CLAUDE.md @@ -0,0 +1,54 @@ +# CLAUDE.md + +This file provides guidance to Claude Code (claude.ai/code) when working with code in this repository. + +## 프로젝트 개요 + +YouTube 뉴스/경제 채널의 새 영상을 감지하면 자막을 추출하고 Claude API로 요약한 뒤 Discord로 전송하는 봇. +- 영상 감지: n8n RSS 트리거 (외부) +- 요약 처리: FastAPI 앱 (이 레포) +- 배포: 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(RSS 감지) → `POST /api/news/summarize` → 자막 추출 → Claude 요약 → Discord 웹훅 + +- `app/main.py` — FastAPI 엔드포인트 (`/api/news/summarize`, `/api/news/health`) +- `app/transcript.py` — YouTube 자막 추출 (`youtube-transcript-api`) +- `app/summarizer.py` — Claude Sonnet 4.6으로 요약 생성 +- `app/discord.py` — Discord 웹훅 전송 +- `app/config.py` — 환경변수 설정 (pydantic-settings) + +## 환경변수 + +`ANTHROPIC_API_KEY`, `DISCORD_WEBHOOK_URL` 필수. `API_SECRET`은 선택(n8n → FastAPI 인증용). + +## n8n 워크플로우 + +``` +RSS Feed Trigger (채널A) ──┐ + ├→ Merge → HTTP Request (POST /api/news/summarize) +RSS Feed Trigger (채널B) ──┘ +``` + +- **RSS Feed Trigger**: 채널별 RSS URL로 새 영상만 감지 (중복 방지 내장, Poll Time으로 주기 설정) +- **Merge**: 두 채널의 새 영상을 하나의 리스트로 합침 +- **HTTP Request**: 각 영상마다 `POST <서버IP>/api/news/summarize` 호출 + +요청 바디: +```json +{"video_url": "https://youtu.be/xxx", "title": "영상 제목"} +``` +`API_SECRET` 설정 시 헤더에 `X-Api-Secret` 포함. diff --git a/Dockerfile b/Dockerfile new file mode 100644 index 0000000..97343b2 --- /dev/null +++ b/Dockerfile @@ -0,0 +1,12 @@ +FROM python:3.12-slim + +WORKDIR /app + +COPY requirements.txt . +RUN pip install --no-cache-dir -r requirements.txt + +COPY app/ ./app/ + +EXPOSE 8000 + +CMD ["uvicorn", "app.main:app", "--host", "0.0.0.0", "--port", "8000"] diff --git a/README.md b/README.md new file mode 100644 index 0000000..fde7a1c --- /dev/null +++ b/README.md @@ -0,0 +1,91 @@ +# News Summary Bot + +YouTube 뉴스/경제 채널의 새 영상을 감지하면 자막을 추출하고, Claude API로 요약한 뒤 Discord로 전송하는 봇입니다. + +## 아키텍처 + +``` +n8n (RSS 감지) → POST /api/news/summarize → 자막 추출 → Claude 요약 → Discord 웹훅 +``` + +| 모듈 | 역할 | +|------|------| +| `app/main.py` | FastAPI 엔드포인트 | +| `app/transcript.py` | YouTube 자막 추출 (yt-dlp) | +| `app/summarizer.py` | Claude Sonnet 4.6으로 요약 생성 | +| `app/discord.py` | Discord 웹훅 전송 | +| `app/config.py` | 환경변수 설정 (pydantic-settings) | + +## 빠른 시작 + +### 환경변수 설정 + +```bash +cp .env.example .env +# .env 파일에서 아래 값을 수정 +``` + +| 변수 | 필수 | 설명 | +|------|------|------| +| `ANTHROPIC_API_KEY` | O | Claude API 키 | +| `DISCORD_WEBHOOK_URL` | O | Discord 웹훅 URL | +| `API_SECRET` | X | n8n → FastAPI 인증용 시크릿 | + +### 로컬 개발 + +```bash +pip install -r requirements.txt +uvicorn app.main:app --reload +``` + +### Docker + +```bash +docker build -t nkey/news-summary-bot . +docker compose up +``` + +## API + +### `POST /api/news/summarize` + +영상 URL을 받아 자막 추출 → 요약 → Discord 전송을 수행합니다. + +**Request:** + +```json +{ + "video_url": "https://youtu.be/xxx", + "title": "영상 제목" +} +``` + +`API_SECRET` 설정 시 헤더에 `X-Api-Secret` 포함 필요. + +**Response:** + +```json +{ + "status": "ok", + "title": "영상 제목", + "summary_length": 1234 +} +``` + +### `GET /api/news/health` + +헬스 체크 엔드포인트. + +## n8n 워크플로우 + +``` +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)를 참고하세요. + +## 배포 + +Docker Hub에 이미지를 푸시하고 서버에서 `docker compose pull && docker compose up -d`로 배포합니다. 자세한 내용은 [docs/operations.md](docs/operations.md)를 참고하세요. diff --git a/app/__init__.py b/app/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/app/config.py b/app/config.py new file mode 100644 index 0000000..1f556d2 --- /dev/null +++ b/app/config.py @@ -0,0 +1,12 @@ +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"} + + +settings = Settings() diff --git a/app/discord.py b/app/discord.py new file mode 100644 index 0000000..ac0533f --- /dev/null +++ b/app/discord.py @@ -0,0 +1,109 @@ +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() diff --git a/app/main.py b/app/main.py new file mode 100644 index 0000000..3219c88 --- /dev/null +++ b/app/main.py @@ -0,0 +1,36 @@ +from fastapi import FastAPI, Header, HTTPException +from pydantic import BaseModel + +from app.config import settings +from app.discord import send_to_discord +from app.summarizer import summarize +from app.transcript import extract_video_id, fetch_transcript + +app = FastAPI(title="News Summary Bot") + + +class SummarizeRequest(BaseModel): + video_url: str + title: str = "" + + +@app.post("/api/news/summarize") +async def summarize_video( + req: SummarizeRequest, + x_api_secret: str = Header(default=""), +): + if settings.api_secret and x_api_secret != settings.api_secret: + raise HTTPException(status_code=401, detail="Unauthorized") + + video_id = extract_video_id(req.video_url) + 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)} + + +@app.get("/api/news/health") +async def health(): + return {"status": "ok"} diff --git a/app/summarizer.py b/app/summarizer.py new file mode 100644 index 0000000..9de06f7 --- /dev/null +++ b/app/summarizer.py @@ -0,0 +1,34 @@ +import anthropic + +from app.config import settings + +client = anthropic.Anthropic(api_key=settings.anthropic_api_key) + +SYSTEM_PROMPT = """너는 뉴스/경제 유튜브 영상 요약 전문가야. +영상 자막 텍스트를 받아서 아래 형식으로 요약해줘. + +## 형식 +- **한줄 요약**: 영상의 핵심을 한 문장으로 +- **주요 내용**: 핵심 포인트를 3~7개 불릿으로 정리 +- **결론/시사점**: 영상이 전달하려는 메시지나 시사점 + +## 규칙 +- 한국어로 작성 +- 간결하고 명확하게 +- 자막의 오타나 말더듬은 무시하고 의미 중심으로 정리 +""" + + +def summarize(transcript: str, title: str) -> str: + message = client.messages.create( + model="claude-sonnet-4-20250514", + max_tokens=2048, + system=SYSTEM_PROMPT, + messages=[ + { + "role": "user", + "content": f"영상 제목: {title}\n\n자막:\n{transcript}", + } + ], + ) + return message.content[0].text diff --git a/app/transcript.py b/app/transcript.py new file mode 100644 index 0000000..51c47b1 --- /dev/null +++ b/app/transcript.py @@ -0,0 +1,55 @@ +import httpx +import yt_dlp + + +def extract_video_id(url: str) -> str: + """YouTube URL에서 video ID 추출.""" + if "youtu.be/" in url: + return url.split("youtu.be/")[1].split("?")[0] + if "v=" in url: + return url.split("v=")[1].split("&")[0] + raise ValueError(f"유효하지 않은 YouTube URL: {url}") + + +def fetch_transcript(video_id: str) -> str: + """yt-dlp로 YouTube 자동생성 자막을 텍스트로 추출.""" + url = f"https://www.youtube.com/watch?v={video_id}" + + ydl_opts = { + "skip_download": True, + "writeautomaticsub": True, + "subtitleslangs": ["ko", "en"], + "subtitlesformat": "json3", + "quiet": True, + "no_warnings": True, + } + + with yt_dlp.YoutubeDL(ydl_opts) as ydl: + info = ydl.extract_info(url, download=False) + + subs = info.get("automatic_captions", {}) + lang = "ko" if "ko" in subs else "en" if "en" in subs else None + if not lang: + raise ValueError(f"자막을 찾을 수 없습니다: {video_id}") + + sub_url = None + for fmt in subs[lang]: + if fmt["ext"] == "json3": + sub_url = fmt["url"] + break + + if not sub_url: + raise ValueError(f"json3 자막 포맷을 찾을 수 없습니다: {video_id}") + + resp = httpx.get(sub_url) + resp.raise_for_status() + data = resp.json() + + texts = [] + for event in data.get("events", []): + for seg in event.get("segs", []): + text = seg.get("utf8", "").strip() + if text and text != "\n": + texts.append(text) + + return " ".join(texts) diff --git a/docker-compose.yml b/docker-compose.yml new file mode 100644 index 0000000..c4291e3 --- /dev/null +++ b/docker-compose.yml @@ -0,0 +1,9 @@ + news-summary-bot: + image: nkey01/news-summary-bot:latest + container_name: news-summary-bot + env_file: + - .env + - ./news-summary-bot/.env + networks: + - nkeysworld-network + - obs \ No newline at end of file diff --git a/docs/development.md b/docs/development.md new file mode 100644 index 0000000..8331fd6 --- /dev/null +++ b/docs/development.md @@ -0,0 +1,102 @@ +# 개발 가이드 + +YouTube 뉴스 영상을 자동 요약하여 Discord로 전송하는 FastAPI 기반 봇의 개발 문서입니다. + +## 프로젝트 구조 + +``` +news-summary-bot/ +├── app/ +│ ├── __init__.py +│ ├── main.py # FastAPI 엔드포인트 (/summarize, /health) +│ ├── config.py # pydantic-settings 환경변수 +│ ├── transcript.py # youtube-transcript-api로 자막 추출 +│ ├── summarizer.py # Claude Sonnet 4.6 요약 +│ └── discord.py # Discord 웹훅 전송 +├── Dockerfile +├── docker-compose.yml +├── requirements.txt +├── .env.example +└── .gitignore +``` + +## 로컬 환경 설정 + +**Python 3.12 이상** 필요. + +```bash +# 의존성 설치 +pip install -r requirements.txt + +# 환경변수 파일 생성 +cp .env.example .env +# .env 파일을 열어 API 키 등 설정 + +# 개발 서버 실행 +uvicorn app.main:app --reload +``` + +서버가 정상 실행되면 `http://localhost:8000` 에서 접근 가능합니다. + +## API 엔드포인트 + +### POST /summarize + +YouTube 영상 URL을 받아 자막을 추출하고, Claude로 요약한 뒤 Discord 웹훅으로 전송합니다. + +- **인증**: `X-Api-Secret` 헤더에 시크릿 값 전달 (API_SECRET 환경변수가 설정된 경우 필수) +- **요청 본문**: + - `video_url` (string, 필수) — YouTube 영상 URL + - `title` (string, 필수) — 영상 제목 + +### GET /health + +서버 상태 확인용 헬스체크 엔드포인트입니다. 별도 인증 없이 호출 가능합니다. + +## 로컬 테스트 + +### 헬스체크 확인 + +```bash +curl http://localhost:8000/health +``` + +### 요약 요청 + +```bash +curl -X POST http://localhost:8000/summarize \ + -H "Content-Type: application/json" \ + -H "X-Api-Secret: your-secret-here" \ + -d '{ + "video_url": "https://www.youtube.com/watch?v=VIDEO_ID", + "title": "뉴스 영상 제목" + }' +``` + +정상 처리 시 Discord 채널에 요약 메시지가 전송됩니다. + +## 주요 모듈 설명 + +### transcript.py + +YouTube URL에서 video ID를 추출하고 `youtube-transcript-api`를 사용하여 자막을 가져옵니다. 한국어(`ko`) 자막을 우선으로 시도하며, 없을 경우 영어 등 다른 언어 자막을 fallback으로 사용합니다. + +### summarizer.py + +Anthropic의 **Claude Sonnet 4.6** 모델을 사용하여 자막 텍스트를 요약합니다. 시스템 프롬프트에 뉴스/경제 요약에 최적화된 포맷을 지정하여 일관된 형식의 요약을 생성합니다. + +### discord.py + +요약 결과를 **Discord Embed** 형식으로 구성하여 웹훅 URL로 전송합니다. 영상 제목, 요약 내용, 원본 링크 등을 포함합니다. + +### config.py + +`pydantic-settings`를 사용하여 `.env` 파일 또는 시스템 환경변수에서 설정값을 로드합니다. 필수 값이 누락되면 서버 시작 시 에러가 발생합니다. + +## 환경변수 + +| 변수 | 필수 | 설명 | +|------|------|------| +| `ANTHROPIC_API_KEY` | O | Anthropic API 키 | +| `DISCORD_WEBHOOK_URL` | O | Discord 웹훅 URL | +| `API_SECRET` | X | n8n → FastAPI 인증용 시크릿 (미설정 시 인증 생략) | diff --git a/docs/n8n-setup.md b/docs/n8n-setup.md new file mode 100644 index 0000000..680d6de --- /dev/null +++ b/docs/n8n-setup.md @@ -0,0 +1,101 @@ +# n8n 워크플로우 구성 가이드 + +## 워크플로우 개요 + +이 워크플로우는 2개의 YouTube 채널(머니머니코믹스, 슈카월드)의 새 영상을 RSS Feed Trigger로 감지하고, FastAPI `/api/news/summarize` 엔드포인트를 호출하여 요약 후 Discord로 전송하는 자동화 파이프라인입니다. + +**전체 흐름:** + +``` +RSS Feed Trigger (채널A) ──┐ + ├→ Merge → HTTP Request (FastAPI) → 완료 +RSS Feed Trigger (채널B) ──┘ ↘ Error Trigger → Discord 알림 +``` + +## 노드 구성 상세 + +### 1. RSS Feed Trigger 노드 (2개) + +RSS Feed Trigger는 **중복 방지가 내장**되어 있어 이전에 처리한 항목을 기억하고, 새 영상만 반환합니다. Poll Time으로 체크 주기를 설정합니다. + +**노드 A - 머니머니코믹스:** + +| 설정 항목 | 값 | +|---|---| +| Feed URL | `https://www.youtube.com/feeds/videos.xml?channel_id=UCJo6G1u0e_-wS-JQn3T-zEw` | +| Poll Time | Every Day / Hour: 10, 14, 20 (하루 3회 권장) | + +**노드 B - 슈카월드:** + +| 설정 항목 | 값 | +|---|---| +| Feed URL | `https://www.youtube.com/feeds/videos.xml?channel_id=UCsJ6RuBiTVWRX156FVbeaGg` | +| Poll Time | Every Day / Hour: 10, 14, 20 (하루 3회 권장) | + +> ⚠️ **첫 실행 주의:** RSS Feed Trigger를 처음 활성화하면 피드에 있는 모든 영상(최대 15개)을 새 영상으로 인식합니다. 워크플로우 활성화 전에 수동으로 한 번 테스트 실행하여 기존 항목을 처리 완료 상태로 만드세요. + +### 2. Merge 노드 + +- **타입:** Merge +- **모드:** Append +- 두 RSS Feed Trigger의 출력을 하나의 리스트로 합칩니다. +- 합쳐진 리스트의 각 항목마다 다음 노드(HTTP Request)가 실행됩니다. + +### 3. HTTP Request 노드 + +FastAPI 서버의 `/api/news/summarize` 엔드포인트를 호출합니다. + +| 설정 항목 | 값 | +|---|---| +| Method | `POST` | +| URL | `http://<서버IP>/api/news/summarize` | +| Body Content Type | JSON | +| Body | 아래 참조 | + +**Body (JSON):** + +```json +{ + "video_url": "{{ $json.link }}", + "title": "{{ $json.title }}" +} +``` + +**Headers:** + +| 헤더 | 값 | +|---|---| +| `Content-Type` | `application/json` | +| `X-Api-Secret` | 설정한 시크릿 값 (`.env`의 `API_SECRET`과 동일) | + +## 에러 처리 + +### Error Trigger 워크플로우 + +메인 워크플로우와 별도로 에러 처리 워크플로우를 생성합니다. + +1. **Error Trigger** 노드를 추가합니다. +2. **HTTP Request** 노드로 Discord Webhook을 호출합니다: + +| 설정 항목 | 값 | +|---|---| +| Method | `POST` | +| URL | Discord Webhook URL | +| Body Content Type | JSON | + +**Body:** + +```json +{ + "content": "뉴스 요약 봇 에러 발생!\n워크플로우: {{ $json.workflow.name }}\n에러: {{ $json.execution.error.message }}" +} +``` + +3. 메인 워크플로우의 **Settings → Error Workflow**에서 이 에러 워크플로우를 지정합니다. + +### HTTP Request 노드 자체 에러 처리 + +HTTP Request 노드 설정에서: + +- **Continue On Fail:** `true`로 설정하면 하나의 영상 요약이 실패해도 나머지 영상은 계속 처리됩니다. +- **Retry On Fail:** `true`, **Max Retries:** `2`, **Wait Between Retries (ms):** `3000` diff --git a/docs/operations.md b/docs/operations.md new file mode 100644 index 0000000..c89ca7a --- /dev/null +++ b/docs/operations.md @@ -0,0 +1,84 @@ +# 운영 가이드 + +YouTube 뉴스 요약 봇의 배포 및 운영 가이드. + +**아키텍처:** n8n (RSS 트리거) → FastAPI 앱 (Docker 컨테이너) → Claude API → Discord 웹훅 + +--- + +## 배포 절차 + +### 1. Docker 이미지 빌드 & Push + +```bash +docker build -t nkey/news-summary-bot:latest . +docker push nkey/news-summary-bot:latest +``` + +### 2. OCI 서버 설정 + +- `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 +# 로컬에서 +docker build -t nkey/news-summary-bot:latest . +docker push nkey/news-summary-bot:latest + +# OCI 서버에서 +docker compose pull && docker compose up -d +``` + +--- + +## 헬스체크 + +```bash +curl http://localhost:8000/health +``` + +--- + +## 로그 확인 + +```bash +docker compose logs -f news-summary-bot +``` + +--- + +## 트러블슈팅 + +| 증상 | 원인 | 해결 | +|------|------|------| +| 자막 추출 실패 | 영상에 자막 없음 | 자동생성 자막이 없는 영상은 스킵됨 | +| Discord 전송 실패 | 웹훅 URL 만료 | Discord에서 웹훅 재생성 후 `.env` 업데이트 | +| 401 Unauthorized | API_SECRET 불일치 | n8n 헤더와 `.env` 값 확인 | +| Claude API 오류 | API 키 만료 또는 잔액 부족 | Anthropic 콘솔에서 확인 | +| Discord embed 글자 수 초과 | 요약이 4096자 초과 | `summarizer.py`의 `max_tokens` 줄이기 | + +--- + +## 모니터링 포인트 + +- `/health` 엔드포인트로 컨테이너 상태 확인 +- Discord 채널에 요약이 정상적으로 올라오는지 확인 +- `docker compose logs`로 에러 로그 모니터링 + +--- + +## 비용 관리 + +- **Claude Sonnet 4.6:** Input $3/MTok, Output $15/MTok +- 일 1~2건 기준 월 ~$3 이내 +- [Anthropic 콘솔](https://console.anthropic.com/)에서 usage 확인 diff --git a/docs/testing.md b/docs/testing.md new file mode 100644 index 0000000..224950e --- /dev/null +++ b/docs/testing.md @@ -0,0 +1,34 @@ +# 테스트 가이드 + +## 의존성 설치 + +```bash +pip install pytest pytest-asyncio +``` + +## 테스트 실행 + +```bash +# 환경변수 더미값 설정 (config.py 로드용) +ANTHROPIC_API_KEY=test DISCORD_WEBHOOK_URL=https://test pytest -v +``` + +## 테스트 구조 + +### `tests/test_discord.py` + +Discord 메시지 전송 모듈(`app/discord.py`)에 대한 유닛 테스트. + +| 테스트 | 설명 | +|--------|------| +| `test_extract_video_id` | 다양한 YouTube URL 형식에서 비디오 ID를 올바르게 추출하는지 확인 (parametrize 5건) | +| `test_parse_summary_bold_format` | `**한줄 요약**:` 볼드 형식의 요약 파싱 검증 | +| `test_parse_summary_heading_format` | `## 한줄 요약` 헤딩 형식의 요약 파싱 검증 | +| `test_parse_summary_empty` | 파싱 불가한 텍스트에 대해 빈 dict 반환 확인 | +| `test_send_to_discord_embed_structure` | 임베드에 제목, URL, 썸네일, 필드, 푸터, 타임스탬프가 올바르게 구성되는지 확인 | +| `test_send_to_discord_fallback_on_unparsable` | 파싱 실패 시 전체 텍스트가 description에 그대로 들어가는지 확인 | + +## 참고 + +- 테스트는 외부 API를 호출하지 않습니다 (`httpx.AsyncClient`를 mock 처리). +- `ANTHROPIC_API_KEY`, `DISCORD_WEBHOOK_URL`은 pydantic-settings가 필수로 요구하므로 더미값을 환경변수로 전달해야 합니다. diff --git a/requirements.txt b/requirements.txt new file mode 100644 index 0000000..3076bf0 --- /dev/null +++ b/requirements.txt @@ -0,0 +1,8 @@ +fastapi==0.115.12 +uvicorn==0.34.2 +yt-dlp>=2025.3.31 +anthropic==0.52.0 +httpx==0.28.1 +pydantic-settings==2.8.1 +pytest==9.0.2 +pytest-asyncio==1.3.0 diff --git a/tests/__init__.py b/tests/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/tests/test_discord.py b/tests/test_discord.py new file mode 100644 index 0000000..ffbaf21 --- /dev/null +++ b/tests/test_discord.py @@ -0,0 +1,137 @@ +"""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"] == "🔗 원본 영상"