feat: init rssKeeper - RSS 抓取、管理与检索系统

完整功能包括:
- FastAPI 后端 + SQLite + FTS5 全文搜索
- RSS 源管理、自动发现、OPML 导入导出
- 文章抓取、去重、分类、全文检索
- RSS 源健康度监控
- Vue 3 + Element Plus 暗色主题 Web UI
- 对外 REST API 供 AI 分析调用
- Docker + docker-compose 部署
This commit is contained in:
congsh
2026-06-11 14:03:36 +08:00
commit 54e7db0ef0
28 changed files with 2915 additions and 0 deletions
+273
View File
@@ -0,0 +1,273 @@
"""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)}