"""对外 API(供 AI/外部系统调用)""" from typing import Optional, List from datetime import datetime, timedelta from fastapi import APIRouter, Depends, Query from sqlalchemy.orm import Session from sqlalchemy import desc, or_ from database import get_db from models import Article, Feed from fulltext_search import search_articles router = APIRouter(tags=["external"]) @router.get("/recent") def get_recent_articles( hours: int = 24, limit: int = 50, feed_id: Optional[int] = None, category: Optional[str] = None, search: Optional[str] = None, unread_only: bool = False, db: Session = Depends(get_db), ): """获取最近 N 小时的文章 供 AI 分析的主要接口,支持多条件组合筛选 """ since = datetime.utcnow() - timedelta(hours=hours) query = db.query(Article, Feed.title.label("feed_title"), Feed.category.label("category")).join(Feed) query = query.filter(Article.created_at >= since) if feed_id: query = query.filter(Article.feed_id == feed_id) if category: query = query.filter(Feed.category == category) if search: query = query.filter( Article.title.contains(search) | Article.summary.contains(search) ) if unread_only: query = query.filter(Article.is_read == False) rows = query.order_by(desc(Article.published_at)).limit(limit).all() return { "query": { "hours": hours, "limit": limit, "feed_id": feed_id, "category": category, "search": search, "unread_only": unread_only, }, "count": len(rows), "articles": [ { "id": article.id, "title": article.title or "", "link": article.link, "author": article.author or "", "summary": article.summary or "", "content": article.content or "" if len(article.content or "") < 10000 else article.summary or "", "published_at": article.published_at.isoformat() if article.published_at else None, "created_at": article.created_at.isoformat(), "feed_title": feed_title or "", "category": category or "", } for article, feed_title, category in rows ], } @router.get("/search") def fulltext_search( q: str = Query(..., description="搜索关键词"), limit: int = Query(50, ge=1, le=200), offset: int = Query(0, ge=0), category: Optional[str] = Query(None, description="按分类筛选"), feed_id: Optional[int] = Query(None, description="按源筛选"), db: Session = Depends(get_db), ): """全文搜索文章(FTS5) 供 AI 按关键词检索文章内容 """ results, total = search_articles(q, limit, offset) # 二次过滤分类和源 if category or feed_id: filtered = [] for r in results: if category and r["category"] != category: continue if feed_id and r["feed_id"] != feed_id: continue filtered.append(r) results = filtered total = len(filtered) return { "query": q, "total": total, "offset": offset, "limit": limit, "articles": results, } @router.get("/feeds") def get_active_feeds( health_status: Optional[str] = Query(None, description="按健康度筛选: healthy/warning/unhealthy/unknown"), category: Optional[str] = Query(None, description="按分类筛选"), error_type: Optional[str] = Query(None, description="按错误类型筛选"), is_active: Optional[bool] = Query(None, description="按启用状态筛选"), db: Session = Depends(get_db), ): """获取 RSS 源列表(支持多条件筛选)""" query = db.query(Feed) if is_active is not None: query = query.filter(Feed.is_active == is_active) else: query = query.filter(Feed.is_active == True) if category: query = query.filter(Feed.category == category) feeds = query.all() results = [] for feed in feeds: status = feed.health_status() if health_status and status != health_status: continue if error_type and feed.error_type != error_type: continue results.append({ "id": feed.id, "title": feed.title or feed.url, "url": feed.url, "category": feed.category or "", "is_active": feed.is_active, "health_status": status, "error_type": feed.error_type, "article_count": feed.article_count, "last_fetch_at": feed.last_fetch_at.isoformat() if feed.last_fetch_at else None, "last_error": feed.last_error or "", }) return { "count": len(results), "feeds": results, } @router.get("/feeds/{feed_id}/articles") def get_feed_articles( feed_id: int, limit: int = 100, since: Optional[str] = None, search: Optional[str] = None, unread_only: bool = False, db: Session = Depends(get_db), ): """获取指定 RSS 源的文章""" feed = db.query(Feed).filter(Feed.id == feed_id).first() if not feed: return {"error": "Feed not found"} query = db.query(Article).filter(Article.feed_id == feed_id) if since: query = query.filter(Article.published_at >= since) if search: query = query.filter( Article.title.contains(search) | Article.summary.contains(search) ) if unread_only: query = query.filter(Article.is_read == False) articles = query.order_by(desc(Article.published_at)).limit(limit).all() return { "feed": { "id": feed.id, "title": feed.title or feed.url, "url": feed.url, }, "count": len(articles), "articles": [ { "id": article.id, "title": article.title or "", "link": article.link, "author": article.author or "", "summary": article.summary or "", "published_at": article.published_at.isoformat() if article.published_at else None, } for article in articles ], } @router.get("/summary") def get_daily_summary( date: Optional[str] = None, category: Optional[str] = None, db: Session = Depends(get_db), ): """获取指定日期的文章摘要统计 供 AI 快速了解某天的 RSS 内容概况 """ if date: try: day = datetime.strptime(date, "%Y-%m-%d") next_day = day + timedelta(days=1) except ValueError: return {"error": "Invalid date format, use YYYY-MM-DD"} else: day = datetime.utcnow().replace(hour=0, minute=0, second=0, microsecond=0) next_day = day + timedelta(days=1) query = db.query(Article, Feed.title.label("feed_title"), Feed.category.label("category")).join(Feed) query = query.filter(Article.created_at >= day, Article.created_at < next_day) if category: query = query.filter(Feed.category == category) rows = query.order_by(desc(Article.published_at)).all() by_category = {} for article, feed_title, cat in rows: c = cat or "未分类" if category and c != category: continue if c not in by_category: by_category[c] = [] by_category[c].append({ "title": article.title or "", "link": article.link, "feed": feed_title or "", "summary": article.summary or "", }) return { "date": day.strftime("%Y-%m-%d"), "total_articles": len(rows), "by_category": by_category, }