Files
rssKeeper/backend/routers/feeds.py
T
congsh 54e7db0ef0 feat: init rssKeeper - RSS 抓取、管理与检索系统
完整功能包括:
- FastAPI 后端 + SQLite + FTS5 全文搜索
- RSS 源管理、自动发现、OPML 导入导出
- 文章抓取、去重、分类、全文检索
- RSS 源健康度监控
- Vue 3 + Element Plus 暗色主题 Web UI
- 对外 REST API 供 AI 分析调用
- Docker + docker-compose 部署
2026-06-11 14:03:36 +08:00

274 lines
8.5 KiB
Python

"""RSS 源管理 API"""
from typing import List, Optional
from fastapi import APIRouter, Depends, HTTPException
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, 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)
# 立即抓取一次
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
@router.post("/import-opml")
def import_opml(opml_content: str, db: Session = Depends(get_db)):
"""导入 OPML 文件内容"""
import xml.etree.ElementTree as ET
try:
root = ET.fromstring(opml_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 文件内容"""
feeds = db.query(Feed).all()
lines = ['<?xml version="1.0" encoding="UTF-8"?>', '<opml version="2.0">', '<head><title>rssKeeper Feeds</title></head>', '<body>']
for feed in feeds:
title = (feed.title or feed.url).replace('"', '&quot;')
lines.append(f' <outline type="rss" text="{title}" xmlUrl="{feed.url}" />')
lines.append('</body>')
lines.append('</opml>')
return {"opml": "\n".join(lines)}