Files
rssKeeper/backend/routers/external_api.py
T
congsh 4286731348 feat: 代理支持、外部API增强、调度器修复、每日文章看板
- 添加 HTTP 代理支持(国内直连、外网走代理)
- 外部 API 新增全文搜索、源健康度/错误筛选、未读筛选
- 修复 APScheduler 线程静默崩溃(_safe_fetch 异常保护)
- 健康检查暴露调度器状态
- Dashboard 新增每日文章数柱状图(按 published_at)
- 文章列表 API 补上 content 字段,日期筛选修复时间范围
- 修复外部 API 双重 external 前缀
- User-Agent 改为 Chrome 标识缓解 403
- 添加完整 API 接口文档

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-06-12 09:58:32 +08:00

249 lines
7.7 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
"""对外 API(供 AI/外部系统调用)"""
from typing import Optional, List
from datetime import datetime, timedelta
from fastapi import APIRouter, Depends, Query
from sqlalchemy.orm import Session
from sqlalchemy import desc, or_
from database import get_db
from models import Article, Feed
from fulltext_search import search_articles
router = APIRouter(tags=["external"])
@router.get("/recent")
def get_recent_articles(
hours: int = 24,
limit: int = 50,
feed_id: Optional[int] = None,
category: Optional[str] = None,
search: Optional[str] = None,
unread_only: bool = False,
db: Session = Depends(get_db),
):
"""获取最近 N 小时的文章
供 AI 分析的主要接口,支持多条件组合筛选
"""
since = datetime.utcnow() - timedelta(hours=hours)
query = db.query(Article, Feed.title.label("feed_title"), Feed.category.label("category")).join(Feed)
query = query.filter(Article.created_at >= since)
if feed_id:
query = query.filter(Article.feed_id == feed_id)
if category:
query = query.filter(Feed.category == category)
if search:
query = query.filter(
Article.title.contains(search) | Article.summary.contains(search)
)
if unread_only:
query = query.filter(Article.is_read == False)
rows = query.order_by(desc(Article.published_at)).limit(limit).all()
return {
"query": {
"hours": hours,
"limit": limit,
"feed_id": feed_id,
"category": category,
"search": search,
"unread_only": unread_only,
},
"count": len(rows),
"articles": [
{
"id": article.id,
"title": article.title or "",
"link": article.link,
"author": article.author or "",
"summary": article.summary or "",
"content": article.content or "" if len(article.content or "") < 10000 else article.summary or "",
"published_at": article.published_at.isoformat() if article.published_at else None,
"created_at": article.created_at.isoformat(),
"feed_title": feed_title or "",
"category": category or "",
}
for article, feed_title, category in rows
],
}
@router.get("/search")
def fulltext_search(
q: str = Query(..., description="搜索关键词"),
limit: int = Query(50, ge=1, le=200),
offset: int = Query(0, ge=0),
category: Optional[str] = Query(None, description="按分类筛选"),
feed_id: Optional[int] = Query(None, description="按源筛选"),
db: Session = Depends(get_db),
):
"""全文搜索文章(FTS5
供 AI 按关键词检索文章内容
"""
results, total = search_articles(q, limit, offset)
# 二次过滤分类和源
if category or feed_id:
filtered = []
for r in results:
if category and r["category"] != category:
continue
if feed_id and r["feed_id"] != feed_id:
continue
filtered.append(r)
results = filtered
total = len(filtered)
return {
"query": q,
"total": total,
"offset": offset,
"limit": limit,
"articles": results,
}
@router.get("/feeds")
def get_active_feeds(
health_status: Optional[str] = Query(None, description="按健康度筛选: healthy/warning/unhealthy/unknown"),
category: Optional[str] = Query(None, description="按分类筛选"),
error_type: Optional[str] = Query(None, description="按错误类型筛选"),
is_active: Optional[bool] = Query(None, description="按启用状态筛选"),
db: Session = Depends(get_db),
):
"""获取 RSS 源列表(支持多条件筛选)"""
query = db.query(Feed)
if is_active is not None:
query = query.filter(Feed.is_active == is_active)
else:
query = query.filter(Feed.is_active == True)
if category:
query = query.filter(Feed.category == category)
feeds = query.all()
results = []
for feed in feeds:
status = feed.health_status()
if health_status and status != health_status:
continue
if error_type and feed.error_type != error_type:
continue
results.append({
"id": feed.id,
"title": feed.title or feed.url,
"url": feed.url,
"category": feed.category or "",
"is_active": feed.is_active,
"health_status": status,
"error_type": feed.error_type,
"article_count": feed.article_count,
"last_fetch_at": feed.last_fetch_at.isoformat() if feed.last_fetch_at else None,
"last_error": feed.last_error or "",
})
return {
"count": len(results),
"feeds": results,
}
@router.get("/feeds/{feed_id}/articles")
def get_feed_articles(
feed_id: int,
limit: int = 100,
since: Optional[str] = None,
search: Optional[str] = None,
unread_only: bool = False,
db: Session = Depends(get_db),
):
"""获取指定 RSS 源的文章"""
feed = db.query(Feed).filter(Feed.id == feed_id).first()
if not feed:
return {"error": "Feed not found"}
query = db.query(Article).filter(Article.feed_id == feed_id)
if since:
query = query.filter(Article.published_at >= since)
if search:
query = query.filter(
Article.title.contains(search) | Article.summary.contains(search)
)
if unread_only:
query = query.filter(Article.is_read == False)
articles = query.order_by(desc(Article.published_at)).limit(limit).all()
return {
"feed": {
"id": feed.id,
"title": feed.title or feed.url,
"url": feed.url,
},
"count": len(articles),
"articles": [
{
"id": article.id,
"title": article.title or "",
"link": article.link,
"author": article.author or "",
"summary": article.summary or "",
"published_at": article.published_at.isoformat() if article.published_at else None,
}
for article in articles
],
}
@router.get("/summary")
def get_daily_summary(
date: Optional[str] = None,
category: Optional[str] = None,
db: Session = Depends(get_db),
):
"""获取指定日期的文章摘要统计
供 AI 快速了解某天的 RSS 内容概况
"""
if date:
try:
day = datetime.strptime(date, "%Y-%m-%d")
next_day = day + timedelta(days=1)
except ValueError:
return {"error": "Invalid date format, use YYYY-MM-DD"}
else:
day = datetime.utcnow().replace(hour=0, minute=0, second=0, microsecond=0)
next_day = day + timedelta(days=1)
query = db.query(Article, Feed.title.label("feed_title"), Feed.category.label("category")).join(Feed)
query = query.filter(Article.created_at >= day, Article.created_at < next_day)
if category:
query = query.filter(Feed.category == category)
rows = query.order_by(desc(Article.published_at)).all()
by_category = {}
for article, feed_title, cat in rows:
c = cat or "未分类"
if category and c != category:
continue
if c not in by_category:
by_category[c] = []
by_category[c].append({
"title": article.title or "",
"link": article.link,
"feed": feed_title or "",
"summary": article.summary or "",
})
return {
"date": day.strftime("%Y-%m-%d"),
"total_articles": len(rows),
"by_category": by_category,
}