2026-06-11 14:03:36 +08:00
|
|
|
|
"""对外 API(供 AI/外部系统调用)"""
|
2026-06-12 09:58:32 +08:00
|
|
|
|
from typing import Optional, List
|
2026-06-11 17:44:54 +08:00
|
|
|
|
from datetime import datetime, timedelta
|
2026-06-12 09:58:32 +08:00
|
|
|
|
from fastapi import APIRouter, Depends, Query
|
2026-06-11 14:03:36 +08:00
|
|
|
|
from sqlalchemy.orm import Session
|
2026-06-12 09:58:32 +08:00
|
|
|
|
from sqlalchemy import desc, or_
|
2026-06-11 14:03:36 +08:00
|
|
|
|
from database import get_db
|
|
|
|
|
|
from models import Article, Feed
|
2026-06-12 09:58:32 +08:00
|
|
|
|
from fulltext_search import search_articles
|
2026-06-11 14:03:36 +08:00
|
|
|
|
|
2026-06-12 09:58:32 +08:00
|
|
|
|
router = APIRouter(tags=["external"])
|
2026-06-11 14:03:36 +08:00
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
@router.get("/recent")
|
|
|
|
|
|
def get_recent_articles(
|
|
|
|
|
|
hours: int = 24,
|
|
|
|
|
|
limit: int = 50,
|
|
|
|
|
|
feed_id: Optional[int] = None,
|
|
|
|
|
|
category: Optional[str] = None,
|
2026-06-12 09:58:32 +08:00
|
|
|
|
search: Optional[str] = None,
|
|
|
|
|
|
unread_only: bool = False,
|
2026-06-11 14:03:36 +08:00
|
|
|
|
db: Session = Depends(get_db),
|
|
|
|
|
|
):
|
|
|
|
|
|
"""获取最近 N 小时的文章
|
2026-06-12 09:58:32 +08:00
|
|
|
|
供 AI 分析的主要接口,支持多条件组合筛选
|
2026-06-11 14:03:36 +08:00
|
|
|
|
"""
|
2026-06-11 17:44:54 +08:00
|
|
|
|
since = datetime.utcnow() - timedelta(hours=hours)
|
2026-06-11 14:03:36 +08:00
|
|
|
|
|
|
|
|
|
|
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)
|
2026-06-12 09:58:32 +08:00
|
|
|
|
if search:
|
|
|
|
|
|
query = query.filter(
|
|
|
|
|
|
Article.title.contains(search) | Article.summary.contains(search)
|
|
|
|
|
|
)
|
|
|
|
|
|
if unread_only:
|
|
|
|
|
|
query = query.filter(Article.is_read == False)
|
2026-06-11 14:03:36 +08:00
|
|
|
|
|
|
|
|
|
|
rows = query.order_by(desc(Article.published_at)).limit(limit).all()
|
|
|
|
|
|
|
|
|
|
|
|
return {
|
|
|
|
|
|
"query": {
|
|
|
|
|
|
"hours": hours,
|
|
|
|
|
|
"limit": limit,
|
|
|
|
|
|
"feed_id": feed_id,
|
|
|
|
|
|
"category": category,
|
2026-06-12 09:58:32 +08:00
|
|
|
|
"search": search,
|
|
|
|
|
|
"unread_only": unread_only,
|
2026-06-11 14:03:36 +08:00
|
|
|
|
},
|
|
|
|
|
|
"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
|
|
|
|
|
|
],
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
|
2026-06-12 09:58:32 +08:00
|
|
|
|
@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,
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
|
2026-06-11 14:03:36 +08:00
|
|
|
|
@router.get("/feeds")
|
2026-06-12 09:58:32 +08:00
|
|
|
|
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 "",
|
|
|
|
|
|
})
|
2026-06-11 14:03:36 +08:00
|
|
|
|
|
|
|
|
|
|
return {
|
2026-06-12 09:58:32 +08:00
|
|
|
|
"count": len(results),
|
|
|
|
|
|
"feeds": results,
|
2026-06-11 14:03:36 +08:00
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
@router.get("/feeds/{feed_id}/articles")
|
|
|
|
|
|
def get_feed_articles(
|
|
|
|
|
|
feed_id: int,
|
|
|
|
|
|
limit: int = 100,
|
|
|
|
|
|
since: Optional[str] = None,
|
2026-06-12 09:58:32 +08:00
|
|
|
|
search: Optional[str] = None,
|
|
|
|
|
|
unread_only: bool = False,
|
2026-06-11 14:03:36 +08:00
|
|
|
|
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)
|
2026-06-12 09:58:32 +08:00
|
|
|
|
if search:
|
|
|
|
|
|
query = query.filter(
|
|
|
|
|
|
Article.title.contains(search) | Article.summary.contains(search)
|
|
|
|
|
|
)
|
|
|
|
|
|
if unread_only:
|
|
|
|
|
|
query = query.filter(Article.is_read == False)
|
2026-06-11 14:03:36 +08:00
|
|
|
|
|
|
|
|
|
|
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,
|
2026-06-12 09:58:32 +08:00
|
|
|
|
category: Optional[str] = None,
|
2026-06-11 14:03:36 +08:00
|
|
|
|
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:
|
2026-06-11 17:44:54 +08:00
|
|
|
|
day = datetime.utcnow().replace(hour=0, minute=0, second=0, microsecond=0)
|
2026-06-11 14:03:36 +08:00
|
|
|
|
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)
|
2026-06-12 09:58:32 +08:00
|
|
|
|
if category:
|
|
|
|
|
|
query = query.filter(Feed.category == category)
|
|
|
|
|
|
|
2026-06-11 14:03:36 +08:00
|
|
|
|
rows = query.order_by(desc(Article.published_at)).all()
|
|
|
|
|
|
|
|
|
|
|
|
by_category = {}
|
2026-06-12 09:58:32 +08:00
|
|
|
|
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({
|
2026-06-11 14:03:36 +08:00
|
|
|
|
"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,
|
|
|
|
|
|
}
|