Fix: [3.0.3] JSON 파싱 강화 + Discord 전송 안정화
Some checks failed
news-summary-bot-cicd / build_push_deploy (push) Has been cancelled

- summarizer: 코드펜스/잡텍스트 포함된 Claude 응답도 안정적으로 파싱
- summarizer: 프롬프트에 코드펜스 금지 명시
- main: Discord embed용 summary 통합 필드 추가
- docs: n8n Discord 노드를 JSON.stringify() 방식으로 변경 (줄바꿈/따옴표 이스케이프)

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
sm4640
2026-03-25 19:56:06 +09:00
parent 9c2bf7c1ce
commit 8aa03232e8
3 changed files with 68 additions and 43 deletions

View File

@@ -35,7 +35,7 @@ n8n(YouTube API로 새 영상 감지) → `POST /api/news/summarize` → 자막
```json
// 성공
{"video_url": "...", "title": "...", "channel_name": "...", "status": "ok", "oneliner": "한줄 요약", "main_points": "• 포인트1\n• 포인트2", "conclusion": "결론"}
{"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": "라이브/예정 영상은 요약 대상이 아닙니다"}
@@ -44,6 +44,8 @@ n8n(YouTube API로 새 영상 감지) → `POST /api/news/summarize` → 자막
{"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 인증용).
@@ -180,20 +182,25 @@ return $input.all().filter(item => {
- **Send Body**: ON
- **Body Content Type**: JSON
- **Specify Body**: Using JSON
- **Body (Expression 모드)**:
```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"
- **Body**: Expression 모드 (`=` 클릭)로 전환 후 아래 입력:
> **주의**: `"Using JSON"` 모드에서 `{{ $json.summary }}` 같은 문자열 치환을 쓰면 요약 텍스트의 줄바꿈(`\n`)·따옴표(`"`) 때문에 JSON이 깨진다. 반드시 `JSON.stringify()`로 전체 객체를 직렬화해야 한다.
```javascript
{{ JSON.stringify({
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 }}" }
footer: {
text: "YouTube 뉴스 요약 봇 • " + $json.channel_name
}
}]
}
}) }}
```
#### 9. Discord 에러 알림 (status=error)
@@ -203,20 +210,23 @@ return $input.all().filter(item => {
- **Send Body**: ON
- **Body Content Type**: JSON
- **Specify Body**: Using JSON
- **Body (Expression 모드)**:
```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 }
- **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 응답" }
footer: {
text: "봇 요약 처리 에러 — FastAPI 응답"
}
}]
}
}) }}
```
### YouTube Data API 키 발급

View File

@@ -41,6 +41,16 @@ async def summarize_video(
"error_message": str(e),
}
# Discord embed description용 통합 요약
parts = []
if summary.get("oneliner"):
parts.append(f"**{summary['oneliner']}**")
if summary.get("main_points"):
parts.append(f"\n{summary['main_points']}")
if summary.get("conclusion"):
parts.append(f"\n> {summary['conclusion']}")
summary["summary"] = "\n".join(parts)
return {**base, "status": "ok", **summary}

View File

@@ -11,17 +11,12 @@ SYSTEM_PROMPT = """너는 뉴스/경제 유튜브 영상을 시청하고 핵심
영상을 직접 다 본 사람처럼, 구체적인 수치·사례·맥락을 포함해서 요약해줘.
읽는 사람이 영상을 안 봐도 내용을 충분히 파악할 수 있어야 해.
## 출력 형식 (반드시 이 JSON 형식으로만 응답)
```json
{
"oneliner": "영상의 핵심 메시지를 구체적으로 한 문장",
"main_points": "• 핵심 포인트 1\\n• 핵심 포인트 2\\n• 핵심 포인트 3",
"conclusion": "영상이 전달하려는 메시지와 시청자가 취할 수 있는 액션"
}
```
## 출력 형식
반드시 아래 JSON만 출력해. 코드펜스(```)로 감싸지 마.
{"oneliner": "영상의 핵심 메시지를 구체적으로 한 문장", "main_points": "• 핵심 포인트 1\\n• 핵심 포인트 2\\n• 핵심 포인트 3", "conclusion": "영상이 전달하려는 메시지와 시청자가 취할 수 있는 액션"}
## 규칙
- 반드시 위 JSON 형식으로만 응답. 다른 텍스트 없이 JSON만 출력
- 반드시 위 JSON 형식으로만 응답. 코드펜스, 설명 등 다른 텍스트 절대 금지
- 한국어로 작성
- main_points는 3~7개 불릿(•)으로 정리. 각 항목에 구체적인 수치, 종목명, 인물, 사건 등을 반드시 포함
- "~에 대해 이야기했다" 같은 메타 서술 금지. 내용 자체를 직접 전달
@@ -44,18 +39,28 @@ def summarize(transcript: str, title: str) -> dict:
)
raw = message.content[0].text
# JSON 블록 추출 (```json ... ``` 또는 순수 JSON)
json_match = re.search(r"```json\s*(.*?)\s*```", raw, re.DOTALL)
json_str = json_match.group(1) if json_match else raw.strip()
try:
parsed = json.loads(json_str)
except json.JSONDecodeError:
# 파싱 실패 시 전체 텍스트를 oneliner로
parsed = {"oneliner": raw, "main_points": "", "conclusion": ""}
parsed = _parse_json_response(raw)
return {
"oneliner": parsed.get("oneliner", ""),
"main_points": parsed.get("main_points", ""),
"conclusion": parsed.get("conclusion", ""),
}
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": ""}