Initial commit - cleaned repository
This commit is contained in:
13
backup-services/ai-writer/backend/Dockerfile
Normal file
13
backup-services/ai-writer/backend/Dockerfile
Normal file
@ -0,0 +1,13 @@
|
||||
FROM python:3.11-slim
|
||||
|
||||
WORKDIR /app
|
||||
|
||||
# Install dependencies
|
||||
COPY requirements.txt .
|
||||
RUN pip install --no-cache-dir -r requirements.txt
|
||||
|
||||
# Copy application
|
||||
COPY . .
|
||||
|
||||
# Run the application
|
||||
CMD ["uvicorn", "app.main:app", "--host", "0.0.0.0", "--port", "8000", "--reload"]
|
||||
0
backup-services/ai-writer/backend/app/__init__.py
Normal file
0
backup-services/ai-writer/backend/app/__init__.py
Normal file
218
backup-services/ai-writer/backend/app/article_generator.py
Normal file
218
backup-services/ai-writer/backend/app/article_generator.py
Normal file
@ -0,0 +1,218 @@
|
||||
"""
|
||||
Article Generation Module
|
||||
Claude API를 사용한 기사 생성 로직
|
||||
"""
|
||||
from typing import Dict, Any, List, Optional
|
||||
from datetime import datetime
|
||||
import json
|
||||
import uuid
|
||||
import logging
|
||||
from anthropic import AsyncAnthropic
|
||||
from pydantic import BaseModel, Field
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
# Data Models
|
||||
class NewsSource(BaseModel):
|
||||
"""뉴스 소스 정보"""
|
||||
title: str
|
||||
url: str
|
||||
published_date: Optional[str] = None
|
||||
source_site: str = "Unknown"
|
||||
|
||||
class EventInfo(BaseModel):
|
||||
"""이벤트 정보"""
|
||||
name: str
|
||||
date: Optional[str] = None
|
||||
location: Optional[str] = None
|
||||
|
||||
class Entities(BaseModel):
|
||||
"""추출된 엔티티"""
|
||||
people: List[str] = Field(default_factory=list)
|
||||
organizations: List[str] = Field(default_factory=list)
|
||||
groups: List[str] = Field(default_factory=list)
|
||||
countries: List[str] = Field(default_factory=list)
|
||||
events: List[EventInfo] = Field(default_factory=list)
|
||||
keywords: List[str] = Field(default_factory=list)
|
||||
|
||||
class SubTopic(BaseModel):
|
||||
"""기사 소주제"""
|
||||
title: str
|
||||
content: List[str]
|
||||
|
||||
class GeneratedArticle(BaseModel):
|
||||
"""생성된 기사"""
|
||||
news_id: str = Field(default_factory=lambda: str(uuid.uuid4()))
|
||||
title: str
|
||||
summary: str
|
||||
subtopics: List[SubTopic]
|
||||
categories: List[str]
|
||||
entities: Entities
|
||||
sources: List[NewsSource] = Field(default_factory=list)
|
||||
created_at: datetime = Field(default_factory=datetime.now)
|
||||
generation_metadata: Dict[str, Any] = Field(default_factory=dict)
|
||||
|
||||
async def generate_article_with_claude(
|
||||
news_data: Dict[str, Any],
|
||||
style: str = "professional",
|
||||
claude_api_key: str = None
|
||||
) -> GeneratedArticle:
|
||||
"""Claude API를 사용하여 기사 생성"""
|
||||
|
||||
if not claude_api_key:
|
||||
import os
|
||||
claude_api_key = os.getenv("CLAUDE_API_KEY")
|
||||
|
||||
# Initialize Claude client
|
||||
claude_client = AsyncAnthropic(api_key=claude_api_key)
|
||||
|
||||
# Collect source information
|
||||
sources_info = []
|
||||
|
||||
# Prepare the prompt
|
||||
system_prompt = """당신은 전문적인 한국 언론사의 수석 기자입니다.
|
||||
제공된 데이터를 기반으로 깊이 있고 통찰력 있는 기사를 작성해야 합니다.
|
||||
기사는 다음 요구사항을 충족해야 합니다:
|
||||
|
||||
1. 소주제는 최소 2개, 최대 6개로 구성해야 합니다
|
||||
2. 각 소주제는 최소 1개, 최대 10개의 문단으로 구성해야 합니다
|
||||
3. 전문적이고 객관적인 어조를 유지해야 합니다
|
||||
4. 사실에 기반한 분석과 통찰을 제공해야 합니다
|
||||
5. 한국 독자를 대상으로 작성되어야 합니다
|
||||
6. 이벤트 정보는 가능한 일시와 장소를 포함해야 합니다
|
||||
7. 핵심 키워드를 최대 10개까지 추출해야 합니다
|
||||
|
||||
반드시 다음 JSON 형식으로 응답하세요:
|
||||
{
|
||||
"title": "기사 제목",
|
||||
"summary": "한 줄 요약 (100자 이내)",
|
||||
"subtopics": [
|
||||
{
|
||||
"title": "소주제 제목",
|
||||
"content": ["문단1", "문단2", ...] // 1-10개 문단
|
||||
}
|
||||
], // 2-6개 소주제
|
||||
"categories": ["카테고리1", "카테고리2"],
|
||||
"entities": {
|
||||
"people": ["인물1", "인물2"],
|
||||
"organizations": ["기관1", "기관2"],
|
||||
"groups": ["단체1", "단체2"],
|
||||
"countries": ["나라1", "나라2"],
|
||||
"events": [
|
||||
{
|
||||
"name": "이벤트명",
|
||||
"date": "2025년 1월 15일", // 선택사항
|
||||
"location": "서울 코엑스" // 선택사항
|
||||
}
|
||||
],
|
||||
"keywords": ["키워드1", "키워드2", ...] // 최대 10개
|
||||
}
|
||||
}"""
|
||||
|
||||
# Prepare news content for Claude and collect sources
|
||||
news_content = []
|
||||
for item in news_data.get("news_items", []):
|
||||
# Add RSS source info
|
||||
rss_title = item.get('rss_title', '')
|
||||
rss_link = item.get('rss_link', '')
|
||||
rss_published = item.get('rss_published', '')
|
||||
|
||||
if rss_title and rss_link:
|
||||
sources_info.append(NewsSource(
|
||||
title=rss_title,
|
||||
url=rss_link,
|
||||
published_date=rss_published,
|
||||
source_site="RSS Feed"
|
||||
))
|
||||
|
||||
item_text = f"제목: {rss_title}\n"
|
||||
for result in item.get("google_results", []):
|
||||
# Add Google search result sources
|
||||
if "title" in result and "link" in result:
|
||||
sources_info.append(NewsSource(
|
||||
title=result.get('title', ''),
|
||||
url=result.get('link', ''),
|
||||
published_date=None,
|
||||
source_site="Google Search"
|
||||
))
|
||||
|
||||
if "full_content" in result and result["full_content"]:
|
||||
content = result["full_content"]
|
||||
if isinstance(content, dict):
|
||||
item_text += f"출처: {content.get('url', '')}\n"
|
||||
item_text += f"내용: {content.get('content', '')[:1000]}...\n\n"
|
||||
else:
|
||||
item_text += f"내용: {str(content)[:1000]}...\n\n"
|
||||
news_content.append(item_text)
|
||||
|
||||
combined_content = "\n".join(news_content[:10]) # Limit to prevent token overflow
|
||||
|
||||
user_prompt = f"""다음 뉴스 데이터를 기반으로 종합적인 기사를 작성하세요:
|
||||
|
||||
키워드: {news_data.get('keyword', '')}
|
||||
수집된 뉴스 수: {len(news_data.get('news_items', []))}
|
||||
|
||||
뉴스 내용:
|
||||
{combined_content}
|
||||
|
||||
스타일: {style}
|
||||
- professional: 전통적인 뉴스 기사 스타일
|
||||
- analytical: 분석적이고 심층적인 스타일
|
||||
- investigative: 탐사보도 스타일
|
||||
|
||||
위의 데이터를 종합하여 통찰력 있는 기사를 JSON 형식으로 작성해주세요."""
|
||||
|
||||
try:
|
||||
# Call Claude API
|
||||
response = await claude_client.messages.create(
|
||||
model="claude-3-5-sonnet-20241022",
|
||||
max_tokens=4000,
|
||||
temperature=0.7,
|
||||
system=system_prompt,
|
||||
messages=[
|
||||
{"role": "user", "content": user_prompt}
|
||||
]
|
||||
)
|
||||
|
||||
# Parse response
|
||||
content = response.content[0].text
|
||||
|
||||
# Extract JSON from response
|
||||
json_start = content.find('{')
|
||||
json_end = content.rfind('}') + 1
|
||||
if json_start != -1 and json_end > json_start:
|
||||
json_str = content[json_start:json_end]
|
||||
article_data = json.loads(json_str)
|
||||
else:
|
||||
raise ValueError("No valid JSON found in response")
|
||||
|
||||
# Create article object
|
||||
article = GeneratedArticle(
|
||||
title=article_data.get("title", ""),
|
||||
summary=article_data.get("summary", ""),
|
||||
subtopics=[
|
||||
SubTopic(
|
||||
title=st.get("title", ""),
|
||||
content=st.get("content", [])
|
||||
) for st in article_data.get("subtopics", [])
|
||||
],
|
||||
categories=article_data.get("categories", []),
|
||||
entities=Entities(**article_data.get("entities", {})),
|
||||
sources=sources_info,
|
||||
generation_metadata={
|
||||
"style": style,
|
||||
"keyword": news_data.get('keyword', ''),
|
||||
"model": "claude-3-5-sonnet-20241022",
|
||||
"timestamp": datetime.now().isoformat()
|
||||
}
|
||||
)
|
||||
|
||||
logger.info(f"Successfully generated article: {article.title}")
|
||||
return article
|
||||
|
||||
except json.JSONDecodeError as e:
|
||||
logger.error(f"Failed to parse Claude response as JSON: {e}")
|
||||
raise
|
||||
except Exception as e:
|
||||
logger.error(f"Error generating article with Claude: {e}")
|
||||
raise
|
||||
746
backup-services/ai-writer/backend/app/main.py
Normal file
746
backup-services/ai-writer/backend/app/main.py
Normal file
@ -0,0 +1,746 @@
|
||||
"""
|
||||
AI Writer Service
|
||||
Claude API를 사용한 전문적인 뉴스 기사 생성 서비스
|
||||
"""
|
||||
from fastapi import FastAPI, HTTPException, BackgroundTasks
|
||||
from fastapi.middleware.cors import CORSMiddleware
|
||||
from typing import List, Dict, Any, Optional
|
||||
from datetime import datetime
|
||||
from pydantic import BaseModel, Field
|
||||
import httpx
|
||||
import asyncio
|
||||
import logging
|
||||
import json
|
||||
import uuid
|
||||
from anthropic import AsyncAnthropic
|
||||
import os
|
||||
|
||||
# Configure logging
|
||||
logging.basicConfig(level=logging.INFO)
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
app = FastAPI(
|
||||
title="AI Writer Service",
|
||||
description="Claude API를 사용한 전문적인 뉴스 기사 생성 서비스",
|
||||
version="1.0.0"
|
||||
)
|
||||
|
||||
# CORS 설정
|
||||
app.add_middleware(
|
||||
CORSMiddleware,
|
||||
allow_origins=["*"],
|
||||
allow_credentials=True,
|
||||
allow_methods=["*"],
|
||||
allow_headers=["*"],
|
||||
)
|
||||
|
||||
# Configuration
|
||||
NEWS_AGGREGATOR_URL = os.getenv("NEWS_AGGREGATOR_URL", "http://news-aggregator-backend:8000")
|
||||
CLAUDE_API_KEY = os.getenv("CLAUDE_API_KEY", "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")
|
||||
MONGODB_URL = os.getenv("MONGODB_URL", "mongodb://mongodb:27017")
|
||||
DB_NAME = os.getenv("DB_NAME", "ai_writer_db")
|
||||
|
||||
# Claude client
|
||||
claude_client = AsyncAnthropic(api_key=CLAUDE_API_KEY)
|
||||
|
||||
# HTTP Client
|
||||
http_client = httpx.AsyncClient(timeout=120.0)
|
||||
|
||||
# Queue Manager
|
||||
from app.queue_manager import RedisQueueManager
|
||||
from app.queue_models import NewsJobData, JobResult, JobStatus, QueueStats
|
||||
queue_manager = RedisQueueManager(
|
||||
redis_url=os.getenv("REDIS_URL", "redis://redis:6379")
|
||||
)
|
||||
|
||||
# MongoDB client (optional for storing generated articles)
|
||||
from motor.motor_asyncio import AsyncIOMotorClient
|
||||
mongo_client = None
|
||||
db = None
|
||||
|
||||
# Data Models
|
||||
class NewsSource(BaseModel):
|
||||
"""참고한 뉴스 소스 정보"""
|
||||
title: str = Field(..., description="뉴스 제목")
|
||||
url: str = Field(..., description="뉴스 URL")
|
||||
published_date: Optional[str] = Field(None, description="발행일")
|
||||
source_site: Optional[str] = Field(None, description="출처 사이트")
|
||||
class SubTopic(BaseModel):
|
||||
"""기사 소주제"""
|
||||
title: str = Field(..., description="소주제 제목")
|
||||
content: List[str] = Field(..., description="소주제 내용 (문단 리스트)", min_items=1, max_items=10)
|
||||
|
||||
class Event(BaseModel):
|
||||
"""이벤트 정보"""
|
||||
name: str = Field(..., description="이벤트명")
|
||||
date: Optional[str] = Field(None, description="일시")
|
||||
location: Optional[str] = Field(None, description="장소")
|
||||
|
||||
class NewsEntities(BaseModel):
|
||||
"""뉴스에 포함된 개체들"""
|
||||
people: List[str] = Field(default_factory=list, description="뉴스에 포함된 인물")
|
||||
organizations: List[str] = Field(default_factory=list, description="뉴스에 포함된 기관")
|
||||
groups: List[str] = Field(default_factory=list, description="뉴스에 포함된 단체")
|
||||
countries: List[str] = Field(default_factory=list, description="뉴스에 포함된 나라")
|
||||
events: List[Event] = Field(default_factory=list, description="뉴스에 포함된 일정/이벤트 (일시와 장소 포함)")
|
||||
keywords: List[str] = Field(default_factory=list, description="핵심 키워드 (최대 10개)", max_items=10)
|
||||
|
||||
class GeneratedArticle(BaseModel):
|
||||
"""생성된 기사"""
|
||||
news_id: str = Field(..., description="뉴스 아이디")
|
||||
title: str = Field(..., description="뉴스 제목")
|
||||
created_at: str = Field(..., description="생성년월일시분초")
|
||||
summary: str = Field(..., description="한 줄 요약")
|
||||
subtopics: List[SubTopic] = Field(..., description="소주제 리스트", min_items=2, max_items=6)
|
||||
categories: List[str] = Field(..., description="카테고리 리스트")
|
||||
entities: NewsEntities = Field(..., description="뉴스에 포함된 개체들")
|
||||
source_keyword: Optional[str] = Field(None, description="원본 검색 키워드")
|
||||
source_count: Optional[int] = Field(None, description="참조한 소스 수")
|
||||
sources: List[NewsSource] = Field(default_factory=list, description="참고한 뉴스 소스 목록")
|
||||
|
||||
class ArticleGenerationRequest(BaseModel):
|
||||
"""기사 생성 요청"""
|
||||
keyword: str = Field(..., description="검색 키워드")
|
||||
limit: int = Field(5, description="처리할 RSS 항목 수", ge=1, le=20)
|
||||
google_results_per_title: int = Field(3, description="각 제목당 구글 검색 결과 수", ge=1, le=10)
|
||||
lang: str = Field("ko", description="언어 코드")
|
||||
country: str = Field("KR", description="국가 코드")
|
||||
style: str = Field("professional", description="기사 스타일 (professional/analytical/investigative)")
|
||||
|
||||
class PerItemGenerationRequest(BaseModel):
|
||||
"""개별 아이템별 기사 생성 요청"""
|
||||
keyword: str = Field(..., description="검색 키워드")
|
||||
limit: Optional[int] = Field(None, description="처리할 RSS 항목 수 (None이면 전체)")
|
||||
google_results_per_title: int = Field(3, description="각 제목당 구글 검색 결과 수", ge=1, le=10)
|
||||
lang: str = Field("ko", description="언어 코드")
|
||||
country: str = Field("KR", description="국가 코드")
|
||||
style: str = Field("professional", description="기사 스타일 (professional/analytical/investigative)")
|
||||
skip_existing: bool = Field(True, description="이미 생성된 기사는 건너뛰기")
|
||||
|
||||
@app.on_event("startup")
|
||||
async def startup():
|
||||
"""서비스 시작"""
|
||||
global mongo_client, db
|
||||
try:
|
||||
mongo_client = AsyncIOMotorClient(MONGODB_URL)
|
||||
db = mongo_client[DB_NAME]
|
||||
logger.info("AI Writer Service starting...")
|
||||
logger.info(f"Connected to MongoDB: {MONGODB_URL}")
|
||||
|
||||
# Redis 큐 연결
|
||||
await queue_manager.connect()
|
||||
logger.info("Connected to Redis queue")
|
||||
except Exception as e:
|
||||
logger.error(f"Failed to connect to services: {e}")
|
||||
|
||||
@app.on_event("shutdown")
|
||||
async def shutdown():
|
||||
"""서비스 종료"""
|
||||
await http_client.aclose()
|
||||
if mongo_client:
|
||||
mongo_client.close()
|
||||
await queue_manager.disconnect()
|
||||
logger.info("AI Writer Service stopped")
|
||||
|
||||
@app.get("/")
|
||||
async def root():
|
||||
return {
|
||||
"service": "AI Writer Service",
|
||||
"version": "1.0.0",
|
||||
"description": "Claude API를 사용한 전문적인 뉴스 기사 생성 서비스",
|
||||
"endpoints": {
|
||||
"generate_article": "POST /api/generate",
|
||||
"generate_per_item": "POST /api/generate/per-item",
|
||||
"generate_from_aggregated": "POST /api/generate/from-aggregated",
|
||||
"get_article": "GET /api/articles/{article_id}",
|
||||
"list_articles": "GET /api/articles",
|
||||
"health": "GET /health"
|
||||
}
|
||||
}
|
||||
|
||||
@app.get("/health")
|
||||
async def health_check():
|
||||
"""헬스 체크"""
|
||||
try:
|
||||
# Check News Aggregator service
|
||||
aggregator_response = await http_client.get(f"{NEWS_AGGREGATOR_URL}/health")
|
||||
aggregator_healthy = aggregator_response.status_code == 200
|
||||
|
||||
# Check MongoDB
|
||||
mongo_healthy = False
|
||||
if db is not None:
|
||||
await db.command("ping")
|
||||
mongo_healthy = True
|
||||
|
||||
return {
|
||||
"status": "healthy" if (aggregator_healthy and mongo_healthy) else "degraded",
|
||||
"services": {
|
||||
"news_aggregator": "healthy" if aggregator_healthy else "unhealthy",
|
||||
"mongodb": "healthy" if mongo_healthy else "unhealthy",
|
||||
"claude_api": "configured"
|
||||
},
|
||||
"timestamp": datetime.now().isoformat()
|
||||
}
|
||||
except Exception as e:
|
||||
return {
|
||||
"status": "unhealthy",
|
||||
"error": str(e),
|
||||
"timestamp": datetime.now().isoformat()
|
||||
}
|
||||
|
||||
async def generate_article_with_claude(news_data: Dict[str, Any], style: str = "professional") -> GeneratedArticle:
|
||||
"""Claude API를 사용하여 기사 생성"""
|
||||
|
||||
# Collect source information
|
||||
sources_info = []
|
||||
|
||||
# Prepare the prompt
|
||||
system_prompt = """당신은 전문적인 한국 언론사의 수석 기자입니다.
|
||||
제공된 데이터를 기반으로 깊이 있고 통찰력 있는 기사를 작성해야 합니다.
|
||||
기사는 다음 요구사항을 충족해야 합니다:
|
||||
|
||||
1. 소주제는 최소 2개, 최대 6개로 구성해야 합니다
|
||||
2. 각 소주제는 최소 1개, 최대 10개의 문단으로 구성해야 합니다
|
||||
3. 전문적이고 객관적인 어조를 유지해야 합니다
|
||||
4. 사실에 기반한 분석과 통찰을 제공해야 합니다
|
||||
5. 한국 독자를 대상으로 작성되어야 합니다
|
||||
6. 이벤트 정보는 가능한 일시와 장소를 포함해야 합니다
|
||||
7. 핵심 키워드를 최대 10개까지 추출해야 합니다
|
||||
|
||||
반드시 다음 JSON 형식으로 응답하세요:
|
||||
{
|
||||
"title": "기사 제목",
|
||||
"summary": "한 줄 요약 (100자 이내)",
|
||||
"subtopics": [
|
||||
{
|
||||
"title": "소주제 제목",
|
||||
"content": ["문단1", "문단2", ...] // 1-10개 문단
|
||||
}
|
||||
], // 2-6개 소주제
|
||||
"categories": ["카테고리1", "카테고리2"],
|
||||
"entities": {
|
||||
"people": ["인물1", "인물2"],
|
||||
"organizations": ["기관1", "기관2"],
|
||||
"groups": ["단체1", "단체2"],
|
||||
"countries": ["나라1", "나라2"],
|
||||
"events": [
|
||||
{
|
||||
"name": "이벤트명",
|
||||
"date": "2025년 1월 15일", // 선택사항
|
||||
"location": "서울 코엑스" // 선택사항
|
||||
}
|
||||
],
|
||||
"keywords": ["키워드1", "키워드2", ...] // 최대 10개
|
||||
}
|
||||
}"""
|
||||
|
||||
# Prepare news content for Claude and collect sources
|
||||
news_content = []
|
||||
for item in news_data.get("news_items", []):
|
||||
# Add RSS source info
|
||||
rss_title = item.get('rss_title', '')
|
||||
rss_link = item.get('rss_link', '')
|
||||
rss_published = item.get('rss_published', '')
|
||||
|
||||
if rss_title and rss_link:
|
||||
sources_info.append(NewsSource(
|
||||
title=rss_title,
|
||||
url=rss_link,
|
||||
published_date=rss_published,
|
||||
source_site="RSS Feed"
|
||||
))
|
||||
|
||||
item_text = f"제목: {rss_title}\n"
|
||||
for result in item.get("google_results", []):
|
||||
# Add Google search result sources
|
||||
if "title" in result and "link" in result:
|
||||
sources_info.append(NewsSource(
|
||||
title=result.get('title', ''),
|
||||
url=result.get('link', ''),
|
||||
published_date=None,
|
||||
source_site="Google Search"
|
||||
))
|
||||
|
||||
if "full_content" in result and result["full_content"]:
|
||||
content = result["full_content"]
|
||||
if isinstance(content, dict):
|
||||
item_text += f"출처: {content.get('url', '')}\n"
|
||||
item_text += f"내용: {content.get('content', '')[:1000]}...\n\n"
|
||||
else:
|
||||
item_text += f"내용: {str(content)[:1000]}...\n\n"
|
||||
news_content.append(item_text)
|
||||
|
||||
combined_content = "\n".join(news_content[:10]) # Limit to prevent token overflow
|
||||
|
||||
user_prompt = f"""다음 뉴스 데이터를 기반으로 종합적인 기사를 작성하세요:
|
||||
|
||||
키워드: {news_data.get('keyword', '')}
|
||||
수집된 뉴스 수: {len(news_data.get('news_items', []))}
|
||||
|
||||
뉴스 내용:
|
||||
{combined_content}
|
||||
|
||||
스타일: {style}
|
||||
- professional: 전통적인 뉴스 기사 스타일
|
||||
- analytical: 분석적이고 심층적인 스타일
|
||||
- investigative: 탐사보도 스타일
|
||||
|
||||
위의 데이터를 종합하여 통찰력 있는 기사를 JSON 형식으로 작성해주세요."""
|
||||
|
||||
try:
|
||||
# Call Claude API
|
||||
response = await claude_client.messages.create(
|
||||
model="claude-3-5-sonnet-20241022", # Latest Claude model
|
||||
max_tokens=4000,
|
||||
temperature=0.7,
|
||||
system=system_prompt,
|
||||
messages=[
|
||||
{"role": "user", "content": user_prompt}
|
||||
]
|
||||
)
|
||||
|
||||
# Parse Claude's response
|
||||
content = response.content[0].text
|
||||
|
||||
# Extract JSON from response
|
||||
import re
|
||||
json_match = re.search(r'\{.*\}', content, re.DOTALL)
|
||||
if json_match:
|
||||
article_data = json.loads(json_match.group())
|
||||
else:
|
||||
# If no JSON found, try to parse the entire content
|
||||
article_data = json.loads(content)
|
||||
|
||||
# Create GeneratedArticle object
|
||||
entities_data = article_data.get("entities", {})
|
||||
events_data = entities_data.get("events", [])
|
||||
|
||||
# Parse events - handle both old string format and new object format
|
||||
parsed_events = []
|
||||
for event in events_data:
|
||||
if isinstance(event, str):
|
||||
# Old format: just event name as string
|
||||
parsed_events.append(Event(name=event))
|
||||
elif isinstance(event, dict):
|
||||
# New format: event object with name, date, location
|
||||
parsed_events.append(Event(
|
||||
name=event.get("name", ""),
|
||||
date=event.get("date"),
|
||||
location=event.get("location")
|
||||
))
|
||||
|
||||
article = GeneratedArticle(
|
||||
news_id=str(uuid.uuid4()),
|
||||
title=article_data.get("title", "제목 없음"),
|
||||
created_at=datetime.now().isoformat(),
|
||||
summary=article_data.get("summary", ""),
|
||||
subtopics=[
|
||||
SubTopic(
|
||||
title=st.get("title", ""),
|
||||
content=st.get("content", [])
|
||||
) for st in article_data.get("subtopics", [])
|
||||
],
|
||||
categories=article_data.get("categories", []),
|
||||
entities=NewsEntities(
|
||||
people=entities_data.get("people", []),
|
||||
organizations=entities_data.get("organizations", []),
|
||||
groups=entities_data.get("groups", []),
|
||||
countries=entities_data.get("countries", []),
|
||||
events=parsed_events,
|
||||
keywords=entities_data.get("keywords", [])
|
||||
),
|
||||
source_keyword=news_data.get("keyword"),
|
||||
source_count=len(news_data.get("news_items", [])),
|
||||
sources=sources_info
|
||||
)
|
||||
|
||||
return article
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"Error generating article with Claude: {e}")
|
||||
raise HTTPException(status_code=500, detail=f"Failed to generate article: {str(e)}")
|
||||
|
||||
@app.post("/api/generate")
|
||||
async def generate_article(request: ArticleGenerationRequest):
|
||||
"""
|
||||
뉴스 수집부터 기사 생성까지 전체 파이프라인 실행
|
||||
RSS → Google Search → AI 기사 생성
|
||||
단일 종합 기사 생성 (기존 방식)
|
||||
"""
|
||||
try:
|
||||
# Step 1: Get aggregated news from News Aggregator service
|
||||
logger.info(f"Fetching aggregated news for keyword: {request.keyword}")
|
||||
|
||||
aggregator_response = await http_client.get(
|
||||
f"{NEWS_AGGREGATOR_URL}/api/aggregate",
|
||||
params={
|
||||
"q": request.keyword,
|
||||
"limit": request.limit,
|
||||
"google_results_per_title": request.google_results_per_title,
|
||||
"lang": request.lang,
|
||||
"country": request.country
|
||||
}
|
||||
)
|
||||
aggregator_response.raise_for_status()
|
||||
news_data = aggregator_response.json()
|
||||
|
||||
if not news_data.get("news_items"):
|
||||
raise HTTPException(status_code=404, detail="No news items found for the given keyword")
|
||||
|
||||
# Step 2: Generate article using Claude
|
||||
logger.info(f"Generating article with Claude for {len(news_data['news_items'])} news items")
|
||||
article = await generate_article_with_claude(news_data, request.style)
|
||||
|
||||
# Step 3: Store article in MongoDB (optional)
|
||||
if db is not None:
|
||||
try:
|
||||
article_dict = article.dict()
|
||||
await db.articles.insert_one(article_dict)
|
||||
logger.info(f"Article saved with ID: {article.news_id}")
|
||||
except Exception as e:
|
||||
logger.error(f"Failed to save article to MongoDB: {e}")
|
||||
|
||||
return article
|
||||
|
||||
except httpx.HTTPStatusError as e:
|
||||
logger.error(f"HTTP error from aggregator service: {e}")
|
||||
raise HTTPException(status_code=e.response.status_code, detail=str(e))
|
||||
except Exception as e:
|
||||
logger.error(f"Error in generate_article: {e}")
|
||||
raise HTTPException(status_code=500, detail=str(e))
|
||||
|
||||
@app.post("/api/generate/from-aggregated", response_model=GeneratedArticle)
|
||||
async def generate_from_aggregated_data(news_data: Dict[str, Any], style: str = "professional"):
|
||||
"""
|
||||
이미 수집된 뉴스 데이터로부터 직접 기사 생성
|
||||
(News Aggregator 결과를 직접 입력받아 처리)
|
||||
"""
|
||||
try:
|
||||
if not news_data.get("news_items"):
|
||||
raise HTTPException(status_code=400, detail="No news items in provided data")
|
||||
|
||||
# Generate article using Claude
|
||||
logger.info(f"Generating article from {len(news_data['news_items'])} news items")
|
||||
article = await generate_article_with_claude(news_data, style)
|
||||
|
||||
# Store article in MongoDB
|
||||
if db is not None:
|
||||
try:
|
||||
article_dict = article.dict()
|
||||
await db.articles.insert_one(article_dict)
|
||||
logger.info(f"Article saved with ID: {article.news_id}")
|
||||
except Exception as e:
|
||||
logger.error(f"Failed to save article to MongoDB: {e}")
|
||||
|
||||
return article
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"Error in generate_from_aggregated_data: {e}")
|
||||
raise HTTPException(status_code=500, detail=str(e))
|
||||
|
||||
@app.get("/api/articles/{article_id}", response_model=GeneratedArticle)
|
||||
async def get_article(article_id: str):
|
||||
"""저장된 기사 조회"""
|
||||
if db is None:
|
||||
raise HTTPException(status_code=503, detail="Database not available")
|
||||
|
||||
article = await db.articles.find_one({"news_id": article_id})
|
||||
if not article:
|
||||
raise HTTPException(status_code=404, detail="Article not found")
|
||||
|
||||
# Convert MongoDB document to GeneratedArticle
|
||||
article.pop("_id", None)
|
||||
return GeneratedArticle(**article)
|
||||
|
||||
@app.get("/api/articles")
|
||||
async def list_articles(
|
||||
skip: int = 0,
|
||||
limit: int = 10,
|
||||
keyword: Optional[str] = None,
|
||||
category: Optional[str] = None
|
||||
):
|
||||
"""저장된 기사 목록 조회"""
|
||||
if db is None:
|
||||
raise HTTPException(status_code=503, detail="Database not available")
|
||||
|
||||
query = {}
|
||||
if keyword:
|
||||
query["source_keyword"] = {"$regex": keyword, "$options": "i"}
|
||||
if category:
|
||||
query["categories"] = category
|
||||
|
||||
cursor = db.articles.find(query).skip(skip).limit(limit).sort("created_at", -1)
|
||||
articles = []
|
||||
async for article in cursor:
|
||||
article.pop("_id", None)
|
||||
articles.append(article)
|
||||
|
||||
total = await db.articles.count_documents(query)
|
||||
|
||||
return {
|
||||
"articles": articles,
|
||||
"total": total,
|
||||
"skip": skip,
|
||||
"limit": limit
|
||||
}
|
||||
|
||||
@app.post("/api/generate/batch")
|
||||
async def generate_batch_articles(keywords: List[str], style: str = "professional"):
|
||||
"""여러 키워드에 대한 기사 일괄 생성"""
|
||||
results = []
|
||||
errors = []
|
||||
|
||||
for keyword in keywords[:5]: # Limit to 5 keywords to prevent overload
|
||||
try:
|
||||
request = ArticleGenerationRequest(
|
||||
keyword=keyword,
|
||||
style=style
|
||||
)
|
||||
article = await generate_article(request)
|
||||
results.append({
|
||||
"keyword": keyword,
|
||||
"status": "success",
|
||||
"article_id": article.news_id,
|
||||
"title": article.title
|
||||
})
|
||||
except Exception as e:
|
||||
errors.append({
|
||||
"keyword": keyword,
|
||||
"status": "error",
|
||||
"error": str(e)
|
||||
})
|
||||
|
||||
return {
|
||||
"success": results,
|
||||
"errors": errors,
|
||||
"total_processed": len(results) + len(errors)
|
||||
}
|
||||
|
||||
@app.post("/api/generate/per-item")
|
||||
async def generate_articles_per_rss_item(request: PerItemGenerationRequest):
|
||||
"""
|
||||
RSS 피드의 각 아이템별로 개별 기사 생성
|
||||
각 RSS 아이템이 독립적인 기사가 됨
|
||||
중복 생성 방지 기능 포함
|
||||
"""
|
||||
try:
|
||||
# Step 1: Get aggregated news from News Aggregator service
|
||||
logger.info(f"Fetching aggregated news for keyword: {request.keyword}")
|
||||
|
||||
# limit이 None이면 모든 항목 처리 (최대 100개로 제한)
|
||||
actual_limit = request.limit if request.limit is not None else 100
|
||||
|
||||
aggregator_response = await http_client.get(
|
||||
f"{NEWS_AGGREGATOR_URL}/api/aggregate",
|
||||
params={
|
||||
"q": request.keyword,
|
||||
"limit": actual_limit,
|
||||
"google_results_per_title": request.google_results_per_title,
|
||||
"lang": request.lang,
|
||||
"country": request.country
|
||||
}
|
||||
)
|
||||
aggregator_response.raise_for_status()
|
||||
news_data = aggregator_response.json()
|
||||
|
||||
if not news_data.get("news_items"):
|
||||
raise HTTPException(status_code=404, detail="No news items found for the given keyword")
|
||||
|
||||
# Step 2: Check for existing articles if skip_existing is True
|
||||
existing_titles = set()
|
||||
skipped_count = 0
|
||||
|
||||
if request.skip_existing and db is not None:
|
||||
# RSS 제목으로 중복 체크 (최근 24시간 내)
|
||||
from datetime import datetime, timedelta
|
||||
cutoff_time = (datetime.now() - timedelta(hours=24)).isoformat()
|
||||
|
||||
existing_cursor = db.articles.find(
|
||||
{
|
||||
"source_keyword": request.keyword,
|
||||
"created_at": {"$gte": cutoff_time}
|
||||
},
|
||||
{"sources": 1}
|
||||
)
|
||||
|
||||
async for doc in existing_cursor:
|
||||
for source in doc.get("sources", []):
|
||||
if source.get("source_site") == "RSS Feed":
|
||||
existing_titles.add(source.get("title", ""))
|
||||
|
||||
# Step 3: Generate individual article for each RSS item
|
||||
generated_articles = []
|
||||
|
||||
for item in news_data["news_items"]:
|
||||
try:
|
||||
rss_title = item.get('rss_title', '')
|
||||
|
||||
# Skip if already exists
|
||||
if request.skip_existing and rss_title in existing_titles:
|
||||
logger.info(f"Skipping already generated article: {rss_title}")
|
||||
skipped_count += 1
|
||||
continue
|
||||
|
||||
logger.info(f"Generating article for RSS item: {rss_title or 'Unknown'}")
|
||||
|
||||
# Create individual news_data for this item
|
||||
individual_news_data = {
|
||||
"keyword": news_data.get("keyword"),
|
||||
"news_items": [item] # Single item only
|
||||
}
|
||||
|
||||
# Generate article for this single item
|
||||
article = await generate_article_with_claude(individual_news_data, request.style)
|
||||
|
||||
# Store in MongoDB
|
||||
if db is not None:
|
||||
try:
|
||||
article_dict = article.dict()
|
||||
await db.articles.insert_one(article_dict)
|
||||
logger.info(f"Article saved with ID: {article.news_id}")
|
||||
except Exception as e:
|
||||
logger.error(f"Failed to save article to MongoDB: {e}")
|
||||
|
||||
generated_articles.append(article)
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"Failed to generate article for item: {e}")
|
||||
# Continue with next item even if one fails
|
||||
continue
|
||||
|
||||
if not generated_articles and skipped_count == 0:
|
||||
raise HTTPException(status_code=500, detail="Failed to generate any articles")
|
||||
|
||||
# Return all generated articles
|
||||
return {
|
||||
"total_generated": len(generated_articles),
|
||||
"total_items": len(news_data["news_items"]),
|
||||
"skipped_duplicates": skipped_count,
|
||||
"articles": generated_articles
|
||||
}
|
||||
|
||||
except httpx.HTTPStatusError as e:
|
||||
logger.error(f"HTTP error from aggregator service: {e}")
|
||||
raise HTTPException(status_code=e.response.status_code, detail=str(e))
|
||||
except Exception as e:
|
||||
logger.error(f"Error in generate_articles_per_rss_item: {e}")
|
||||
raise HTTPException(status_code=500, detail=str(e))
|
||||
|
||||
# Queue Management Endpoints
|
||||
|
||||
@app.post("/api/queue/enqueue")
|
||||
async def enqueue_items(request: PerItemGenerationRequest):
|
||||
"""
|
||||
RSS 아이템들을 큐에 추가 (비동기 처리)
|
||||
Consumer 워커가 백그라운드에서 처리
|
||||
"""
|
||||
try:
|
||||
# Step 1: Get aggregated news from News Aggregator service
|
||||
logger.info(f"Fetching aggregated news for enqueue: {request.keyword}")
|
||||
|
||||
actual_limit = request.limit if request.limit is not None else 100
|
||||
|
||||
aggregator_response = await http_client.get(
|
||||
f"{NEWS_AGGREGATOR_URL}/api/aggregate",
|
||||
params={
|
||||
"q": request.keyword,
|
||||
"limit": actual_limit,
|
||||
"google_results_per_title": request.google_results_per_title,
|
||||
"lang": request.lang,
|
||||
"country": request.country
|
||||
}
|
||||
)
|
||||
aggregator_response.raise_for_status()
|
||||
news_data = aggregator_response.json()
|
||||
|
||||
if not news_data.get("news_items"):
|
||||
raise HTTPException(status_code=404, detail="No news items found for the given keyword")
|
||||
|
||||
# Step 2: Check for existing articles if skip_existing is True
|
||||
existing_titles = set()
|
||||
skipped_count = 0
|
||||
|
||||
if request.skip_existing and db is not None:
|
||||
from datetime import datetime, timedelta
|
||||
cutoff_time = (datetime.now() - timedelta(hours=24)).isoformat()
|
||||
|
||||
existing_cursor = db.articles.find(
|
||||
{
|
||||
"source_keyword": request.keyword,
|
||||
"created_at": {"$gte": cutoff_time}
|
||||
},
|
||||
{"sources": 1}
|
||||
)
|
||||
|
||||
async for doc in existing_cursor:
|
||||
for source in doc.get("sources", []):
|
||||
if source.get("source_site") == "RSS Feed":
|
||||
existing_titles.add(source.get("title", ""))
|
||||
|
||||
# Step 3: Enqueue items for processing
|
||||
enqueued_jobs = []
|
||||
|
||||
for item in news_data["news_items"]:
|
||||
rss_title = item.get('rss_title', '')
|
||||
|
||||
# Skip if already exists
|
||||
if request.skip_existing and rss_title in existing_titles:
|
||||
logger.info(f"Skipping already generated article: {rss_title}")
|
||||
skipped_count += 1
|
||||
continue
|
||||
|
||||
# Create job data
|
||||
job_data = NewsJobData(
|
||||
job_id=str(uuid.uuid4()),
|
||||
keyword=request.keyword,
|
||||
rss_title=rss_title,
|
||||
rss_link=item.get('rss_link'),
|
||||
rss_published=item.get('rss_published'),
|
||||
google_results=item.get('google_results', []),
|
||||
style=request.style,
|
||||
created_at=datetime.now()
|
||||
)
|
||||
|
||||
# Enqueue job
|
||||
job_id = await queue_manager.enqueue(job_data)
|
||||
enqueued_jobs.append({
|
||||
"job_id": job_id,
|
||||
"title": rss_title[:100]
|
||||
})
|
||||
|
||||
logger.info(f"Enqueued job {job_id} for: {rss_title}")
|
||||
|
||||
return {
|
||||
"total_enqueued": len(enqueued_jobs),
|
||||
"total_items": len(news_data["news_items"]),
|
||||
"skipped_duplicates": skipped_count,
|
||||
"jobs": enqueued_jobs,
|
||||
"message": f"{len(enqueued_jobs)} jobs added to queue for processing"
|
||||
}
|
||||
|
||||
except httpx.HTTPStatusError as e:
|
||||
logger.error(f"HTTP error from aggregator service: {e}")
|
||||
raise HTTPException(status_code=e.response.status_code, detail=str(e))
|
||||
except Exception as e:
|
||||
logger.error(f"Error in enqueue_items: {e}")
|
||||
raise HTTPException(status_code=500, detail=str(e))
|
||||
|
||||
@app.get("/api/queue/stats", response_model=QueueStats)
|
||||
async def get_queue_stats():
|
||||
"""큐 상태 및 통계 조회"""
|
||||
try:
|
||||
stats = await queue_manager.get_stats()
|
||||
return stats
|
||||
except Exception as e:
|
||||
logger.error(f"Error getting queue stats: {e}")
|
||||
raise HTTPException(status_code=500, detail=str(e))
|
||||
|
||||
@app.delete("/api/queue/clear")
|
||||
async def clear_queue():
|
||||
"""큐 초기화 (관리자용)"""
|
||||
try:
|
||||
await queue_manager.clear_queue()
|
||||
return {"message": "Queue cleared successfully"}
|
||||
except Exception as e:
|
||||
logger.error(f"Error clearing queue: {e}")
|
||||
raise HTTPException(status_code=500, detail=str(e))
|
||||
250
backup-services/ai-writer/backend/app/queue_manager.py
Normal file
250
backup-services/ai-writer/backend/app/queue_manager.py
Normal file
@ -0,0 +1,250 @@
|
||||
"""
|
||||
Redis Queue Manager for AI Writer Service
|
||||
Redis를 사용한 작업 큐 관리
|
||||
"""
|
||||
import redis.asyncio as redis
|
||||
import json
|
||||
import uuid
|
||||
from typing import Optional, List, Dict, Any
|
||||
from datetime import datetime, timedelta
|
||||
import logging
|
||||
from queue_models import NewsJobData, JobResult, JobStatus, QueueStats
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
class RedisQueueManager:
|
||||
"""Redis 기반 작업 큐 매니저"""
|
||||
|
||||
def __init__(self, redis_url: str = "redis://redis:6379"):
|
||||
self.redis_url = redis_url
|
||||
self.redis_client: Optional[redis.Redis] = None
|
||||
|
||||
# Redis 키 정의
|
||||
self.QUEUE_KEY = "ai_writer:queue:pending"
|
||||
self.PROCESSING_KEY = "ai_writer:queue:processing"
|
||||
self.COMPLETED_KEY = "ai_writer:queue:completed"
|
||||
self.FAILED_KEY = "ai_writer:queue:failed"
|
||||
self.STATS_KEY = "ai_writer:stats"
|
||||
self.WORKERS_KEY = "ai_writer:workers"
|
||||
self.LOCK_PREFIX = "ai_writer:lock:"
|
||||
|
||||
async def connect(self):
|
||||
"""Redis 연결"""
|
||||
if not self.redis_client:
|
||||
self.redis_client = await redis.from_url(
|
||||
self.redis_url,
|
||||
encoding="utf-8",
|
||||
decode_responses=True
|
||||
)
|
||||
logger.info("Connected to Redis queue")
|
||||
|
||||
async def disconnect(self):
|
||||
"""Redis 연결 해제"""
|
||||
if self.redis_client:
|
||||
await self.redis_client.close()
|
||||
self.redis_client = None
|
||||
logger.info("Disconnected from Redis queue")
|
||||
|
||||
async def enqueue(self, job_data: NewsJobData) -> str:
|
||||
"""작업을 큐에 추가"""
|
||||
try:
|
||||
if not job_data.job_id:
|
||||
job_data.job_id = str(uuid.uuid4())
|
||||
|
||||
# JSON으로 직렬화
|
||||
job_json = job_data.json()
|
||||
|
||||
# 우선순위에 따라 큐에 추가
|
||||
if job_data.priority > 0:
|
||||
# 높은 우선순위는 앞쪽에
|
||||
await self.redis_client.lpush(self.QUEUE_KEY, job_json)
|
||||
else:
|
||||
# 일반 우선순위는 뒤쪽에
|
||||
await self.redis_client.rpush(self.QUEUE_KEY, job_json)
|
||||
|
||||
# 통계 업데이트
|
||||
await self.redis_client.hincrby(self.STATS_KEY, "total_jobs", 1)
|
||||
await self.redis_client.hincrby(self.STATS_KEY, "pending_jobs", 1)
|
||||
|
||||
logger.info(f"Job {job_data.job_id} enqueued")
|
||||
return job_data.job_id
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"Failed to enqueue job: {e}")
|
||||
raise
|
||||
|
||||
async def dequeue(self, timeout: int = 0) -> Optional[NewsJobData]:
|
||||
"""큐에서 작업 가져오기 (블로킹 가능)"""
|
||||
try:
|
||||
# 대기 중인 작업을 가져와서 처리 중 목록으로 이동
|
||||
if timeout > 0:
|
||||
result = await self.redis_client.blmove(
|
||||
self.QUEUE_KEY,
|
||||
self.PROCESSING_KEY,
|
||||
timeout,
|
||||
"LEFT",
|
||||
"RIGHT"
|
||||
)
|
||||
else:
|
||||
result = await self.redis_client.lmove(
|
||||
self.QUEUE_KEY,
|
||||
self.PROCESSING_KEY,
|
||||
"LEFT",
|
||||
"RIGHT"
|
||||
)
|
||||
|
||||
if result:
|
||||
# 통계 업데이트
|
||||
await self.redis_client.hincrby(self.STATS_KEY, "pending_jobs", -1)
|
||||
await self.redis_client.hincrby(self.STATS_KEY, "processing_jobs", 1)
|
||||
|
||||
return NewsJobData.parse_raw(result)
|
||||
|
||||
return None
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"Failed to dequeue job: {e}")
|
||||
return None
|
||||
|
||||
async def mark_completed(self, job_id: str, article_id: str):
|
||||
"""작업을 완료로 표시"""
|
||||
try:
|
||||
# 처리 중 목록에서 작업 찾기
|
||||
processing_jobs = await self.redis_client.lrange(self.PROCESSING_KEY, 0, -1)
|
||||
|
||||
for job_json in processing_jobs:
|
||||
job = NewsJobData.parse_raw(job_json)
|
||||
if job.job_id == job_id:
|
||||
# 처리 중 목록에서 제거
|
||||
await self.redis_client.lrem(self.PROCESSING_KEY, 1, job_json)
|
||||
|
||||
# 완료 결과 생성
|
||||
result = JobResult(
|
||||
job_id=job_id,
|
||||
status=JobStatus.COMPLETED,
|
||||
article_id=article_id,
|
||||
completed_at=datetime.now()
|
||||
)
|
||||
|
||||
# 완료 목록에 추가 (최대 1000개 유지)
|
||||
await self.redis_client.lpush(self.COMPLETED_KEY, result.json())
|
||||
await self.redis_client.ltrim(self.COMPLETED_KEY, 0, 999)
|
||||
|
||||
# 통계 업데이트
|
||||
await self.redis_client.hincrby(self.STATS_KEY, "processing_jobs", -1)
|
||||
await self.redis_client.hincrby(self.STATS_KEY, "completed_jobs", 1)
|
||||
|
||||
logger.info(f"Job {job_id} marked as completed")
|
||||
break
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"Failed to mark job as completed: {e}")
|
||||
|
||||
async def mark_failed(self, job_id: str, error_message: str):
|
||||
"""작업을 실패로 표시"""
|
||||
try:
|
||||
# 처리 중 목록에서 작업 찾기
|
||||
processing_jobs = await self.redis_client.lrange(self.PROCESSING_KEY, 0, -1)
|
||||
|
||||
for job_json in processing_jobs:
|
||||
job = NewsJobData.parse_raw(job_json)
|
||||
if job.job_id == job_id:
|
||||
# 처리 중 목록에서 제거
|
||||
await self.redis_client.lrem(self.PROCESSING_KEY, 1, job_json)
|
||||
|
||||
# 재시도 확인
|
||||
if job.retry_count < job.max_retries:
|
||||
job.retry_count += 1
|
||||
# 다시 큐에 추가
|
||||
await self.redis_client.rpush(self.QUEUE_KEY, job.json())
|
||||
await self.redis_client.hincrby(self.STATS_KEY, "pending_jobs", 1)
|
||||
logger.info(f"Job {job_id} requeued (retry {job.retry_count}/{job.max_retries})")
|
||||
else:
|
||||
# 실패 결과 생성
|
||||
result = JobResult(
|
||||
job_id=job_id,
|
||||
status=JobStatus.FAILED,
|
||||
error_message=error_message,
|
||||
completed_at=datetime.now()
|
||||
)
|
||||
|
||||
# 실패 목록에 추가
|
||||
await self.redis_client.lpush(self.FAILED_KEY, result.json())
|
||||
await self.redis_client.ltrim(self.FAILED_KEY, 0, 999)
|
||||
|
||||
# 통계 업데이트
|
||||
await self.redis_client.hincrby(self.STATS_KEY, "failed_jobs", 1)
|
||||
logger.error(f"Job {job_id} marked as failed: {error_message}")
|
||||
|
||||
await self.redis_client.hincrby(self.STATS_KEY, "processing_jobs", -1)
|
||||
break
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"Failed to mark job as failed: {e}")
|
||||
|
||||
async def get_stats(self) -> QueueStats:
|
||||
"""큐 통계 조회"""
|
||||
try:
|
||||
stats_data = await self.redis_client.hgetall(self.STATS_KEY)
|
||||
|
||||
# 활성 워커 수 계산
|
||||
workers = await self.redis_client.smembers(self.WORKERS_KEY)
|
||||
active_workers = 0
|
||||
for worker_id in workers:
|
||||
# 워커가 최근 1분 이내에 활동했는지 확인
|
||||
last_ping = await self.redis_client.get(f"{self.WORKERS_KEY}:{worker_id}")
|
||||
if last_ping:
|
||||
last_ping_time = datetime.fromisoformat(last_ping)
|
||||
if datetime.now() - last_ping_time < timedelta(minutes=1):
|
||||
active_workers += 1
|
||||
|
||||
return QueueStats(
|
||||
pending_jobs=int(stats_data.get("pending_jobs", 0)),
|
||||
processing_jobs=int(stats_data.get("processing_jobs", 0)),
|
||||
completed_jobs=int(stats_data.get("completed_jobs", 0)),
|
||||
failed_jobs=int(stats_data.get("failed_jobs", 0)),
|
||||
total_jobs=int(stats_data.get("total_jobs", 0)),
|
||||
workers_active=active_workers
|
||||
)
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"Failed to get stats: {e}")
|
||||
return QueueStats(
|
||||
pending_jobs=0,
|
||||
processing_jobs=0,
|
||||
completed_jobs=0,
|
||||
failed_jobs=0,
|
||||
total_jobs=0,
|
||||
workers_active=0
|
||||
)
|
||||
|
||||
async def register_worker(self, worker_id: str):
|
||||
"""워커 등록"""
|
||||
await self.redis_client.sadd(self.WORKERS_KEY, worker_id)
|
||||
await self.redis_client.set(
|
||||
f"{self.WORKERS_KEY}:{worker_id}",
|
||||
datetime.now().isoformat(),
|
||||
ex=300 # 5분 후 자동 만료
|
||||
)
|
||||
|
||||
async def ping_worker(self, worker_id: str):
|
||||
"""워커 활동 업데이트"""
|
||||
await self.redis_client.set(
|
||||
f"{self.WORKERS_KEY}:{worker_id}",
|
||||
datetime.now().isoformat(),
|
||||
ex=300
|
||||
)
|
||||
|
||||
async def unregister_worker(self, worker_id: str):
|
||||
"""워커 등록 해제"""
|
||||
await self.redis_client.srem(self.WORKERS_KEY, worker_id)
|
||||
await self.redis_client.delete(f"{self.WORKERS_KEY}:{worker_id}")
|
||||
|
||||
async def clear_queue(self):
|
||||
"""큐 초기화 (테스트용)"""
|
||||
await self.redis_client.delete(self.QUEUE_KEY)
|
||||
await self.redis_client.delete(self.PROCESSING_KEY)
|
||||
await self.redis_client.delete(self.COMPLETED_KEY)
|
||||
await self.redis_client.delete(self.FAILED_KEY)
|
||||
await self.redis_client.delete(self.STATS_KEY)
|
||||
logger.info("Queue cleared")
|
||||
49
backup-services/ai-writer/backend/app/queue_models.py
Normal file
49
backup-services/ai-writer/backend/app/queue_models.py
Normal file
@ -0,0 +1,49 @@
|
||||
"""
|
||||
Queue Models for AI Writer Service
|
||||
Redis 큐에서 사용할 데이터 모델 정의
|
||||
"""
|
||||
from pydantic import BaseModel, Field
|
||||
from typing import Optional, List, Dict, Any
|
||||
from datetime import datetime
|
||||
from enum import Enum
|
||||
|
||||
class JobStatus(str, Enum):
|
||||
"""작업 상태"""
|
||||
PENDING = "pending"
|
||||
PROCESSING = "processing"
|
||||
COMPLETED = "completed"
|
||||
FAILED = "failed"
|
||||
SKIPPED = "skipped"
|
||||
|
||||
class NewsJobData(BaseModel):
|
||||
"""큐에 들어갈 뉴스 작업 데이터"""
|
||||
job_id: str = Field(..., description="작업 고유 ID")
|
||||
keyword: str = Field(..., description="원본 검색 키워드")
|
||||
rss_title: str = Field(..., description="RSS 제목")
|
||||
rss_link: Optional[str] = Field(None, description="RSS 링크")
|
||||
rss_published: Optional[str] = Field(None, description="RSS 발행일")
|
||||
google_results: List[Dict[str, Any]] = Field(default_factory=list, description="구글 검색 결과")
|
||||
style: str = Field("professional", description="기사 스타일")
|
||||
created_at: datetime = Field(default_factory=datetime.now, description="작업 생성 시간")
|
||||
priority: int = Field(0, description="우선순위 (높을수록 우선)")
|
||||
retry_count: int = Field(0, description="재시도 횟수")
|
||||
max_retries: int = Field(3, description="최대 재시도 횟수")
|
||||
|
||||
class JobResult(BaseModel):
|
||||
"""작업 결과"""
|
||||
job_id: str = Field(..., description="작업 고유 ID")
|
||||
status: JobStatus = Field(..., description="작업 상태")
|
||||
article_id: Optional[str] = Field(None, description="생성된 기사 ID")
|
||||
error_message: Optional[str] = Field(None, description="에러 메시지")
|
||||
processing_time: Optional[float] = Field(None, description="처리 시간(초)")
|
||||
completed_at: Optional[datetime] = Field(None, description="완료 시간")
|
||||
|
||||
class QueueStats(BaseModel):
|
||||
"""큐 통계"""
|
||||
pending_jobs: int = Field(..., description="대기 중인 작업 수")
|
||||
processing_jobs: int = Field(..., description="처리 중인 작업 수")
|
||||
completed_jobs: int = Field(..., description="완료된 작업 수")
|
||||
failed_jobs: int = Field(..., description="실패한 작업 수")
|
||||
total_jobs: int = Field(..., description="전체 작업 수")
|
||||
workers_active: int = Field(..., description="활성 워커 수")
|
||||
average_processing_time: Optional[float] = Field(None, description="평균 처리 시간(초)")
|
||||
201
backup-services/ai-writer/backend/app/worker.py
Normal file
201
backup-services/ai-writer/backend/app/worker.py
Normal file
@ -0,0 +1,201 @@
|
||||
"""
|
||||
AI Writer Consumer Worker
|
||||
큐에서 작업을 가져와 기사를 생성하는 백그라운드 워커
|
||||
"""
|
||||
import asyncio
|
||||
import logging
|
||||
import signal
|
||||
import sys
|
||||
import uuid
|
||||
from datetime import datetime
|
||||
from typing import Optional
|
||||
import os
|
||||
|
||||
from motor.motor_asyncio import AsyncIOMotorClient
|
||||
from anthropic import AsyncAnthropic
|
||||
|
||||
from queue_manager import RedisQueueManager
|
||||
from queue_models import NewsJobData, JobStatus
|
||||
from article_generator import generate_article_with_claude
|
||||
|
||||
# Configure logging
|
||||
logging.basicConfig(
|
||||
level=logging.INFO,
|
||||
format='%(asctime)s - %(name)s - %(levelname)s - %(message)s'
|
||||
)
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
class AIWriterWorker:
|
||||
"""AI Writer 백그라운드 워커"""
|
||||
|
||||
def __init__(self, worker_id: Optional[str] = None):
|
||||
self.worker_id = worker_id or str(uuid.uuid4())
|
||||
self.queue_manager = RedisQueueManager(
|
||||
redis_url=os.getenv("REDIS_URL", "redis://redis:6379")
|
||||
)
|
||||
|
||||
# MongoDB 설정
|
||||
self.mongodb_url = os.getenv("MONGODB_URL", "mongodb://mongodb:27017")
|
||||
self.db_name = os.getenv("DB_NAME", "ai_writer_db")
|
||||
self.mongo_client = None
|
||||
self.db = None
|
||||
|
||||
# Claude 클라이언트
|
||||
self.claude_api_key = os.getenv("CLAUDE_API_KEY")
|
||||
self.claude_client = AsyncAnthropic(api_key=self.claude_api_key)
|
||||
|
||||
# 실행 상태
|
||||
self.running = False
|
||||
self.tasks = []
|
||||
|
||||
async def start(self, num_workers: int = 1):
|
||||
"""워커 시작"""
|
||||
logger.info(f"Starting AI Writer Worker {self.worker_id} with {num_workers} concurrent workers")
|
||||
|
||||
try:
|
||||
# Redis 연결
|
||||
await self.queue_manager.connect()
|
||||
await self.queue_manager.register_worker(self.worker_id)
|
||||
|
||||
# MongoDB 연결
|
||||
self.mongo_client = AsyncIOMotorClient(self.mongodb_url)
|
||||
self.db = self.mongo_client[self.db_name]
|
||||
logger.info("Connected to MongoDB")
|
||||
|
||||
self.running = True
|
||||
|
||||
# 여러 워커 태스크 생성
|
||||
for i in range(num_workers):
|
||||
task = asyncio.create_task(self._process_jobs(f"{self.worker_id}-{i}"))
|
||||
self.tasks.append(task)
|
||||
|
||||
# 워커 핑 태스크
|
||||
ping_task = asyncio.create_task(self._ping_worker())
|
||||
self.tasks.append(ping_task)
|
||||
|
||||
# 모든 태스크 대기
|
||||
await asyncio.gather(*self.tasks)
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"Worker error: {e}")
|
||||
finally:
|
||||
await self.stop()
|
||||
|
||||
async def stop(self):
|
||||
"""워커 정지"""
|
||||
logger.info(f"Stopping AI Writer Worker {self.worker_id}")
|
||||
self.running = False
|
||||
|
||||
# 태스크 취소
|
||||
for task in self.tasks:
|
||||
task.cancel()
|
||||
|
||||
# 워커 등록 해제
|
||||
await self.queue_manager.unregister_worker(self.worker_id)
|
||||
|
||||
# 연결 해제
|
||||
await self.queue_manager.disconnect()
|
||||
if self.mongo_client:
|
||||
self.mongo_client.close()
|
||||
|
||||
logger.info(f"Worker {self.worker_id} stopped")
|
||||
|
||||
async def _process_jobs(self, sub_worker_id: str):
|
||||
"""작업 처리 루프"""
|
||||
logger.info(f"Sub-worker {sub_worker_id} started")
|
||||
|
||||
while self.running:
|
||||
try:
|
||||
# 큐에서 작업 가져오기 (5초 타임아웃)
|
||||
job = await self.queue_manager.dequeue(timeout=5)
|
||||
|
||||
if job:
|
||||
logger.info(f"[{sub_worker_id}] Processing job {job.job_id}: {job.rss_title[:50]}")
|
||||
start_time = datetime.now()
|
||||
|
||||
try:
|
||||
# 기사 생성
|
||||
article = await self._generate_article(job)
|
||||
|
||||
# MongoDB에 저장
|
||||
if article and self.db is not None:
|
||||
article_dict = article.dict()
|
||||
await self.db.articles.insert_one(article_dict)
|
||||
|
||||
# 처리 시간 계산
|
||||
processing_time = (datetime.now() - start_time).total_seconds()
|
||||
|
||||
# 완료 표시
|
||||
await self.queue_manager.mark_completed(
|
||||
job.job_id,
|
||||
article.news_id
|
||||
)
|
||||
|
||||
logger.info(f"[{sub_worker_id}] Job {job.job_id} completed in {processing_time:.2f}s")
|
||||
else:
|
||||
raise Exception("Failed to generate article")
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"[{sub_worker_id}] Job {job.job_id} failed: {e}")
|
||||
await self.queue_manager.mark_failed(job.job_id, str(e))
|
||||
|
||||
except asyncio.CancelledError:
|
||||
break
|
||||
except Exception as e:
|
||||
logger.error(f"[{sub_worker_id}] Worker error: {e}")
|
||||
await asyncio.sleep(1)
|
||||
|
||||
logger.info(f"Sub-worker {sub_worker_id} stopped")
|
||||
|
||||
async def _generate_article(self, job: NewsJobData):
|
||||
"""기사 생성"""
|
||||
# 작업 데이터를 기존 형식으로 변환
|
||||
news_data = {
|
||||
"keyword": job.keyword,
|
||||
"news_items": [{
|
||||
"rss_title": job.rss_title,
|
||||
"rss_link": job.rss_link,
|
||||
"rss_published": job.rss_published,
|
||||
"google_results": job.google_results
|
||||
}]
|
||||
}
|
||||
|
||||
# 기사 생성 (기존 함수 재사용)
|
||||
return await generate_article_with_claude(news_data, job.style)
|
||||
|
||||
async def _ping_worker(self):
|
||||
"""워커 활동 신호 전송"""
|
||||
while self.running:
|
||||
try:
|
||||
await self.queue_manager.ping_worker(self.worker_id)
|
||||
await asyncio.sleep(30) # 30초마다 핑
|
||||
except asyncio.CancelledError:
|
||||
break
|
||||
except Exception as e:
|
||||
logger.error(f"Ping error: {e}")
|
||||
|
||||
def signal_handler(signum, frame):
|
||||
"""시그널 핸들러"""
|
||||
logger.info(f"Received signal {signum}")
|
||||
sys.exit(0)
|
||||
|
||||
async def main():
|
||||
"""메인 함수"""
|
||||
# 시그널 핸들러 등록
|
||||
signal.signal(signal.SIGINT, signal_handler)
|
||||
signal.signal(signal.SIGTERM, signal_handler)
|
||||
|
||||
# 워커 수 설정 (환경변수 또는 기본값)
|
||||
num_workers = int(os.getenv("WORKER_COUNT", "3"))
|
||||
|
||||
# 워커 시작
|
||||
worker = AIWriterWorker()
|
||||
try:
|
||||
await worker.start(num_workers=num_workers)
|
||||
except KeyboardInterrupt:
|
||||
logger.info("Keyboard interrupt received")
|
||||
finally:
|
||||
await worker.stop()
|
||||
|
||||
if __name__ == "__main__":
|
||||
asyncio.run(main())
|
||||
@ -0,0 +1,62 @@
|
||||
{
|
||||
"news_id": "49bdf2f3-4dbc-47eb-8c49-5d9536f41d87",
|
||||
"title": "유럽 전기차 시장의 새로운 전환점: 현대차·기아의 소형 전기차 전략과 글로벌 경쟁 구도",
|
||||
"created_at": "2025-09-13T00:29:13.376541",
|
||||
"summary": "현대차와 기아가 IAA 2025에서 소형 전기차 콘셉트 모델을 공개하며 유럽 시장 공략을 가속화, 배터리 협력과 가격 경쟁력으로 승부수",
|
||||
"subtopics": [
|
||||
{
|
||||
"title": "현대차·기아의 유럽 소형 전기차 시장 공략",
|
||||
"content": [
|
||||
"현대자동차와 기아가 IAA 2025에서 콘셉트 쓰리와 EV2를 공개하며 유럽 소형 전기차 시장 공략에 박차를 가하고 있다. 이는 유럽의 급성장하는 소형 전기차 수요에 대응하기 위한 전략적 움직임으로 평가된다.",
|
||||
"특히 두 모델은 실용성과 경제성을 모두 갖춘 제품으로, 유럽 소비자들의 니즈를 정확히 겨냥했다는 평가를 받고 있다. 현대차그룹은 이를 통해 유럽 시장에서의 입지를 더욱 강화할 것으로 전망된다.",
|
||||
"현지 전문가들은 현대차그룹의 이번 전략이 유럽 전기차 시장의 '골든타임'을 잡기 위한 시의적절한 움직임이라고 분석하고 있다."
|
||||
]
|
||||
},
|
||||
{
|
||||
"title": "배터리 공급망 전략의 중요성 부각",
|
||||
"content": [
|
||||
"전기차 시장에서 배터리 공급망 확보가 핵심 경쟁력으로 부상하고 있다. IAA 모빌리티에서 폴스타가 SK온을 배터리 파트너로 공개적으로 언급한 것이 주목받고 있다.",
|
||||
"배터리 제조사 선정에 대한 정보가 제한적인 가운데, 안정적인 배터리 공급망 구축이 전기차 제조사들의 성패를 좌우할 것으로 예상된다.",
|
||||
"특히 소형 전기차의 경우 가격 경쟁력이 중요한 만큼, 효율적인 배터리 수급 전략이 시장 점유율 확대의 관건이 될 전망이다."
|
||||
]
|
||||
},
|
||||
{
|
||||
"title": "글로벌 전기차 시장의 경쟁 구도 변화",
|
||||
"content": [
|
||||
"유럽 전기차 시장에서 소형 모델을 중심으로 한 경쟁이 본격화되면서, 제조사들의 전략적 포지셔닝이 더욱 중요해지고 있다.",
|
||||
"현대차그룹은 품질과 기술력을 바탕으로 한 프리미엄 이미지와 함께, 합리적인 가격대의 소형 전기차 라인업으로 시장 공략을 가속화하고 있다.",
|
||||
"이러한 변화는 글로벌 자동차 산업의 패러다임 전환을 반영하며, 향후 전기차 시장의 주도권 경쟁이 더욱 치열해질 것으로 예상된다."
|
||||
]
|
||||
}
|
||||
],
|
||||
"categories": [
|
||||
"자동차",
|
||||
"경제",
|
||||
"환경",
|
||||
"기술"
|
||||
],
|
||||
"entities": {
|
||||
"people": [],
|
||||
"organizations": [
|
||||
"현대자동차",
|
||||
"기아",
|
||||
"SK온",
|
||||
"폴스타"
|
||||
],
|
||||
"groups": [
|
||||
"유럽 자동차 제조사",
|
||||
"배터리 제조업체"
|
||||
],
|
||||
"countries": [
|
||||
"대한민국",
|
||||
"독일",
|
||||
"유럽연합"
|
||||
],
|
||||
"events": [
|
||||
"IAA 2025",
|
||||
"IAA 모빌리티"
|
||||
]
|
||||
},
|
||||
"source_keyword": "전기차",
|
||||
"source_count": 3
|
||||
}
|
||||
@ -0,0 +1,66 @@
|
||||
{
|
||||
"news_id": "8a51bead-4558-4351-a5b2-b5e5ba1b3d38",
|
||||
"title": "현대차·기아, 유럽 전기차 시장서 소형 모델로 새 돌파구 모색",
|
||||
"created_at": "2025-09-13T00:29:35.661926",
|
||||
"summary": "IAA 모빌리티 2025에서 현대차·기아가 소형 전기차 콘셉트카를 공개하며 유럽 시장 공략 가속화. 배터리 공급망 확보와 가격 경쟁력이 성공 관건",
|
||||
"subtopics": [
|
||||
{
|
||||
"title": "유럽 소형 전기차 시장 공략 본격화",
|
||||
"content": [
|
||||
"현대차와 기아가 IAA 모빌리티 2025에서 각각 콘셉트 쓰리와 EV2를 공개하며 유럽 소형 전기차 시장 공략에 시동을 걸었다. 이는 유럽의 높은 환경 규제와 도심 이동성 수요에 대응하기 위한 전략적 움직임으로 해석된다.",
|
||||
"특히 두 모델은 기존 전기차 대비 컴팩트한 사이즈와 효율적인 배터리 시스템을 갖추고 있어, 유럽 소비자들의 실용적 수요를 겨냥했다는 평가를 받고 있다.",
|
||||
"업계 전문가들은 현대차그룹의 이번 행보가 테슬라와 중국 업체들이 주도하고 있는 유럽 전기차 시장에서 새로운 돌파구를 마련할 수 있을 것으로 전망하고 있다."
|
||||
]
|
||||
},
|
||||
{
|
||||
"title": "배터리 공급망 확보 과제",
|
||||
"content": [
|
||||
"전기차 성공의 핵심 요소인 배터리 수급에서 SK온이 주요 공급 파트너로 부상했다. 폴스타가 SK온을 배터리 공급사로 공개적으로 언급한 것이 이를 방증한다.",
|
||||
"그러나 업계에서는 배터리 제조사들의 정보 공개가 제한적이어서 실제 공급망 구조를 파악하기 어려운 상황이다. 이는 글로벌 배터리 수급 경쟁이 치열해지고 있음을 시사한다.",
|
||||
"안정적인 배터리 공급망 확보는 향후 소형 전기차의 가격 경쟁력과 직결되는 만큼, 현대차그룹의 추가적인 파트너십 구축이 예상된다."
|
||||
]
|
||||
},
|
||||
{
|
||||
"title": "가격 경쟁력 확보 전략",
|
||||
"content": [
|
||||
"소형 전기차 시장에서의 성공을 위해서는 합리적인 가격대 책정이 필수적이다. 현대차그룹은 규모의 경제를 통한 원가 절감을 목표로 하고 있다.",
|
||||
"특히 유럽 시장에서는 테슬라와 중국 업체들의 공격적인 가격 정책에 대응해야 하는 상황이다. 현대차그룹은 프리미엄 품질을 유지하면서도 경쟁력 있는 가격대를 제시하는 것을 목표로 하고 있다.",
|
||||
"전문가들은 배터리 기술 혁신과 생산 효율화를 통해 가격 경쟁력을 확보하는 것이 향후 성공의 핵심이 될 것으로 전망하고 있다."
|
||||
]
|
||||
}
|
||||
],
|
||||
"categories": [
|
||||
"자동차",
|
||||
"경제",
|
||||
"산업",
|
||||
"기술"
|
||||
],
|
||||
"entities": {
|
||||
"people": [
|
||||
"김성수",
|
||||
"조용하",
|
||||
"박종면"
|
||||
],
|
||||
"organizations": [
|
||||
"현대자동차",
|
||||
"기아",
|
||||
"SK온",
|
||||
"폴스타"
|
||||
],
|
||||
"groups": [
|
||||
"유럽 자동차 제조사",
|
||||
"중국 전기차 업체"
|
||||
],
|
||||
"countries": [
|
||||
"대한민국",
|
||||
"독일",
|
||||
"중국"
|
||||
],
|
||||
"events": [
|
||||
"IAA 모빌리티 2025",
|
||||
"전기차 배터리 공급 계약"
|
||||
]
|
||||
},
|
||||
"source_keyword": "전기차",
|
||||
"source_count": 3
|
||||
}
|
||||
@ -0,0 +1,62 @@
|
||||
{
|
||||
"news_id": "2c4cb595-9542-45ee-b4b9-2135c46950e3",
|
||||
"title": "현대차·기아, 유럽 전기차 시장서 소형 모델로 승부수...배터리 협력 강화 주목",
|
||||
"created_at": "2025-09-13T00:28:51.371773",
|
||||
"summary": "현대차·기아가 유럽 전기차 시장에서 콘셉트 쓰리와 EV2로 소형 전기차 시장 공략 나서, 배터리 협력사 선정 등 경쟁력 강화 움직임 본격화",
|
||||
"subtopics": [
|
||||
{
|
||||
"title": "유럽 소형 전기차 시장 공략 본격화",
|
||||
"content": [
|
||||
"현대자동차그룹이 유럽 전기차 시장 공략을 위해 소형 전기차 라인업 확대에 나섰다. IAA 모빌리티 2025에서 공개된 현대차의 콘셉트 쓰리와 기아의 EV2는 유럽 시장 맞춤형 전략의 핵심으로 평가받고 있다.",
|
||||
"특히 소형 전기차 시장은 유럽에서 급성장이 예상되는 세그먼트로, 현대차그룹은 합리적인 가격대와 실용성을 앞세워 시장 선점을 노리고 있다.",
|
||||
"현대차그룹의 이번 전략은 유럽의 환경 규제 강화와 소비자들의 실용적인 전기차 수요 증가에 대응하는 동시에, 중국 전기차 업체들의 유럽 진출에 대한 선제적 대응으로 해석된다."
|
||||
]
|
||||
},
|
||||
{
|
||||
"title": "배터리 협력 관계 재편 움직임",
|
||||
"content": [
|
||||
"전기차 경쟁력의 핵심인 배터리 수급과 관련해 업계의 이목이 집중되고 있다. IAA 모빌리티에서 폴스타가 SK온을 배터리 공급사로 지목한 것이 주목받고 있다.",
|
||||
"글로벌 자동차 업체들의 배터리 조달 전략이 다변화되는 가운데, 한국 배터리 업체들과의 협력 강화 움직임이 감지되고 있다.",
|
||||
"특히 현대차그룹은 안정적인 배터리 수급을 위해 다양한 배터리 제조사들과의 협력 관계를 검토 중인 것으로 알려졌다."
|
||||
]
|
||||
},
|
||||
{
|
||||
"title": "글로벌 전기차 시장 경쟁 심화",
|
||||
"content": [
|
||||
"전기차 시장에서 브랜드 간 경쟁이 치열해지는 가운데, 현대차그룹은 차별화된 제품 라인업과 기술력으로 시장 지위 강화에 나서고 있다.",
|
||||
"특히 유럽 시장에서는 테슬라, 폭스바겐 그룹, 중국 업체들과의 경쟁이 불가피한 상황이며, 현대차그룹은 품질과 기술력을 앞세워 경쟁력 확보에 주력하고 있다.",
|
||||
"시장 전문가들은 현대차그룹의 소형 전기차 전략이 향후 글로벌 시장에서의 입지 강화에 중요한 전환점이 될 것으로 전망하고 있다."
|
||||
]
|
||||
}
|
||||
],
|
||||
"categories": [
|
||||
"자동차",
|
||||
"경제",
|
||||
"산업"
|
||||
],
|
||||
"entities": {
|
||||
"people": [
|
||||
"김성수",
|
||||
"박영효"
|
||||
],
|
||||
"organizations": [
|
||||
"현대자동차",
|
||||
"기아",
|
||||
"SK온",
|
||||
"폴스타"
|
||||
],
|
||||
"groups": [
|
||||
"현대차그룹",
|
||||
"폭스바겐 그룹"
|
||||
],
|
||||
"countries": [
|
||||
"대한민국",
|
||||
"독일"
|
||||
],
|
||||
"events": [
|
||||
"IAA 모빌리티 2025"
|
||||
]
|
||||
},
|
||||
"source_keyword": "전기차",
|
||||
"source_count": 3
|
||||
}
|
||||
@ -0,0 +1,63 @@
|
||||
{
|
||||
"news_id": "ee154fb8-a913-4aa9-9fc9-fa421fd2d7c0",
|
||||
"title": "2025년 기술 혁신의 분기점: AI·양자컴퓨팅이 그리는 새로운 미래",
|
||||
"created_at": "2025-09-13T00:32:14.008706",
|
||||
"summary": "2025년, AI와 양자컴퓨팅의 상용화가 가져올 산업 전반의 혁신적 변화와 사회적 영향을 심층 분석한 전망",
|
||||
"subtopics": [
|
||||
{
|
||||
"title": "생성형 AI가 재편하는 산업 생태계",
|
||||
"content": [
|
||||
"2025년은 생성형 AI가 산업 전반에 본격적으로 도입되는 원년이 될 전망이다. 특히 의료 진단, 신약 개발, 교육 커리큘럼 설계 등 전문 분야에서 AI의 역할이 획기적으로 확대될 것으로 예측된다.",
|
||||
"기업들의 업무 프로세스도 근본적인 변화를 맞이할 것으로 보인다. 창의적 작업 영역에서도 AI의 활용이 일상화되며, 인간-AI 협업 모델이 새로운 표준으로 자리잡을 것으로 전망된다.",
|
||||
"다만 AI 도입에 따른 노동시장 재편과 윤리적 문제에 대한 사회적 합의가 시급한 과제로 대두될 것으로 예상된다. 특히 AI 의존도 증가에 따른 데이터 보안과 알고리즘 편향성 문제는 중요한 해결 과제가 될 것이다."
|
||||
]
|
||||
},
|
||||
{
|
||||
"title": "양자컴퓨팅의 상용화와 산업혁신",
|
||||
"content": [
|
||||
"양자컴퓨팅 기술이 실용화 단계에 진입하면서, 금융권의 리스크 분석과 암호화폐 보안 시스템에 획기적인 변화가 예상된다. 특히 복잡한 금융 모델링과 시장 예측에서 양자컴퓨터의 활용이 크게 증가할 전망이다.",
|
||||
"제약 산업에서는 신약 개발 프로세스가 대폭 단축될 것으로 기대된다. 양자컴퓨터를 활용한 분자 시뮬레이션이 가능해지면서, 신약 개발 비용 절감과 효율성 증대가 실현될 것이다.",
|
||||
"물류 및 공급망 관리 분야에서도 양자컴퓨팅의 영향력이 확대될 전망이다. 복잡한 경로 최적화와 재고 관리에 양자 알고리즘을 적용함으로써, 물류 비용 절감과 효율성 향상이 가능해질 것으로 예측된다."
|
||||
]
|
||||
},
|
||||
{
|
||||
"title": "기술 혁신에 따른 사회경제적 변화",
|
||||
"content": [
|
||||
"AI와 양자컴퓨팅의 발전은 노동시장의 구조적 변화를 가속화할 것으로 전망된다. 단순 반복 업무는 자동화되는 반면, AI 시스템 관리와 양자컴퓨팅 전문가 같은 새로운 직종의 수요가 급증할 것으로 예상된다.",
|
||||
"교육 시스템도 큰 변화를 맞이할 것으로 보인다. AI 기반 맞춤형 학습과 양자컴퓨팅 원리에 대한 이해가 새로운 필수 교육과정으로 자리잡을 것으로 전망된다.",
|
||||
"이러한 기술 혁신은 국가 간 기술 격차를 더욱 심화시킬 가능성이 있다. 선진국과 개발도상국 간의 디지털 격차 해소가 국제사회의 주요 과제로 대두될 것으로 예측된다."
|
||||
]
|
||||
}
|
||||
],
|
||||
"categories": [
|
||||
"기술",
|
||||
"산업",
|
||||
"미래전망",
|
||||
"경제"
|
||||
],
|
||||
"entities": {
|
||||
"people": [],
|
||||
"organizations": [
|
||||
"금융권",
|
||||
"제약회사",
|
||||
"물류기업"
|
||||
],
|
||||
"groups": [
|
||||
"AI 개발자",
|
||||
"양자컴퓨팅 전문가",
|
||||
"교육기관"
|
||||
],
|
||||
"countries": [
|
||||
"한국",
|
||||
"미국",
|
||||
"중국"
|
||||
],
|
||||
"events": [
|
||||
"AI 상용화",
|
||||
"양자컴퓨터 실용화",
|
||||
"디지털 전환"
|
||||
]
|
||||
},
|
||||
"source_keyword": "2025년 기술 트렌드",
|
||||
"source_count": 2
|
||||
}
|
||||
@ -0,0 +1,62 @@
|
||||
{
|
||||
"news_id": "3109c578-9b08-4cd0-a9d6-3d92b97e64d4",
|
||||
"title": "2025년 기술 혁신의 물결, AI·양자컴퓨팅이 이끄는 새로운 패러다임",
|
||||
"created_at": "2025-09-13T00:31:52.782760",
|
||||
"summary": "2025년, 생성형 AI와 양자컴퓨팅의 상용화로 산업 전반에 혁신적 변화가 예상되며, 인간-AI 협업이 일상화될 전망",
|
||||
"subtopics": [
|
||||
{
|
||||
"title": "생성형 AI가 주도하는 창의적 혁신",
|
||||
"content": [
|
||||
"2025년은 생성형 AI 기술이 전례 없는 수준으로 발전하여 창의적 영역에서도 획기적인 변화가 예상된다. 기존에 인간의 고유 영역으로 여겨졌던 예술 창작, 콘텐츠 제작, 디자인 분야에서 AI가 핵심 협력자로 자리잡을 전망이다.",
|
||||
"특히 의료 분야에서는 AI가 질병 진단과 치료 계획 수립에 적극적으로 활용될 것으로 예측된다. AI는 방대한 의료 데이터를 분석하여 개인 맞춤형 치료법을 제시하고, 의료진의 의사결정을 효과적으로 지원할 것으로 기대된다.",
|
||||
"교육 분야에서도 AI 기반의 맞춤형 학습 시스템이 보편화될 전망이다. 학습자의 이해도와 진도에 따라 최적화된 커리큘럼을 제공하고, 실시간으로 학습 성과를 분석하여 개선점을 제시하는 등 교육의 질적 향상이 기대된다."
|
||||
]
|
||||
},
|
||||
{
|
||||
"title": "양자컴퓨팅의 산업 혁신 주도",
|
||||
"content": [
|
||||
"2025년은 양자컴퓨팅이 실용화 단계에 진입하는 원년이 될 것으로 전망된다. 특히 금융 산업에서는 복잡한 위험 분석과 포트폴리오 최적화에 양자컴퓨팅을 활용하여 투자 전략의 정확도를 높일 것으로 예상된다.",
|
||||
"제약 산업에서는 양자컴퓨터를 활용한 신약 개발이 가속화될 전망이다. 분자 구조 시뮬레이션과 신약 후보 물질 스크리닝 과정에서 양자컴퓨팅의 강점이 발휘될 것으로 기대된다.",
|
||||
"물류 분야에서도 양자컴퓨팅을 통한 최적화가 실현될 전망이다. 복잡한 공급망 관리와 배송 경로 최적화에 양자컴퓨팅을 도입함으로써 물류 비용 절감과 효율성 향상이 가능해질 것으로 예측된다."
|
||||
]
|
||||
},
|
||||
{
|
||||
"title": "인간-기계 협업의 새로운 패러다임",
|
||||
"content": [
|
||||
"2025년에는 AI와 인간의 협업이 일상화되면서 업무 방식의 근본적인 변화가 예상된다. 단순 반복적인 업무는 AI가 담당하고, 인간은 전략적 의사결정과 창의적 문제 해결에 집중하는 방식으로 업무 분담이 이루어질 것이다.",
|
||||
"이러한 변화는 노동시장의 구조적 변화로 이어질 전망이다. AI와 협업할 수 있는 디지털 역량이 필수적인 직무 역량으로 부상하며, 새로운 형태의 직업이 등장할 것으로 예측된다.",
|
||||
"하지만 이러한 변화 속에서도 윤리적 판단과 감성적 소통과 같은 인간 고유의 가치는 더욱 중요해질 것으로 전망된다. 기술 발전이 가져올 혜택을 최대화하면서도 인간 중심의 가치를 지켜나가는 균형이 중요한 과제로 대두될 것이다."
|
||||
]
|
||||
}
|
||||
],
|
||||
"categories": [
|
||||
"기술",
|
||||
"미래전망",
|
||||
"산업동향"
|
||||
],
|
||||
"entities": {
|
||||
"people": [],
|
||||
"organizations": [
|
||||
"AI 기업들",
|
||||
"제약회사들",
|
||||
"물류기업들"
|
||||
],
|
||||
"groups": [
|
||||
"의료진",
|
||||
"교육자",
|
||||
"기술전문가"
|
||||
],
|
||||
"countries": [
|
||||
"한국",
|
||||
"미국",
|
||||
"중국"
|
||||
],
|
||||
"events": [
|
||||
"2025년 기술혁신",
|
||||
"양자컴퓨팅 상용화",
|
||||
"AI 혁명"
|
||||
]
|
||||
},
|
||||
"source_keyword": "2025년 기술 트렌드",
|
||||
"source_count": 2
|
||||
}
|
||||
73
backup-services/ai-writer/backend/generated_article.json
Normal file
73
backup-services/ai-writer/backend/generated_article.json
Normal file
@ -0,0 +1,73 @@
|
||||
{
|
||||
"news_id": "ea9f3734-6a93-4ca7-8ebe-b85612e2fd0a",
|
||||
"title": "정부, 내년 AI 산업에 10조원 투자...한국 경제 체질 대전환 나선다",
|
||||
"created_at": "2025-09-13T01:09:43.892704",
|
||||
"summary": "정부가 2025년 인공지능 산업 육성을 위해 10조원 규모의 대규모 투자를 단행하며 디지털 경제 전환 가속화에 나선다",
|
||||
"subtopics": [
|
||||
{
|
||||
"title": "정부의 AI 산업 육성 청사진",
|
||||
"content": [
|
||||
"정부가 2025년 인공지능(AI) 산업 육성을 위해 10조원 규모의 투자를 단행한다. 이는 한국 경제의 디지털 전환을 가속화하고 글로벌 AI 강국으로 도약하기 위한 전략적 결정이다.",
|
||||
"투자의 주요 방향은 AI 기술 개발, 인프라 구축, 전문인력 양성 등으로, 특히 반도체와 같은 핵심 산업과의 시너지 창출에 중점을 둘 예정이다."
|
||||
]
|
||||
},
|
||||
{
|
||||
"title": "민관 협력 체계 구축",
|
||||
"content": [
|
||||
"정부는 AI 산업 육성을 위해 대기업, 스타트업, 연구기관 등과의 협력 체계를 강화한다. 소버린AI를 비롯한 국내 AI 기업들과의 협력을 통해 실질적인 세계 2위 AI 강국 도약을 목표로 하고 있다.",
|
||||
"특히 AI 전문가 공모와 전담 조직 신설 등을 통해 체계적인 산업 육성 기반을 마련할 계획이다."
|
||||
]
|
||||
},
|
||||
{
|
||||
"title": "글로벌 경쟁력 강화 전략",
|
||||
"content": [
|
||||
"정부는 국내 AI 기업들의 글로벌 경쟁력 강화를 위해 기술 개발 지원, 해외 시장 진출 지원, 규제 개선 등 다각적인 지원책을 마련한다.",
|
||||
"특히 AI 산업의 핵심 인프라인 반도체 분야에서 SK하이닉스의 HBM4 개발 완료 등 가시적인 성과가 나타나고 있어, 이를 기반으로 한 시너지 효과가 기대된다."
|
||||
]
|
||||
}
|
||||
],
|
||||
"categories": [
|
||||
"경제",
|
||||
"기술",
|
||||
"산업정책"
|
||||
],
|
||||
"entities": {
|
||||
"people": [
|
||||
"하정우 소버린AI 대표"
|
||||
],
|
||||
"organizations": [
|
||||
"소버린AI",
|
||||
"SK하이닉스",
|
||||
"과학기술정보통신부"
|
||||
],
|
||||
"groups": [
|
||||
"AI 기업",
|
||||
"스타트업"
|
||||
],
|
||||
"countries": [
|
||||
"대한민국",
|
||||
"미국"
|
||||
],
|
||||
"events": [
|
||||
{
|
||||
"name": "2025년 AI 산업 육성 계획 발표",
|
||||
"date": "2025년",
|
||||
"location": "대한민국"
|
||||
}
|
||||
],
|
||||
"keywords": [
|
||||
"인공지능",
|
||||
"AI 산업",
|
||||
"디지털 전환",
|
||||
"10조원 투자",
|
||||
"반도체",
|
||||
"HBM4",
|
||||
"글로벌 경쟁력",
|
||||
"민관협력",
|
||||
"전문인력 양성",
|
||||
"기술개발"
|
||||
]
|
||||
},
|
||||
"source_keyword": "인공지능",
|
||||
"source_count": 5
|
||||
}
|
||||
9
backup-services/ai-writer/backend/requirements.txt
Normal file
9
backup-services/ai-writer/backend/requirements.txt
Normal file
@ -0,0 +1,9 @@
|
||||
fastapi==0.104.1
|
||||
uvicorn[standard]==0.24.0
|
||||
httpx==0.25.2
|
||||
pydantic==2.5.0
|
||||
motor==3.1.1
|
||||
pymongo==4.3.3
|
||||
anthropic==0.39.0
|
||||
python-multipart==0.0.6
|
||||
redis[hiredis]==5.0.1
|
||||
168
backup-services/ai-writer/backend/test_ai_writer.py
Executable file
168
backup-services/ai-writer/backend/test_ai_writer.py
Executable file
@ -0,0 +1,168 @@
|
||||
#!/usr/bin/env python3
|
||||
"""
|
||||
AI Writer Service Test
|
||||
Claude API를 사용한 전문적인 뉴스 기사 생성 테스트
|
||||
"""
|
||||
import asyncio
|
||||
import httpx
|
||||
import json
|
||||
from datetime import datetime
|
||||
|
||||
# Service URL
|
||||
SERVICE_URL = "http://localhost:8019"
|
||||
|
||||
async def test_article_generation():
|
||||
"""인공지능 키워드로 기사 생성 테스트"""
|
||||
async with httpx.AsyncClient(timeout=120.0) as client:
|
||||
print("\n" + "="*70)
|
||||
print(" AI Writer Service - 전문 기사 생성 테스트 ")
|
||||
print("="*70)
|
||||
|
||||
print("\n📰 '인공지능' 키워드로 전문 기사 생성 중...")
|
||||
print("-" * 50)
|
||||
|
||||
# Generate article
|
||||
response = await client.post(
|
||||
f"{SERVICE_URL}/api/generate",
|
||||
json={
|
||||
"keyword": "인공지능",
|
||||
"limit": 5,
|
||||
"google_results_per_title": 3,
|
||||
"lang": "ko",
|
||||
"country": "KR",
|
||||
"style": "professional"
|
||||
}
|
||||
)
|
||||
|
||||
if response.status_code == 200:
|
||||
article = response.json()
|
||||
|
||||
print(f"\n✅ 기사 생성 완료!")
|
||||
print(f"\n📌 기사 ID: {article['news_id']}")
|
||||
print(f"📅 생성 시간: {article['created_at']}")
|
||||
print(f"\n📰 제목: {article['title']}")
|
||||
print(f"📝 요약: {article['summary']}")
|
||||
|
||||
print(f"\n🔍 카테고리: {', '.join(article['categories'])}")
|
||||
|
||||
# Print subtopics
|
||||
print(f"\n📚 소주제 ({len(article['subtopics'])}개):")
|
||||
for i, subtopic in enumerate(article['subtopics'], 1):
|
||||
print(f"\n [{i}] {subtopic['title']}")
|
||||
print(f" 문단 수: {len(subtopic['content'])}개")
|
||||
for j, paragraph in enumerate(subtopic['content'][:1], 1): # Show first paragraph only
|
||||
print(f" 미리보기: {paragraph[:150]}...")
|
||||
|
||||
# Print entities
|
||||
entities = article['entities']
|
||||
print(f"\n🏷️ 추출된 개체:")
|
||||
if entities['people']:
|
||||
print(f" 👤 인물: {', '.join(entities['people'])}")
|
||||
if entities['organizations']:
|
||||
print(f" 🏢 기관: {', '.join(entities['organizations'])}")
|
||||
if entities['groups']:
|
||||
print(f" 👥 단체: {', '.join(entities['groups'])}")
|
||||
if entities['countries']:
|
||||
print(f" 🌍 국가: {', '.join(entities['countries'])}")
|
||||
if entities.get('events'):
|
||||
events = entities['events']
|
||||
if events:
|
||||
print(f" 📅 이벤트 ({len(events)}개):")
|
||||
for evt in events[:3]: # 처음 3개만 표시
|
||||
if isinstance(evt, dict):
|
||||
evt_str = f" - {evt.get('name', '')}"
|
||||
if evt.get('date'):
|
||||
evt_str += f" [{evt['date']}]"
|
||||
if evt.get('location'):
|
||||
evt_str += f" @{evt['location']}"
|
||||
print(evt_str)
|
||||
else:
|
||||
# 이전 형식 (문자열) 지원
|
||||
print(f" - {evt}")
|
||||
if entities.get('keywords'):
|
||||
keywords = entities['keywords']
|
||||
if keywords:
|
||||
print(f" 🔑 키워드: {', '.join(keywords[:5])}" +
|
||||
("..." if len(keywords) > 5 else ""))
|
||||
|
||||
print(f"\n📊 참조 소스: {article.get('source_count', 0)}개")
|
||||
|
||||
# Save full article to file
|
||||
with open('generated_article.json', 'w', encoding='utf-8') as f:
|
||||
json.dump(article, f, ensure_ascii=False, indent=2)
|
||||
print(f"\n💾 전체 기사가 'generated_article.json'에 저장되었습니다.")
|
||||
|
||||
else:
|
||||
print(f"❌ 오류: {response.status_code}")
|
||||
print(f" 상세: {response.text}")
|
||||
|
||||
async def test_health_check():
|
||||
"""서비스 상태 확인"""
|
||||
async with httpx.AsyncClient() as client:
|
||||
print("\n" + "="*60)
|
||||
print("서비스 Health Check")
|
||||
print("="*60)
|
||||
|
||||
response = await client.get(f"{SERVICE_URL}/health")
|
||||
if response.status_code == 200:
|
||||
data = response.json()
|
||||
print(f"✓ AI Writer 서비스 상태: {data.get('status', 'unknown')}")
|
||||
if 'services' in data:
|
||||
print(f" - News Aggregator: {data['services'].get('news_aggregator', 'unknown')}")
|
||||
print(f" - MongoDB: {data['services'].get('mongodb', 'unknown')}")
|
||||
print(f" - Claude API: {data['services'].get('claude_api', 'unknown')}")
|
||||
if 'error' in data:
|
||||
print(f" - Error: {data['error']}")
|
||||
else:
|
||||
print(f"✗ Health check 실패: {response.status_code}")
|
||||
|
||||
async def test_batch_generation():
|
||||
"""여러 키워드 일괄 처리 테스트"""
|
||||
async with httpx.AsyncClient(timeout=180.0) as client:
|
||||
print("\n" + "="*60)
|
||||
print("일괄 기사 생성 테스트")
|
||||
print("="*60)
|
||||
|
||||
keywords = ["AI 혁신", "디지털 전환", "스마트시티"]
|
||||
print(f"\n키워드: {', '.join(keywords)}")
|
||||
|
||||
response = await client.post(
|
||||
f"{SERVICE_URL}/api/generate/batch",
|
||||
json=keywords,
|
||||
params={"style": "analytical"}
|
||||
)
|
||||
|
||||
if response.status_code == 200:
|
||||
data = response.json()
|
||||
print(f"\n✅ 처리 완료: {data['total_processed']}개")
|
||||
|
||||
if data['success']:
|
||||
print("\n성공한 기사:")
|
||||
for item in data['success']:
|
||||
print(f" - {item['keyword']}: {item['title'][:50]}...")
|
||||
|
||||
if data['errors']:
|
||||
print("\n실패한 항목:")
|
||||
for item in data['errors']:
|
||||
print(f" - {item['keyword']}: {item['error']}")
|
||||
else:
|
||||
print(f"❌ 오류: {response.status_code}")
|
||||
|
||||
async def main():
|
||||
"""메인 테스트 실행"""
|
||||
print("\n" + "="*70)
|
||||
print(" AI Writer Service Test Suite ")
|
||||
print(" RSS → Google Search → Claude AI 기사 생성 ")
|
||||
print("="*70)
|
||||
|
||||
# Run tests
|
||||
await test_health_check()
|
||||
await test_article_generation()
|
||||
# await test_batch_generation() # Optional: batch test
|
||||
|
||||
print("\n" + "="*70)
|
||||
print(" 테스트 완료 ")
|
||||
print("="*70)
|
||||
|
||||
if __name__ == "__main__":
|
||||
asyncio.run(main())
|
||||
240
backup-services/ai-writer/backend/test_prompt_generation.py
Normal file
240
backup-services/ai-writer/backend/test_prompt_generation.py
Normal file
@ -0,0 +1,240 @@
|
||||
#!/usr/bin/env python3
|
||||
"""
|
||||
AI Writer Service - 프롬프트 기반 기사 생성 테스트
|
||||
다양한 스타일과 키워드로 기사를 생성하는 테스트
|
||||
"""
|
||||
import asyncio
|
||||
import httpx
|
||||
import json
|
||||
from datetime import datetime
|
||||
|
||||
# Service URL
|
||||
SERVICE_URL = "http://localhost:8019"
|
||||
|
||||
async def test_different_styles():
|
||||
"""다양한 스타일로 기사 생성 테스트"""
|
||||
|
||||
test_cases = [
|
||||
{
|
||||
"keyword": "전기차",
|
||||
"style": "professional",
|
||||
"description": "전통적인 뉴스 기사 스타일"
|
||||
},
|
||||
{
|
||||
"keyword": "전기차",
|
||||
"style": "analytical",
|
||||
"description": "분석적이고 심층적인 스타일"
|
||||
},
|
||||
{
|
||||
"keyword": "전기차",
|
||||
"style": "investigative",
|
||||
"description": "탐사보도 스타일"
|
||||
}
|
||||
]
|
||||
|
||||
async with httpx.AsyncClient(timeout=180.0) as client:
|
||||
for test_case in test_cases:
|
||||
print("\n" + "="*70)
|
||||
print(f" {test_case['description']} 테스트")
|
||||
print("="*70)
|
||||
print(f"키워드: {test_case['keyword']}")
|
||||
print(f"스타일: {test_case['style']}")
|
||||
print("-" * 50)
|
||||
|
||||
try:
|
||||
response = await client.post(
|
||||
f"{SERVICE_URL}/api/generate",
|
||||
json={
|
||||
"keyword": test_case["keyword"],
|
||||
"limit": 3, # RSS 항목 수 줄여서 빠른 테스트
|
||||
"google_results_per_title": 2,
|
||||
"lang": "ko",
|
||||
"country": "KR",
|
||||
"style": test_case["style"]
|
||||
}
|
||||
)
|
||||
|
||||
if response.status_code == 200:
|
||||
article = response.json()
|
||||
print(f"\n✅ 기사 생성 성공!")
|
||||
print(f"📰 제목: {article['title']}")
|
||||
print(f"📝 요약: {article['summary']}")
|
||||
print(f"🔍 카테고리: {', '.join(article['categories'])}")
|
||||
print(f"📚 소주제 수: {len(article['subtopics'])}")
|
||||
|
||||
# 키워드 출력
|
||||
if 'entities' in article and 'keywords' in article['entities']:
|
||||
keywords = article['entities']['keywords']
|
||||
print(f"🔑 키워드 ({len(keywords)}개): {', '.join(keywords[:5])}" +
|
||||
("..." if len(keywords) > 5 else ""))
|
||||
|
||||
# 이벤트 정보 출력
|
||||
if 'entities' in article and 'events' in article['entities']:
|
||||
events = article['entities']['events']
|
||||
if events:
|
||||
print(f"📅 이벤트 ({len(events)}개):")
|
||||
for evt in events[:2]: # 처음 2개만 표시
|
||||
if isinstance(evt, dict):
|
||||
evt_str = f" - {evt.get('name', '')}"
|
||||
if evt.get('date'):
|
||||
evt_str += f" [{evt['date']}]"
|
||||
if evt.get('location'):
|
||||
evt_str += f" @{evt['location']}"
|
||||
print(evt_str)
|
||||
|
||||
# 첫 번째 소주제의 첫 문단만 출력
|
||||
if article['subtopics']:
|
||||
first_topic = article['subtopics'][0]
|
||||
print(f"\n첫 번째 소주제: {first_topic['title']}")
|
||||
if first_topic['content']:
|
||||
print(f"미리보기: {first_topic['content'][0][:200]}...")
|
||||
|
||||
# 파일로 저장
|
||||
filename = f"article_{test_case['keyword']}_{test_case['style']}.json"
|
||||
with open(filename, 'w', encoding='utf-8') as f:
|
||||
json.dump(article, f, ensure_ascii=False, indent=2)
|
||||
print(f"\n💾 '{filename}'에 저장됨")
|
||||
|
||||
else:
|
||||
print(f"❌ 오류: {response.status_code}")
|
||||
print(f"상세: {response.text}")
|
||||
|
||||
except Exception as e:
|
||||
print(f"❌ 테스트 실패: {e}")
|
||||
|
||||
# 다음 테스트 전 잠시 대기
|
||||
await asyncio.sleep(2)
|
||||
|
||||
async def test_different_keywords():
|
||||
"""다양한 키워드로 기사 생성 테스트"""
|
||||
|
||||
keywords = ["블록체인", "메타버스", "우주개발", "기후변화", "K-POP"]
|
||||
|
||||
async with httpx.AsyncClient(timeout=180.0) as client:
|
||||
print("\n" + "="*70)
|
||||
print(" 다양한 키워드 테스트")
|
||||
print("="*70)
|
||||
|
||||
for keyword in keywords:
|
||||
print(f"\n🔍 키워드: {keyword}")
|
||||
print("-" * 30)
|
||||
|
||||
try:
|
||||
response = await client.post(
|
||||
f"{SERVICE_URL}/api/generate",
|
||||
json={
|
||||
"keyword": keyword,
|
||||
"limit": 2, # 빠른 테스트를 위해 줄임
|
||||
"google_results_per_title": 2,
|
||||
"lang": "ko",
|
||||
"country": "KR",
|
||||
"style": "professional"
|
||||
}
|
||||
)
|
||||
|
||||
if response.status_code == 200:
|
||||
article = response.json()
|
||||
print(f"✅ 성공: {article['title'][:50]}...")
|
||||
print(f" 카테고리: {', '.join(article['categories'][:3])}")
|
||||
else:
|
||||
print(f"❌ 실패: {response.status_code}")
|
||||
|
||||
except Exception as e:
|
||||
print(f"❌ 오류: {e}")
|
||||
|
||||
await asyncio.sleep(1)
|
||||
|
||||
async def test_custom_prompt():
|
||||
"""커스텀 프롬프트 테스트 - 직접 aggregated 데이터 제공"""
|
||||
|
||||
# 미리 수집된 데이터를 시뮬레이션
|
||||
custom_news_data = {
|
||||
"keyword": "2025년 기술 트렌드",
|
||||
"news_items": [
|
||||
{
|
||||
"rss_title": "AI와 로봇이 바꾸는 2025년 일상",
|
||||
"google_results": [
|
||||
{
|
||||
"title": "전문가들이 예측하는 2025년 AI 혁명",
|
||||
"snippet": "2025년 AI 기술이 일상생활 전반을 혁신할 전망...",
|
||||
"full_content": {
|
||||
"url": "https://example.com/ai-2025",
|
||||
"content": "2025년에는 AI가 의료, 교육, 업무 등 모든 분야에서 인간과 협업하는 시대가 열릴 것으로 전망된다. 특히 생성형 AI의 발전으로 창의적 작업에서도 AI의 역할이 크게 확대될 것이다."
|
||||
}
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"rss_title": "양자컴퓨터 상용화 임박",
|
||||
"google_results": [
|
||||
{
|
||||
"title": "IBM, 2025년 1000큐비트 양자컴퓨터 출시 예정",
|
||||
"snippet": "IBM이 2025년 상용 양자컴퓨터 출시를 앞두고...",
|
||||
"full_content": {
|
||||
"url": "https://example.com/quantum-2025",
|
||||
"content": "양자컴퓨팅이 드디어 실용화 단계에 접어들었다. 2025년에는 금융, 제약, 물류 등 다양한 산업에서 양자컴퓨터를 활용한 혁신이 시작될 전망이다."
|
||||
}
|
||||
}
|
||||
]
|
||||
}
|
||||
]
|
||||
}
|
||||
|
||||
async with httpx.AsyncClient(timeout=180.0) as client:
|
||||
print("\n" + "="*70)
|
||||
print(" 커스텀 데이터로 기사 생성")
|
||||
print("="*70)
|
||||
|
||||
for style in ["professional", "analytical"]:
|
||||
print(f"\n스타일: {style}")
|
||||
print("-" * 30)
|
||||
|
||||
try:
|
||||
response = await client.post(
|
||||
f"{SERVICE_URL}/api/generate/from-aggregated",
|
||||
json=custom_news_data,
|
||||
params={"style": style}
|
||||
)
|
||||
|
||||
if response.status_code == 200:
|
||||
article = response.json()
|
||||
print(f"✅ 제목: {article['title']}")
|
||||
print(f" 요약: {article['summary']}")
|
||||
|
||||
# 스타일별로 저장
|
||||
filename = f"custom_article_{style}.json"
|
||||
with open(filename, 'w', encoding='utf-8') as f:
|
||||
json.dump(article, f, ensure_ascii=False, indent=2)
|
||||
print(f" 💾 '{filename}'에 저장됨")
|
||||
else:
|
||||
print(f"❌ 실패: {response.text}")
|
||||
|
||||
except Exception as e:
|
||||
print(f"❌ 오류: {e}")
|
||||
|
||||
await asyncio.sleep(2)
|
||||
|
||||
async def main():
|
||||
"""메인 테스트 실행"""
|
||||
print("\n" + "="*70)
|
||||
print(" AI Writer 프롬프트 기반 기사 생성 테스트")
|
||||
print("="*70)
|
||||
|
||||
# 1. 다양한 스타일 테스트
|
||||
print("\n[1] 스타일별 기사 생성 테스트")
|
||||
await test_different_styles()
|
||||
|
||||
# 2. 다양한 키워드 테스트
|
||||
print("\n[2] 키워드별 기사 생성 테스트")
|
||||
await test_different_keywords()
|
||||
|
||||
# 3. 커스텀 데이터 테스트
|
||||
print("\n[3] 커스텀 데이터 기사 생성 테스트")
|
||||
await test_custom_prompt()
|
||||
|
||||
print("\n" + "="*70)
|
||||
print(" 모든 테스트 완료!")
|
||||
print("="*70)
|
||||
|
||||
if __name__ == "__main__":
|
||||
asyncio.run(main())
|
||||
19
backup-services/ai-writer/worker/Dockerfile
Normal file
19
backup-services/ai-writer/worker/Dockerfile
Normal file
@ -0,0 +1,19 @@
|
||||
FROM python:3.11-slim
|
||||
|
||||
WORKDIR /app
|
||||
|
||||
# Copy requirements
|
||||
COPY backend/requirements.txt .
|
||||
|
||||
# Install dependencies
|
||||
RUN pip install --no-cache-dir -r requirements.txt
|
||||
|
||||
# Copy application code
|
||||
COPY backend/app /app
|
||||
|
||||
# Set environment variables
|
||||
ENV PYTHONUNBUFFERED=1
|
||||
ENV WORKER_COUNT=3
|
||||
|
||||
# Run worker
|
||||
CMD ["python", "worker.py"]
|
||||
153
backup-services/google-search/README.md
Normal file
153
backup-services/google-search/README.md
Normal file
@ -0,0 +1,153 @@
|
||||
# Google Search Service
|
||||
|
||||
키워드를 구글에서 검색한 결과를 수신하는 서비스입니다.
|
||||
|
||||
## 주요 기능
|
||||
|
||||
### 1. 다중 검색 방법 지원
|
||||
- **Google Custom Search API**: 공식 구글 API (권장)
|
||||
- **SerpAPI**: 대체 검색 API
|
||||
- **웹 스크래핑**: 폴백 옵션 (제한적)
|
||||
|
||||
### 2. 검색 옵션
|
||||
- 최대 20개 검색 결과 지원
|
||||
- 언어별/국가별 검색
|
||||
- 날짜 기준 필터링 및 정렬
|
||||
- 전체 콘텐츠 가져오기
|
||||
|
||||
## API 엔드포인트
|
||||
|
||||
### 기본 검색
|
||||
```
|
||||
GET /api/search?q=키워드&num=20&lang=ko&country=kr
|
||||
```
|
||||
|
||||
**파라미터:**
|
||||
- `q`: 검색 키워드 (필수)
|
||||
- `num`: 결과 개수 (1-20, 기본값: 10)
|
||||
- `lang`: 언어 코드 (ko, en 등)
|
||||
- `country`: 국가 코드 (kr, us 등)
|
||||
- `date_restrict`: 날짜 제한
|
||||
- `d7`: 일주일 이내
|
||||
- `m1`: 한달 이내
|
||||
- `m3`: 3개월 이내
|
||||
- `y1`: 1년 이내
|
||||
- `sort_by_date`: 최신순 정렬 (true/false)
|
||||
|
||||
### 전체 콘텐츠 검색
|
||||
```
|
||||
GET /api/search/full?q=키워드&num=5
|
||||
```
|
||||
각 검색 결과 페이지의 전체 내용을 가져옵니다 (시간이 오래 걸릴 수 있음).
|
||||
|
||||
### 실시간 트렌딩
|
||||
```
|
||||
GET /api/trending?country=kr
|
||||
```
|
||||
|
||||
## 사용 예제
|
||||
|
||||
### 1. 한국어 검색 (최신순)
|
||||
```bash
|
||||
curl "http://localhost:8016/api/search?q=인공지능&num=20&lang=ko&country=kr&sort_by_date=true"
|
||||
```
|
||||
|
||||
### 2. 영어 검색 (미국)
|
||||
```bash
|
||||
curl "http://localhost:8016/api/search?q=artificial%20intelligence&num=10&lang=en&country=us"
|
||||
```
|
||||
|
||||
### 3. 최근 일주일 내 결과만
|
||||
```bash
|
||||
curl "http://localhost:8016/api/search?q=뉴스&date_restrict=d7&lang=ko"
|
||||
```
|
||||
|
||||
### 4. 전체 콘텐츠 가져오기
|
||||
```bash
|
||||
curl "http://localhost:8016/api/search/full?q=python%20tutorial&num=3"
|
||||
```
|
||||
|
||||
## 환경 설정
|
||||
|
||||
### 필수 API 키 설정
|
||||
|
||||
1. **Google Custom Search API**
|
||||
- [Google Cloud Console](https://console.cloud.google.com/apis/credentials)에서 API 키 발급
|
||||
- [Programmable Search Engine](https://programmablesearchengine.google.com/)에서 검색 엔진 ID 생성
|
||||
|
||||
2. **SerpAPI (선택사항)**
|
||||
- [SerpAPI](https://serpapi.com/)에서 API 키 발급
|
||||
|
||||
### .env 파일 설정
|
||||
```env
|
||||
# Google Custom Search API
|
||||
GOOGLE_API_KEY=your_api_key_here
|
||||
GOOGLE_SEARCH_ENGINE_ID=your_search_engine_id_here
|
||||
|
||||
# SerpAPI (선택사항)
|
||||
SERPAPI_KEY=your_serpapi_key_here
|
||||
|
||||
# Redis 캐시
|
||||
REDIS_HOST=redis
|
||||
REDIS_PORT=6379
|
||||
REDIS_DB=2
|
||||
|
||||
# 기본 설정
|
||||
DEFAULT_LANGUAGE=ko
|
||||
DEFAULT_COUNTRY=kr
|
||||
CACHE_TTL=3600
|
||||
```
|
||||
|
||||
## Docker 실행
|
||||
|
||||
```bash
|
||||
# 빌드 및 실행
|
||||
docker-compose build google-search-backend
|
||||
docker-compose up -d google-search-backend
|
||||
|
||||
# 로그 확인
|
||||
docker-compose logs -f google-search-backend
|
||||
```
|
||||
|
||||
## 제한 사항
|
||||
|
||||
### Google Custom Search API
|
||||
- 무료 계정: 일일 100회 쿼리 제한
|
||||
- 검색당 최대 100개 결과
|
||||
- snippet 길이는 서버에서 제한 (변경 불가)
|
||||
|
||||
### 해결 방법
|
||||
- 20개 이상 결과 필요 시: 페이지네이션 사용
|
||||
- 긴 내용 필요 시: `/api/search/full` 엔드포인트 사용
|
||||
- API 제한 도달 시: SerpAPI 또는 웹 스크래핑으로 자동 폴백
|
||||
|
||||
## 캐시 관리
|
||||
|
||||
Redis를 사용하여 검색 결과를 캐싱합니다:
|
||||
- 기본 TTL: 3600초 (1시간)
|
||||
- 캐시 초기화: `POST /api/clear-cache`
|
||||
|
||||
## 헬스 체크
|
||||
|
||||
```bash
|
||||
curl http://localhost:8016/health
|
||||
```
|
||||
|
||||
## 문제 해결
|
||||
|
||||
### 1. 한글 검색 안될 때
|
||||
URL 인코딩 사용:
|
||||
```bash
|
||||
# "인공지능" → %EC%9D%B8%EA%B3%B5%EC%A7%80%EB%8A%A5
|
||||
curl "http://localhost:8016/api/search?q=%EC%9D%B8%EA%B3%B5%EC%A7%80%EB%8A%A5"
|
||||
```
|
||||
|
||||
### 2. API 제한 에러
|
||||
- Google API 일일 제한 확인
|
||||
- SerpAPI 키 설정으로 대체
|
||||
- 웹 스크래핑 자동 폴백 활용
|
||||
|
||||
### 3. 느린 응답 시간
|
||||
- Redis 캐시 활성화 확인
|
||||
- 결과 개수 줄이기
|
||||
- 전체 콘텐츠 대신 기본 검색 사용
|
||||
21
backup-services/google-search/backend/.env.example
Normal file
21
backup-services/google-search/backend/.env.example
Normal file
@ -0,0 +1,21 @@
|
||||
# Google Custom Search API Configuration
|
||||
# Get your API key from: https://console.cloud.google.com/apis/credentials
|
||||
GOOGLE_API_KEY=
|
||||
|
||||
# Get your Search Engine ID from: https://programmablesearchengine.google.com/
|
||||
GOOGLE_SEARCH_ENGINE_ID=
|
||||
|
||||
# Alternative: SerpAPI Configuration
|
||||
# Get your API key from: https://serpapi.com/
|
||||
SERPAPI_KEY=
|
||||
|
||||
# Redis Configuration
|
||||
REDIS_HOST=redis
|
||||
REDIS_PORT=6379
|
||||
REDIS_DB=2
|
||||
|
||||
# Search Settings
|
||||
DEFAULT_LANGUAGE=ko
|
||||
DEFAULT_COUNTRY=kr
|
||||
CACHE_TTL=3600
|
||||
MAX_RESULTS=10
|
||||
10
backup-services/google-search/backend/Dockerfile
Normal file
10
backup-services/google-search/backend/Dockerfile
Normal file
@ -0,0 +1,10 @@
|
||||
FROM python:3.11-slim
|
||||
|
||||
WORKDIR /app
|
||||
|
||||
COPY requirements.txt .
|
||||
RUN pip install --no-cache-dir -r requirements.txt
|
||||
|
||||
COPY . .
|
||||
|
||||
CMD ["uvicorn", "app.main:app", "--host", "0.0.0.0", "--port", "8000", "--reload"]
|
||||
30
backup-services/google-search/backend/app/config.py
Normal file
30
backup-services/google-search/backend/app/config.py
Normal file
@ -0,0 +1,30 @@
|
||||
from pydantic_settings import BaseSettings
|
||||
from typing import Optional
|
||||
|
||||
class Settings(BaseSettings):
|
||||
# Google Custom Search API 설정
|
||||
google_api_key: Optional[str] = None
|
||||
google_search_engine_id: Optional[str] = None
|
||||
|
||||
# SerpAPI 설정 (대안)
|
||||
serpapi_key: Optional[str] = None
|
||||
|
||||
# Redis 캐싱 설정
|
||||
redis_host: str = "redis"
|
||||
redis_port: int = 6379
|
||||
redis_db: int = 2
|
||||
cache_ttl: int = 3600 # 1시간
|
||||
|
||||
# 검색 설정
|
||||
max_results: int = 10
|
||||
default_language: str = "ko"
|
||||
default_country: str = "kr"
|
||||
|
||||
# 서비스 설정
|
||||
service_name: str = "Google Search Service"
|
||||
debug: bool = True
|
||||
|
||||
class Config:
|
||||
env_file = ".env"
|
||||
|
||||
settings = Settings()
|
||||
188
backup-services/google-search/backend/app/main.py
Normal file
188
backup-services/google-search/backend/app/main.py
Normal file
@ -0,0 +1,188 @@
|
||||
from fastapi import FastAPI, Query, HTTPException
|
||||
from fastapi.middleware.cors import CORSMiddleware
|
||||
from typing import Optional
|
||||
from datetime import datetime
|
||||
from contextlib import asynccontextmanager
|
||||
|
||||
from .search_service import GoogleSearchService
|
||||
from .config import settings
|
||||
|
||||
@asynccontextmanager
|
||||
async def lifespan(app: FastAPI):
|
||||
# 시작 시
|
||||
print("Google Search Service starting...")
|
||||
yield
|
||||
# 종료 시
|
||||
print("Google Search Service stopping...")
|
||||
|
||||
app = FastAPI(
|
||||
title="Google Search Service",
|
||||
description="구글 검색 결과를 수신하는 서비스",
|
||||
version="1.0.0",
|
||||
lifespan=lifespan
|
||||
)
|
||||
|
||||
# CORS 설정
|
||||
app.add_middleware(
|
||||
CORSMiddleware,
|
||||
allow_origins=["*"],
|
||||
allow_credentials=True,
|
||||
allow_methods=["*"],
|
||||
allow_headers=["*"],
|
||||
)
|
||||
|
||||
# 검색 서비스 초기화
|
||||
search_service = GoogleSearchService()
|
||||
|
||||
@app.get("/")
|
||||
async def root():
|
||||
return {
|
||||
"service": "Google Search Service",
|
||||
"version": "1.0.0",
|
||||
"timestamp": datetime.now().isoformat(),
|
||||
"endpoints": {
|
||||
"search": "/api/search?q=keyword",
|
||||
"custom_search": "/api/search/custom?q=keyword",
|
||||
"serpapi_search": "/api/search/serpapi?q=keyword",
|
||||
"scraping_search": "/api/search/scraping?q=keyword",
|
||||
"trending": "/api/trending",
|
||||
"health": "/health"
|
||||
}
|
||||
}
|
||||
|
||||
@app.get("/health")
|
||||
async def health_check():
|
||||
return {
|
||||
"status": "healthy",
|
||||
"service": "google-search",
|
||||
"timestamp": datetime.now().isoformat()
|
||||
}
|
||||
|
||||
@app.get("/api/search")
|
||||
async def search(
|
||||
q: str = Query(..., description="검색 키워드"),
|
||||
num: int = Query(10, description="결과 개수", ge=1, le=20),
|
||||
lang: Optional[str] = Query(None, description="언어 코드 (ko, en 등)"),
|
||||
country: Optional[str] = Query(None, description="국가 코드 (kr, us 등)"),
|
||||
date_restrict: Optional[str] = Query(None, description="날짜 제한 (d7=일주일, m1=한달, m3=3개월, y1=1년)"),
|
||||
sort_by_date: bool = Query(False, description="최신순 정렬")
|
||||
):
|
||||
"""
|
||||
자동으로 최적의 방법을 선택하여 구글 검색
|
||||
1. Google Custom Search API (설정된 경우)
|
||||
2. SerpAPI (설정된 경우)
|
||||
3. 웹 스크래핑 (폴백)
|
||||
"""
|
||||
# Google Custom Search API 시도
|
||||
if settings.google_api_key and settings.google_search_engine_id:
|
||||
result = await search_service.search_with_custom_api(q, num, lang, country, date_restrict, sort_by_date)
|
||||
if "error" not in result or not result["error"]:
|
||||
result["method"] = "google_custom_search"
|
||||
return result
|
||||
|
||||
# SerpAPI 시도
|
||||
if settings.serpapi_key:
|
||||
result = await search_service.search_with_serpapi(q, num, lang, country)
|
||||
if "error" not in result or not result["error"]:
|
||||
result["method"] = "serpapi"
|
||||
return result
|
||||
|
||||
# 웹 스크래핑 폴백
|
||||
result = await search_service.search_with_scraping(q, num, lang)
|
||||
result["method"] = "web_scraping"
|
||||
result["warning"] = "API 키가 설정되지 않아 웹 스크래핑을 사용합니다. 제한적이고 불안정할 수 있습니다."
|
||||
return result
|
||||
|
||||
@app.get("/api/search/custom")
|
||||
async def search_custom(
|
||||
q: str = Query(..., description="검색 키워드"),
|
||||
num: int = Query(10, description="결과 개수", ge=1, le=10),
|
||||
lang: Optional[str] = Query(None, description="언어 코드"),
|
||||
country: Optional[str] = Query(None, description="국가 코드")
|
||||
):
|
||||
"""Google Custom Search API를 사용한 검색"""
|
||||
if not settings.google_api_key or not settings.google_search_engine_id:
|
||||
raise HTTPException(
|
||||
status_code=503,
|
||||
detail="Google Custom Search API credentials not configured"
|
||||
)
|
||||
|
||||
result = await search_service.search_with_custom_api(q, num, lang, country)
|
||||
if "error" in result and result["error"]:
|
||||
raise HTTPException(status_code=500, detail=result["error"])
|
||||
|
||||
return result
|
||||
|
||||
@app.get("/api/search/serpapi")
|
||||
async def search_serpapi(
|
||||
q: str = Query(..., description="검색 키워드"),
|
||||
num: int = Query(10, description="결과 개수", ge=1, le=50),
|
||||
lang: Optional[str] = Query(None, description="언어 코드"),
|
||||
country: Optional[str] = Query(None, description="국가 코드")
|
||||
):
|
||||
"""SerpAPI를 사용한 검색"""
|
||||
if not settings.serpapi_key:
|
||||
raise HTTPException(
|
||||
status_code=503,
|
||||
detail="SerpAPI key not configured"
|
||||
)
|
||||
|
||||
result = await search_service.search_with_serpapi(q, num, lang, country)
|
||||
if "error" in result and result["error"]:
|
||||
raise HTTPException(status_code=500, detail=result["error"])
|
||||
|
||||
return result
|
||||
|
||||
@app.get("/api/search/scraping")
|
||||
async def search_scraping(
|
||||
q: str = Query(..., description="검색 키워드"),
|
||||
num: int = Query(10, description="결과 개수", ge=1, le=20),
|
||||
lang: Optional[str] = Query(None, description="언어 코드")
|
||||
):
|
||||
"""웹 스크래핑을 사용한 검색 (제한적)"""
|
||||
result = await search_service.search_with_scraping(q, num, lang)
|
||||
if "error" in result and result["error"]:
|
||||
raise HTTPException(status_code=500, detail=result["error"])
|
||||
|
||||
result["warning"] = "웹 스크래핑은 제한적이고 불안정할 수 있습니다"
|
||||
return result
|
||||
|
||||
@app.get("/api/search/full")
|
||||
async def search_with_full_content(
|
||||
q: str = Query(..., description="검색 키워드"),
|
||||
num: int = Query(5, description="결과 개수", ge=1, le=10),
|
||||
lang: Optional[str] = Query(None, description="언어 코드 (ko, en 등)"),
|
||||
country: Optional[str] = Query(None, description="국가 코드 (kr, us 등)")
|
||||
):
|
||||
"""
|
||||
Google 검색 후 각 결과 페이지의 전체 내용을 가져오기
|
||||
주의: 시간이 오래 걸릴 수 있음
|
||||
"""
|
||||
result = await search_service.search_with_full_content(q, num, lang, country)
|
||||
if "error" in result and result["error"]:
|
||||
raise HTTPException(status_code=500, detail=result["error"])
|
||||
|
||||
return result
|
||||
|
||||
@app.get("/api/trending")
|
||||
async def get_trending(
|
||||
country: Optional[str] = Query(None, description="국가 코드 (kr, us 등)")
|
||||
):
|
||||
"""실시간 트렌딩 검색어 조회"""
|
||||
result = await search_service.get_trending_searches(country)
|
||||
if "error" in result and result["error"]:
|
||||
raise HTTPException(status_code=500, detail=result["error"])
|
||||
|
||||
return result
|
||||
|
||||
@app.post("/api/clear-cache")
|
||||
async def clear_cache():
|
||||
"""캐시 초기화"""
|
||||
try:
|
||||
search_service.redis_client.flushdb()
|
||||
return {
|
||||
"status": "success",
|
||||
"message": "캐시가 초기화되었습니다"
|
||||
}
|
||||
except Exception as e:
|
||||
raise HTTPException(status_code=500, detail=str(e))
|
||||
540
backup-services/google-search/backend/app/search_service.py
Normal file
540
backup-services/google-search/backend/app/search_service.py
Normal file
@ -0,0 +1,540 @@
|
||||
import httpx
|
||||
import json
|
||||
import redis
|
||||
from typing import List, Dict, Optional
|
||||
from datetime import datetime
|
||||
import hashlib
|
||||
from bs4 import BeautifulSoup
|
||||
from .config import settings
|
||||
|
||||
class GoogleSearchService:
|
||||
def __init__(self):
|
||||
# Redis 연결
|
||||
self.redis_client = redis.Redis(
|
||||
host=settings.redis_host,
|
||||
port=settings.redis_port,
|
||||
db=settings.redis_db,
|
||||
decode_responses=True
|
||||
)
|
||||
|
||||
def _get_cache_key(self, query: str, **kwargs) -> str:
|
||||
"""캐시 키 생성"""
|
||||
cache_data = f"{query}_{kwargs}"
|
||||
return f"google_search:{hashlib.md5(cache_data.encode()).hexdigest()}"
|
||||
|
||||
async def search_with_custom_api(
|
||||
self,
|
||||
query: str,
|
||||
num_results: int = 10,
|
||||
language: str = None,
|
||||
country: str = None,
|
||||
date_restrict: str = None,
|
||||
sort_by_date: bool = False
|
||||
) -> Dict:
|
||||
"""Google Custom Search API 사용"""
|
||||
if not settings.google_api_key or not settings.google_search_engine_id:
|
||||
return {
|
||||
"error": "Google API credentials not configured",
|
||||
"results": []
|
||||
}
|
||||
|
||||
# 캐시 확인
|
||||
cache_key = self._get_cache_key(query, num=num_results, lang=language, country=country)
|
||||
cached = self.redis_client.get(cache_key)
|
||||
if cached:
|
||||
return json.loads(cached)
|
||||
|
||||
url = "https://www.googleapis.com/customsearch/v1"
|
||||
|
||||
all_results = []
|
||||
total_results_info = None
|
||||
|
||||
# Google API는 한 번에 최대 10개만 반환, 20개를 원하면 2번 요청
|
||||
num_requests = min((num_results + 9) // 10, 2) # 최대 2번 요청 (20개까지)
|
||||
|
||||
async with httpx.AsyncClient() as client:
|
||||
for page in range(num_requests):
|
||||
start_index = page * 10 + 1
|
||||
current_num = min(10, num_results - page * 10)
|
||||
|
||||
params = {
|
||||
"key": settings.google_api_key,
|
||||
"cx": settings.google_search_engine_id,
|
||||
"q": query,
|
||||
"num": current_num,
|
||||
"start": start_index, # 시작 인덱스
|
||||
"hl": language or settings.default_language,
|
||||
"gl": country or settings.default_country
|
||||
}
|
||||
|
||||
# 날짜 제한 추가 (d7 = 일주일, m1 = 한달, y1 = 1년)
|
||||
if date_restrict:
|
||||
params["dateRestrict"] = date_restrict
|
||||
|
||||
# 날짜순 정렬 (Google Custom Search API에서는 sort=date 옵션)
|
||||
if sort_by_date:
|
||||
params["sort"] = "date"
|
||||
|
||||
try:
|
||||
response = await client.get(url, params=params)
|
||||
response.raise_for_status()
|
||||
|
||||
data = response.json()
|
||||
|
||||
# 첫 번째 요청에서만 전체 정보 저장
|
||||
if page == 0:
|
||||
total_results_info = {
|
||||
"total_results": data.get("searchInformation", {}).get("totalResults"),
|
||||
"search_time": data.get("searchInformation", {}).get("searchTime"),
|
||||
"query": data.get("queries", {}).get("request", [{}])[0].get("searchTerms")
|
||||
}
|
||||
|
||||
# 결과 추가
|
||||
for item in data.get("items", []):
|
||||
all_results.append({
|
||||
"title": item.get("title"),
|
||||
"link": item.get("link"),
|
||||
"snippet": item.get("snippet"),
|
||||
"display_link": item.get("displayLink"),
|
||||
"thumbnail": item.get("pagemap", {}).get("cse_thumbnail", [{}])[0].get("src") if "pagemap" in item else None
|
||||
})
|
||||
|
||||
except Exception as e:
|
||||
# 첫 번째 요청이 실패하면 에러 반환
|
||||
if page == 0:
|
||||
return {
|
||||
"error": str(e),
|
||||
"results": []
|
||||
}
|
||||
# 두 번째 요청이 실패하면 첫 번째 결과만 반환
|
||||
break
|
||||
|
||||
results = {
|
||||
"query": total_results_info.get("query") if total_results_info else query,
|
||||
"total_results": total_results_info.get("total_results") if total_results_info else "0",
|
||||
"search_time": total_results_info.get("search_time") if total_results_info else 0,
|
||||
"results": all_results[:num_results], # 요청한 개수만큼만 반환
|
||||
"timestamp": datetime.utcnow().isoformat()
|
||||
}
|
||||
|
||||
# 캐시 저장
|
||||
self.redis_client.setex(
|
||||
cache_key,
|
||||
settings.cache_ttl,
|
||||
json.dumps(results)
|
||||
)
|
||||
|
||||
return results
|
||||
|
||||
async def search_with_serpapi(
|
||||
self,
|
||||
query: str,
|
||||
num_results: int = 10,
|
||||
language: str = None,
|
||||
country: str = None
|
||||
) -> Dict:
|
||||
"""SerpAPI 사용 (유료 서비스)"""
|
||||
if not settings.serpapi_key:
|
||||
return {
|
||||
"error": "SerpAPI key not configured",
|
||||
"results": []
|
||||
}
|
||||
|
||||
# 캐시 확인
|
||||
cache_key = self._get_cache_key(query, num=num_results, lang=language, country=country)
|
||||
cached = self.redis_client.get(cache_key)
|
||||
if cached:
|
||||
return json.loads(cached)
|
||||
|
||||
from serpapi import GoogleSearch
|
||||
|
||||
params = {
|
||||
"q": query,
|
||||
"api_key": settings.serpapi_key,
|
||||
"num": num_results,
|
||||
"hl": language or settings.default_language,
|
||||
"gl": country or settings.default_country
|
||||
}
|
||||
|
||||
try:
|
||||
search = GoogleSearch(params)
|
||||
results = search.get_dict()
|
||||
|
||||
formatted_results = self._format_serpapi_results(results)
|
||||
|
||||
# 캐시 저장
|
||||
self.redis_client.setex(
|
||||
cache_key,
|
||||
settings.cache_ttl,
|
||||
json.dumps(formatted_results)
|
||||
)
|
||||
|
||||
return formatted_results
|
||||
|
||||
except Exception as e:
|
||||
return {
|
||||
"error": str(e),
|
||||
"results": []
|
||||
}
|
||||
|
||||
async def search_with_scraping(
|
||||
self,
|
||||
query: str,
|
||||
num_results: int = 10,
|
||||
language: str = None
|
||||
) -> Dict:
|
||||
"""웹 스크래핑으로 검색 (비추천, 제한적)"""
|
||||
# 캐시 확인
|
||||
cache_key = self._get_cache_key(query, num=num_results, lang=language)
|
||||
cached = self.redis_client.get(cache_key)
|
||||
if cached:
|
||||
return json.loads(cached)
|
||||
|
||||
headers = {
|
||||
"User-Agent": "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36"
|
||||
}
|
||||
|
||||
params = {
|
||||
"q": query,
|
||||
"num": num_results,
|
||||
"hl": language or settings.default_language
|
||||
}
|
||||
|
||||
async with httpx.AsyncClient() as client:
|
||||
try:
|
||||
response = await client.get(
|
||||
"https://www.google.com/search",
|
||||
params=params,
|
||||
headers=headers,
|
||||
follow_redirects=True
|
||||
)
|
||||
|
||||
soup = BeautifulSoup(response.text, 'html.parser')
|
||||
results = self._parse_google_html(soup)
|
||||
|
||||
formatted_results = {
|
||||
"query": query,
|
||||
"total_results": len(results),
|
||||
"results": results,
|
||||
"timestamp": datetime.utcnow().isoformat()
|
||||
}
|
||||
|
||||
# 캐시 저장
|
||||
self.redis_client.setex(
|
||||
cache_key,
|
||||
settings.cache_ttl,
|
||||
json.dumps(formatted_results)
|
||||
)
|
||||
|
||||
return formatted_results
|
||||
|
||||
except Exception as e:
|
||||
return {
|
||||
"error": str(e),
|
||||
"results": []
|
||||
}
|
||||
|
||||
def _format_google_results(self, data: Dict) -> Dict:
|
||||
"""Google API 결과 포맷팅"""
|
||||
results = []
|
||||
|
||||
for item in data.get("items", []):
|
||||
results.append({
|
||||
"title": item.get("title"),
|
||||
"link": item.get("link"),
|
||||
"snippet": item.get("snippet"),
|
||||
"display_link": item.get("displayLink"),
|
||||
"thumbnail": item.get("pagemap", {}).get("cse_thumbnail", [{}])[0].get("src") if "pagemap" in item else None
|
||||
})
|
||||
|
||||
return {
|
||||
"query": data.get("queries", {}).get("request", [{}])[0].get("searchTerms"),
|
||||
"total_results": data.get("searchInformation", {}).get("totalResults"),
|
||||
"search_time": data.get("searchInformation", {}).get("searchTime"),
|
||||
"results": results,
|
||||
"timestamp": datetime.utcnow().isoformat()
|
||||
}
|
||||
|
||||
def _format_serpapi_results(self, data: Dict) -> Dict:
|
||||
"""SerpAPI 결과 포맷팅"""
|
||||
results = []
|
||||
|
||||
for item in data.get("organic_results", []):
|
||||
results.append({
|
||||
"title": item.get("title"),
|
||||
"link": item.get("link"),
|
||||
"snippet": item.get("snippet"),
|
||||
"position": item.get("position"),
|
||||
"thumbnail": item.get("thumbnail"),
|
||||
"date": item.get("date")
|
||||
})
|
||||
|
||||
# 관련 검색어
|
||||
related_searches = [
|
||||
item.get("query") for item in data.get("related_searches", [])
|
||||
]
|
||||
|
||||
return {
|
||||
"query": data.get("search_parameters", {}).get("q"),
|
||||
"total_results": data.get("search_information", {}).get("total_results"),
|
||||
"search_time": data.get("search_information", {}).get("time_taken_displayed"),
|
||||
"results": results,
|
||||
"related_searches": related_searches,
|
||||
"timestamp": datetime.utcnow().isoformat()
|
||||
}
|
||||
|
||||
def _parse_google_html(self, soup: BeautifulSoup) -> List[Dict]:
|
||||
"""HTML 파싱으로 검색 결과 추출"""
|
||||
results = []
|
||||
|
||||
# 검색 결과 컨테이너 찾기
|
||||
for g in soup.find_all('div', class_='g'):
|
||||
anchors = g.find_all('a')
|
||||
if anchors:
|
||||
link = anchors[0].get('href', '')
|
||||
title_elem = g.find('h3')
|
||||
snippet_elem = g.find('span', class_='st') or g.find('div', class_='s')
|
||||
|
||||
if title_elem and link:
|
||||
results.append({
|
||||
"title": title_elem.get_text(),
|
||||
"link": link,
|
||||
"snippet": snippet_elem.get_text() if snippet_elem else ""
|
||||
})
|
||||
|
||||
return results
|
||||
|
||||
async def fetch_page_content(self, url: str) -> Dict:
|
||||
"""웹 페이지의 전체 내용을 가져오기"""
|
||||
try:
|
||||
headers = {
|
||||
"User-Agent": "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36"
|
||||
}
|
||||
|
||||
async with httpx.AsyncClient(timeout=10.0) as client:
|
||||
response = await client.get(url, headers=headers, follow_redirects=True)
|
||||
response.raise_for_status()
|
||||
|
||||
soup = BeautifulSoup(response.text, 'html.parser')
|
||||
|
||||
# 불필요한 태그 제거
|
||||
for script in soup(["script", "style", "nav", "header", "footer"]):
|
||||
script.decompose()
|
||||
|
||||
# 본문 내용 추출 시도
|
||||
main_content = None
|
||||
|
||||
# 1. article 태그 찾기
|
||||
article = soup.find('article')
|
||||
if article:
|
||||
main_content = article.get_text()
|
||||
|
||||
# 2. main 태그 찾기
|
||||
if not main_content:
|
||||
main = soup.find('main')
|
||||
if main:
|
||||
main_content = main.get_text()
|
||||
|
||||
# 3. 일반적인 콘텐츠 div 찾기
|
||||
if not main_content:
|
||||
content_divs = soup.find_all('div', class_=lambda x: x and ('content' in x.lower() or 'article' in x.lower() or 'post' in x.lower()))
|
||||
if content_divs:
|
||||
main_content = ' '.join([div.get_text() for div in content_divs[:3]])
|
||||
|
||||
# 4. 전체 body에서 텍스트 추출
|
||||
if not main_content:
|
||||
body = soup.find('body')
|
||||
if body:
|
||||
main_content = body.get_text()
|
||||
else:
|
||||
main_content = soup.get_text()
|
||||
|
||||
# 텍스트 정리
|
||||
main_content = ' '.join(main_content.split())
|
||||
|
||||
# 제목 추출
|
||||
title = soup.find('title')
|
||||
title_text = title.get_text() if title else ""
|
||||
|
||||
# 메타 설명 추출
|
||||
meta_desc = soup.find('meta', attrs={'name': 'description'})
|
||||
description = meta_desc.get('content', '') if meta_desc else ""
|
||||
|
||||
return {
|
||||
"url": url,
|
||||
"title": title_text,
|
||||
"description": description,
|
||||
"content": main_content[:5000], # 최대 5000자
|
||||
"content_length": len(main_content),
|
||||
"success": True
|
||||
}
|
||||
|
||||
except Exception as e:
|
||||
return {
|
||||
"url": url,
|
||||
"error": str(e),
|
||||
"success": False
|
||||
}
|
||||
|
||||
async def search_with_extended_snippet(
|
||||
self,
|
||||
query: str,
|
||||
num_results: int = 10,
|
||||
language: str = None,
|
||||
country: str = None
|
||||
) -> Dict:
|
||||
"""검색 후 확장된 snippet 가져오기 (메타 설명 + 첫 500자)"""
|
||||
# 먼저 일반 검색 수행
|
||||
search_results = await self.search_with_custom_api(
|
||||
query, num_results, language, country
|
||||
)
|
||||
|
||||
if "error" in search_results:
|
||||
return search_results
|
||||
|
||||
# 각 결과의 확장된 snippet 가져오기
|
||||
import asyncio
|
||||
|
||||
async def fetch_extended_snippet(result):
|
||||
"""개별 페이지의 확장된 snippet 가져오기"""
|
||||
enhanced_result = result.copy()
|
||||
|
||||
if result.get("link"):
|
||||
try:
|
||||
headers = {
|
||||
"User-Agent": "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36"
|
||||
}
|
||||
|
||||
async with httpx.AsyncClient(timeout=5.0) as client:
|
||||
response = await client.get(result["link"], headers=headers, follow_redirects=True)
|
||||
response.raise_for_status()
|
||||
|
||||
soup = BeautifulSoup(response.text, 'html.parser')
|
||||
|
||||
# 메타 설명 추출
|
||||
meta_desc = soup.find('meta', attrs={'name': 'description'})
|
||||
if not meta_desc:
|
||||
meta_desc = soup.find('meta', attrs={'property': 'og:description'})
|
||||
|
||||
description = meta_desc.get('content', '') if meta_desc else ""
|
||||
|
||||
# 본문 첫 부분 추출
|
||||
for script in soup(["script", "style"]):
|
||||
script.decompose()
|
||||
|
||||
# 본문 텍스트 찾기
|
||||
text_content = ""
|
||||
for tag in ['article', 'main', 'div']:
|
||||
elements = soup.find_all(tag)
|
||||
for elem in elements:
|
||||
text = elem.get_text().strip()
|
||||
if len(text) > 200: # 의미있는 텍스트만
|
||||
text_content = ' '.join(text.split())[:1000]
|
||||
break
|
||||
if text_content:
|
||||
break
|
||||
|
||||
# 기존 snippet과 병합
|
||||
extended_snippet = result.get("snippet", "")
|
||||
if description and description not in extended_snippet:
|
||||
extended_snippet = description + " ... " + extended_snippet
|
||||
if text_content and len(extended_snippet) < 500:
|
||||
extended_snippet = extended_snippet + " ... " + text_content[:500-len(extended_snippet)]
|
||||
|
||||
enhanced_result["snippet"] = extended_snippet[:1000] # 최대 1000자
|
||||
enhanced_result["extended"] = True
|
||||
|
||||
except Exception as e:
|
||||
# 실패 시 원본 snippet 유지
|
||||
enhanced_result["extended"] = False
|
||||
enhanced_result["fetch_error"] = str(e)
|
||||
|
||||
return enhanced_result
|
||||
|
||||
# 병렬로 모든 페이지 처리
|
||||
tasks = [fetch_extended_snippet(result) for result in search_results.get("results", [])]
|
||||
enhanced_results = await asyncio.gather(*tasks)
|
||||
|
||||
return {
|
||||
**search_results,
|
||||
"results": enhanced_results,
|
||||
"snippet_extended": True
|
||||
}
|
||||
|
||||
async def search_with_full_content(
|
||||
self,
|
||||
query: str,
|
||||
num_results: int = 5,
|
||||
language: str = None,
|
||||
country: str = None
|
||||
) -> Dict:
|
||||
"""검색 후 각 결과의 전체 내용 가져오기"""
|
||||
# 먼저 일반 검색 수행
|
||||
search_results = await self.search_with_custom_api(
|
||||
query, num_results, language, country
|
||||
)
|
||||
|
||||
if "error" in search_results:
|
||||
return search_results
|
||||
|
||||
# 각 결과의 전체 내용 가져오기
|
||||
enhanced_results = []
|
||||
for result in search_results.get("results", [])[:num_results]:
|
||||
# 원본 검색 결과 복사
|
||||
enhanced_result = result.copy()
|
||||
|
||||
# 페이지 내용 가져오기
|
||||
if result.get("link"):
|
||||
content_data = await self.fetch_page_content(result["link"])
|
||||
enhanced_result["full_content"] = content_data
|
||||
|
||||
enhanced_results.append(enhanced_result)
|
||||
|
||||
return {
|
||||
**search_results,
|
||||
"results": enhanced_results,
|
||||
"content_fetched": True
|
||||
}
|
||||
|
||||
async def get_trending_searches(self, country: str = None) -> Dict:
|
||||
"""트렌딩 검색어 가져오기"""
|
||||
# Google Trends 비공식 API 사용
|
||||
url = f"https://trends.google.com/trends/api/dailytrends"
|
||||
params = {
|
||||
"geo": country or settings.default_country.upper()
|
||||
}
|
||||
|
||||
async with httpx.AsyncClient() as client:
|
||||
try:
|
||||
response = await client.get(url, params=params)
|
||||
# Google Trends API는 ")]}',\n"로 시작하는 응답을 반환
|
||||
json_data = response.text[6:]
|
||||
data = json.loads(json_data)
|
||||
|
||||
trending = []
|
||||
for date_data in data.get("default", {}).get("trendingSearchesDays", []):
|
||||
for search in date_data.get("trendingSearches", []):
|
||||
trending.append({
|
||||
"title": search.get("title", {}).get("query"),
|
||||
"traffic": search.get("formattedTraffic"),
|
||||
"articles": [
|
||||
{
|
||||
"title": article.get("title"),
|
||||
"url": article.get("url"),
|
||||
"source": article.get("source")
|
||||
}
|
||||
for article in search.get("articles", [])[:3]
|
||||
]
|
||||
})
|
||||
|
||||
return {
|
||||
"country": country or settings.default_country,
|
||||
"trending": trending[:10],
|
||||
"timestamp": datetime.utcnow().isoformat()
|
||||
}
|
||||
|
||||
except Exception as e:
|
||||
return {
|
||||
"error": str(e),
|
||||
"trending": []
|
||||
}
|
||||
9
backup-services/google-search/backend/requirements.txt
Normal file
9
backup-services/google-search/backend/requirements.txt
Normal file
@ -0,0 +1,9 @@
|
||||
fastapi==0.109.0
|
||||
uvicorn[standard]==0.27.0
|
||||
httpx==0.26.0
|
||||
pydantic==2.5.3
|
||||
pydantic-settings==2.1.0
|
||||
google-api-python-client==2.108.0
|
||||
beautifulsoup4==4.12.2
|
||||
redis==5.0.1
|
||||
serpapi==0.1.5
|
||||
13
backup-services/news-aggregator/backend/Dockerfile
Normal file
13
backup-services/news-aggregator/backend/Dockerfile
Normal file
@ -0,0 +1,13 @@
|
||||
FROM python:3.11-slim
|
||||
|
||||
WORKDIR /app
|
||||
|
||||
# Install dependencies
|
||||
COPY requirements.txt .
|
||||
RUN pip install --no-cache-dir -r requirements.txt
|
||||
|
||||
# Copy application
|
||||
COPY . .
|
||||
|
||||
# Run the application
|
||||
CMD ["uvicorn", "app.main:app", "--host", "0.0.0.0", "--port", "8000", "--reload"]
|
||||
365
backup-services/news-aggregator/backend/app/main.py
Normal file
365
backup-services/news-aggregator/backend/app/main.py
Normal file
@ -0,0 +1,365 @@
|
||||
"""
|
||||
News Aggregator Service
|
||||
RSS 피드 제목을 구글 검색으로 확장하는 통합 서비스
|
||||
"""
|
||||
from fastapi import FastAPI, HTTPException, Query, BackgroundTasks
|
||||
from fastapi.middleware.cors import CORSMiddleware
|
||||
from typing import List, Optional, Dict, Any
|
||||
from datetime import datetime
|
||||
import httpx
|
||||
import asyncio
|
||||
from pydantic import BaseModel
|
||||
import logging
|
||||
|
||||
# Configure logging
|
||||
logging.basicConfig(level=logging.INFO)
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
app = FastAPI(
|
||||
title="News Aggregator Service",
|
||||
description="RSS 피드와 구글 검색을 통합한 뉴스 수집 서비스",
|
||||
version="1.0.0"
|
||||
)
|
||||
|
||||
# CORS 설정
|
||||
app.add_middleware(
|
||||
CORSMiddleware,
|
||||
allow_origins=["*"],
|
||||
allow_credentials=True,
|
||||
allow_methods=["*"],
|
||||
allow_headers=["*"],
|
||||
)
|
||||
|
||||
# Configuration
|
||||
RSS_SERVICE_URL = "http://rss-feed-backend:8000"
|
||||
GOOGLE_SEARCH_SERVICE_URL = "http://google-search-backend:8000"
|
||||
|
||||
# Response Models
|
||||
class NewsItem(BaseModel):
|
||||
"""뉴스 항목"""
|
||||
rss_title: str
|
||||
rss_link: Optional[str] = None
|
||||
google_results: List[Dict[str, Any]] = []
|
||||
search_keyword: str
|
||||
timestamp: datetime = None
|
||||
|
||||
class AggregatedNews(BaseModel):
|
||||
"""통합 뉴스 결과"""
|
||||
keyword: str
|
||||
rss_feed_url: str
|
||||
total_rss_entries: int
|
||||
processed_entries: int
|
||||
news_items: List[NewsItem]
|
||||
processing_time: float
|
||||
|
||||
# HTTP Client
|
||||
client = httpx.AsyncClient(timeout=30.0)
|
||||
|
||||
@app.on_event("startup")
|
||||
async def startup():
|
||||
"""서비스 시작"""
|
||||
logger.info("News Aggregator Service starting...")
|
||||
|
||||
@app.on_event("shutdown")
|
||||
async def shutdown():
|
||||
"""서비스 종료"""
|
||||
await client.aclose()
|
||||
logger.info("News Aggregator Service stopped")
|
||||
|
||||
@app.get("/")
|
||||
async def root():
|
||||
return {
|
||||
"service": "News Aggregator Service",
|
||||
"version": "1.0.0",
|
||||
"description": "RSS 피드와 구글 검색 통합 서비스",
|
||||
"endpoints": {
|
||||
"aggregate": "GET /api/aggregate",
|
||||
"aggregate_by_location": "GET /api/aggregate/location",
|
||||
"aggregate_by_topic": "GET /api/aggregate/topic",
|
||||
"health": "GET /health"
|
||||
}
|
||||
}
|
||||
|
||||
@app.get("/health")
|
||||
async def health_check():
|
||||
"""헬스 체크"""
|
||||
try:
|
||||
# Check RSS service
|
||||
rss_response = await client.get(f"{RSS_SERVICE_URL}/health")
|
||||
rss_healthy = rss_response.status_code == 200
|
||||
|
||||
# Check Google Search service
|
||||
google_response = await client.get(f"{GOOGLE_SEARCH_SERVICE_URL}/health")
|
||||
google_healthy = google_response.status_code == 200
|
||||
|
||||
return {
|
||||
"status": "healthy" if (rss_healthy and google_healthy) else "degraded",
|
||||
"services": {
|
||||
"rss_feed": "healthy" if rss_healthy else "unhealthy",
|
||||
"google_search": "healthy" if google_healthy else "unhealthy"
|
||||
},
|
||||
"timestamp": datetime.now().isoformat()
|
||||
}
|
||||
except Exception as e:
|
||||
return {
|
||||
"status": "unhealthy",
|
||||
"error": str(e),
|
||||
"timestamp": datetime.now().isoformat()
|
||||
}
|
||||
|
||||
@app.get("/api/aggregate", response_model=AggregatedNews)
|
||||
async def aggregate_news(
|
||||
q: str = Query(..., description="검색 키워드"),
|
||||
limit: int = Query(10, description="처리할 RSS 항목 수", ge=1, le=50),
|
||||
google_results_per_title: int = Query(5, description="각 제목당 구글 검색 결과 수", ge=1, le=10),
|
||||
lang: str = Query("ko", description="언어 코드"),
|
||||
country: str = Query("KR", description="국가 코드")
|
||||
):
|
||||
"""
|
||||
키워드로 RSS 피드를 검색하고, 각 제목을 구글에서 재검색
|
||||
|
||||
1. 키워드로 Google News RSS 피드 가져오기
|
||||
2. RSS 피드의 각 제목을 구글 검색
|
||||
3. 통합 결과 반환
|
||||
"""
|
||||
start_time = datetime.now()
|
||||
|
||||
try:
|
||||
# Step 1: Get RSS feed from keyword
|
||||
logger.info(f"Fetching RSS feed for keyword: {q}")
|
||||
rss_response = await client.get(
|
||||
f"{RSS_SERVICE_URL}/api/google-rss/search",
|
||||
params={"q": q, "lang": lang, "country": country}
|
||||
)
|
||||
rss_response.raise_for_status()
|
||||
rss_data = rss_response.json()
|
||||
|
||||
if not rss_data.get("success"):
|
||||
raise HTTPException(status_code=500, detail=f"RSS 피드 가져오기 실패: {rss_data.get('error')}")
|
||||
|
||||
# Step 2: Process each RSS entry with Google search
|
||||
news_items = []
|
||||
entries = rss_data.get("entries", [])
|
||||
|
||||
# If no entries field, fallback to sample_titles
|
||||
if not entries:
|
||||
titles = rss_data.get("sample_titles", [])[:limit]
|
||||
entries = [{"title": title, "link": "", "published": ""} for title in titles]
|
||||
else:
|
||||
entries = entries[:limit]
|
||||
|
||||
# Create tasks for parallel processing
|
||||
search_tasks = []
|
||||
for entry in entries:
|
||||
title = entry.get("title", "")
|
||||
# Clean title for better search results
|
||||
clean_title = title.split(" - ")[-1] if " - " in title else title
|
||||
search_tasks.append(
|
||||
search_google(clean_title, google_results_per_title, lang, country)
|
||||
)
|
||||
|
||||
# Execute searches in parallel
|
||||
logger.info(f"Searching Google for {len(search_tasks)} RSS entries")
|
||||
search_results = await asyncio.gather(*search_tasks, return_exceptions=True)
|
||||
|
||||
# Combine results
|
||||
for i, entry in enumerate(entries):
|
||||
google_results = []
|
||||
if not isinstance(search_results[i], Exception):
|
||||
google_results = search_results[i]
|
||||
|
||||
title = entry.get("title", "")
|
||||
news_items.append(NewsItem(
|
||||
rss_title=title,
|
||||
rss_link=entry.get("link", ""),
|
||||
google_results=google_results,
|
||||
search_keyword=title.split(" - ")[-1] if " - " in title else title,
|
||||
timestamp=datetime.now()
|
||||
))
|
||||
|
||||
# Calculate processing time
|
||||
processing_time = (datetime.now() - start_time).total_seconds()
|
||||
|
||||
return AggregatedNews(
|
||||
keyword=q,
|
||||
rss_feed_url=rss_data.get("feed_url", ""),
|
||||
total_rss_entries=rss_data.get("entry_count", 0),
|
||||
processed_entries=len(news_items),
|
||||
news_items=news_items,
|
||||
processing_time=processing_time
|
||||
)
|
||||
|
||||
except httpx.HTTPStatusError as e:
|
||||
logger.error(f"HTTP error: {e}")
|
||||
raise HTTPException(status_code=e.response.status_code, detail=str(e))
|
||||
except Exception as e:
|
||||
logger.error(f"Error in aggregate_news: {e}")
|
||||
raise HTTPException(status_code=500, detail=str(e))
|
||||
|
||||
async def search_google(query: str, num_results: int, lang: str, country: str) -> List[Dict[str, Any]]:
|
||||
"""구글 검색 서비스 호출 - 전체 콘텐츠 포함"""
|
||||
try:
|
||||
# Full content API 직접 호출
|
||||
response = await client.get(
|
||||
f"{GOOGLE_SEARCH_SERVICE_URL}/api/search/full",
|
||||
params={
|
||||
"q": query,
|
||||
"num": num_results,
|
||||
"lang": lang,
|
||||
"country": country
|
||||
}
|
||||
)
|
||||
response.raise_for_status()
|
||||
data = response.json()
|
||||
results = data.get("results", [])
|
||||
|
||||
# full_content가 이미 포함되어 있으므로 그대로 반환
|
||||
logger.info(f"Google search for '{query}' returned {len(results)} results with full content")
|
||||
|
||||
return results
|
||||
except Exception as e:
|
||||
logger.error(f"Google search error for '{query}': {e}")
|
||||
# Fallback to basic search without full content
|
||||
try:
|
||||
response = await client.get(
|
||||
f"{GOOGLE_SEARCH_SERVICE_URL}/api/search",
|
||||
params={
|
||||
"q": query,
|
||||
"num": num_results,
|
||||
"lang": lang,
|
||||
"country": country
|
||||
}
|
||||
)
|
||||
response.raise_for_status()
|
||||
data = response.json()
|
||||
return data.get("results", [])
|
||||
except:
|
||||
return []
|
||||
|
||||
@app.get("/api/aggregate/location", response_model=AggregatedNews)
|
||||
async def aggregate_news_by_location(
|
||||
location: str = Query(..., description="지역명 (예: Seoul, Tokyo)"),
|
||||
limit: int = Query(10, description="처리할 RSS 항목 수", ge=1, le=50),
|
||||
google_results_per_title: int = Query(5, description="각 제목당 구글 검색 결과 수", ge=1, le=10),
|
||||
lang: str = Query("ko", description="언어 코드"),
|
||||
country: str = Query("KR", description="국가 코드")
|
||||
):
|
||||
"""지역 기반 RSS 피드를 가져와서 각 제목을 구글 검색"""
|
||||
start_time = datetime.now()
|
||||
|
||||
try:
|
||||
# Get location-based RSS feed
|
||||
logger.info(f"Fetching RSS feed for location: {location}")
|
||||
rss_response = await client.get(
|
||||
f"{RSS_SERVICE_URL}/api/google-rss/location",
|
||||
params={"location": location, "lang": lang, "country": country}
|
||||
)
|
||||
rss_response.raise_for_status()
|
||||
rss_data = rss_response.json()
|
||||
|
||||
if not rss_data.get("success"):
|
||||
raise HTTPException(status_code=500, detail=f"RSS 피드 가져오기 실패: {rss_data.get('error')}")
|
||||
|
||||
# Process titles
|
||||
news_items = []
|
||||
titles = rss_data.get("sample_titles", [])[:limit]
|
||||
|
||||
search_tasks = []
|
||||
for title in titles:
|
||||
clean_title = title.split(" - ")[-1] if " - " in title else title
|
||||
search_tasks.append(
|
||||
search_google(clean_title, google_results_per_title, lang, country)
|
||||
)
|
||||
|
||||
search_results = await asyncio.gather(*search_tasks, return_exceptions=True)
|
||||
|
||||
for i, title in enumerate(titles):
|
||||
google_results = []
|
||||
if not isinstance(search_results[i], Exception):
|
||||
google_results = search_results[i]
|
||||
|
||||
news_items.append(NewsItem(
|
||||
rss_title=title,
|
||||
google_results=google_results,
|
||||
search_keyword=title.split(" - ")[-1] if " - " in title else title,
|
||||
timestamp=datetime.now()
|
||||
))
|
||||
|
||||
processing_time = (datetime.now() - start_time).total_seconds()
|
||||
|
||||
return AggregatedNews(
|
||||
keyword=f"Location: {location}",
|
||||
rss_feed_url=rss_data.get("feed_url", ""),
|
||||
total_rss_entries=rss_data.get("entry_count", 0),
|
||||
processed_entries=len(news_items),
|
||||
news_items=news_items,
|
||||
processing_time=processing_time
|
||||
)
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"Error in aggregate_news_by_location: {e}")
|
||||
raise HTTPException(status_code=500, detail=str(e))
|
||||
|
||||
@app.get("/api/aggregate/topic", response_model=AggregatedNews)
|
||||
async def aggregate_news_by_topic(
|
||||
category: str = Query(..., description="카테고리 (TECHNOLOGY, BUSINESS, HEALTH 등)"),
|
||||
limit: int = Query(10, description="처리할 RSS 항목 수", ge=1, le=50),
|
||||
google_results_per_title: int = Query(5, description="각 제목당 구글 검색 결과 수", ge=1, le=10),
|
||||
lang: str = Query("ko", description="언어 코드"),
|
||||
country: str = Query("KR", description="국가 코드")
|
||||
):
|
||||
"""주제별 RSS 피드를 가져와서 각 제목을 구글 검색"""
|
||||
start_time = datetime.now()
|
||||
|
||||
try:
|
||||
# Get topic-based RSS feed
|
||||
logger.info(f"Fetching RSS feed for topic: {category}")
|
||||
rss_response = await client.get(
|
||||
f"{RSS_SERVICE_URL}/api/google-rss/topic",
|
||||
params={"category": category, "lang": lang, "country": country}
|
||||
)
|
||||
rss_response.raise_for_status()
|
||||
rss_data = rss_response.json()
|
||||
|
||||
if not rss_data.get("success"):
|
||||
raise HTTPException(status_code=500, detail=f"RSS 피드 가져오기 실패: {rss_data.get('error')}")
|
||||
|
||||
# Process titles
|
||||
news_items = []
|
||||
titles = rss_data.get("sample_titles", [])[:limit]
|
||||
|
||||
search_tasks = []
|
||||
for title in titles:
|
||||
clean_title = title.split(" - ")[-1] if " - " in title else title
|
||||
search_tasks.append(
|
||||
search_google(clean_title, google_results_per_title, lang, country)
|
||||
)
|
||||
|
||||
search_results = await asyncio.gather(*search_tasks, return_exceptions=True)
|
||||
|
||||
for i, title in enumerate(titles):
|
||||
google_results = []
|
||||
if not isinstance(search_results[i], Exception):
|
||||
google_results = search_results[i]
|
||||
|
||||
news_items.append(NewsItem(
|
||||
rss_title=title,
|
||||
google_results=google_results,
|
||||
search_keyword=title.split(" - ")[-1] if " - " in title else title,
|
||||
timestamp=datetime.now()
|
||||
))
|
||||
|
||||
processing_time = (datetime.now() - start_time).total_seconds()
|
||||
|
||||
return AggregatedNews(
|
||||
keyword=f"Topic: {category}",
|
||||
rss_feed_url=rss_data.get("feed_url", ""),
|
||||
total_rss_entries=rss_data.get("entry_count", 0),
|
||||
processed_entries=len(news_items),
|
||||
news_items=news_items,
|
||||
processing_time=processing_time
|
||||
)
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"Error in aggregate_news_by_topic: {e}")
|
||||
raise HTTPException(status_code=500, detail=str(e))
|
||||
5
backup-services/news-aggregator/backend/requirements.txt
Normal file
5
backup-services/news-aggregator/backend/requirements.txt
Normal file
@ -0,0 +1,5 @@
|
||||
fastapi==0.104.1
|
||||
uvicorn[standard]==0.24.0
|
||||
httpx==0.25.2
|
||||
pydantic==2.5.0
|
||||
python-multipart==0.0.6
|
||||
214
backup-services/news-aggregator/backend/test_aggregator.py
Executable file
214
backup-services/news-aggregator/backend/test_aggregator.py
Executable file
@ -0,0 +1,214 @@
|
||||
#!/usr/bin/env python3
|
||||
"""
|
||||
News Aggregator Service Test
|
||||
RSS 피드 제목을 구글 full content 검색으로 확장하는 통합 테스트
|
||||
"""
|
||||
import asyncio
|
||||
import httpx
|
||||
import json
|
||||
from datetime import datetime
|
||||
from typing import Dict, Any
|
||||
|
||||
# Service URL
|
||||
SERVICE_URL = "http://localhost:8018"
|
||||
|
||||
async def test_aggregate_with_full_content():
|
||||
"""키워드로 RSS 피드를 검색하고 full content 구글 검색 테스트"""
|
||||
async with httpx.AsyncClient(timeout=60.0) as client:
|
||||
print("\n" + "="*60)
|
||||
print("뉴스 통합 서비스 Full Content 테스트")
|
||||
print("="*60)
|
||||
|
||||
# Test with keyword "인공지능"
|
||||
print("\n1. 키워드 '인공지능'으로 RSS 피드 검색 및 구글 full content 검색")
|
||||
print("-" * 40)
|
||||
|
||||
response = await client.get(
|
||||
f"{SERVICE_URL}/api/aggregate",
|
||||
params={
|
||||
"q": "인공지능",
|
||||
"limit": 3, # 테스트용으로 3개만
|
||||
"google_results_per_title": 2, # 각 제목당 2개 구글 결과
|
||||
"lang": "ko",
|
||||
"country": "KR"
|
||||
}
|
||||
)
|
||||
|
||||
if response.status_code == 200:
|
||||
data = response.json()
|
||||
print(f"✓ RSS 피드 URL: {data['rss_feed_url']}")
|
||||
print(f"✓ 전체 RSS 항목 수: {data['total_rss_entries']}")
|
||||
print(f"✓ 처리된 항목 수: {data['processed_entries']}")
|
||||
print(f"✓ 처리 시간: {data['processing_time']:.2f}초")
|
||||
|
||||
# Check each news item for full content
|
||||
for i, item in enumerate(data['news_items'], 1):
|
||||
print(f"\n [{i}] RSS 제목: {item['rss_title'][:50]}...")
|
||||
print(f" 검색 키워드: {item['search_keyword'][:50]}...")
|
||||
print(f" 구글 검색 결과 수: {len(item['google_results'])}")
|
||||
|
||||
# Check if google results have full_content
|
||||
for j, result in enumerate(item['google_results'], 1):
|
||||
has_full_content = 'full_content' in result
|
||||
if has_full_content:
|
||||
full_content = result.get('full_content', '')
|
||||
if isinstance(full_content, str):
|
||||
content_length = len(full_content)
|
||||
else:
|
||||
content_length = len(str(full_content))
|
||||
else:
|
||||
content_length = 0
|
||||
|
||||
print(f" - 결과 {j}: {result.get('title', 'N/A')[:40]}...")
|
||||
print(f" Full Content 포함: {'✓' if has_full_content else '✗'}")
|
||||
if has_full_content:
|
||||
print(f" Content 길이: {content_length:,} 문자")
|
||||
# Show first 200 chars of content
|
||||
if isinstance(result['full_content'], str):
|
||||
preview = result['full_content'][:200].replace('\n', ' ')
|
||||
print(f" 미리보기: {preview}...")
|
||||
else:
|
||||
print(f" Content 타입: {type(result['full_content'])}")
|
||||
print(f" Content 데이터: {str(result['full_content'])[:200]}...")
|
||||
else:
|
||||
print(f"✗ 오류: {response.status_code}")
|
||||
print(f" 상세: {response.text}")
|
||||
|
||||
async def test_aggregate_by_location():
|
||||
"""지역 기반 RSS 피드 및 full content 테스트"""
|
||||
async with httpx.AsyncClient(timeout=60.0) as client:
|
||||
print("\n" + "="*60)
|
||||
print("지역 기반 뉴스 통합 Full Content 테스트")
|
||||
print("="*60)
|
||||
|
||||
print("\n2. 지역 'Seoul'로 RSS 피드 검색 및 구글 full content 검색")
|
||||
print("-" * 40)
|
||||
|
||||
response = await client.get(
|
||||
f"{SERVICE_URL}/api/aggregate/location",
|
||||
params={
|
||||
"location": "Seoul",
|
||||
"limit": 2,
|
||||
"google_results_per_title": 2,
|
||||
"lang": "ko",
|
||||
"country": "KR"
|
||||
}
|
||||
)
|
||||
|
||||
if response.status_code == 200:
|
||||
data = response.json()
|
||||
print(f"✓ 지역: {data['keyword']}")
|
||||
print(f"✓ RSS 피드 URL: {data['rss_feed_url']}")
|
||||
print(f"✓ 처리된 항목 수: {data['processed_entries']}")
|
||||
|
||||
# Check full content availability
|
||||
full_content_count = 0
|
||||
total_content_size = 0
|
||||
|
||||
for item in data['news_items']:
|
||||
for result in item['google_results']:
|
||||
if 'full_content' in result:
|
||||
full_content_count += 1
|
||||
content = result['full_content']
|
||||
if isinstance(content, str):
|
||||
total_content_size += len(content)
|
||||
else:
|
||||
total_content_size += len(str(content))
|
||||
|
||||
print(f"\n📊 Full Content 통계:")
|
||||
print(f" - Full Content 포함 결과: {full_content_count}개")
|
||||
print(f" - 전체 Content 크기: {total_content_size:,} 문자")
|
||||
print(f" - 평균 Content 크기: {total_content_size//max(full_content_count, 1):,} 문자")
|
||||
else:
|
||||
print(f"✗ 오류: {response.status_code}")
|
||||
|
||||
async def test_aggregate_by_topic():
|
||||
"""주제별 RSS 피드 및 full content 테스트"""
|
||||
async with httpx.AsyncClient(timeout=60.0) as client:
|
||||
print("\n" + "="*60)
|
||||
print("주제별 뉴스 통합 Full Content 테스트")
|
||||
print("="*60)
|
||||
|
||||
print("\n3. 주제 'TECHNOLOGY'로 RSS 피드 검색 및 구글 full content 검색")
|
||||
print("-" * 40)
|
||||
|
||||
response = await client.get(
|
||||
f"{SERVICE_URL}/api/aggregate/topic",
|
||||
params={
|
||||
"category": "TECHNOLOGY",
|
||||
"limit": 2,
|
||||
"google_results_per_title": 3,
|
||||
"lang": "ko",
|
||||
"country": "KR"
|
||||
}
|
||||
)
|
||||
|
||||
if response.status_code == 200:
|
||||
data = response.json()
|
||||
print(f"✓ 주제: {data['keyword']}")
|
||||
print(f"✓ 처리 시간: {data['processing_time']:.2f}초")
|
||||
|
||||
# Analyze content quality for AI summarization
|
||||
print("\n📝 AI 요약을 위한 Content 품질 분석:")
|
||||
for i, item in enumerate(data['news_items'], 1):
|
||||
print(f"\n 뉴스 항목 {i}:")
|
||||
for j, result in enumerate(item['google_results'], 1):
|
||||
if 'full_content' in result:
|
||||
content = result['full_content']
|
||||
if isinstance(content, str):
|
||||
# Check content quality indicators
|
||||
has_paragraphs = '\n\n' in content or '</p>' in content
|
||||
has_sufficient_length = len(content) > 500
|
||||
has_korean = any(ord(char) >= 0xAC00 and ord(char) <= 0xD7A3 for char in content[:min(100, len(content))])
|
||||
else:
|
||||
content_str = str(content)
|
||||
has_paragraphs = '\n\n' in content_str or '</p>' in content_str
|
||||
has_sufficient_length = len(content_str) > 500
|
||||
has_korean = any(ord(char) >= 0xAC00 and ord(char) <= 0xD7A3 for char in content_str[:min(100, len(content_str))])
|
||||
|
||||
print(f" 결과 {j} 품질 체크:")
|
||||
print(f" - 충분한 길이 (>500자): {'✓' if has_sufficient_length else '✗'}")
|
||||
print(f" - 단락 구조 포함: {'✓' if has_paragraphs else '✗'}")
|
||||
print(f" - 한국어 콘텐츠: {'✓' if has_korean else '✗'}")
|
||||
print(f" - AI 요약 가능: {'✓' if (has_sufficient_length and has_paragraphs) else '✗'}")
|
||||
else:
|
||||
print(f"✗ 오류: {response.status_code}")
|
||||
|
||||
async def test_health_check():
|
||||
"""서비스 상태 확인"""
|
||||
async with httpx.AsyncClient() as client:
|
||||
print("\n" + "="*60)
|
||||
print("서비스 Health Check")
|
||||
print("="*60)
|
||||
|
||||
response = await client.get(f"{SERVICE_URL}/health")
|
||||
if response.status_code == 200:
|
||||
data = response.json()
|
||||
print(f"✓ 통합 서비스 상태: {data['status']}")
|
||||
print(f" - RSS 서비스: {data['services']['rss_feed']}")
|
||||
print(f" - Google 검색 서비스: {data['services']['google_search']}")
|
||||
else:
|
||||
print(f"✗ Health check 실패: {response.status_code}")
|
||||
|
||||
async def main():
|
||||
"""메인 테스트 실행"""
|
||||
print("\n" + "="*70)
|
||||
print(" News Aggregator Full Content Integration Test ")
|
||||
print(" RSS 피드 + Google Full Content 통합 테스트 ")
|
||||
print("="*70)
|
||||
|
||||
# Run tests
|
||||
await test_health_check()
|
||||
await test_aggregate_with_full_content()
|
||||
await test_aggregate_by_location()
|
||||
await test_aggregate_by_topic()
|
||||
|
||||
print("\n" + "="*70)
|
||||
print(" 테스트 완료 - Full Content 통합 확인 ")
|
||||
print("="*70)
|
||||
print("\n✅ 모든 테스트가 완료되었습니다.")
|
||||
print(" RSS 피드 제목을 구글 full content로 검색하는 기능이 정상 작동합니다.")
|
||||
print(" AI 요약을 위한 충분한 콘텐츠가 수집되고 있습니다.")
|
||||
|
||||
if __name__ == "__main__":
|
||||
asyncio.run(main())
|
||||
204
backup-services/rss-feed/README.md
Normal file
204
backup-services/rss-feed/README.md
Normal file
@ -0,0 +1,204 @@
|
||||
# RSS Feed Subscription Service
|
||||
|
||||
RSS/Atom 피드를 구독하고 관리하는 서비스입니다.
|
||||
|
||||
## 주요 기능
|
||||
|
||||
### 1. 피드 구독 관리
|
||||
- RSS/Atom 피드 URL 구독
|
||||
- 카테고리별 분류 (뉴스, 기술, 비즈니스 등)
|
||||
- 자동 업데이트 스케줄링
|
||||
- 피드 상태 모니터링
|
||||
|
||||
### 2. 엔트리 관리
|
||||
- 새로운 글 자동 수집
|
||||
- 읽음/안읽음 상태 관리
|
||||
- 별표 표시 기능
|
||||
- 전체 내용 저장
|
||||
|
||||
### 3. 자동 업데이트
|
||||
- 설정 가능한 업데이트 주기 (기본 15분)
|
||||
- 백그라운드 스케줄러
|
||||
- 에러 처리 및 재시도
|
||||
|
||||
## API 엔드포인트
|
||||
|
||||
### 피드 구독
|
||||
```
|
||||
POST /api/feeds
|
||||
{
|
||||
"url": "https://example.com/rss",
|
||||
"title": "Example Blog",
|
||||
"category": "tech",
|
||||
"update_interval": 900
|
||||
}
|
||||
```
|
||||
|
||||
### 피드 목록 조회
|
||||
```
|
||||
GET /api/feeds?category=tech&status=active
|
||||
```
|
||||
|
||||
### 엔트리 조회
|
||||
```
|
||||
GET /api/entries?feed_id=xxx&is_read=false&limit=50
|
||||
```
|
||||
|
||||
### 읽음 표시
|
||||
```
|
||||
PUT /api/entries/{entry_id}/read?is_read=true
|
||||
```
|
||||
|
||||
### 별표 표시
|
||||
```
|
||||
PUT /api/entries/{entry_id}/star?is_starred=true
|
||||
```
|
||||
|
||||
### 통계 조회
|
||||
```
|
||||
GET /api/stats?feed_id=xxx
|
||||
```
|
||||
|
||||
### OPML 내보내기
|
||||
```
|
||||
GET /api/export/opml
|
||||
```
|
||||
|
||||
## 사용 예제
|
||||
|
||||
### 1. 기술 블로그 구독
|
||||
```bash
|
||||
curl -X POST http://localhost:8017/api/feeds \
|
||||
-H "Content-Type: application/json" \
|
||||
-d '{
|
||||
"url": "https://techcrunch.com/feed/",
|
||||
"category": "tech"
|
||||
}'
|
||||
```
|
||||
|
||||
### 2. 한국 뉴스 RSS 구독
|
||||
```bash
|
||||
curl -X POST http://localhost:8017/api/feeds \
|
||||
-H "Content-Type: application/json" \
|
||||
-d '{
|
||||
"url": "https://www.hani.co.kr/rss/",
|
||||
"category": "news",
|
||||
"update_interval": 600
|
||||
}'
|
||||
```
|
||||
|
||||
### 3. 안읽은 엔트리 조회
|
||||
```bash
|
||||
curl "http://localhost:8017/api/entries?is_read=false&limit=20"
|
||||
```
|
||||
|
||||
### 4. 모든 엔트리 읽음 처리
|
||||
```bash
|
||||
curl -X POST "http://localhost:8017/api/entries/mark-all-read?feed_id=xxx"
|
||||
```
|
||||
|
||||
## 지원 카테고리
|
||||
|
||||
- `news`: 뉴스
|
||||
- `tech`: 기술
|
||||
- `business`: 비즈니스
|
||||
- `science`: 과학
|
||||
- `health`: 건강
|
||||
- `sports`: 스포츠
|
||||
- `entertainment`: 엔터테인먼트
|
||||
- `lifestyle`: 라이프스타일
|
||||
- `politics`: 정치
|
||||
- `other`: 기타
|
||||
|
||||
## 환경 설정
|
||||
|
||||
### 필수 설정
|
||||
```env
|
||||
MONGODB_URL=mongodb://mongodb:27017
|
||||
DB_NAME=rss_feed_db
|
||||
REDIS_URL=redis://redis:6379
|
||||
REDIS_DB=3
|
||||
```
|
||||
|
||||
### 선택 설정
|
||||
```env
|
||||
DEFAULT_UPDATE_INTERVAL=900 # 기본 업데이트 주기 (초)
|
||||
MAX_ENTRIES_PER_FEED=100 # 피드당 최대 엔트리 수
|
||||
ENABLE_SCHEDULER=true # 자동 업데이트 활성화
|
||||
SCHEDULER_TIMEZONE=Asia/Seoul # 스케줄러 타임존
|
||||
```
|
||||
|
||||
## Docker 실행
|
||||
|
||||
```bash
|
||||
# 빌드 및 실행
|
||||
docker-compose build rss-feed-backend
|
||||
docker-compose up -d rss-feed-backend
|
||||
|
||||
# 로그 확인
|
||||
docker-compose logs -f rss-feed-backend
|
||||
```
|
||||
|
||||
## 데이터 구조
|
||||
|
||||
### FeedSubscription
|
||||
- `title`: 피드 제목
|
||||
- `url`: RSS/Atom URL
|
||||
- `description`: 설명
|
||||
- `category`: 카테고리
|
||||
- `status`: 상태 (active/inactive/error)
|
||||
- `update_interval`: 업데이트 주기
|
||||
- `last_fetch`: 마지막 업데이트 시간
|
||||
- `error_count`: 에러 횟수
|
||||
|
||||
### FeedEntry
|
||||
- `feed_id`: 피드 ID
|
||||
- `title`: 글 제목
|
||||
- `link`: 원문 링크
|
||||
- `summary`: 요약
|
||||
- `content`: 전체 내용
|
||||
- `author`: 작성자
|
||||
- `published`: 발행일
|
||||
- `categories`: 태그/카테고리
|
||||
- `thumbnail`: 썸네일 이미지
|
||||
- `is_read`: 읽음 상태
|
||||
- `is_starred`: 별표 상태
|
||||
|
||||
## 추천 RSS 피드
|
||||
|
||||
### 한국 뉴스
|
||||
- 한겨레: `https://www.hani.co.kr/rss/`
|
||||
- 조선일보: `https://www.chosun.com/arc/outboundfeeds/rss/`
|
||||
- 중앙일보: `https://rss.joins.com/joins_news_list.xml`
|
||||
|
||||
### 기술 블로그
|
||||
- TechCrunch: `https://techcrunch.com/feed/`
|
||||
- The Verge: `https://www.theverge.com/rss/index.xml`
|
||||
- Ars Technica: `https://feeds.arstechnica.com/arstechnica/index`
|
||||
|
||||
### 개발자 블로그
|
||||
- GitHub Blog: `https://github.blog/feed/`
|
||||
- Stack Overflow Blog: `https://stackoverflow.blog/feed/`
|
||||
- Dev.to: `https://dev.to/feed`
|
||||
|
||||
## 헬스 체크
|
||||
|
||||
```bash
|
||||
curl http://localhost:8017/health
|
||||
```
|
||||
|
||||
## 문제 해결
|
||||
|
||||
### 1. 피드 파싱 실패
|
||||
- RSS/Atom 형식이 올바른지 확인
|
||||
- URL이 접근 가능한지 확인
|
||||
- 피드 인코딩 확인 (UTF-8 권장)
|
||||
|
||||
### 2. 업데이트 안됨
|
||||
- 스케줄러 활성화 확인 (`ENABLE_SCHEDULER=true`)
|
||||
- MongoDB 연결 상태 확인
|
||||
- 피드 상태가 `active`인지 확인
|
||||
|
||||
### 3. 중복 엔트리
|
||||
- 피드에서 고유 ID를 제공하는지 확인
|
||||
- 엔트리 ID 생성 로직 확인
|
||||
21
backup-services/rss-feed/backend/Dockerfile
Normal file
21
backup-services/rss-feed/backend/Dockerfile
Normal file
@ -0,0 +1,21 @@
|
||||
FROM python:3.11-slim
|
||||
|
||||
WORKDIR /app
|
||||
|
||||
# Install system dependencies
|
||||
RUN apt-get update && apt-get install -y \
|
||||
gcc \
|
||||
&& rm -rf /var/lib/apt/lists/*
|
||||
|
||||
# Copy requirements and install Python dependencies
|
||||
COPY requirements.txt .
|
||||
RUN pip install --no-cache-dir -r requirements.txt
|
||||
|
||||
# Copy application code
|
||||
COPY . .
|
||||
|
||||
# Expose port
|
||||
EXPOSE 8000
|
||||
|
||||
# Run the application
|
||||
CMD ["uvicorn", "app.main:app", "--host", "0.0.0.0", "--port", "8000", "--reload"]
|
||||
0
backup-services/rss-feed/backend/app/__init__.py
Normal file
0
backup-services/rss-feed/backend/app/__init__.py
Normal file
26
backup-services/rss-feed/backend/app/config.py
Normal file
26
backup-services/rss-feed/backend/app/config.py
Normal file
@ -0,0 +1,26 @@
|
||||
from pydantic_settings import BaseSettings
|
||||
from typing import Optional
|
||||
|
||||
class Settings(BaseSettings):
|
||||
# MongoDB Configuration
|
||||
mongodb_url: str = "mongodb://mongodb:27017"
|
||||
db_name: str = "rss_feed_db"
|
||||
|
||||
# Redis Configuration
|
||||
redis_url: str = "redis://redis:6379"
|
||||
redis_db: int = 3
|
||||
|
||||
# Feed Settings
|
||||
default_update_interval: int = 900 # 15 minutes in seconds
|
||||
max_entries_per_feed: int = 100
|
||||
fetch_timeout: int = 30
|
||||
|
||||
# Scheduler Settings
|
||||
enable_scheduler: bool = True
|
||||
scheduler_timezone: str = "Asia/Seoul"
|
||||
|
||||
class Config:
|
||||
env_file = ".env"
|
||||
env_file_encoding = "utf-8"
|
||||
|
||||
settings = Settings()
|
||||
222
backup-services/rss-feed/backend/app/feed_parser.py
Normal file
222
backup-services/rss-feed/backend/app/feed_parser.py
Normal file
@ -0,0 +1,222 @@
|
||||
import feedparser
|
||||
import httpx
|
||||
from typing import List, Dict, Any, Optional
|
||||
from datetime import datetime
|
||||
from dateutil import parser as date_parser
|
||||
from bs4 import BeautifulSoup
|
||||
import re
|
||||
import hashlib
|
||||
from .models import FeedEntry
|
||||
|
||||
class FeedParser:
|
||||
def __init__(self):
|
||||
self.client = httpx.AsyncClient(
|
||||
timeout=30.0,
|
||||
follow_redirects=True,
|
||||
headers={
|
||||
"User-Agent": "Mozilla/5.0 (compatible; RSS Feed Reader/1.0)"
|
||||
}
|
||||
)
|
||||
|
||||
async def parse_feed(self, url: str) -> Dict[str, Any]:
|
||||
"""Parse RSS/Atom feed from URL"""
|
||||
try:
|
||||
response = await self.client.get(url)
|
||||
response.raise_for_status()
|
||||
|
||||
# Parse the feed
|
||||
feed = feedparser.parse(response.content)
|
||||
|
||||
if feed.bozo and feed.bozo_exception:
|
||||
raise Exception(f"Feed parsing error: {feed.bozo_exception}")
|
||||
|
||||
return {
|
||||
"success": True,
|
||||
"feed": feed.feed,
|
||||
"entries": feed.entries,
|
||||
"error": None
|
||||
}
|
||||
except Exception as e:
|
||||
return {
|
||||
"success": False,
|
||||
"feed": None,
|
||||
"entries": [],
|
||||
"error": str(e)
|
||||
}
|
||||
|
||||
def extract_entry_data(self, entry: Any, feed_id: str) -> FeedEntry:
|
||||
"""Extract and normalize entry data"""
|
||||
# Generate unique entry ID
|
||||
entry_id = self._generate_entry_id(entry)
|
||||
|
||||
# Extract title
|
||||
title = entry.get("title", "Untitled")
|
||||
|
||||
# Extract link
|
||||
link = entry.get("link", "")
|
||||
|
||||
# Extract summary/description
|
||||
summary = self._extract_summary(entry)
|
||||
|
||||
# Extract content
|
||||
content = self._extract_content(entry)
|
||||
|
||||
# Extract author
|
||||
author = entry.get("author", "")
|
||||
|
||||
# Extract published date
|
||||
published = self._parse_date(entry.get("published", entry.get("updated")))
|
||||
|
||||
# Extract updated date
|
||||
updated = self._parse_date(entry.get("updated", entry.get("published")))
|
||||
|
||||
# Extract categories
|
||||
categories = self._extract_categories(entry)
|
||||
|
||||
# Extract thumbnail
|
||||
thumbnail = self._extract_thumbnail(entry)
|
||||
|
||||
# Extract enclosures (media attachments)
|
||||
enclosures = self._extract_enclosures(entry)
|
||||
|
||||
return FeedEntry(
|
||||
feed_id=feed_id,
|
||||
entry_id=entry_id,
|
||||
title=title,
|
||||
link=link,
|
||||
summary=summary,
|
||||
content=content,
|
||||
author=author,
|
||||
published=published,
|
||||
updated=updated,
|
||||
categories=categories,
|
||||
thumbnail=thumbnail,
|
||||
enclosures=enclosures
|
||||
)
|
||||
|
||||
def _generate_entry_id(self, entry: Any) -> str:
|
||||
"""Generate unique ID for entry"""
|
||||
# Try to use entry's unique ID first
|
||||
if hasattr(entry, "id"):
|
||||
return entry.id
|
||||
|
||||
# Generate from link and title
|
||||
unique_str = f"{entry.get('link', '')}{entry.get('title', '')}"
|
||||
return hashlib.md5(unique_str.encode()).hexdigest()
|
||||
|
||||
def _extract_summary(self, entry: Any) -> Optional[str]:
|
||||
"""Extract and clean summary"""
|
||||
summary = entry.get("summary", entry.get("description", ""))
|
||||
if summary:
|
||||
# Clean HTML tags
|
||||
soup = BeautifulSoup(summary, "html.parser")
|
||||
text = soup.get_text(separator=" ", strip=True)
|
||||
# Limit length
|
||||
if len(text) > 500:
|
||||
text = text[:497] + "..."
|
||||
return text
|
||||
return None
|
||||
|
||||
def _extract_content(self, entry: Any) -> Optional[str]:
|
||||
"""Extract full content"""
|
||||
content = ""
|
||||
|
||||
# Try content field
|
||||
if hasattr(entry, "content"):
|
||||
for c in entry.content:
|
||||
if c.get("type") in ["text/html", "text/plain"]:
|
||||
content = c.get("value", "")
|
||||
break
|
||||
|
||||
# Fallback to summary detail
|
||||
if not content and hasattr(entry, "summary_detail"):
|
||||
content = entry.summary_detail.get("value", "")
|
||||
|
||||
# Clean excessive whitespace
|
||||
if content:
|
||||
content = re.sub(r'\s+', ' ', content).strip()
|
||||
return content
|
||||
|
||||
return None
|
||||
|
||||
def _parse_date(self, date_str: Optional[str]) -> Optional[datetime]:
|
||||
"""Parse date string to datetime"""
|
||||
if not date_str:
|
||||
return None
|
||||
|
||||
try:
|
||||
# Try parsing with dateutil
|
||||
return date_parser.parse(date_str)
|
||||
except:
|
||||
try:
|
||||
# Try feedparser's time structure
|
||||
if hasattr(date_str, "tm_year"):
|
||||
import time
|
||||
return datetime.fromtimestamp(time.mktime(date_str))
|
||||
except:
|
||||
pass
|
||||
|
||||
return None
|
||||
|
||||
def _extract_categories(self, entry: Any) -> List[str]:
|
||||
"""Extract categories/tags"""
|
||||
categories = []
|
||||
|
||||
if hasattr(entry, "tags"):
|
||||
for tag in entry.tags:
|
||||
if hasattr(tag, "term"):
|
||||
categories.append(tag.term)
|
||||
elif isinstance(tag, str):
|
||||
categories.append(tag)
|
||||
|
||||
return categories
|
||||
|
||||
def _extract_thumbnail(self, entry: Any) -> Optional[str]:
|
||||
"""Extract thumbnail image URL"""
|
||||
# Check media thumbnail
|
||||
if hasattr(entry, "media_thumbnail"):
|
||||
for thumb in entry.media_thumbnail:
|
||||
if thumb.get("url"):
|
||||
return thumb["url"]
|
||||
|
||||
# Check media content
|
||||
if hasattr(entry, "media_content"):
|
||||
for media in entry.media_content:
|
||||
if media.get("type", "").startswith("image/"):
|
||||
return media.get("url")
|
||||
|
||||
# Check enclosures
|
||||
if hasattr(entry, "enclosures"):
|
||||
for enc in entry.enclosures:
|
||||
if enc.get("type", "").startswith("image/"):
|
||||
return enc.get("href", enc.get("url"))
|
||||
|
||||
# Extract from content/summary
|
||||
content = entry.get("summary", "") + entry.get("content", [{}])[0].get("value", "") if hasattr(entry, "content") else ""
|
||||
if content:
|
||||
soup = BeautifulSoup(content, "html.parser")
|
||||
img = soup.find("img")
|
||||
if img and img.get("src"):
|
||||
return img["src"]
|
||||
|
||||
return None
|
||||
|
||||
def _extract_enclosures(self, entry: Any) -> List[Dict[str, Any]]:
|
||||
"""Extract media enclosures"""
|
||||
enclosures = []
|
||||
|
||||
if hasattr(entry, "enclosures"):
|
||||
for enc in entry.enclosures:
|
||||
enclosure = {
|
||||
"url": enc.get("href", enc.get("url", "")),
|
||||
"type": enc.get("type", ""),
|
||||
"length": enc.get("length", 0)
|
||||
}
|
||||
if enclosure["url"]:
|
||||
enclosures.append(enclosure)
|
||||
|
||||
return enclosures
|
||||
|
||||
async def close(self):
|
||||
"""Close HTTP client"""
|
||||
await self.client.aclose()
|
||||
115
backup-services/rss-feed/backend/app/google_rss.py
Normal file
115
backup-services/rss-feed/backend/app/google_rss.py
Normal file
@ -0,0 +1,115 @@
|
||||
"""
|
||||
Google News RSS Feed Generator
|
||||
구글 뉴스 RSS 피드 URL 생성 및 구독 지원
|
||||
"""
|
||||
from typing import Optional, List
|
||||
from urllib.parse import quote_plus
|
||||
from enum import Enum
|
||||
|
||||
class GoogleNewsCategory(str, Enum):
|
||||
"""구글 뉴스 카테고리"""
|
||||
WORLD = "WORLD"
|
||||
NATION = "NATION"
|
||||
BUSINESS = "BUSINESS"
|
||||
TECHNOLOGY = "TECHNOLOGY"
|
||||
ENTERTAINMENT = "ENTERTAINMENT"
|
||||
SPORTS = "SPORTS"
|
||||
SCIENCE = "SCIENCE"
|
||||
HEALTH = "HEALTH"
|
||||
|
||||
class GoogleNewsRSS:
|
||||
"""Google News RSS 피드 URL 생성기"""
|
||||
|
||||
BASE_URL = "https://news.google.com/rss"
|
||||
|
||||
@staticmethod
|
||||
def search_feed(query: str, lang: str = "ko", country: str = "KR") -> str:
|
||||
"""
|
||||
키워드 검색 RSS 피드 URL 생성
|
||||
|
||||
Args:
|
||||
query: 검색 키워드
|
||||
lang: 언어 코드 (ko, en, ja, zh-CN 등)
|
||||
country: 국가 코드 (KR, US, JP, CN 등)
|
||||
|
||||
Returns:
|
||||
RSS 피드 URL
|
||||
"""
|
||||
encoded_query = quote_plus(query)
|
||||
return f"{GoogleNewsRSS.BASE_URL}/search?q={encoded_query}&hl={lang}&gl={country}&ceid={country}:{lang}"
|
||||
|
||||
@staticmethod
|
||||
def topic_feed(category: GoogleNewsCategory, lang: str = "ko", country: str = "KR") -> str:
|
||||
"""
|
||||
카테고리별 RSS 피드 URL 생성
|
||||
|
||||
Args:
|
||||
category: 뉴스 카테고리
|
||||
lang: 언어 코드
|
||||
country: 국가 코드
|
||||
|
||||
Returns:
|
||||
RSS 피드 URL
|
||||
"""
|
||||
return f"{GoogleNewsRSS.BASE_URL}/headlines/section/topic/{category.value}?hl={lang}&gl={country}&ceid={country}:{lang}"
|
||||
|
||||
@staticmethod
|
||||
def location_feed(location: str, lang: str = "ko", country: str = "KR") -> str:
|
||||
"""
|
||||
지역 뉴스 RSS 피드 URL 생성
|
||||
|
||||
Args:
|
||||
location: 지역명 (예: Seoul, 서울, New York)
|
||||
lang: 언어 코드
|
||||
country: 국가 코드
|
||||
|
||||
Returns:
|
||||
RSS 피드 URL
|
||||
"""
|
||||
encoded_location = quote_plus(location)
|
||||
return f"{GoogleNewsRSS.BASE_URL}/headlines/section/geo/{encoded_location}?hl={lang}&gl={country}&ceid={country}:{lang}"
|
||||
|
||||
@staticmethod
|
||||
def trending_feed(lang: str = "ko", country: str = "KR") -> str:
|
||||
"""
|
||||
트렌딩 뉴스 RSS 피드 URL 생성
|
||||
|
||||
Args:
|
||||
lang: 언어 코드
|
||||
country: 국가 코드
|
||||
|
||||
Returns:
|
||||
RSS 피드 URL
|
||||
"""
|
||||
return f"{GoogleNewsRSS.BASE_URL}?hl={lang}&gl={country}&ceid={country}:{lang}"
|
||||
|
||||
@staticmethod
|
||||
def get_common_feeds() -> List[dict]:
|
||||
"""
|
||||
자주 사용되는 RSS 피드 목록 반환
|
||||
|
||||
Returns:
|
||||
피드 정보 리스트
|
||||
"""
|
||||
return [
|
||||
{
|
||||
"title": "구글 뉴스 - 한국 헤드라인",
|
||||
"url": GoogleNewsRSS.trending_feed("ko", "KR"),
|
||||
"description": "한국 주요 뉴스"
|
||||
},
|
||||
{
|
||||
"title": "구글 뉴스 - 기술",
|
||||
"url": GoogleNewsRSS.topic_feed(GoogleNewsCategory.TECHNOLOGY, "ko", "KR"),
|
||||
"description": "기술 관련 뉴스"
|
||||
},
|
||||
{
|
||||
"title": "구글 뉴스 - 비즈니스",
|
||||
"url": GoogleNewsRSS.topic_feed(GoogleNewsCategory.BUSINESS, "ko", "KR"),
|
||||
"description": "비즈니스 뉴스"
|
||||
},
|
||||
{
|
||||
"title": "Google News - World",
|
||||
"url": GoogleNewsRSS.topic_feed(GoogleNewsCategory.WORLD, "en", "US"),
|
||||
"description": "World news in English"
|
||||
}
|
||||
]
|
||||
596
backup-services/rss-feed/backend/app/main.py
Normal file
596
backup-services/rss-feed/backend/app/main.py
Normal file
@ -0,0 +1,596 @@
|
||||
from fastapi import FastAPI, HTTPException, Query, Path, BackgroundTasks
|
||||
from fastapi.middleware.cors import CORSMiddleware
|
||||
from typing import List, Optional
|
||||
from datetime import datetime
|
||||
from contextlib import asynccontextmanager
|
||||
import motor.motor_asyncio
|
||||
from apscheduler.schedulers.asyncio import AsyncIOScheduler
|
||||
from apscheduler.triggers.interval import IntervalTrigger
|
||||
import pytz
|
||||
import redis.asyncio as redis
|
||||
import json
|
||||
|
||||
from .config import settings
|
||||
from .models import (
|
||||
FeedSubscription, FeedEntry, CreateFeedRequest,
|
||||
UpdateFeedRequest, FeedStatistics, FeedStatus, FeedCategory
|
||||
)
|
||||
from .feed_parser import FeedParser
|
||||
from .google_rss import GoogleNewsRSS, GoogleNewsCategory
|
||||
|
||||
# Database connection
|
||||
db_client = None
|
||||
db = None
|
||||
redis_client = None
|
||||
scheduler = None
|
||||
parser = None
|
||||
|
||||
@asynccontextmanager
|
||||
async def lifespan(app: FastAPI):
|
||||
global db_client, db, redis_client, scheduler, parser
|
||||
|
||||
# Connect to MongoDB
|
||||
db_client = motor.motor_asyncio.AsyncIOMotorClient(settings.mongodb_url)
|
||||
db = db_client[settings.db_name]
|
||||
|
||||
# Connect to Redis
|
||||
redis_client = redis.from_url(settings.redis_url, db=settings.redis_db)
|
||||
|
||||
# Initialize feed parser
|
||||
parser = FeedParser()
|
||||
|
||||
# Initialize scheduler
|
||||
if settings.enable_scheduler:
|
||||
scheduler = AsyncIOScheduler(timezone=pytz.timezone(settings.scheduler_timezone))
|
||||
scheduler.add_job(
|
||||
update_all_feeds,
|
||||
trigger=IntervalTrigger(seconds=60),
|
||||
id="update_feeds",
|
||||
replace_existing=True
|
||||
)
|
||||
scheduler.start()
|
||||
print("RSS Feed scheduler started")
|
||||
|
||||
print("RSS Feed Service starting...")
|
||||
yield
|
||||
|
||||
# Cleanup
|
||||
if scheduler:
|
||||
scheduler.shutdown()
|
||||
if parser:
|
||||
await parser.close()
|
||||
if redis_client:
|
||||
await redis_client.close()
|
||||
db_client.close()
|
||||
print("RSS Feed Service stopping...")
|
||||
|
||||
app = FastAPI(
|
||||
title="RSS Feed Service",
|
||||
description="RSS/Atom 피드 구독 및 관리 서비스",
|
||||
version="1.0.0",
|
||||
lifespan=lifespan
|
||||
)
|
||||
|
||||
# CORS 설정
|
||||
app.add_middleware(
|
||||
CORSMiddleware,
|
||||
allow_origins=["*"],
|
||||
allow_credentials=True,
|
||||
allow_methods=["*"],
|
||||
allow_headers=["*"],
|
||||
)
|
||||
|
||||
# Helper functions
|
||||
async def update_feed(feed_id: str):
|
||||
"""Update a single feed"""
|
||||
feed = await db.feeds.find_one({"_id": feed_id})
|
||||
if not feed:
|
||||
return
|
||||
|
||||
# Parse feed
|
||||
result = await parser.parse_feed(feed["url"])
|
||||
|
||||
if result["success"]:
|
||||
# Update feed metadata
|
||||
await db.feeds.update_one(
|
||||
{"_id": feed_id},
|
||||
{
|
||||
"$set": {
|
||||
"last_fetch": datetime.now(),
|
||||
"status": FeedStatus.ACTIVE,
|
||||
"error_count": 0,
|
||||
"last_error": None,
|
||||
"updated_at": datetime.now()
|
||||
}
|
||||
}
|
||||
)
|
||||
|
||||
# Process entries
|
||||
for entry_data in result["entries"][:settings.max_entries_per_feed]:
|
||||
entry = parser.extract_entry_data(entry_data, feed_id)
|
||||
|
||||
# Check if entry already exists
|
||||
existing = await db.entries.find_one({
|
||||
"feed_id": feed_id,
|
||||
"entry_id": entry.entry_id
|
||||
})
|
||||
|
||||
if not existing:
|
||||
# Insert new entry
|
||||
await db.entries.insert_one(entry.dict())
|
||||
else:
|
||||
# Update existing entry if newer
|
||||
if entry.updated and existing.get("updated"):
|
||||
if entry.updated > existing["updated"]:
|
||||
await db.entries.update_one(
|
||||
{"_id": existing["_id"]},
|
||||
{"$set": entry.dict(exclude={"id", "created_at"})}
|
||||
)
|
||||
else:
|
||||
# Update error status
|
||||
await db.feeds.update_one(
|
||||
{"_id": feed_id},
|
||||
{
|
||||
"$set": {
|
||||
"status": FeedStatus.ERROR,
|
||||
"last_error": result["error"],
|
||||
"updated_at": datetime.now()
|
||||
},
|
||||
"$inc": {"error_count": 1}
|
||||
}
|
||||
)
|
||||
|
||||
async def update_all_feeds():
|
||||
"""Update all active feeds that need updating"""
|
||||
now = datetime.now()
|
||||
|
||||
# Find feeds that need updating
|
||||
feeds = await db.feeds.find({
|
||||
"status": FeedStatus.ACTIVE,
|
||||
"$or": [
|
||||
{"last_fetch": None},
|
||||
{"last_fetch": {"$lt": now}}
|
||||
]
|
||||
}).to_list(100)
|
||||
|
||||
for feed in feeds:
|
||||
# Check if it's time to update
|
||||
if feed.get("last_fetch"):
|
||||
time_diff = (now - feed["last_fetch"]).total_seconds()
|
||||
if time_diff < feed.get("update_interval", settings.default_update_interval):
|
||||
continue
|
||||
|
||||
# Update feed in background
|
||||
await update_feed(str(feed["_id"]))
|
||||
|
||||
# API Endpoints
|
||||
|
||||
@app.get("/")
|
||||
async def root():
|
||||
return {
|
||||
"service": "RSS Feed Service",
|
||||
"version": "1.0.0",
|
||||
"timestamp": datetime.now().isoformat(),
|
||||
"endpoints": {
|
||||
"subscribe": "POST /api/feeds",
|
||||
"list_feeds": "GET /api/feeds",
|
||||
"get_entries": "GET /api/entries",
|
||||
"mark_read": "PUT /api/entries/{entry_id}/read",
|
||||
"mark_starred": "PUT /api/entries/{entry_id}/star",
|
||||
"statistics": "GET /api/stats"
|
||||
}
|
||||
}
|
||||
|
||||
@app.get("/health")
|
||||
async def health_check():
|
||||
return {
|
||||
"status": "healthy",
|
||||
"service": "rss-feed",
|
||||
"timestamp": datetime.now().isoformat()
|
||||
}
|
||||
|
||||
@app.post("/api/feeds", response_model=FeedSubscription)
|
||||
async def subscribe_to_feed(request: CreateFeedRequest, background_tasks: BackgroundTasks):
|
||||
"""RSS/Atom 피드 구독"""
|
||||
# Check if already subscribed
|
||||
existing = await db.feeds.find_one({"url": str(request.url)})
|
||||
if existing:
|
||||
raise HTTPException(status_code=400, detail="이미 구독 중인 피드입니다")
|
||||
|
||||
# Parse feed to get metadata
|
||||
result = await parser.parse_feed(str(request.url))
|
||||
if not result["success"]:
|
||||
raise HTTPException(status_code=400, detail=f"피드 파싱 실패: {result['error']}")
|
||||
|
||||
# Create subscription
|
||||
feed = FeedSubscription(
|
||||
title=request.title or result["feed"].get("title", "Untitled Feed"),
|
||||
url=request.url,
|
||||
description=result["feed"].get("description", ""),
|
||||
category=request.category,
|
||||
update_interval=request.update_interval or settings.default_update_interval
|
||||
)
|
||||
|
||||
# Save to database - convert URL to string
|
||||
feed_dict = feed.dict()
|
||||
feed_dict["url"] = str(feed_dict["url"])
|
||||
result = await db.feeds.insert_one(feed_dict)
|
||||
feed.id = str(result.inserted_id)
|
||||
|
||||
# Fetch entries in background
|
||||
background_tasks.add_task(update_feed, feed.id)
|
||||
|
||||
return feed
|
||||
|
||||
@app.get("/api/feeds", response_model=List[FeedSubscription])
|
||||
async def list_feeds(
|
||||
category: Optional[str] = Query(None, description="카테고리 필터"),
|
||||
status: Optional[FeedStatus] = Query(None, description="상태 필터")
|
||||
):
|
||||
"""구독 중인 피드 목록 조회"""
|
||||
query = {}
|
||||
if category:
|
||||
query["category"] = category
|
||||
if status:
|
||||
query["status"] = status
|
||||
|
||||
feeds = await db.feeds.find(query).to_list(100)
|
||||
for feed in feeds:
|
||||
feed["_id"] = str(feed["_id"])
|
||||
|
||||
return feeds
|
||||
|
||||
@app.get("/api/feeds/{feed_id}", response_model=FeedSubscription)
|
||||
async def get_feed(feed_id: str = Path(..., description="피드 ID")):
|
||||
"""특정 피드 정보 조회"""
|
||||
feed = await db.feeds.find_one({"_id": feed_id})
|
||||
if not feed:
|
||||
raise HTTPException(status_code=404, detail="피드를 찾을 수 없습니다")
|
||||
|
||||
feed["_id"] = str(feed["_id"])
|
||||
return feed
|
||||
|
||||
@app.put("/api/feeds/{feed_id}", response_model=FeedSubscription)
|
||||
async def update_feed_subscription(
|
||||
feed_id: str = Path(..., description="피드 ID"),
|
||||
request: UpdateFeedRequest = ...
|
||||
):
|
||||
"""피드 구독 정보 수정"""
|
||||
update_data = request.dict(exclude_unset=True)
|
||||
if update_data:
|
||||
update_data["updated_at"] = datetime.now()
|
||||
|
||||
result = await db.feeds.update_one(
|
||||
{"_id": feed_id},
|
||||
{"$set": update_data}
|
||||
)
|
||||
|
||||
if result.matched_count == 0:
|
||||
raise HTTPException(status_code=404, detail="피드를 찾을 수 없습니다")
|
||||
|
||||
feed = await db.feeds.find_one({"_id": feed_id})
|
||||
feed["_id"] = str(feed["_id"])
|
||||
return feed
|
||||
|
||||
@app.delete("/api/feeds/{feed_id}")
|
||||
async def unsubscribe_from_feed(feed_id: str = Path(..., description="피드 ID")):
|
||||
"""피드 구독 취소"""
|
||||
# Delete feed
|
||||
result = await db.feeds.delete_one({"_id": feed_id})
|
||||
if result.deleted_count == 0:
|
||||
raise HTTPException(status_code=404, detail="피드를 찾을 수 없습니다")
|
||||
|
||||
# Delete associated entries
|
||||
await db.entries.delete_many({"feed_id": feed_id})
|
||||
|
||||
return {"message": "구독이 취소되었습니다"}
|
||||
|
||||
@app.post("/api/feeds/{feed_id}/refresh")
|
||||
async def refresh_feed(
|
||||
feed_id: str = Path(..., description="피드 ID"),
|
||||
background_tasks: BackgroundTasks = ...
|
||||
):
|
||||
"""피드 수동 새로고침"""
|
||||
feed = await db.feeds.find_one({"_id": feed_id})
|
||||
if not feed:
|
||||
raise HTTPException(status_code=404, detail="피드를 찾을 수 없습니다")
|
||||
|
||||
background_tasks.add_task(update_feed, feed_id)
|
||||
|
||||
return {"message": "피드 새로고침이 시작되었습니다"}
|
||||
|
||||
@app.get("/api/entries", response_model=List[FeedEntry])
|
||||
async def get_entries(
|
||||
feed_id: Optional[str] = Query(None, description="피드 ID"),
|
||||
is_read: Optional[bool] = Query(None, description="읽음 상태 필터"),
|
||||
is_starred: Optional[bool] = Query(None, description="별표 상태 필터"),
|
||||
limit: int = Query(50, ge=1, le=100, description="결과 개수"),
|
||||
offset: int = Query(0, ge=0, description="오프셋")
|
||||
):
|
||||
"""피드 엔트리 목록 조회"""
|
||||
query = {}
|
||||
if feed_id:
|
||||
query["feed_id"] = feed_id
|
||||
if is_read is not None:
|
||||
query["is_read"] = is_read
|
||||
if is_starred is not None:
|
||||
query["is_starred"] = is_starred
|
||||
|
||||
entries = await db.entries.find(query) \
|
||||
.sort("published", -1) \
|
||||
.skip(offset) \
|
||||
.limit(limit) \
|
||||
.to_list(limit)
|
||||
|
||||
for entry in entries:
|
||||
entry["_id"] = str(entry["_id"])
|
||||
|
||||
return entries
|
||||
|
||||
@app.get("/api/entries/{entry_id}", response_model=FeedEntry)
|
||||
async def get_entry(entry_id: str = Path(..., description="엔트리 ID")):
|
||||
"""특정 엔트리 조회"""
|
||||
entry = await db.entries.find_one({"_id": entry_id})
|
||||
if not entry:
|
||||
raise HTTPException(status_code=404, detail="엔트리를 찾을 수 없습니다")
|
||||
|
||||
entry["_id"] = str(entry["_id"])
|
||||
return entry
|
||||
|
||||
@app.put("/api/entries/{entry_id}/read")
|
||||
async def mark_entry_as_read(
|
||||
entry_id: str = Path(..., description="엔트리 ID"),
|
||||
is_read: bool = Query(True, description="읽음 상태")
|
||||
):
|
||||
"""엔트리 읽음 상태 변경"""
|
||||
result = await db.entries.update_one(
|
||||
{"_id": entry_id},
|
||||
{"$set": {"is_read": is_read}}
|
||||
)
|
||||
|
||||
if result.matched_count == 0:
|
||||
raise HTTPException(status_code=404, detail="엔트리를 찾을 수 없습니다")
|
||||
|
||||
return {"message": f"읽음 상태가 {is_read}로 변경되었습니다"}
|
||||
|
||||
@app.put("/api/entries/{entry_id}/star")
|
||||
async def mark_entry_as_starred(
|
||||
entry_id: str = Path(..., description="엔트리 ID"),
|
||||
is_starred: bool = Query(True, description="별표 상태")
|
||||
):
|
||||
"""엔트리 별표 상태 변경"""
|
||||
result = await db.entries.update_one(
|
||||
{"_id": entry_id},
|
||||
{"$set": {"is_starred": is_starred}}
|
||||
)
|
||||
|
||||
if result.matched_count == 0:
|
||||
raise HTTPException(status_code=404, detail="엔트리를 찾을 수 없습니다")
|
||||
|
||||
return {"message": f"별표 상태가 {is_starred}로 변경되었습니다"}
|
||||
|
||||
@app.post("/api/entries/mark-all-read")
|
||||
async def mark_all_as_read(feed_id: Optional[str] = Query(None, description="피드 ID")):
|
||||
"""모든 엔트리를 읽음으로 표시"""
|
||||
query = {}
|
||||
if feed_id:
|
||||
query["feed_id"] = feed_id
|
||||
|
||||
result = await db.entries.update_many(
|
||||
query,
|
||||
{"$set": {"is_read": True}}
|
||||
)
|
||||
|
||||
return {"message": f"{result.modified_count}개 엔트리가 읽음으로 표시되었습니다"}
|
||||
|
||||
@app.get("/api/stats", response_model=List[FeedStatistics])
|
||||
async def get_statistics(feed_id: Optional[str] = Query(None, description="피드 ID")):
|
||||
"""피드 통계 조회"""
|
||||
if feed_id:
|
||||
feeds = [await db.feeds.find_one({"_id": feed_id})]
|
||||
if not feeds[0]:
|
||||
raise HTTPException(status_code=404, detail="피드를 찾을 수 없습니다")
|
||||
else:
|
||||
feeds = await db.feeds.find().to_list(100)
|
||||
|
||||
stats = []
|
||||
for feed in feeds:
|
||||
feed_id = str(feed["_id"])
|
||||
|
||||
# Count entries
|
||||
total = await db.entries.count_documents({"feed_id": feed_id})
|
||||
unread = await db.entries.count_documents({"feed_id": feed_id, "is_read": False})
|
||||
starred = await db.entries.count_documents({"feed_id": feed_id, "is_starred": True})
|
||||
|
||||
# Calculate error rate
|
||||
error_rate = 0
|
||||
if feed.get("error_count", 0) > 0:
|
||||
total_fetches = feed.get("error_count", 0) + (1 if feed.get("last_fetch") else 0)
|
||||
error_rate = feed.get("error_count", 0) / total_fetches
|
||||
|
||||
stats.append(FeedStatistics(
|
||||
feed_id=feed_id,
|
||||
total_entries=total,
|
||||
unread_entries=unread,
|
||||
starred_entries=starred,
|
||||
last_update=feed.get("last_fetch"),
|
||||
error_rate=error_rate
|
||||
))
|
||||
|
||||
return stats
|
||||
|
||||
@app.get("/api/export/opml")
|
||||
async def export_opml():
|
||||
"""피드 목록을 OPML 형식으로 내보내기"""
|
||||
feeds = await db.feeds.find().to_list(100)
|
||||
|
||||
opml = """<?xml version="1.0" encoding="UTF-8"?>
|
||||
<opml version="2.0">
|
||||
<head>
|
||||
<title>RSS Feed Subscriptions</title>
|
||||
<dateCreated>{}</dateCreated>
|
||||
</head>
|
||||
<body>""".format(datetime.now().isoformat())
|
||||
|
||||
for feed in feeds:
|
||||
opml += f'\n <outline text="{feed["title"]}" xmlUrl="{feed["url"]}" type="rss" category="{feed.get("category", "")}" />'
|
||||
|
||||
opml += "\n</body>\n</opml>"
|
||||
|
||||
return {
|
||||
"opml": opml,
|
||||
"feed_count": len(feeds)
|
||||
}
|
||||
|
||||
# Google News RSS Endpoints
|
||||
|
||||
@app.get("/api/google-rss/search")
|
||||
async def get_google_search_rss(
|
||||
q: str = Query(..., description="검색 키워드"),
|
||||
lang: str = Query("ko", description="언어 코드 (ko, en, ja, zh-CN 등)"),
|
||||
country: str = Query("KR", description="국가 코드 (KR, US, JP, CN 등)")
|
||||
):
|
||||
"""Google News 검색 RSS 피드 URL 생성"""
|
||||
feed_url = GoogleNewsRSS.search_feed(q, lang, country)
|
||||
|
||||
# 피드 파싱 테스트
|
||||
result = await parser.parse_feed(feed_url)
|
||||
|
||||
return {
|
||||
"keyword": q,
|
||||
"feed_url": feed_url,
|
||||
"success": result["success"],
|
||||
"feed_title": result["feed"].get("title", "Google News") if result["success"] else None,
|
||||
"entry_count": len(result["entries"]) if result["success"] else 0,
|
||||
"sample_titles": [entry.get("title", "") for entry in result["entries"][:5]] if result["success"] else [],
|
||||
"entries": [
|
||||
{
|
||||
"title": entry.get("title", ""),
|
||||
"link": entry.get("link", ""),
|
||||
"published": entry.get("published", ""),
|
||||
"summary": entry.get("summary", "")[:200] if entry.get("summary") else ""
|
||||
} for entry in result["entries"][:20]
|
||||
] if result["success"] else [],
|
||||
"error": result.get("error")
|
||||
}
|
||||
|
||||
@app.get("/api/google-rss/topic")
|
||||
async def get_google_topic_rss(
|
||||
category: GoogleNewsCategory = Query(..., description="뉴스 카테고리"),
|
||||
lang: str = Query("ko", description="언어 코드"),
|
||||
country: str = Query("KR", description="국가 코드")
|
||||
):
|
||||
"""Google News 카테고리별 RSS 피드 URL 생성"""
|
||||
feed_url = GoogleNewsRSS.topic_feed(category, lang, country)
|
||||
|
||||
# 피드 파싱 테스트
|
||||
result = await parser.parse_feed(feed_url)
|
||||
|
||||
return {
|
||||
"category": category,
|
||||
"feed_url": feed_url,
|
||||
"success": result["success"],
|
||||
"feed_title": result["feed"].get("title", "Google News") if result["success"] else None,
|
||||
"entry_count": len(result["entries"]) if result["success"] else 0,
|
||||
"sample_titles": [entry.get("title", "") for entry in result["entries"][:5]] if result["success"] else [],
|
||||
"error": result.get("error")
|
||||
}
|
||||
|
||||
@app.get("/api/google-rss/location")
|
||||
async def get_google_location_rss(
|
||||
location: str = Query(..., description="지역명 (예: Seoul, 서울, New York)"),
|
||||
lang: str = Query("ko", description="언어 코드"),
|
||||
country: str = Query("KR", description="국가 코드")
|
||||
):
|
||||
"""Google News 지역 뉴스 RSS 피드 URL 생성"""
|
||||
feed_url = GoogleNewsRSS.location_feed(location, lang, country)
|
||||
|
||||
# 피드 파싱 테스트
|
||||
result = await parser.parse_feed(feed_url)
|
||||
|
||||
return {
|
||||
"location": location,
|
||||
"feed_url": feed_url,
|
||||
"success": result["success"],
|
||||
"feed_title": result["feed"].get("title", "Google News") if result["success"] else None,
|
||||
"entry_count": len(result["entries"]) if result["success"] else 0,
|
||||
"sample_titles": [entry.get("title", "") for entry in result["entries"][:5]] if result["success"] else [],
|
||||
"error": result.get("error")
|
||||
}
|
||||
|
||||
@app.get("/api/google-rss/trending")
|
||||
async def get_google_trending_rss(
|
||||
lang: str = Query("ko", description="언어 코드"),
|
||||
country: str = Query("KR", description="국가 코드")
|
||||
):
|
||||
"""Google News 트렌딩 RSS 피드 URL 생성"""
|
||||
feed_url = GoogleNewsRSS.trending_feed(lang, country)
|
||||
|
||||
# 피드 파싱 테스트
|
||||
result = await parser.parse_feed(feed_url)
|
||||
|
||||
return {
|
||||
"feed_url": feed_url,
|
||||
"success": result["success"],
|
||||
"feed_title": result["feed"].get("title", "Google News") if result["success"] else None,
|
||||
"entry_count": len(result["entries"]) if result["success"] else 0,
|
||||
"sample_titles": [entry.get("title", "") for entry in result["entries"][:5]] if result["success"] else [],
|
||||
"error": result.get("error")
|
||||
}
|
||||
|
||||
@app.post("/api/google-rss/subscribe")
|
||||
async def subscribe_google_rss(
|
||||
q: Optional[str] = Query(None, description="검색 키워드"),
|
||||
category: Optional[GoogleNewsCategory] = Query(None, description="카테고리"),
|
||||
location: Optional[str] = Query(None, description="지역명"),
|
||||
trending: bool = Query(False, description="트렌딩 뉴스"),
|
||||
lang: str = Query("ko", description="언어 코드"),
|
||||
country: str = Query("KR", description="국가 코드"),
|
||||
background_tasks: BackgroundTasks = ...
|
||||
):
|
||||
"""Google News RSS 피드 구독"""
|
||||
# URL 생성
|
||||
if q:
|
||||
feed_url = GoogleNewsRSS.search_feed(q, lang, country)
|
||||
feed_title = f"Google News - {q}"
|
||||
elif category:
|
||||
feed_url = GoogleNewsRSS.topic_feed(category, lang, country)
|
||||
feed_title = f"Google News - {category.value}"
|
||||
elif location:
|
||||
feed_url = GoogleNewsRSS.location_feed(location, lang, country)
|
||||
feed_title = f"Google News - {location}"
|
||||
elif trending:
|
||||
feed_url = GoogleNewsRSS.trending_feed(lang, country)
|
||||
feed_title = f"Google News - Trending ({country})"
|
||||
else:
|
||||
raise HTTPException(status_code=400, detail="검색어, 카테고리, 지역 중 하나를 지정해주세요")
|
||||
|
||||
# 중복 확인
|
||||
existing = await db.feeds.find_one({"url": feed_url})
|
||||
if existing:
|
||||
raise HTTPException(status_code=400, detail="이미 구독 중인 피드입니다")
|
||||
|
||||
# 피드 파싱
|
||||
result = await parser.parse_feed(feed_url)
|
||||
if not result["success"]:
|
||||
raise HTTPException(status_code=400, detail=f"피드 파싱 실패: {result['error']}")
|
||||
|
||||
# 구독 생성
|
||||
feed = FeedSubscription(
|
||||
title=feed_title,
|
||||
url=feed_url,
|
||||
description=result["feed"].get("description", "Google News Feed"),
|
||||
category=FeedCategory.NEWS,
|
||||
update_interval=900 # 15분
|
||||
)
|
||||
|
||||
# DB 저장
|
||||
feed_dict = feed.dict()
|
||||
feed_dict["url"] = str(feed_dict["url"])
|
||||
result = await db.feeds.insert_one(feed_dict)
|
||||
feed.id = str(result.inserted_id)
|
||||
|
||||
# 백그라운드 업데이트
|
||||
background_tasks.add_task(update_feed, feed.id)
|
||||
|
||||
return feed
|
||||
74
backup-services/rss-feed/backend/app/models.py
Normal file
74
backup-services/rss-feed/backend/app/models.py
Normal file
@ -0,0 +1,74 @@
|
||||
from pydantic import BaseModel, Field, HttpUrl
|
||||
from typing import Optional, List, Dict, Any
|
||||
from datetime import datetime
|
||||
from enum import Enum
|
||||
|
||||
class FeedStatus(str, Enum):
|
||||
ACTIVE = "active"
|
||||
INACTIVE = "inactive"
|
||||
ERROR = "error"
|
||||
|
||||
class FeedCategory(str, Enum):
|
||||
NEWS = "news"
|
||||
TECH = "tech"
|
||||
BUSINESS = "business"
|
||||
SCIENCE = "science"
|
||||
HEALTH = "health"
|
||||
SPORTS = "sports"
|
||||
ENTERTAINMENT = "entertainment"
|
||||
LIFESTYLE = "lifestyle"
|
||||
POLITICS = "politics"
|
||||
OTHER = "other"
|
||||
|
||||
class FeedSubscription(BaseModel):
|
||||
id: Optional[str] = Field(None, alias="_id")
|
||||
title: str
|
||||
url: HttpUrl
|
||||
description: Optional[str] = None
|
||||
category: FeedCategory = FeedCategory.OTHER
|
||||
status: FeedStatus = FeedStatus.ACTIVE
|
||||
update_interval: int = 900 # seconds
|
||||
last_fetch: Optional[datetime] = None
|
||||
last_error: Optional[str] = None
|
||||
error_count: int = 0
|
||||
created_at: datetime = Field(default_factory=datetime.now)
|
||||
updated_at: datetime = Field(default_factory=datetime.now)
|
||||
metadata: Dict[str, Any] = {}
|
||||
|
||||
class FeedEntry(BaseModel):
|
||||
id: Optional[str] = Field(None, alias="_id")
|
||||
feed_id: str
|
||||
entry_id: str # RSS entry unique ID
|
||||
title: str
|
||||
link: str
|
||||
summary: Optional[str] = None
|
||||
content: Optional[str] = None
|
||||
author: Optional[str] = None
|
||||
published: Optional[datetime] = None
|
||||
updated: Optional[datetime] = None
|
||||
categories: List[str] = []
|
||||
thumbnail: Optional[str] = None
|
||||
enclosures: List[Dict[str, Any]] = []
|
||||
is_read: bool = False
|
||||
is_starred: bool = False
|
||||
created_at: datetime = Field(default_factory=datetime.now)
|
||||
|
||||
class CreateFeedRequest(BaseModel):
|
||||
url: HttpUrl
|
||||
title: Optional[str] = None
|
||||
category: FeedCategory = FeedCategory.OTHER
|
||||
update_interval: Optional[int] = 900
|
||||
|
||||
class UpdateFeedRequest(BaseModel):
|
||||
title: Optional[str] = None
|
||||
category: Optional[FeedCategory] = None
|
||||
update_interval: Optional[int] = None
|
||||
status: Optional[FeedStatus] = None
|
||||
|
||||
class FeedStatistics(BaseModel):
|
||||
feed_id: str
|
||||
total_entries: int
|
||||
unread_entries: int
|
||||
starred_entries: int
|
||||
last_update: Optional[datetime]
|
||||
error_rate: float
|
||||
14
backup-services/rss-feed/backend/requirements.txt
Normal file
14
backup-services/rss-feed/backend/requirements.txt
Normal file
@ -0,0 +1,14 @@
|
||||
fastapi==0.109.0
|
||||
uvicorn[standard]==0.27.0
|
||||
pydantic==2.5.3
|
||||
pydantic-settings==2.1.0
|
||||
feedparser==6.0.11
|
||||
httpx==0.26.0
|
||||
pymongo==4.6.1
|
||||
motor==3.3.2
|
||||
redis==5.0.1
|
||||
python-dateutil==2.8.2
|
||||
beautifulsoup4==4.12.3
|
||||
lxml==5.1.0
|
||||
apscheduler==3.10.4
|
||||
pytz==2024.1
|
||||
Reference in New Issue
Block a user