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>
This commit is contained in:
congsh
2026-06-12 09:58:32 +08:00
parent 68bba3d9e0
commit 4286731348
12 changed files with 1057 additions and 44 deletions
+113 -28
View File
@@ -1,13 +1,14 @@
"""对外 API(供 AI/外部系统调用)"""
from typing import Optional
from typing import Optional, List
from datetime import datetime, timedelta
from fastapi import APIRouter, Depends
from fastapi import APIRouter, Depends, Query
from sqlalchemy.orm import Session
from sqlalchemy import desc
from sqlalchemy import desc, or_
from database import get_db
from models import Article, Feed
from fulltext_search import search_articles
router = APIRouter(prefix="/external", tags=["external"])
router = APIRouter(tags=["external"])
@router.get("/recent")
@@ -16,21 +17,28 @@ def get_recent_articles(
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 分析的主要接口
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()
@@ -40,6 +48,8 @@ def get_recent_articles(
"limit": limit,
"feed_id": feed_id,
"category": category,
"search": search,
"unread_only": unread_only,
},
"count": len(rows),
"articles": [
@@ -60,24 +70,86 @@ def get_recent_articles(
}
@router.get("/feeds")
def get_active_feeds(db: Session = Depends(get_db)):
"""获取所有活跃的 RSS 源列表"""
feeds = db.query(Feed).filter(Feed.is_active == True).all()
@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 {
"count": len(feeds),
"feeds": [
{
"id": feed.id,
"title": feed.title or feed.url,
"url": feed.url,
"category": feed.category or "",
"article_count": feed.article_count,
"last_fetch_at": feed.last_fetch_at.isoformat() if feed.last_fetch_at else None,
}
for feed in feeds
],
"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,
}
@@ -86,6 +158,8 @@ 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 源的文章"""
@@ -97,6 +171,12 @@ def get_feed_articles(
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()
@@ -124,6 +204,7 @@ def get_feed_articles(
@router.get("/summary")
def get_daily_summary(
date: Optional[str] = None,
category: Optional[str] = None,
db: Session = Depends(get_db),
):
"""获取指定日期的文章摘要统计
@@ -141,15 +222,19 @@ def get_daily_summary(
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, category in rows:
cat = category or "未分类"
if cat not in by_category:
by_category[cat] = []
by_category[cat].append({
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 "",