Files
rssKeeper/backend/routers/feeds.py
T
congsh c59dd304f7 fix: 端口更换 & 代码审核修复
端口:
- 服务端口 8000 → 7329
- 前端开发端口 5173 → 7330

安全:
- CORS 收紧为白名单,关闭 credentials
- SPA 路由白名单完善
- 前端 XSS 转义

可靠性:
- 时区统一为 datetime.now(timezone.utc)
- 文章入库改为内存去重 + 增量计数
- OPML 导入改为 body 参数接收
- OPML 导出 URL XML 转义
- 首次抓取改为 BackgroundTasks 异步
- articles.py HTTPException 移到顶部 import
- FTS5 异常显式日志
- FTS5 查询加引号包裹防布尔注入
- 中文摘要支持中文标点
- 去掉未使用的 hashlib import

部署:
- Dockerfile 锁 python:3.12.7-slim
- requirements 锁定具体版本
- healthcheck 不用 curl(镜像里没有)
- docker-compose 使用 .env 文件
- 新增 .env 配置文件
2026-06-11 14:31:29 +08:00

292 lines
9.0 KiB
Python

"""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 = ['<?xml version="1.0" encoding="UTF-8"?>', '<opml version="2.0">', '<head><title>rssKeeper Feeds</title></head>', '<body>']
for feed in feeds:
title = escape(feed.title or feed.url, {'"': '&quot;'})
url = escape(feed.url)
lines.append(f' <outline type="rss" text="{title}" xmlUrl="{url}" />')
lines.append('</body>')
lines.append('</opml>')
return {"opml": "\n".join(lines)}