"""RSS 源管理 API""" from typing import List, Optional from fastapi import APIRouter, Depends, HTTPException, BackgroundTasks from pydantic import BaseModel, HttpUrl from sqlalchemy.orm import Session from database import get_db from models import Feed from rss_fetcher import discover_feed_url, fetch_and_store_feed from scheduler import add_feed_job, remove_feed_job router = APIRouter(prefix="/feeds", tags=["feeds"]) class FeedCreate(BaseModel): url: str title: Optional[str] = "" description: Optional[str] = "" category: Optional[str] = "" is_active: Optional[bool] = True fetch_interval_minutes: Optional[int] = 60 class FeedUpdate(BaseModel): title: Optional[str] = None description: Optional[str] = None category: Optional[str] = None is_active: Optional[bool] = None fetch_interval_minutes: Optional[int] = None class FeedOut(BaseModel): id: int url: str title: str description: str category: str is_active: bool fetch_interval_minutes: int last_fetch_at: Optional[str] = None last_fetch_status: str success_count: int fail_count: int article_count: int health_status: str created_at: str class Config: from_attributes = True @router.get("", response_model=dict) def list_feeds( skip: int = 0, limit: int = 100, category: Optional[str] = None, search: Optional[str] = None, is_active: Optional[bool] = None, db: Session = Depends(get_db), ): """获取 RSS 源列表,支持分页、分类筛选、搜索""" query = db.query(Feed) if category: query = query.filter(Feed.category == category) if is_active is not None: query = query.filter(Feed.is_active == is_active) if search: query = query.filter( Feed.title.contains(search) | Feed.url.contains(search) | Feed.description.contains(search) ) total = query.count() feeds = query.order_by(Feed.created_at.desc()).offset(skip).limit(limit).all() results = [] for feed in feeds: data = { "id": feed.id, "url": feed.url, "title": feed.title or feed.url, "description": feed.description or "", "category": feed.category or "", "is_active": feed.is_active, "fetch_interval_minutes": feed.fetch_interval_minutes, "last_fetch_at": feed.last_fetch_at.isoformat() if feed.last_fetch_at else None, "last_fetch_status": feed.last_fetch_status, "success_count": feed.success_count, "fail_count": feed.fail_count, "article_count": feed.article_count, "health_status": feed.health_status(), "created_at": feed.created_at.isoformat(), } results.append(data) return {"total": total, "items": results} @router.get("/categories") def list_categories(db: Session = Depends(get_db)): """获取所有分类列表""" categories = db.query(Feed.category).filter(Feed.category != "").distinct().all() return [c[0] for c in categories if c[0]] @router.post("", response_model=dict) def create_feed( data: FeedCreate, background_tasks: BackgroundTasks, db: Session = Depends(get_db), ): """添加 RSS 源""" # 检查是否已存在 existing = db.query(Feed).filter(Feed.url == data.url).first() if existing: raise HTTPException(status_code=409, detail="该 RSS 源已存在") feed = Feed( url=data.url, title=data.title or "", description=data.description or "", category=data.category or "", is_active=data.is_active, fetch_interval_minutes=data.fetch_interval_minutes or 60, ) db.add(feed) db.commit() db.refresh(feed) # 注册定时任务 if feed.is_active: add_feed_job(feed.id, feed.fetch_interval_minutes) # 后台异步首次抓取,不阻塞 HTTP 响应 background_tasks.add_task(fetch_and_store_feed, feed.id) return {"id": feed.id, "message": "RSS 源添加成功,正在后台抓取", "url": feed.url} @router.post("/discover") def discover_feed(url: str, db: Session = Depends(get_db)): """从网页自动发现 RSS feed URL""" feed_urls = discover_feed_url(url) return {"source_url": url, "found_feeds": feed_urls} @router.get("/{feed_id}", response_model=dict) def get_feed(feed_id: int, db: Session = Depends(get_db)): """获取 RSS 源详情""" feed = db.query(Feed).filter(Feed.id == feed_id).first() if not feed: raise HTTPException(status_code=404, detail="RSS 源不存在") return { "id": feed.id, "url": feed.url, "title": feed.title or feed.url, "description": feed.description or "", "category": feed.category or "", "is_active": feed.is_active, "fetch_interval_minutes": feed.fetch_interval_minutes, "last_fetch_at": feed.last_fetch_at.isoformat() if feed.last_fetch_at else None, "last_fetch_status": feed.last_fetch_status, "last_error": feed.last_error, "success_count": feed.success_count, "fail_count": feed.fail_count, "article_count": feed.article_count, "health_status": feed.health_status(), "created_at": feed.created_at.isoformat(), } @router.put("/{feed_id}", response_model=dict) def update_feed(feed_id: int, data: FeedUpdate, db: Session = Depends(get_db)): """更新 RSS 源""" feed = db.query(Feed).filter(Feed.id == feed_id).first() if not feed: raise HTTPException(status_code=404, detail="RSS 源不存在") if data.title is not None: feed.title = data.title if data.description is not None: feed.description = data.description if data.category is not None: feed.category = data.category if data.is_active is not None: feed.is_active = data.is_active if feed.is_active: add_feed_job(feed.id, feed.fetch_interval_minutes) else: remove_feed_job(feed.id) if data.fetch_interval_minutes is not None: feed.fetch_interval_minutes = data.fetch_interval_minutes if feed.is_active: add_feed_job(feed.id, feed.fetch_interval_minutes) db.commit() return {"message": "RSS 源更新成功"} @router.delete("/{feed_id}") def delete_feed(feed_id: int, db: Session = Depends(get_db)): """删除 RSS 源(级联删除文章和日志)""" feed = db.query(Feed).filter(Feed.id == feed_id).first() if not feed: raise HTTPException(status_code=404, detail="RSS 源不存在") remove_feed_job(feed_id) db.delete(feed) db.commit() return {"message": "RSS 源已删除"} @router.post("/{feed_id}/fetch") def trigger_fetch(feed_id: int, db: Session = Depends(get_db)): """手动触发抓取""" feed = db.query(Feed).filter(Feed.id == feed_id).first() if not feed: raise HTTPException(status_code=404, detail="RSS 源不存在") result = fetch_and_store_feed(feed_id) return result class OpmlImport(BaseModel): opml_content: str @router.post("/import-opml") def import_opml(data: OpmlImport, db: Session = Depends(get_db)): """导入 OPML 文件内容""" import xml.etree.ElementTree as ET content = data.opml_content.strip() if not content: raise HTTPException(status_code=400, detail="OPML 内容不能为空") # 限制大小(防止滥用) if len(content) > 5_000_000: # 5MB raise HTTPException(status_code=413, detail="OPML 文件过大") try: root = ET.fromstring(content) except ET.ParseError: raise HTTPException(status_code=400, detail="无效的 OPML 文件") added = 0 skipped = 0 for outline in root.iter("outline"): url = outline.get("xmlUrl") or outline.get("xmlurl") if not url: continue existing = db.query(Feed).filter(Feed.url == url).first() if existing: skipped += 1 continue feed = Feed( url=url, title=outline.get("title", "") or outline.get("text", ""), description=outline.get("description", ""), category=outline.get("category", ""), is_active=True, fetch_interval_minutes=60, ) db.add(feed) db.commit() db.refresh(feed) add_feed_job(feed.id, feed.fetch_interval_minutes) added += 1 return {"added": added, "skipped": skipped, "message": f"成功导入 {added} 个 RSS 源"} @router.get("/export-opml") def export_opml(db: Session = Depends(get_db)): """导出 OPML 文件内容""" from xml.sax.saxutils import escape feeds = db.query(Feed).all() lines = ['', '', 'rssKeeper Feeds', ''] for feed in feeds: title = escape(feed.title or feed.url, {'"': '"'}) url = escape(feed.url) lines.append(f' ') lines.append('') lines.append('') return {"opml": "\n".join(lines)}