c59dd304f7
端口: - 服务端口 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 配置文件
292 lines
9.0 KiB
Python
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, {'"': '"'})
|
|
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)}
|