Refactor: [3.0.0] Discord 전송을 n8n으로 이관, YouTube RSS → Data API 전환
Some checks failed
news-summary-bot-cicd / build_push_deploy (push) Has been cancelled
Some checks failed
news-summary-bot-cicd / build_push_deploy (push) Has been cancelled
- FastAPI에서 Discord 직접 전송 제거, 요약 결과를 JSON으로 반환 - app/discord.py 삭제, DISCORD_WEBHOOK_URL 환경변수 제거 - 에러 시 500 대신 200 + status: error로 응답 (n8n에서 분기 처리) - CLAUDE.md에 n8n 워크플로우 노드별 상세 설정 문서화 Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
@@ -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"}
|
||||
|
||||
146
app/discord.py
146
app/discord.py
@@ -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)
|
||||
14
app/main.py
14
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")
|
||||
|
||||
Reference in New Issue
Block a user