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:
+54
@@ -0,0 +1,54 @@
|
|||||||
|
# Python
|
||||||
|
__pycache__/
|
||||||
|
*.py[cod]
|
||||||
|
*$py.class
|
||||||
|
*.so
|
||||||
|
.Python
|
||||||
|
build/
|
||||||
|
develop-eggs/
|
||||||
|
dist/
|
||||||
|
downloads/
|
||||||
|
eggs/
|
||||||
|
.eggs/
|
||||||
|
lib/
|
||||||
|
lib64/
|
||||||
|
parts/
|
||||||
|
sdist/
|
||||||
|
var/
|
||||||
|
wheels/
|
||||||
|
*.egg-info/
|
||||||
|
.installed.cfg
|
||||||
|
*.egg
|
||||||
|
|
||||||
|
# Virtual environments
|
||||||
|
venv/
|
||||||
|
ENV/
|
||||||
|
env/
|
||||||
|
.venv
|
||||||
|
|
||||||
|
# Database
|
||||||
|
data/*.db
|
||||||
|
!data/.gitkeep
|
||||||
|
|
||||||
|
# Node
|
||||||
|
node_modules/
|
||||||
|
npm-debug.log*
|
||||||
|
yarn-debug.log*
|
||||||
|
yarn-error.log*
|
||||||
|
|
||||||
|
# Frontend build (will be built in Docker)
|
||||||
|
frontend/dist/
|
||||||
|
|
||||||
|
# IDE
|
||||||
|
.idea/
|
||||||
|
.vscode/
|
||||||
|
*.swp
|
||||||
|
*.swo
|
||||||
|
*~
|
||||||
|
|
||||||
|
# OS
|
||||||
|
.DS_Store
|
||||||
|
Thumbs.db
|
||||||
|
|
||||||
|
# Docker
|
||||||
|
.dockerignore
|
||||||
+36
@@ -0,0 +1,36 @@
|
|||||||
|
# rssKeeper - 多阶段构建
|
||||||
|
# Stage 1: 构建前端
|
||||||
|
FROM node:20-alpine AS frontend-builder
|
||||||
|
WORKDIR /app/frontend
|
||||||
|
COPY frontend/package.json frontend/package-lock.json* ./
|
||||||
|
RUN npm install
|
||||||
|
COPY frontend/ .
|
||||||
|
RUN npm run build
|
||||||
|
|
||||||
|
# Stage 2: Python 后端
|
||||||
|
FROM python:3.12-slim
|
||||||
|
WORKDIR /app
|
||||||
|
|
||||||
|
# 安装系统依赖
|
||||||
|
RUN apt-get update && apt-get install -y --no-install-recommends \
|
||||||
|
gcc \
|
||||||
|
libxml2-dev \
|
||||||
|
libxslt1-dev \
|
||||||
|
&& rm -rf /var/lib/apt/lists/*
|
||||||
|
|
||||||
|
# 安装 Python 依赖
|
||||||
|
COPY backend/requirements.txt .
|
||||||
|
RUN pip install --no-cache-dir -r requirements.txt
|
||||||
|
|
||||||
|
# 复制后端代码
|
||||||
|
COPY backend/ .
|
||||||
|
|
||||||
|
# 复制前端构建产物
|
||||||
|
COPY --from=frontend-builder /app/frontend/dist ./static
|
||||||
|
|
||||||
|
# 创建数据目录
|
||||||
|
RUN mkdir -p /app/data
|
||||||
|
|
||||||
|
EXPOSE 8000
|
||||||
|
|
||||||
|
CMD ["uvicorn", "main:app", "--host", "0.0.0.0", "--port", "8000", "--workers", "1"]
|
||||||
@@ -0,0 +1,81 @@
|
|||||||
|
# rssKeeper
|
||||||
|
|
||||||
|
RSS 抓取、管理与检索系统。支持 Docker 部署,包含 Web UI 和 REST API。
|
||||||
|
|
||||||
|
## 功能特性
|
||||||
|
|
||||||
|
- 📡 **RSS 源管理** — 添加、编辑、删除 RSS 源,支持自动发现 feed URL
|
||||||
|
- 📄 **文章管理** — 自动抓取、去重、分类,全文搜索
|
||||||
|
- 🩺 **健康度监控** — 实时展示每个 RSS 源的成功率、最后更新、文章数量
|
||||||
|
- 🔍 **全文检索** — 基于 SQLite FTS5 的全文搜索
|
||||||
|
- 🐳 **Docker 部署** — 单容器部署,数据持久化
|
||||||
|
- 🔌 **对外 API** — RESTful API 供 AI 或外部系统调用
|
||||||
|
|
||||||
|
## 快速开始
|
||||||
|
|
||||||
|
### Docker 部署(推荐)
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# 克隆项目
|
||||||
|
git clone <repo-url>
|
||||||
|
cd rssKeeper
|
||||||
|
|
||||||
|
# 启动
|
||||||
|
docker-compose up -d --build
|
||||||
|
|
||||||
|
# 访问 http://localhost:8000
|
||||||
|
```
|
||||||
|
|
||||||
|
### 开发模式
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# 后端
|
||||||
|
cd backend
|
||||||
|
pip install -r requirements.txt
|
||||||
|
uvicorn main:app --reload --port 8000
|
||||||
|
|
||||||
|
# 前端(另开终端)
|
||||||
|
cd frontend
|
||||||
|
npm install
|
||||||
|
npm run dev
|
||||||
|
```
|
||||||
|
|
||||||
|
## 对外 API
|
||||||
|
|
||||||
|
### 获取最近文章(供 AI 分析)
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# 获取最近 24 小时的文章
|
||||||
|
curl "http://localhost:8000/api/v1/external/recent?hours=24&limit=50"
|
||||||
|
|
||||||
|
# 指定 RSS 源
|
||||||
|
curl "http://localhost:8000/api/v1/external/recent?feed_id=1&hours=48"
|
||||||
|
|
||||||
|
# 指定分类
|
||||||
|
curl "http://localhost:8000/api/v1/external/recent?category=科技&hours=24"
|
||||||
|
```
|
||||||
|
|
||||||
|
### 获取源列表
|
||||||
|
|
||||||
|
```bash
|
||||||
|
curl "http://localhost:8000/api/v1/external/feeds"
|
||||||
|
```
|
||||||
|
|
||||||
|
### 按源获取文章
|
||||||
|
|
||||||
|
```bash
|
||||||
|
curl "http://localhost:8000/api/v1/external/feeds/1/articles?limit=100"
|
||||||
|
```
|
||||||
|
|
||||||
|
### 获取每日摘要
|
||||||
|
|
||||||
|
```bash
|
||||||
|
curl "http://localhost:8000/api/v1/external/summary?date=2024-06-01"
|
||||||
|
```
|
||||||
|
|
||||||
|
## 技术栈
|
||||||
|
|
||||||
|
- **后端**: Python 3.12 + FastAPI + SQLAlchemy + APScheduler
|
||||||
|
- **数据库**: SQLite(FTS5 全文搜索)
|
||||||
|
- **前端**: Vue 3 + Element Plus + Vite
|
||||||
|
- **部署**: Docker + docker-compose
|
||||||
@@ -0,0 +1,26 @@
|
|||||||
|
"""配置管理 - 环境变量 + 默认值"""
|
||||||
|
import os
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
# 项目根目录
|
||||||
|
BASE_DIR = Path(__file__).parent
|
||||||
|
DATA_DIR = Path(os.getenv("DATA_DIR", "/app/data"))
|
||||||
|
DATA_DIR.mkdir(parents=True, exist_ok=True)
|
||||||
|
|
||||||
|
# 数据库
|
||||||
|
DATABASE_URL = os.getenv("DATABASE_URL", str(DATA_DIR / "rsskeeper.db"))
|
||||||
|
|
||||||
|
# RSS 抓取配置
|
||||||
|
FETCH_CONCURRENCY = int(os.getenv("FETCH_CONCURRENCY", "10"))
|
||||||
|
FETCH_TIMEOUT = int(os.getenv("FETCH_TIMEOUT", "30"))
|
||||||
|
DEFAULT_FETCH_INTERVAL = int(os.getenv("DEFAULT_FETCH_INTERVAL", "60")) # 分钟
|
||||||
|
MIN_FETCH_INTERVAL = int(os.getenv("MIN_FETCH_INTERVAL", "15")) # 最小间隔15分钟
|
||||||
|
|
||||||
|
# 内容处理
|
||||||
|
MAX_ARTICLE_CONTENT_LENGTH = int(os.getenv("MAX_ARTICLE_CONTENT_LENGTH", "50000"))
|
||||||
|
MAX_SUMMARY_LENGTH = int(os.getenv("MAX_SUMMARY_LENGTH", "500"))
|
||||||
|
ARTICLE_RETENTION_DAYS = int(os.getenv("ARTICLE_RETENTION_DAYS", "0")) # 0 = 永久保留
|
||||||
|
|
||||||
|
# API 配置
|
||||||
|
API_PREFIX = "/api"
|
||||||
|
EXTERNAL_API_PREFIX = "/api/v1/external"
|
||||||
@@ -0,0 +1,89 @@
|
|||||||
|
"""数据库连接与初始化"""
|
||||||
|
from sqlalchemy import create_engine, event
|
||||||
|
from sqlalchemy.orm import sessionmaker, declarative_base
|
||||||
|
from config import DATABASE_URL
|
||||||
|
|
||||||
|
# SQLite 连接
|
||||||
|
engine = create_engine(
|
||||||
|
f"sqlite:///{DATABASE_URL}",
|
||||||
|
connect_args={"check_same_thread": False},
|
||||||
|
echo=False,
|
||||||
|
)
|
||||||
|
|
||||||
|
# 启用 SQLite 外键约束
|
||||||
|
@event.listens_for(engine, "connect")
|
||||||
|
def set_sqlite_pragma(dbapi_conn, connection_record):
|
||||||
|
cursor = dbapi_conn.cursor()
|
||||||
|
cursor.execute("PRAGMA foreign_keys=ON")
|
||||||
|
cursor.close()
|
||||||
|
|
||||||
|
SessionLocal = sessionmaker(autocommit=False, autoflush=False, bind=engine)
|
||||||
|
Base = declarative_base()
|
||||||
|
|
||||||
|
|
||||||
|
def get_db():
|
||||||
|
"""FastAPI 依赖注入用"""
|
||||||
|
db = SessionLocal()
|
||||||
|
try:
|
||||||
|
yield db
|
||||||
|
finally:
|
||||||
|
db.close()
|
||||||
|
|
||||||
|
|
||||||
|
def init_db():
|
||||||
|
"""初始化数据库表"""
|
||||||
|
from models import Feed, Article, FetchLog # noqa
|
||||||
|
|
||||||
|
Base.metadata.create_all(bind=engine)
|
||||||
|
init_fts5()
|
||||||
|
|
||||||
|
|
||||||
|
def init_fts5():
|
||||||
|
"""初始化 FTS5 全文搜索虚拟表"""
|
||||||
|
conn = engine.raw_connection()
|
||||||
|
cursor = conn.cursor()
|
||||||
|
|
||||||
|
# 检查 FTS5 扩展是否可用
|
||||||
|
try:
|
||||||
|
cursor.execute("SELECT sqlite_compileoption_used('ENABLE_FTS5')")
|
||||||
|
has_fts5 = cursor.fetchone()[0]
|
||||||
|
if not has_fts5:
|
||||||
|
print("警告: SQLite 未启用 FTS5 扩展,全文搜索将不可用")
|
||||||
|
return
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
|
|
||||||
|
# 创建 FTS5 虚拟表
|
||||||
|
cursor.execute("""
|
||||||
|
CREATE VIRTUAL TABLE IF NOT EXISTS articles_fts USING fts5(
|
||||||
|
title, content,
|
||||||
|
content='articles',
|
||||||
|
content_rowid='id'
|
||||||
|
)
|
||||||
|
""")
|
||||||
|
|
||||||
|
# 创建触发器,自动同步 articles 表到 FTS5
|
||||||
|
cursor.execute("""
|
||||||
|
CREATE TRIGGER IF NOT EXISTS articles_fts_insert AFTER INSERT ON articles BEGIN
|
||||||
|
INSERT INTO articles_fts(rowid, title, content)
|
||||||
|
VALUES (new.id, new.title, new.content);
|
||||||
|
END
|
||||||
|
""")
|
||||||
|
cursor.execute("""
|
||||||
|
CREATE TRIGGER IF NOT EXISTS articles_fts_delete AFTER DELETE ON articles BEGIN
|
||||||
|
INSERT INTO articles_fts(articles_fts, rowid, title, content)
|
||||||
|
VALUES ('delete', old.id, old.title, old.content);
|
||||||
|
END
|
||||||
|
""")
|
||||||
|
cursor.execute("""
|
||||||
|
CREATE TRIGGER IF NOT EXISTS articles_fts_update AFTER UPDATE ON articles BEGIN
|
||||||
|
INSERT INTO articles_fts(articles_fts, rowid, title, content)
|
||||||
|
VALUES ('delete', old.id, old.title, old.content);
|
||||||
|
INSERT INTO articles_fts(rowid, title, content)
|
||||||
|
VALUES (new.id, new.title, new.content);
|
||||||
|
END
|
||||||
|
""")
|
||||||
|
|
||||||
|
conn.commit()
|
||||||
|
cursor.close()
|
||||||
|
conn.close()
|
||||||
@@ -0,0 +1,81 @@
|
|||||||
|
"""SQLite FTS5 全文搜索封装"""
|
||||||
|
from sqlalchemy import text
|
||||||
|
from database import engine
|
||||||
|
|
||||||
|
|
||||||
|
def search_articles(query: str, limit: int = 50, offset: int = 0):
|
||||||
|
"""全文搜索文章
|
||||||
|
返回 [(article_id, title, content_snippet, rank), ...]
|
||||||
|
"""
|
||||||
|
if not query or not query.strip():
|
||||||
|
return [], 0
|
||||||
|
|
||||||
|
# 转义 FTS5 特殊字符
|
||||||
|
query = query.replace('"', '""').strip()
|
||||||
|
|
||||||
|
conn = engine.raw_connection()
|
||||||
|
cursor = conn.cursor()
|
||||||
|
|
||||||
|
try:
|
||||||
|
# 使用 FTS5 查询
|
||||||
|
sql = """
|
||||||
|
SELECT a.id, a.title, a.summary, a.link, a.published_at, a.created_at,
|
||||||
|
f.id as feed_id, f.title as feed_title, f.category,
|
||||||
|
rank
|
||||||
|
FROM articles_fts
|
||||||
|
JOIN articles a ON articles_fts.rowid = a.id
|
||||||
|
JOIN feeds f ON a.feed_id = f.id
|
||||||
|
WHERE articles_fts MATCH ?
|
||||||
|
ORDER BY rank
|
||||||
|
LIMIT ? OFFSET ?
|
||||||
|
"""
|
||||||
|
cursor.execute(sql, (query, limit, offset))
|
||||||
|
rows = cursor.fetchall()
|
||||||
|
|
||||||
|
# 获取总数
|
||||||
|
count_sql = """
|
||||||
|
SELECT COUNT(*) FROM articles_fts WHERE articles_fts MATCH ?
|
||||||
|
"""
|
||||||
|
cursor.execute(count_sql, (query,))
|
||||||
|
total = cursor.fetchone()[0]
|
||||||
|
|
||||||
|
results = []
|
||||||
|
for row in rows:
|
||||||
|
results.append({
|
||||||
|
"id": row[0],
|
||||||
|
"title": row[1],
|
||||||
|
"summary": row[2],
|
||||||
|
"link": row[3],
|
||||||
|
"published_at": row[4],
|
||||||
|
"created_at": row[5],
|
||||||
|
"feed_id": row[6],
|
||||||
|
"feed_title": row[7],
|
||||||
|
"category": row[8],
|
||||||
|
})
|
||||||
|
|
||||||
|
return results, total
|
||||||
|
except Exception as e:
|
||||||
|
# FTS5 查询失败时返回空结果
|
||||||
|
return [], 0
|
||||||
|
finally:
|
||||||
|
cursor.close()
|
||||||
|
conn.close()
|
||||||
|
|
||||||
|
|
||||||
|
def rebuild_fts_index():
|
||||||
|
"""重建 FTS5 索引(数据不一致时使用)"""
|
||||||
|
conn = engine.raw_connection()
|
||||||
|
cursor = conn.cursor()
|
||||||
|
try:
|
||||||
|
cursor.execute("DELETE FROM articles_fts")
|
||||||
|
cursor.execute("""
|
||||||
|
INSERT INTO articles_fts(rowid, title, content)
|
||||||
|
SELECT id, title, content FROM articles
|
||||||
|
""")
|
||||||
|
conn.commit()
|
||||||
|
return True
|
||||||
|
except Exception:
|
||||||
|
return False
|
||||||
|
finally:
|
||||||
|
cursor.close()
|
||||||
|
conn.close()
|
||||||
@@ -0,0 +1,112 @@
|
|||||||
|
"""RSS 源健康度检测"""
|
||||||
|
from datetime import datetime, timedelta
|
||||||
|
from typing import List, Dict
|
||||||
|
from sqlalchemy.orm import Session
|
||||||
|
from models import Feed, FetchLog
|
||||||
|
|
||||||
|
|
||||||
|
def get_feed_health(db: Session, feed_id: int = None) -> List[Dict]:
|
||||||
|
"""获取 RSS 源健康度信息
|
||||||
|
返回每个源的健康状态详情
|
||||||
|
"""
|
||||||
|
query = db.query(Feed)
|
||||||
|
if feed_id:
|
||||||
|
query = query.filter(Feed.id == feed_id)
|
||||||
|
|
||||||
|
feeds = query.all()
|
||||||
|
results = []
|
||||||
|
|
||||||
|
for feed in feeds:
|
||||||
|
total = feed.success_count + feed.fail_count
|
||||||
|
success_rate = round(feed.success_count / total * 100, 1) if total > 0 else 0
|
||||||
|
|
||||||
|
days_since_fetch = None
|
||||||
|
if feed.last_fetch_at:
|
||||||
|
days_since_fetch = (datetime.utcnow() - feed.last_fetch_at).days
|
||||||
|
|
||||||
|
# 获取最近 7 天抓取记录
|
||||||
|
recent_logs = db.query(FetchLog).filter(
|
||||||
|
FetchLog.feed_id == feed.id,
|
||||||
|
FetchLog.created_at >= datetime.utcnow() - timedelta(days=7)
|
||||||
|
).order_by(FetchLog.created_at.desc()).limit(10).all()
|
||||||
|
|
||||||
|
health = feed.health_status()
|
||||||
|
|
||||||
|
results.append({
|
||||||
|
"id": feed.id,
|
||||||
|
"title": feed.title or feed.url,
|
||||||
|
"url": feed.url,
|
||||||
|
"is_active": feed.is_active,
|
||||||
|
"health_status": health,
|
||||||
|
"health_label": _health_label(health),
|
||||||
|
"success_rate": success_rate,
|
||||||
|
"success_count": feed.success_count,
|
||||||
|
"fail_count": feed.fail_count,
|
||||||
|
"total_fetches": total,
|
||||||
|
"last_fetch_at": feed.last_fetch_at.isoformat() if feed.last_fetch_at else None,
|
||||||
|
"days_since_fetch": days_since_fetch,
|
||||||
|
"article_count": feed.article_count,
|
||||||
|
"last_error": feed.last_error,
|
||||||
|
"recent_logs": [
|
||||||
|
{
|
||||||
|
"status": log.status,
|
||||||
|
"articles_fetched": log.articles_fetched,
|
||||||
|
"response_time_ms": log.response_time_ms,
|
||||||
|
"created_at": log.created_at.isoformat(),
|
||||||
|
"error_message": log.error_message if log.status == "fail" else None,
|
||||||
|
}
|
||||||
|
for log in recent_logs
|
||||||
|
],
|
||||||
|
})
|
||||||
|
|
||||||
|
return results
|
||||||
|
|
||||||
|
|
||||||
|
def _health_label(status: str) -> str:
|
||||||
|
labels = {
|
||||||
|
"healthy": "健康",
|
||||||
|
"warning": "警告",
|
||||||
|
"unhealthy": "异常",
|
||||||
|
"unknown": "未知",
|
||||||
|
}
|
||||||
|
return labels.get(status, "未知")
|
||||||
|
|
||||||
|
|
||||||
|
def get_overall_stats(db: Session) -> Dict:
|
||||||
|
"""获取整体统计信息"""
|
||||||
|
total_feeds = db.query(Feed).count()
|
||||||
|
active_feeds = db.query(Feed).filter(Feed.is_active == True).count()
|
||||||
|
total_articles = db.query(Feed).with_entities(Feed.article_count).all()
|
||||||
|
total_articles_count = sum(a[0] for a in total_articles) if total_articles else 0
|
||||||
|
|
||||||
|
# 健康源统计
|
||||||
|
feeds = db.query(Feed).all()
|
||||||
|
healthy = warning = unhealthy = 0
|
||||||
|
for feed in feeds:
|
||||||
|
status = feed.health_status()
|
||||||
|
if status == "healthy":
|
||||||
|
healthy += 1
|
||||||
|
elif status == "warning":
|
||||||
|
warning += 1
|
||||||
|
elif status == "unhealthy":
|
||||||
|
unhealthy += 1
|
||||||
|
|
||||||
|
# 今日抓取
|
||||||
|
today = datetime.utcnow().replace(hour=0, minute=0, second=0, microsecond=0)
|
||||||
|
from models import FetchLog
|
||||||
|
today_fetches = db.query(FetchLog).filter(FetchLog.created_at >= today).count()
|
||||||
|
today_success = db.query(FetchLog).filter(
|
||||||
|
FetchLog.created_at >= today, FetchLog.status == "success"
|
||||||
|
).count()
|
||||||
|
|
||||||
|
return {
|
||||||
|
"total_feeds": total_feeds,
|
||||||
|
"active_feeds": active_feeds,
|
||||||
|
"total_articles": total_articles_count,
|
||||||
|
"healthy_feeds": healthy,
|
||||||
|
"warning_feeds": warning,
|
||||||
|
"unhealthy_feeds": unhealthy,
|
||||||
|
"today_fetches": today_fetches,
|
||||||
|
"today_success": today_success,
|
||||||
|
"today_success_rate": round(today_success / today_fetches * 100, 1) if today_fetches > 0 else 0,
|
||||||
|
}
|
||||||
@@ -0,0 +1,75 @@
|
|||||||
|
"""rssKeeper - FastAPI 入口"""
|
||||||
|
import os
|
||||||
|
from contextlib import asynccontextmanager
|
||||||
|
from fastapi import FastAPI
|
||||||
|
from fastapi.staticfiles import StaticFiles
|
||||||
|
from fastapi.responses import FileResponse
|
||||||
|
from starlette.middleware.cors import CORSMiddleware
|
||||||
|
from database import init_db, SessionLocal
|
||||||
|
from scheduler import init_feed_jobs, stop_scheduler
|
||||||
|
from routers import feeds, articles, dashboard, external_api
|
||||||
|
import config
|
||||||
|
|
||||||
|
|
||||||
|
@asynccontextmanager
|
||||||
|
async def lifespan(app: FastAPI):
|
||||||
|
"""应用生命周期管理"""
|
||||||
|
# 启动时:初始化数据库 + 注册定时任务
|
||||||
|
init_db()
|
||||||
|
db = SessionLocal()
|
||||||
|
try:
|
||||||
|
init_feed_jobs(db)
|
||||||
|
finally:
|
||||||
|
db.close()
|
||||||
|
|
||||||
|
yield
|
||||||
|
|
||||||
|
# 关闭时:停止调度器
|
||||||
|
stop_scheduler()
|
||||||
|
|
||||||
|
|
||||||
|
app = FastAPI(
|
||||||
|
title="rssKeeper",
|
||||||
|
description="RSS 抓取、管理与检索系统",
|
||||||
|
version="1.0.0",
|
||||||
|
lifespan=lifespan,
|
||||||
|
)
|
||||||
|
|
||||||
|
# CORS
|
||||||
|
app.add_middleware(
|
||||||
|
CORSMiddleware,
|
||||||
|
allow_origins=["*"],
|
||||||
|
allow_credentials=True,
|
||||||
|
allow_methods=["*"],
|
||||||
|
allow_headers=["*"],
|
||||||
|
)
|
||||||
|
|
||||||
|
# API 路由
|
||||||
|
app.include_router(feeds.router, prefix=config.API_PREFIX)
|
||||||
|
app.include_router(articles.router, prefix=config.API_PREFIX)
|
||||||
|
app.include_router(dashboard.router, prefix=config.API_PREFIX)
|
||||||
|
app.include_router(external_api.router, prefix=config.EXTERNAL_API_PREFIX)
|
||||||
|
|
||||||
|
|
||||||
|
@app.get("/api/health")
|
||||||
|
def health_check():
|
||||||
|
"""健康检查"""
|
||||||
|
return {"status": "ok", "service": "rssKeeper"}
|
||||||
|
|
||||||
|
|
||||||
|
# 静态文件服务(前端构建产物)
|
||||||
|
static_dir = os.path.join(config.BASE_DIR, "static")
|
||||||
|
if os.path.exists(static_dir):
|
||||||
|
app.mount("/static", StaticFiles(directory=static_dir), name="static")
|
||||||
|
|
||||||
|
@app.get("/{full_path:path}")
|
||||||
|
async def serve_spa(full_path: str):
|
||||||
|
"""Vue SPA 路由回退"""
|
||||||
|
# API 路由不走这里
|
||||||
|
if full_path.startswith("api/") or full_path.startswith("docs") or full_path.startswith("openapi.json"):
|
||||||
|
return {"detail": "Not found"}
|
||||||
|
|
||||||
|
index_path = os.path.join(static_dir, "index.html")
|
||||||
|
if os.path.exists(index_path):
|
||||||
|
return FileResponse(index_path)
|
||||||
|
return {"detail": "Frontend not built"}
|
||||||
@@ -0,0 +1,90 @@
|
|||||||
|
"""SQLAlchemy 数据模型"""
|
||||||
|
from datetime import datetime
|
||||||
|
from sqlalchemy import Column, Integer, String, Text, Boolean, DateTime, ForeignKey
|
||||||
|
from sqlalchemy.orm import relationship
|
||||||
|
from database import Base
|
||||||
|
|
||||||
|
|
||||||
|
class Feed(Base):
|
||||||
|
"""RSS 源"""
|
||||||
|
__tablename__ = "feeds"
|
||||||
|
|
||||||
|
id = Column(Integer, primary_key=True, index=True)
|
||||||
|
url = Column(String(2048), unique=True, nullable=False, index=True)
|
||||||
|
title = Column(String(512), default="")
|
||||||
|
description = Column(Text, default="")
|
||||||
|
category = Column(String(128), default="")
|
||||||
|
is_active = Column(Boolean, default=True, index=True)
|
||||||
|
fetch_interval_minutes = Column(Integer, default=60)
|
||||||
|
|
||||||
|
# 抓取统计
|
||||||
|
last_fetch_at = Column(DateTime, nullable=True)
|
||||||
|
last_fetch_status = Column(String(20), default="")
|
||||||
|
last_error = Column(Text, default="")
|
||||||
|
success_count = Column(Integer, default=0)
|
||||||
|
fail_count = Column(Integer, default=0)
|
||||||
|
article_count = Column(Integer, default=0)
|
||||||
|
|
||||||
|
created_at = Column(DateTime, default=datetime.utcnow)
|
||||||
|
|
||||||
|
# 关联
|
||||||
|
articles = relationship("Article", back_populates="feed", cascade="all, delete-orphan")
|
||||||
|
fetch_logs = relationship("FetchLog", back_populates="feed", cascade="all, delete-orphan")
|
||||||
|
|
||||||
|
def health_status(self):
|
||||||
|
"""计算健康度
|
||||||
|
🟢 健康: 成功率 >= 90%, 最近7天有更新
|
||||||
|
🟡 警告: 成功率 50%-90%, 或超过3天未更新
|
||||||
|
🔴 异常: 成功率 < 50%, 或超过7天未更新
|
||||||
|
"""
|
||||||
|
total = self.success_count + self.fail_count
|
||||||
|
if total == 0:
|
||||||
|
return "unknown"
|
||||||
|
|
||||||
|
success_rate = self.success_count / total
|
||||||
|
|
||||||
|
days_since_last_fetch = None
|
||||||
|
if self.last_fetch_at:
|
||||||
|
days_since_last_fetch = (datetime.utcnow() - self.last_fetch_at).days
|
||||||
|
|
||||||
|
if success_rate >= 0.9 and (days_since_last_fetch is None or days_since_last_fetch <= 7):
|
||||||
|
return "healthy"
|
||||||
|
elif success_rate >= 0.5 and (days_since_last_fetch is None or days_since_last_fetch <= 7):
|
||||||
|
return "warning"
|
||||||
|
else:
|
||||||
|
return "unhealthy"
|
||||||
|
|
||||||
|
|
||||||
|
class Article(Base):
|
||||||
|
"""RSS 文章"""
|
||||||
|
__tablename__ = "articles"
|
||||||
|
|
||||||
|
id = Column(Integer, primary_key=True, index=True)
|
||||||
|
feed_id = Column(Integer, ForeignKey("feeds.id", ondelete="CASCADE"), nullable=False, index=True)
|
||||||
|
title = Column(String(1024), default="", index=True)
|
||||||
|
link = Column(String(2048), unique=True, nullable=False, index=True)
|
||||||
|
author = Column(String(256), default="")
|
||||||
|
published_at = Column(DateTime, nullable=True, index=True)
|
||||||
|
content = Column(Text, default="")
|
||||||
|
summary = Column(Text, default="")
|
||||||
|
is_read = Column(Boolean, default=False)
|
||||||
|
created_at = Column(DateTime, default=datetime.utcnow, index=True)
|
||||||
|
|
||||||
|
# 关联
|
||||||
|
feed = relationship("Feed", back_populates="articles")
|
||||||
|
|
||||||
|
|
||||||
|
class FetchLog(Base):
|
||||||
|
"""抓取日志"""
|
||||||
|
__tablename__ = "fetch_logs"
|
||||||
|
|
||||||
|
id = Column(Integer, primary_key=True, index=True)
|
||||||
|
feed_id = Column(Integer, ForeignKey("feeds.id", ondelete="CASCADE"), nullable=False, index=True)
|
||||||
|
status = Column(String(20), nullable=False) # success / fail
|
||||||
|
articles_fetched = Column(Integer, default=0)
|
||||||
|
error_message = Column(Text, default="")
|
||||||
|
response_time_ms = Column(Integer, nullable=True)
|
||||||
|
created_at = Column(DateTime, default=datetime.utcnow, index=True)
|
||||||
|
|
||||||
|
# 关联
|
||||||
|
feed = relationship("Feed", back_populates="fetch_logs")
|
||||||
@@ -0,0 +1,9 @@
|
|||||||
|
fastapi>=0.110.0
|
||||||
|
uvicorn[standard]>=0.29.0
|
||||||
|
sqlalchemy>=2.0.0
|
||||||
|
pydantic>=2.6.0
|
||||||
|
feedparser>=6.0.11
|
||||||
|
requests>=2.31.0
|
||||||
|
beautifulsoup4>=4.12.0
|
||||||
|
apscheduler>=3.10.4
|
||||||
|
lxml>=5.1.0
|
||||||
@@ -0,0 +1,133 @@
|
|||||||
|
"""文章管理 API"""
|
||||||
|
from typing import Optional
|
||||||
|
from fastapi import APIRouter, Depends
|
||||||
|
from pydantic import BaseModel
|
||||||
|
from sqlalchemy.orm import Session
|
||||||
|
from sqlalchemy import desc
|
||||||
|
from database import get_db
|
||||||
|
from models import Article, Feed
|
||||||
|
from fulltext_search import search_articles
|
||||||
|
|
||||||
|
router = APIRouter(prefix="/articles", tags=["articles"])
|
||||||
|
|
||||||
|
|
||||||
|
class ArticleOut(BaseModel):
|
||||||
|
id: int
|
||||||
|
feed_id: int
|
||||||
|
title: str
|
||||||
|
link: str
|
||||||
|
author: str
|
||||||
|
published_at: Optional[str]
|
||||||
|
summary: str
|
||||||
|
is_read: bool
|
||||||
|
created_at: str
|
||||||
|
feed_title: str
|
||||||
|
category: str
|
||||||
|
|
||||||
|
class Config:
|
||||||
|
from_attributes = True
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("")
|
||||||
|
def list_articles(
|
||||||
|
skip: int = 0,
|
||||||
|
limit: int = 50,
|
||||||
|
feed_id: Optional[int] = None,
|
||||||
|
category: Optional[str] = None,
|
||||||
|
search: Optional[str] = None,
|
||||||
|
since: Optional[str] = None,
|
||||||
|
until: Optional[str] = None,
|
||||||
|
is_read: Optional[bool] = None,
|
||||||
|
db: Session = Depends(get_db),
|
||||||
|
):
|
||||||
|
"""获取文章列表,支持多种筛选条件"""
|
||||||
|
|
||||||
|
# 如果有搜索关键词,使用 FTS5 全文搜索
|
||||||
|
if search and search.strip():
|
||||||
|
results, total = search_articles(search.strip(), limit=limit, offset=skip)
|
||||||
|
return {"total": total, "items": results}
|
||||||
|
|
||||||
|
query = db.query(Article, Feed.title.label("feed_title"), Feed.category.label("category")).join(Feed)
|
||||||
|
|
||||||
|
if feed_id:
|
||||||
|
query = query.filter(Article.feed_id == feed_id)
|
||||||
|
if category:
|
||||||
|
query = query.filter(Feed.category == category)
|
||||||
|
if is_read is not None:
|
||||||
|
query = query.filter(Article.is_read == is_read)
|
||||||
|
if since:
|
||||||
|
query = query.filter(Article.published_at >= since)
|
||||||
|
if until:
|
||||||
|
query = query.filter(Article.published_at <= until)
|
||||||
|
|
||||||
|
total = query.count()
|
||||||
|
rows = query.order_by(desc(Article.published_at)).offset(skip).limit(limit).all()
|
||||||
|
|
||||||
|
items = []
|
||||||
|
for article, feed_title, category in rows:
|
||||||
|
items.append({
|
||||||
|
"id": article.id,
|
||||||
|
"feed_id": article.feed_id,
|
||||||
|
"title": article.title or "",
|
||||||
|
"link": article.link,
|
||||||
|
"author": article.author or "",
|
||||||
|
"published_at": article.published_at.isoformat() if article.published_at else None,
|
||||||
|
"summary": article.summary or "",
|
||||||
|
"is_read": article.is_read,
|
||||||
|
"created_at": article.created_at.isoformat(),
|
||||||
|
"feed_title": feed_title or "",
|
||||||
|
"category": category or "",
|
||||||
|
})
|
||||||
|
|
||||||
|
return {"total": total, "items": items}
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/{article_id}")
|
||||||
|
def get_article(article_id: int, db: Session = Depends(get_db)):
|
||||||
|
"""获取文章详情"""
|
||||||
|
article = db.query(Article).filter(Article.id == article_id).first()
|
||||||
|
if not article:
|
||||||
|
raise HTTPException(status_code=404, detail="文章不存在")
|
||||||
|
|
||||||
|
feed = db.query(Feed).filter(Feed.id == article.feed_id).first()
|
||||||
|
|
||||||
|
return {
|
||||||
|
"id": article.id,
|
||||||
|
"feed_id": article.feed_id,
|
||||||
|
"title": article.title or "",
|
||||||
|
"link": article.link,
|
||||||
|
"author": article.author or "",
|
||||||
|
"published_at": article.published_at.isoformat() if article.published_at else None,
|
||||||
|
"content": article.content or "",
|
||||||
|
"summary": article.summary or "",
|
||||||
|
"is_read": article.is_read,
|
||||||
|
"created_at": article.created_at.isoformat(),
|
||||||
|
"feed_title": feed.title if feed else "",
|
||||||
|
"category": feed.category if feed else "",
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
@router.put("/{article_id}/read")
|
||||||
|
def mark_read(article_id: int, db: Session = Depends(get_db)):
|
||||||
|
"""标记文章为已读"""
|
||||||
|
article = db.query(Article).filter(Article.id == article_id).first()
|
||||||
|
if not article:
|
||||||
|
raise HTTPException(status_code=404, detail="文章不存在")
|
||||||
|
|
||||||
|
article.is_read = True
|
||||||
|
db.commit()
|
||||||
|
return {"message": "已标记为已读"}
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/search/fulltext")
|
||||||
|
def fulltext_search(
|
||||||
|
q: str,
|
||||||
|
skip: int = 0,
|
||||||
|
limit: int = 50,
|
||||||
|
):
|
||||||
|
"""全文搜索文章"""
|
||||||
|
results, total = search_articles(q, limit=limit, offset=skip)
|
||||||
|
return {"total": total, "items": results}
|
||||||
|
|
||||||
|
|
||||||
|
from fastapi import HTTPException
|
||||||
@@ -0,0 +1,58 @@
|
|||||||
|
"""仪表盘统计 API"""
|
||||||
|
from fastapi import APIRouter, Depends
|
||||||
|
from sqlalchemy.orm import Session
|
||||||
|
from database import get_db
|
||||||
|
from health_checker import get_overall_stats, get_feed_health
|
||||||
|
|
||||||
|
router = APIRouter(prefix="/dashboard", tags=["dashboard"])
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/stats")
|
||||||
|
def dashboard_stats(db: Session = Depends(get_db)):
|
||||||
|
"""仪表盘统计数据"""
|
||||||
|
return get_overall_stats(db)
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/health")
|
||||||
|
def dashboard_health(
|
||||||
|
skip: int = 0,
|
||||||
|
limit: int = 100,
|
||||||
|
db: Session = Depends(get_db),
|
||||||
|
):
|
||||||
|
"""RSS 源健康度列表"""
|
||||||
|
all_health = get_feed_health(db)
|
||||||
|
total = len(all_health)
|
||||||
|
|
||||||
|
# 按健康状态排序:异常在前
|
||||||
|
status_order = {"unhealthy": 0, "warning": 1, "unknown": 2, "healthy": 3}
|
||||||
|
all_health.sort(key=lambda x: status_order.get(x["health_status"], 2))
|
||||||
|
|
||||||
|
items = all_health[skip:skip + limit]
|
||||||
|
return {"total": total, "items": items}
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/recent-activity")
|
||||||
|
def recent_activity(limit: int = 20, db: Session = Depends(get_db)):
|
||||||
|
"""最近的抓取活动"""
|
||||||
|
from models import FetchLog, Feed
|
||||||
|
from sqlalchemy import desc
|
||||||
|
|
||||||
|
logs = db.query(FetchLog, Feed.title.label("feed_title")).join(Feed).order_by(
|
||||||
|
desc(FetchLog.created_at)
|
||||||
|
).limit(limit).all()
|
||||||
|
|
||||||
|
return {
|
||||||
|
"items": [
|
||||||
|
{
|
||||||
|
"id": log.id,
|
||||||
|
"feed_id": log.feed_id,
|
||||||
|
"feed_title": feed_title or "",
|
||||||
|
"status": log.status,
|
||||||
|
"articles_fetched": log.articles_fetched,
|
||||||
|
"response_time_ms": log.response_time_ms,
|
||||||
|
"error_message": log.error_message,
|
||||||
|
"created_at": log.created_at.isoformat(),
|
||||||
|
}
|
||||||
|
for log, feed_title in logs
|
||||||
|
]
|
||||||
|
}
|
||||||
@@ -0,0 +1,163 @@
|
|||||||
|
"""对外 API(供 AI/外部系统调用)"""
|
||||||
|
from typing import Optional
|
||||||
|
from datetime import datetime, timedelta
|
||||||
|
from fastapi import APIRouter, Depends
|
||||||
|
from sqlalchemy.orm import Session
|
||||||
|
from sqlalchemy import desc
|
||||||
|
from database import get_db
|
||||||
|
from models import Article, Feed
|
||||||
|
|
||||||
|
router = APIRouter(prefix="/external", tags=["external"])
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/recent")
|
||||||
|
def get_recent_articles(
|
||||||
|
hours: int = 24,
|
||||||
|
limit: int = 50,
|
||||||
|
feed_id: Optional[int] = None,
|
||||||
|
category: Optional[str] = None,
|
||||||
|
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)
|
||||||
|
|
||||||
|
rows = query.order_by(desc(Article.published_at)).limit(limit).all()
|
||||||
|
|
||||||
|
return {
|
||||||
|
"query": {
|
||||||
|
"hours": hours,
|
||||||
|
"limit": limit,
|
||||||
|
"feed_id": feed_id,
|
||||||
|
"category": category,
|
||||||
|
},
|
||||||
|
"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("/feeds")
|
||||||
|
def get_active_feeds(db: Session = Depends(get_db)):
|
||||||
|
"""获取所有活跃的 RSS 源列表"""
|
||||||
|
feeds = db.query(Feed).filter(Feed.is_active == True).all()
|
||||||
|
|
||||||
|
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
|
||||||
|
],
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/feeds/{feed_id}/articles")
|
||||||
|
def get_feed_articles(
|
||||||
|
feed_id: int,
|
||||||
|
limit: int = 100,
|
||||||
|
since: Optional[str] = None,
|
||||||
|
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)
|
||||||
|
|
||||||
|
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,
|
||||||
|
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)
|
||||||
|
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({
|
||||||
|
"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,
|
||||||
|
}
|
||||||
@@ -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('"', '"')
|
||||||
|
lines.append(f' <outline type="rss" text="{title}" xmlUrl="{feed.url}" />')
|
||||||
|
lines.append('</body>')
|
||||||
|
lines.append('</opml>')
|
||||||
|
|
||||||
|
return {"opml": "\n".join(lines)}
|
||||||
@@ -0,0 +1,298 @@
|
|||||||
|
"""RSS 抓取核心逻辑"""
|
||||||
|
import time
|
||||||
|
import re
|
||||||
|
import html
|
||||||
|
import hashlib
|
||||||
|
from datetime import datetime, timezone
|
||||||
|
from concurrent.futures import ThreadPoolExecutor, as_completed
|
||||||
|
from urllib.parse import urljoin
|
||||||
|
import requests
|
||||||
|
import feedparser
|
||||||
|
from bs4 import BeautifulSoup
|
||||||
|
from sqlalchemy.orm import Session
|
||||||
|
from models import Feed, Article, FetchLog
|
||||||
|
from database import SessionLocal
|
||||||
|
import config
|
||||||
|
|
||||||
|
|
||||||
|
def fetch_feed(url: str, timeout: int = config.FETCH_TIMEOUT) -> dict:
|
||||||
|
"""抓取单个 RSS 源
|
||||||
|
返回 {"success": bool, "feed_data": parsed, "error": str, "response_time_ms": int}
|
||||||
|
"""
|
||||||
|
start_time = time.time()
|
||||||
|
try:
|
||||||
|
headers = {
|
||||||
|
"User-Agent": "rssKeeper/1.0 (+https://github.com/rssKeeper)",
|
||||||
|
"Accept": "application/rss+xml, application/atom+xml, application/xml, text/xml, */*",
|
||||||
|
}
|
||||||
|
response = requests.get(url, headers=headers, timeout=timeout, allow_redirects=True)
|
||||||
|
response.raise_for_status()
|
||||||
|
|
||||||
|
# 解析 RSS
|
||||||
|
parsed = feedparser.parse(response.content)
|
||||||
|
|
||||||
|
response_time_ms = int((time.time() - start_time) * 1000)
|
||||||
|
|
||||||
|
if parsed.bozo and hasattr(parsed, 'bozo_exception'):
|
||||||
|
# 有解析警告但可能仍然可用
|
||||||
|
pass
|
||||||
|
|
||||||
|
return {
|
||||||
|
"success": True,
|
||||||
|
"feed_data": parsed,
|
||||||
|
"error": None,
|
||||||
|
"response_time_ms": response_time_ms,
|
||||||
|
}
|
||||||
|
except requests.exceptions.RequestException as e:
|
||||||
|
return {"success": False, "feed_data": None, "error": str(e), "response_time_ms": None}
|
||||||
|
except Exception as e:
|
||||||
|
return {"success": False, "feed_data": None, "error": str(e), "response_time_ms": None}
|
||||||
|
|
||||||
|
|
||||||
|
def discover_feed_url(url: str, timeout: int = 15) -> list:
|
||||||
|
"""从任意网页自动发现 RSS/Atom feed URL
|
||||||
|
返回找到的 feed URL 列表
|
||||||
|
"""
|
||||||
|
try:
|
||||||
|
headers = {
|
||||||
|
"User-Agent": "rssKeeper/1.0 (+https://github.com/rssKeeper)",
|
||||||
|
}
|
||||||
|
response = requests.get(url, headers=headers, timeout=timeout, allow_redirects=True)
|
||||||
|
response.raise_for_status()
|
||||||
|
|
||||||
|
soup = BeautifulSoup(response.content, "html.parser")
|
||||||
|
feed_urls = []
|
||||||
|
|
||||||
|
# 查找 <link rel="alternate"> 标签
|
||||||
|
for link in soup.find_all("link", rel="alternate"):
|
||||||
|
link_type = link.get("type", "").lower()
|
||||||
|
href = link.get("href", "")
|
||||||
|
if href and any(t in link_type for t in ["rss", "atom", "xml"]):
|
||||||
|
full_url = urljoin(response.url, href)
|
||||||
|
feed_urls.append(full_url)
|
||||||
|
|
||||||
|
# 也查找常见的 RSS 链接
|
||||||
|
common_patterns = [
|
||||||
|
"/rss", "/feed", "/feeds", "/atom.xml", "/rss.xml",
|
||||||
|
"/index.xml", "/feed.xml", "/?feed=rss2",
|
||||||
|
]
|
||||||
|
for pattern in common_patterns:
|
||||||
|
candidate = urljoin(response.url, pattern)
|
||||||
|
if candidate not in feed_urls:
|
||||||
|
# 验证是否是有效的 feed
|
||||||
|
try:
|
||||||
|
resp = requests.head(candidate, headers=headers, timeout=5, allow_redirects=True)
|
||||||
|
content_type = resp.headers.get("Content-Type", "").lower()
|
||||||
|
if any(t in content_type for t in ["rss", "atom", "xml"]):
|
||||||
|
feed_urls.append(candidate)
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
|
|
||||||
|
return list(dict.fromkeys(feed_urls)) # 去重保持顺序
|
||||||
|
except Exception:
|
||||||
|
return []
|
||||||
|
|
||||||
|
|
||||||
|
def parse_article(entry, feed_id: int) -> dict:
|
||||||
|
"""从 feedparser entry 解析文章数据"""
|
||||||
|
title = entry.get("title", "")
|
||||||
|
link = entry.get("link", "")
|
||||||
|
author = entry.get("author", "")
|
||||||
|
|
||||||
|
# 发布时间
|
||||||
|
published_at = None
|
||||||
|
if hasattr(entry, "published_parsed") and entry.published_parsed:
|
||||||
|
try:
|
||||||
|
published_at = datetime(*entry.published_parsed[:6], tzinfo=timezone.utc).replace(tzinfo=None)
|
||||||
|
except (ValueError, TypeError):
|
||||||
|
pass
|
||||||
|
if not published_at and hasattr(entry, "updated_parsed") and entry.updated_parsed:
|
||||||
|
try:
|
||||||
|
published_at = datetime(*entry.updated_parsed[:6], tzinfo=timezone.utc).replace(tzinfo=None)
|
||||||
|
except (ValueError, TypeError):
|
||||||
|
pass
|
||||||
|
|
||||||
|
# 内容:优先 summary,其次 content
|
||||||
|
content = ""
|
||||||
|
if hasattr(entry, "content") and entry.content:
|
||||||
|
content = entry.content[0].value
|
||||||
|
elif hasattr(entry, "summary"):
|
||||||
|
content = entry.summary
|
||||||
|
|
||||||
|
# 清洗 HTML
|
||||||
|
content = clean_html(content)
|
||||||
|
|
||||||
|
# 生成摘要
|
||||||
|
summary = generate_summary(content)
|
||||||
|
|
||||||
|
return {
|
||||||
|
"feed_id": feed_id,
|
||||||
|
"title": title[:1024],
|
||||||
|
"link": link[:2048],
|
||||||
|
"author": author[:256],
|
||||||
|
"published_at": published_at,
|
||||||
|
"content": content[:config.MAX_ARTICLE_CONTENT_LENGTH],
|
||||||
|
"summary": summary[:config.MAX_SUMMARY_LENGTH],
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def clean_html(html_text: str) -> str:
|
||||||
|
"""清洗 HTML,去除 script/style 标签,转为安全文本"""
|
||||||
|
if not html_text:
|
||||||
|
return ""
|
||||||
|
|
||||||
|
# 先解码 HTML 实体
|
||||||
|
text = html.unescape(html_text)
|
||||||
|
|
||||||
|
# 用 BeautifulSoup 清理
|
||||||
|
soup = BeautifulSoup(text, "html.parser")
|
||||||
|
|
||||||
|
# 移除 script 和 style
|
||||||
|
for tag in soup(["script", "style", "iframe", "object", "embed"]):
|
||||||
|
tag.decompose()
|
||||||
|
|
||||||
|
# 获取纯文本
|
||||||
|
cleaned = soup.get_text(separator="\n")
|
||||||
|
|
||||||
|
# 压缩空白行
|
||||||
|
cleaned = re.sub(r"\n\s*\n+", "\n\n", cleaned)
|
||||||
|
cleaned = cleaned.strip()
|
||||||
|
|
||||||
|
return cleaned
|
||||||
|
|
||||||
|
|
||||||
|
def generate_summary(content: str, max_length: int = 300) -> str:
|
||||||
|
"""从内容生成摘要"""
|
||||||
|
if not content:
|
||||||
|
return ""
|
||||||
|
|
||||||
|
# 去掉多余空白
|
||||||
|
text = re.sub(r"\s+", " ", content).strip()
|
||||||
|
|
||||||
|
if len(text) <= max_length:
|
||||||
|
return text
|
||||||
|
|
||||||
|
# 在句子边界截断
|
||||||
|
truncated = text[:max_length]
|
||||||
|
last_period = max(truncated.rfind("。"), truncated.rfind(". "), truncated.rfind("! "), truncated.rfind("? "))
|
||||||
|
if last_period > max_length * 0.5:
|
||||||
|
return truncated[:last_period + 1]
|
||||||
|
|
||||||
|
return truncated + "..."
|
||||||
|
|
||||||
|
|
||||||
|
def fetch_and_store_feed(feed_id: int) -> dict:
|
||||||
|
"""抓取指定 RSS 源并存储文章
|
||||||
|
返回抓取结果统计
|
||||||
|
"""
|
||||||
|
db = SessionLocal()
|
||||||
|
try:
|
||||||
|
feed = db.query(Feed).filter(Feed.id == feed_id).first()
|
||||||
|
if not feed:
|
||||||
|
return {"success": False, "error": "Feed not found", "articles_count": 0}
|
||||||
|
|
||||||
|
result = fetch_feed(feed.url)
|
||||||
|
|
||||||
|
if not result["success"]:
|
||||||
|
# 记录失败
|
||||||
|
feed.last_fetch_at = datetime.utcnow()
|
||||||
|
feed.last_fetch_status = "fail"
|
||||||
|
feed.last_error = result["error"]
|
||||||
|
feed.fail_count += 1
|
||||||
|
|
||||||
|
log = FetchLog(
|
||||||
|
feed_id=feed_id,
|
||||||
|
status="fail",
|
||||||
|
error_message=result["error"],
|
||||||
|
response_time_ms=result.get("response_time_ms"),
|
||||||
|
)
|
||||||
|
db.add(log)
|
||||||
|
db.commit()
|
||||||
|
return {"success": False, "error": result["error"], "articles_count": 0}
|
||||||
|
|
||||||
|
parsed = result["feed_data"]
|
||||||
|
|
||||||
|
# 更新 feed 元信息
|
||||||
|
if hasattr(parsed.feed, "title"):
|
||||||
|
feed.title = parsed.feed.title[:512]
|
||||||
|
if hasattr(parsed.feed, "description"):
|
||||||
|
feed.description = parsed.feed.description[:1000]
|
||||||
|
|
||||||
|
# 存储文章
|
||||||
|
new_count = 0
|
||||||
|
for entry in parsed.entries:
|
||||||
|
article_data = parse_article(entry, feed_id)
|
||||||
|
if not article_data["link"]:
|
||||||
|
continue
|
||||||
|
|
||||||
|
# 检查是否已存在(基于 link)
|
||||||
|
existing = db.query(Article).filter(Article.link == article_data["link"]).first()
|
||||||
|
if existing:
|
||||||
|
# 更新已有文章
|
||||||
|
existing.title = article_data["title"] or existing.title
|
||||||
|
existing.content = article_data["content"] or existing.content
|
||||||
|
existing.summary = article_data["summary"] or existing.summary
|
||||||
|
existing.author = article_data["author"] or existing.author
|
||||||
|
if article_data["published_at"]:
|
||||||
|
existing.published_at = article_data["published_at"]
|
||||||
|
else:
|
||||||
|
article = Article(**article_data)
|
||||||
|
db.add(article)
|
||||||
|
new_count += 1
|
||||||
|
|
||||||
|
# 更新 feed 统计
|
||||||
|
feed.last_fetch_at = datetime.utcnow()
|
||||||
|
feed.last_fetch_status = "success"
|
||||||
|
feed.last_error = ""
|
||||||
|
feed.success_count += 1
|
||||||
|
feed.article_count = db.query(Article).filter(Article.feed_id == feed_id).count()
|
||||||
|
|
||||||
|
log = FetchLog(
|
||||||
|
feed_id=feed_id,
|
||||||
|
status="success",
|
||||||
|
articles_fetched=new_count,
|
||||||
|
response_time_ms=result.get("response_time_ms"),
|
||||||
|
)
|
||||||
|
db.add(log)
|
||||||
|
db.commit()
|
||||||
|
|
||||||
|
return {
|
||||||
|
"success": True,
|
||||||
|
"articles_count": new_count,
|
||||||
|
"feed_title": feed.title,
|
||||||
|
}
|
||||||
|
except Exception as e:
|
||||||
|
db.rollback()
|
||||||
|
return {"success": False, "error": str(e), "articles_count": 0}
|
||||||
|
finally:
|
||||||
|
db.close()
|
||||||
|
|
||||||
|
|
||||||
|
def fetch_all_feeds(feed_ids: list = None) -> list:
|
||||||
|
"""并发抓取多个 RSS 源
|
||||||
|
返回每个源的抓取结果列表
|
||||||
|
"""
|
||||||
|
db = SessionLocal()
|
||||||
|
try:
|
||||||
|
query = db.query(Feed).filter(Feed.is_active == True)
|
||||||
|
if feed_ids:
|
||||||
|
query = query.filter(Feed.id.in_(feed_ids))
|
||||||
|
feeds = query.all()
|
||||||
|
finally:
|
||||||
|
db.close()
|
||||||
|
|
||||||
|
results = []
|
||||||
|
with ThreadPoolExecutor(max_workers=config.FETCH_CONCURRENCY) as executor:
|
||||||
|
future_to_feed = {
|
||||||
|
executor.submit(fetch_and_store_feed, feed.id): feed
|
||||||
|
for feed in feeds
|
||||||
|
}
|
||||||
|
for future in as_completed(future_to_feed):
|
||||||
|
feed = future_to_feed[future]
|
||||||
|
try:
|
||||||
|
result = future.result()
|
||||||
|
results.append({"feed_id": feed.id, **result})
|
||||||
|
except Exception as e:
|
||||||
|
results.append({"feed_id": feed.id, "success": False, "error": str(e), "articles_count": 0})
|
||||||
|
|
||||||
|
return results
|
||||||
@@ -0,0 +1,74 @@
|
|||||||
|
"""APScheduler 定时任务管理"""
|
||||||
|
from apscheduler.schedulers.background import BackgroundScheduler
|
||||||
|
from apscheduler.triggers.interval import IntervalTrigger
|
||||||
|
from rss_fetcher import fetch_and_store_feed
|
||||||
|
import config
|
||||||
|
|
||||||
|
_scheduler = None
|
||||||
|
|
||||||
|
|
||||||
|
def get_scheduler():
|
||||||
|
"""获取或创建调度器实例"""
|
||||||
|
global _scheduler
|
||||||
|
if _scheduler is None:
|
||||||
|
_scheduler = BackgroundScheduler()
|
||||||
|
return _scheduler
|
||||||
|
|
||||||
|
|
||||||
|
def add_feed_job(feed_id: int, interval_minutes: int):
|
||||||
|
"""为指定 RSS 源添加定时抓取任务"""
|
||||||
|
scheduler = get_scheduler()
|
||||||
|
job_id = f"fetch_feed_{feed_id}"
|
||||||
|
|
||||||
|
# 确保间隔不低于最小值
|
||||||
|
interval = max(interval_minutes, config.MIN_FETCH_INTERVAL)
|
||||||
|
|
||||||
|
# 如果任务已存在则更新
|
||||||
|
existing = scheduler.get_job(job_id)
|
||||||
|
if existing:
|
||||||
|
existing.reschedule(trigger=IntervalTrigger(minutes=interval))
|
||||||
|
return
|
||||||
|
|
||||||
|
scheduler.add_job(
|
||||||
|
fetch_and_store_feed,
|
||||||
|
trigger=IntervalTrigger(minutes=interval),
|
||||||
|
id=job_id,
|
||||||
|
args=[feed_id],
|
||||||
|
replace_existing=True,
|
||||||
|
misfire_grace_time=300, # 5分钟容错
|
||||||
|
coalesce=True, # 合并错过的任务
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def remove_feed_job(feed_id: int):
|
||||||
|
"""移除指定 RSS 源的定时任务"""
|
||||||
|
scheduler = get_scheduler()
|
||||||
|
job_id = f"fetch_feed_{feed_id}"
|
||||||
|
try:
|
||||||
|
scheduler.remove_job(job_id)
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
|
|
||||||
|
|
||||||
|
def start_scheduler():
|
||||||
|
"""启动调度器"""
|
||||||
|
scheduler = get_scheduler()
|
||||||
|
if not scheduler.running:
|
||||||
|
scheduler.start()
|
||||||
|
|
||||||
|
|
||||||
|
def stop_scheduler():
|
||||||
|
"""停止调度器"""
|
||||||
|
global _scheduler
|
||||||
|
if _scheduler and _scheduler.running:
|
||||||
|
_scheduler.shutdown(wait=False)
|
||||||
|
_scheduler = None
|
||||||
|
|
||||||
|
|
||||||
|
def init_feed_jobs(db):
|
||||||
|
"""从数据库加载所有活跃 RSS 源并注册定时任务"""
|
||||||
|
from models import Feed
|
||||||
|
feeds = db.query(Feed).filter(Feed.is_active == True).all()
|
||||||
|
for feed in feeds:
|
||||||
|
add_feed_job(feed.id, feed.fetch_interval_minutes or config.DEFAULT_FETCH_INTERVAL)
|
||||||
|
start_scheduler()
|
||||||
@@ -0,0 +1,28 @@
|
|||||||
|
version: '3.8'
|
||||||
|
|
||||||
|
services:
|
||||||
|
rsskeeper:
|
||||||
|
build:
|
||||||
|
context: .
|
||||||
|
dockerfile: Dockerfile
|
||||||
|
container_name: rsskeeper
|
||||||
|
ports:
|
||||||
|
- "8000:8000"
|
||||||
|
volumes:
|
||||||
|
- ./data:/app/data
|
||||||
|
environment:
|
||||||
|
- DATA_DIR=/app/data
|
||||||
|
- DATABASE_URL=/app/data/rsskeeper.db
|
||||||
|
- FETCH_CONCURRENCY=10
|
||||||
|
- FETCH_TIMEOUT=30
|
||||||
|
- DEFAULT_FETCH_INTERVAL=60
|
||||||
|
- MIN_FETCH_INTERVAL=15
|
||||||
|
- MAX_ARTICLE_CONTENT_LENGTH=50000
|
||||||
|
- MAX_SUMMARY_LENGTH=500
|
||||||
|
restart: unless-stopped
|
||||||
|
healthcheck:
|
||||||
|
test: ["CMD", "curl", "-f", "http://localhost:8000/api/health"]
|
||||||
|
interval: 30s
|
||||||
|
timeout: 10s
|
||||||
|
retries: 3
|
||||||
|
start_period: 10s
|
||||||
@@ -0,0 +1,12 @@
|
|||||||
|
<!DOCTYPE html>
|
||||||
|
<html lang="zh-CN">
|
||||||
|
<head>
|
||||||
|
<meta charset="UTF-8">
|
||||||
|
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||||
|
<title>rssKeeper - RSS 管理与检索</title>
|
||||||
|
</head>
|
||||||
|
<body>
|
||||||
|
<div id="app"></div>
|
||||||
|
<script type="module" src="/src/main.js"></script>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
@@ -0,0 +1,22 @@
|
|||||||
|
{
|
||||||
|
"name": "rsskeeper-frontend",
|
||||||
|
"private": true,
|
||||||
|
"version": "1.0.0",
|
||||||
|
"type": "module",
|
||||||
|
"scripts": {
|
||||||
|
"dev": "vite",
|
||||||
|
"build": "vite build",
|
||||||
|
"preview": "vite preview"
|
||||||
|
},
|
||||||
|
"dependencies": {
|
||||||
|
"vue": "^3.4.21",
|
||||||
|
"vue-router": "^4.3.0",
|
||||||
|
"element-plus": "^2.6.3",
|
||||||
|
"axios": "^1.6.8",
|
||||||
|
"@element-plus/icons-vue": "^2.3.1"
|
||||||
|
},
|
||||||
|
"devDependencies": {
|
||||||
|
"@vitejs/plugin-vue": "^5.0.4",
|
||||||
|
"vite": "^5.2.0"
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,176 @@
|
|||||||
|
<template>
|
||||||
|
<el-container class="app-container">
|
||||||
|
<!-- 侧边栏 -->
|
||||||
|
<el-aside width="200px" class="sidebar">
|
||||||
|
<div class="logo">
|
||||||
|
<el-icon size="24"><Document /></el-icon>
|
||||||
|
<span>rssKeeper</span>
|
||||||
|
</div>
|
||||||
|
<el-menu
|
||||||
|
:default-active="$route.path"
|
||||||
|
router
|
||||||
|
class="sidebar-menu"
|
||||||
|
background-color="#1a1a2e"
|
||||||
|
text-color="#a0aec0"
|
||||||
|
active-text-color="#fff"
|
||||||
|
>
|
||||||
|
<el-menu-item index="/">
|
||||||
|
<el-icon><Odometer /></el-icon>
|
||||||
|
<span>仪表盘</span>
|
||||||
|
</el-menu-item>
|
||||||
|
<el-menu-item index="/feeds">
|
||||||
|
<el-icon><Collection /></el-icon>
|
||||||
|
<span>RSS 源管理</span>
|
||||||
|
</el-menu-item>
|
||||||
|
<el-menu-item index="/articles">
|
||||||
|
<el-icon><Document /></el-icon>
|
||||||
|
<span>文章列表</span>
|
||||||
|
</el-menu-item>
|
||||||
|
</el-menu>
|
||||||
|
</el-aside>
|
||||||
|
|
||||||
|
<!-- 主内容区 -->
|
||||||
|
<el-main class="main-content">
|
||||||
|
<router-view />
|
||||||
|
</el-main>
|
||||||
|
</el-container>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script setup>
|
||||||
|
import { Document, Odometer, Collection } from '@element-plus/icons-vue'
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<style>
|
||||||
|
* {
|
||||||
|
margin: 0;
|
||||||
|
padding: 0;
|
||||||
|
box-sizing: border-box;
|
||||||
|
}
|
||||||
|
|
||||||
|
body {
|
||||||
|
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif;
|
||||||
|
background: #0f0f23;
|
||||||
|
color: #e2e8f0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.app-container {
|
||||||
|
height: 100vh;
|
||||||
|
}
|
||||||
|
|
||||||
|
.sidebar {
|
||||||
|
background: #1a1a2e;
|
||||||
|
border-right: 1px solid #2d3748;
|
||||||
|
}
|
||||||
|
|
||||||
|
.logo {
|
||||||
|
height: 60px;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
gap: 8px;
|
||||||
|
font-size: 18px;
|
||||||
|
font-weight: bold;
|
||||||
|
color: #63b3ed;
|
||||||
|
border-bottom: 1px solid #2d3748;
|
||||||
|
}
|
||||||
|
|
||||||
|
.sidebar-menu {
|
||||||
|
border-right: none !important;
|
||||||
|
}
|
||||||
|
|
||||||
|
.sidebar-menu .el-menu-item:hover {
|
||||||
|
background: #2d3748 !important;
|
||||||
|
}
|
||||||
|
|
||||||
|
.main-content {
|
||||||
|
background: #0f0f23;
|
||||||
|
padding: 20px;
|
||||||
|
overflow-y: auto;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Element Plus 暗色主题覆盖 */
|
||||||
|
.el-card {
|
||||||
|
background: #1a1a2e;
|
||||||
|
border: 1px solid #2d3748;
|
||||||
|
color: #e2e8f0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.el-card__header {
|
||||||
|
border-bottom: 1px solid #2d3748;
|
||||||
|
color: #e2e8f0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.el-table {
|
||||||
|
background: #1a1a2e;
|
||||||
|
--el-table-header-bg-color: #2d3748;
|
||||||
|
--el-table-row-hover-bg-color: #2d3748;
|
||||||
|
--el-table-border-color: #2d3748;
|
||||||
|
--el-table-text-color: #e2e8f0;
|
||||||
|
--el-table-header-text-color: #a0aec0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.el-table th {
|
||||||
|
background: #2d3748 !important;
|
||||||
|
}
|
||||||
|
|
||||||
|
.el-input__wrapper {
|
||||||
|
background: #2d3748;
|
||||||
|
box-shadow: 0 0 0 1px #4a5568 inset;
|
||||||
|
}
|
||||||
|
|
||||||
|
.el-input__inner {
|
||||||
|
color: #e2e8f0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.el-button--primary {
|
||||||
|
--el-button-bg-color: #3182ce;
|
||||||
|
--el-button-border-color: #3182ce;
|
||||||
|
--el-button-hover-bg-color: #2b6cb0;
|
||||||
|
--el-button-hover-border-color: #2b6cb0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.el-pagination {
|
||||||
|
--el-pagination-button-bg-color: #1a1a2e;
|
||||||
|
--el-pagination-button-color: #a0aec0;
|
||||||
|
--el-pagination-hover-color: #63b3ed;
|
||||||
|
}
|
||||||
|
|
||||||
|
.el-dialog {
|
||||||
|
background: #1a1a2e;
|
||||||
|
}
|
||||||
|
|
||||||
|
.el-dialog__title {
|
||||||
|
color: #e2e8f0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.el-tag {
|
||||||
|
border: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.el-empty__description {
|
||||||
|
color: #a0aec0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.page-title {
|
||||||
|
font-size: 24px;
|
||||||
|
font-weight: bold;
|
||||||
|
margin-bottom: 20px;
|
||||||
|
color: #e2e8f0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.stat-card {
|
||||||
|
text-align: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
.stat-value {
|
||||||
|
font-size: 32px;
|
||||||
|
font-weight: bold;
|
||||||
|
color: #63b3ed;
|
||||||
|
}
|
||||||
|
|
||||||
|
.stat-label {
|
||||||
|
font-size: 14px;
|
||||||
|
color: #a0aec0;
|
||||||
|
margin-top: 8px;
|
||||||
|
}
|
||||||
|
</style>
|
||||||
@@ -0,0 +1,63 @@
|
|||||||
|
import axios from 'axios'
|
||||||
|
|
||||||
|
const api = axios.create({
|
||||||
|
baseURL: '',
|
||||||
|
timeout: 30000,
|
||||||
|
headers: {
|
||||||
|
'Content-Type': 'application/json',
|
||||||
|
},
|
||||||
|
})
|
||||||
|
|
||||||
|
// 请求拦截
|
||||||
|
api.interceptors.request.use(
|
||||||
|
(config) => config,
|
||||||
|
(error) => Promise.reject(error)
|
||||||
|
)
|
||||||
|
|
||||||
|
// 响应拦截
|
||||||
|
api.interceptors.response.use(
|
||||||
|
(response) => response.data,
|
||||||
|
(error) => {
|
||||||
|
const msg = error.response?.data?.detail || error.message || '请求失败'
|
||||||
|
return Promise.reject(new Error(msg))
|
||||||
|
}
|
||||||
|
)
|
||||||
|
|
||||||
|
// RSS 源管理
|
||||||
|
export const feedsApi = {
|
||||||
|
list: (params = {}) => api.get('/api/feeds', { params }),
|
||||||
|
categories: () => api.get('/api/feeds/categories'),
|
||||||
|
get: (id) => api.get(`/api/feeds/${id}`),
|
||||||
|
create: (data) => api.post('/api/feeds', data),
|
||||||
|
update: (id, data) => api.put(`/api/feeds/${id}`, data),
|
||||||
|
remove: (id) => api.delete(`/api/feeds/${id}`),
|
||||||
|
fetch: (id) => api.post(`/api/feeds/${id}/fetch`),
|
||||||
|
discover: (url) => api.post('/api/feeds/discover', null, { params: { url } }),
|
||||||
|
importOpml: (content) => api.post('/api/feeds/import-opml', { opml_content: content }),
|
||||||
|
exportOpml: () => api.get('/api/feeds/export-opml'),
|
||||||
|
}
|
||||||
|
|
||||||
|
// 文章管理
|
||||||
|
export const articlesApi = {
|
||||||
|
list: (params = {}) => api.get('/api/articles', { params }),
|
||||||
|
get: (id) => api.get(`/api/articles/${id}`),
|
||||||
|
search: (q) => api.get('/api/articles/search/fulltext', { params: { q } }),
|
||||||
|
markRead: (id) => api.put(`/api/articles/${id}/read`),
|
||||||
|
}
|
||||||
|
|
||||||
|
// 仪表盘
|
||||||
|
export const dashboardApi = {
|
||||||
|
stats: () => api.get('/api/dashboard/stats'),
|
||||||
|
health: (params = {}) => api.get('/api/dashboard/health', { params }),
|
||||||
|
recentActivity: () => api.get('/api/dashboard/recent-activity'),
|
||||||
|
}
|
||||||
|
|
||||||
|
// 对外 API
|
||||||
|
export const externalApi = {
|
||||||
|
recent: (params = {}) => api.get('/api/v1/external/recent', { params }),
|
||||||
|
feeds: () => api.get('/api/v1/external/feeds'),
|
||||||
|
feedArticles: (id, params = {}) => api.get(`/api/v1/external/feeds/${id}/articles`, { params }),
|
||||||
|
summary: (date) => api.get('/api/v1/external/summary', { params: { date } }),
|
||||||
|
}
|
||||||
|
|
||||||
|
export default api
|
||||||
@@ -0,0 +1,35 @@
|
|||||||
|
import { createApp } from 'vue'
|
||||||
|
import ElementPlus from 'element-plus'
|
||||||
|
import 'element-plus/dist/index.css'
|
||||||
|
import * as ElementPlusIconsVue from '@element-plus/icons-vue'
|
||||||
|
import { createRouter, createWebHistory } from 'vue-router'
|
||||||
|
import App from './App.vue'
|
||||||
|
|
||||||
|
// 页面组件
|
||||||
|
import Dashboard from './views/Dashboard.vue'
|
||||||
|
import Feeds from './views/Feeds.vue'
|
||||||
|
import Articles from './views/Articles.vue'
|
||||||
|
import ArticleDetail from './views/ArticleDetail.vue'
|
||||||
|
|
||||||
|
const routes = [
|
||||||
|
{ path: '/', component: Dashboard, name: 'Dashboard' },
|
||||||
|
{ path: '/feeds', component: Feeds, name: 'Feeds' },
|
||||||
|
{ path: '/articles', component: Articles, name: 'Articles' },
|
||||||
|
{ path: '/articles/:id', component: ArticleDetail, name: 'ArticleDetail' },
|
||||||
|
]
|
||||||
|
|
||||||
|
const router = createRouter({
|
||||||
|
history: createWebHistory(),
|
||||||
|
routes,
|
||||||
|
})
|
||||||
|
|
||||||
|
const app = createApp(App)
|
||||||
|
|
||||||
|
// 注册所有图标
|
||||||
|
for (const [key, component] of Object.entries(ElementPlusIconsVue)) {
|
||||||
|
app.component(key, component)
|
||||||
|
}
|
||||||
|
|
||||||
|
app.use(ElementPlus)
|
||||||
|
app.use(router)
|
||||||
|
app.mount('#app')
|
||||||
@@ -0,0 +1,120 @@
|
|||||||
|
<template>
|
||||||
|
<div v-loading="loading">
|
||||||
|
<el-button text @click="$router.back()" style="margin-bottom: 16px;">
|
||||||
|
<el-icon><ArrowLeft /></el-icon> 返回
|
||||||
|
</el-button>
|
||||||
|
|
||||||
|
<el-card v-if="article.id" shadow="hover">
|
||||||
|
<template #header>
|
||||||
|
<div>
|
||||||
|
<h1 style="font-size: 22px; margin-bottom: 12px;">{{ article.title || '无标题' }}</h1>
|
||||||
|
<div style="display: flex; align-items: center; gap: 12px; font-size: 13px; color: #a0aec0;">
|
||||||
|
<el-tag size="small">{{ article.feed_title }}</el-tag>
|
||||||
|
<span v-if="article.author">作者: {{ article.author }}</span>
|
||||||
|
<span>发布时间: {{ formatTime(article.published_at) }}</span>
|
||||||
|
<span>抓取时间: {{ formatTime(article.created_at) }}</span>
|
||||||
|
<el-link v-if="article.link" :href="article.link" target="_blank" type="primary">
|
||||||
|
查看原文
|
||||||
|
</el-link>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<div class="article-content" v-html="formatContent(article.content)"></div>
|
||||||
|
</el-card>
|
||||||
|
|
||||||
|
<el-empty v-else description="文章不存在" />
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script setup>
|
||||||
|
import { ref, onMounted } from 'vue'
|
||||||
|
import { useRoute } from 'vue-router'
|
||||||
|
import { ElMessage } from 'element-plus'
|
||||||
|
import { ArrowLeft } from '@element-plus/icons-vue'
|
||||||
|
import { articlesApi } from '../api/index.js'
|
||||||
|
|
||||||
|
const route = useRoute()
|
||||||
|
const article = ref({})
|
||||||
|
const loading = ref(false)
|
||||||
|
|
||||||
|
const formatTime = (iso) => {
|
||||||
|
if (!iso) return '-'
|
||||||
|
const d = new Date(iso)
|
||||||
|
return d.toLocaleString('zh-CN')
|
||||||
|
}
|
||||||
|
|
||||||
|
const formatContent = (content) => {
|
||||||
|
if (!content) return '<p style="color: #718096;">暂无内容</p>'
|
||||||
|
// 将纯文本中的换行转为 HTML 换行
|
||||||
|
return content
|
||||||
|
.replace(/\n/g, '<br>')
|
||||||
|
.replace(/ /g, ' ')
|
||||||
|
}
|
||||||
|
|
||||||
|
const loadArticle = async () => {
|
||||||
|
const id = parseInt(route.params.id)
|
||||||
|
if (!id) return
|
||||||
|
|
||||||
|
loading.value = true
|
||||||
|
try {
|
||||||
|
article.value = await articlesApi.get(id)
|
||||||
|
} catch (e) {
|
||||||
|
ElMessage.error(e.message)
|
||||||
|
} finally {
|
||||||
|
loading.value = false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
onMounted(loadArticle)
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<style scoped>
|
||||||
|
.article-content {
|
||||||
|
font-size: 15px;
|
||||||
|
line-height: 1.8;
|
||||||
|
color: #e2e8f0;
|
||||||
|
white-space: pre-wrap;
|
||||||
|
}
|
||||||
|
|
||||||
|
.article-content :deep(p) {
|
||||||
|
margin-bottom: 1em;
|
||||||
|
}
|
||||||
|
|
||||||
|
.article-content :deep(a) {
|
||||||
|
color: #63b3ed;
|
||||||
|
}
|
||||||
|
|
||||||
|
.article-content :deep(h1),
|
||||||
|
.article-content :deep(h2),
|
||||||
|
.article-content :deep(h3) {
|
||||||
|
color: #e2e8f0;
|
||||||
|
margin: 1.5em 0 0.5em;
|
||||||
|
}
|
||||||
|
|
||||||
|
.article-content :deep(code) {
|
||||||
|
background: #2d3748;
|
||||||
|
padding: 2px 6px;
|
||||||
|
border-radius: 4px;
|
||||||
|
font-size: 13px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.article-content :deep(pre) {
|
||||||
|
background: #2d3748;
|
||||||
|
padding: 16px;
|
||||||
|
border-radius: 8px;
|
||||||
|
overflow-x: auto;
|
||||||
|
}
|
||||||
|
|
||||||
|
.article-content :deep(blockquote) {
|
||||||
|
border-left: 4px solid #4a5568;
|
||||||
|
padding-left: 16px;
|
||||||
|
margin-left: 0;
|
||||||
|
color: #a0aec0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.article-content :deep(ul),
|
||||||
|
.article-content :deep(ol) {
|
||||||
|
padding-left: 2em;
|
||||||
|
}
|
||||||
|
</style>
|
||||||
@@ -0,0 +1,216 @@
|
|||||||
|
<template>
|
||||||
|
<div>
|
||||||
|
<div style="display: flex; justify-content: space-between; align-items: center; margin-bottom: 20px;">
|
||||||
|
<h1 class="page-title">📄 文章列表</h1>
|
||||||
|
<el-tag v-if="stats.total" type="info">共 {{ stats.total }} 篇文章</el-tag>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- 筛选栏 -->
|
||||||
|
<el-row :gutter="10" style="margin-bottom: 16px;">
|
||||||
|
<el-col :span="8">
|
||||||
|
<el-input v-model="searchQuery" placeholder="全文搜索文章..." clearable @change="handleSearch" :prefix-icon="Search">
|
||||||
|
<template #append>
|
||||||
|
<el-button @click="handleSearch">搜索</el-button>
|
||||||
|
</template>
|
||||||
|
</el-input>
|
||||||
|
</el-col>
|
||||||
|
<el-col :span="4">
|
||||||
|
<el-select v-model="filterFeed" placeholder="RSS 源筛选" clearable @change="loadArticles">
|
||||||
|
<el-option
|
||||||
|
v-for="feed in feedOptions"
|
||||||
|
:key="feed.id"
|
||||||
|
:label="feed.title || feed.url"
|
||||||
|
:value="feed.id"
|
||||||
|
/>
|
||||||
|
</el-select>
|
||||||
|
</el-col>
|
||||||
|
<el-col :span="4">
|
||||||
|
<el-select v-model="filterCategory" placeholder="分类筛选" clearable @change="loadArticles">
|
||||||
|
<el-option v-for="cat in categories" :key="cat" :label="cat || '未分类'" :value="cat" />
|
||||||
|
</el-select>
|
||||||
|
</el-col>
|
||||||
|
<el-col :span="8">
|
||||||
|
<el-date-picker
|
||||||
|
v-model="dateRange"
|
||||||
|
type="daterange"
|
||||||
|
range-separator="至"
|
||||||
|
start-placeholder="开始日期"
|
||||||
|
end-placeholder="结束日期"
|
||||||
|
value-format="YYYY-MM-DD"
|
||||||
|
@change="loadArticles"
|
||||||
|
style="width: 100%;"
|
||||||
|
/>
|
||||||
|
</el-col>
|
||||||
|
</el-row>
|
||||||
|
|
||||||
|
<!-- 文章列表 -->
|
||||||
|
<el-card shadow="hover" v-loading="loading">
|
||||||
|
<el-empty v-if="articles.length === 0" description="暂无文章" />
|
||||||
|
|
||||||
|
<div v-else>
|
||||||
|
<div
|
||||||
|
v-for="article in articles"
|
||||||
|
:key="article.id"
|
||||||
|
class="article-item"
|
||||||
|
@click="viewArticle(article.id)"
|
||||||
|
>
|
||||||
|
<div class="article-header">
|
||||||
|
<h3 class="article-title">{{ article.title || '无标题' }}</h3>
|
||||||
|
<div class="article-meta">
|
||||||
|
<el-tag size="small" type="info">{{ article.feed_title }}</el-tag>
|
||||||
|
<el-tag v-if="article.category" size="small" style="margin-left: 4px;">{{ article.category }}</el-tag>
|
||||||
|
<span class="article-time">{{ formatTime(article.published_at) }}</span>
|
||||||
|
<el-link v-if="article.link" :href="article.link" target="_blank" type="primary" @click.stop>
|
||||||
|
原文
|
||||||
|
</el-link>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<p class="article-summary">{{ article.summary || '暂无摘要' }}</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<el-pagination
|
||||||
|
v-model:current-page="page"
|
||||||
|
v-model:page-size="pageSize"
|
||||||
|
:total="stats.total"
|
||||||
|
:page-sizes="[20, 50, 100]"
|
||||||
|
layout="total, sizes, prev, pager, next"
|
||||||
|
@change="loadArticles"
|
||||||
|
style="margin-top: 20px; justify-content: flex-end;"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</el-card>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script setup>
|
||||||
|
import { ref, onMounted } from 'vue'
|
||||||
|
import { useRouter } from 'vue-router'
|
||||||
|
import { ElMessage } from 'element-plus'
|
||||||
|
import { Search } from '@element-plus/icons-vue'
|
||||||
|
import { articlesApi, feedsApi } from '../api/index.js'
|
||||||
|
|
||||||
|
const router = useRouter()
|
||||||
|
const articles = ref([])
|
||||||
|
const stats = ref({ total: 0 })
|
||||||
|
const loading = ref(false)
|
||||||
|
const page = ref(1)
|
||||||
|
const pageSize = ref(20)
|
||||||
|
const searchQuery = ref('')
|
||||||
|
const filterFeed = ref('')
|
||||||
|
const filterCategory = ref('')
|
||||||
|
const dateRange = ref(null)
|
||||||
|
const feedOptions = ref([])
|
||||||
|
const categories = ref([])
|
||||||
|
|
||||||
|
const formatTime = (iso) => {
|
||||||
|
if (!iso) return '-'
|
||||||
|
const d = new Date(iso)
|
||||||
|
return d.toLocaleString('zh-CN', { year: 'numeric', month: '2-digit', day: '2-digit', hour: '2-digit', minute: '2-digit' })
|
||||||
|
}
|
||||||
|
|
||||||
|
const loadArticles = async () => {
|
||||||
|
loading.value = true
|
||||||
|
try {
|
||||||
|
const params = {
|
||||||
|
skip: (page.value - 1) * pageSize.value,
|
||||||
|
limit: pageSize.value,
|
||||||
|
}
|
||||||
|
if (filterFeed.value) params.feed_id = filterFeed.value
|
||||||
|
if (filterCategory.value) params.category = filterCategory.value
|
||||||
|
if (dateRange.value && dateRange.value[0]) {
|
||||||
|
params.since = dateRange.value[0]
|
||||||
|
params.until = dateRange.value[1]
|
||||||
|
}
|
||||||
|
|
||||||
|
// 如果有搜索词,使用全文搜索
|
||||||
|
if (searchQuery.value && searchQuery.value.trim()) {
|
||||||
|
const res = await articlesApi.search(searchQuery.value.trim())
|
||||||
|
articles.value = res.items || []
|
||||||
|
stats.value = { total: res.total }
|
||||||
|
} else {
|
||||||
|
const res = await articlesApi.list(params)
|
||||||
|
articles.value = res.items || []
|
||||||
|
stats.value = { total: res.total }
|
||||||
|
}
|
||||||
|
} catch (e) {
|
||||||
|
ElMessage.error(e.message)
|
||||||
|
} finally {
|
||||||
|
loading.value = false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const handleSearch = () => {
|
||||||
|
page.value = 1
|
||||||
|
loadArticles()
|
||||||
|
}
|
||||||
|
|
||||||
|
const viewArticle = (id) => {
|
||||||
|
router.push(`/articles/${id}`)
|
||||||
|
}
|
||||||
|
|
||||||
|
const loadFeedOptions = async () => {
|
||||||
|
try {
|
||||||
|
const res = await feedsApi.list({ limit: 1000 })
|
||||||
|
feedOptions.value = res.items || []
|
||||||
|
const cats = new Set()
|
||||||
|
for (const f of feedOptions.value) {
|
||||||
|
if (f.category) cats.add(f.category)
|
||||||
|
}
|
||||||
|
categories.value = Array.from(cats)
|
||||||
|
} catch (e) {
|
||||||
|
console.error(e)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
onMounted(() => {
|
||||||
|
loadArticles()
|
||||||
|
loadFeedOptions()
|
||||||
|
})
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<style scoped>
|
||||||
|
.article-item {
|
||||||
|
padding: 16px;
|
||||||
|
border-bottom: 1px solid #2d3748;
|
||||||
|
cursor: pointer;
|
||||||
|
transition: background 0.2s;
|
||||||
|
}
|
||||||
|
|
||||||
|
.article-item:hover {
|
||||||
|
background: #2d3748;
|
||||||
|
}
|
||||||
|
|
||||||
|
.article-item:last-child {
|
||||||
|
border-bottom: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.article-title {
|
||||||
|
font-size: 16px;
|
||||||
|
font-weight: 600;
|
||||||
|
color: #e2e8f0;
|
||||||
|
margin-bottom: 8px;
|
||||||
|
line-height: 1.4;
|
||||||
|
}
|
||||||
|
|
||||||
|
.article-meta {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 8px;
|
||||||
|
margin-bottom: 8px;
|
||||||
|
font-size: 12px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.article-time {
|
||||||
|
color: #718096;
|
||||||
|
}
|
||||||
|
|
||||||
|
.article-summary {
|
||||||
|
color: #a0aec0;
|
||||||
|
font-size: 14px;
|
||||||
|
line-height: 1.6;
|
||||||
|
display: -webkit-box;
|
||||||
|
-webkit-line-clamp: 2;
|
||||||
|
-webkit-box-orient: vertical;
|
||||||
|
overflow: hidden;
|
||||||
|
}
|
||||||
|
</style>
|
||||||
@@ -0,0 +1,192 @@
|
|||||||
|
<template>
|
||||||
|
<div>
|
||||||
|
<h1 class="page-title">📊 仪表盘</h1>
|
||||||
|
|
||||||
|
<!-- 统计卡片 -->
|
||||||
|
<el-row :gutter="20" class="stats-row">
|
||||||
|
<el-col :span="6" v-for="stat in statsCards" :key="stat.key">
|
||||||
|
<el-card class="stat-card" shadow="hover">
|
||||||
|
<div class="stat-value" :style="{ color: stat.color }">{{ stat.value }}</div>
|
||||||
|
<div class="stat-label">{{ stat.label }}</div>
|
||||||
|
</el-card>
|
||||||
|
</el-col>
|
||||||
|
</el-row>
|
||||||
|
|
||||||
|
<!-- 健康度概览 + 最近活动 -->
|
||||||
|
<el-row :gutter="20" style="margin-top: 20px;">
|
||||||
|
<el-col :span="14">
|
||||||
|
<el-card shadow="hover">
|
||||||
|
<template #header>
|
||||||
|
<div style="display: flex; justify-content: space-between; align-items: center;">
|
||||||
|
<span>🩺 RSS 源健康度</span>
|
||||||
|
<el-button text size="small" @click="$router.push('/feeds')">查看全部</el-button>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
<el-table :data="healthData" size="small" v-loading="loadingHealth">
|
||||||
|
<el-table-column prop="title" label="源名称" min-width="200" show-overflow-tooltip>
|
||||||
|
<template #default="scope">
|
||||||
|
<el-link :href="scope.row.url" target="_blank" type="primary">{{ scope.row.title }}</el-link>
|
||||||
|
</template>
|
||||||
|
</el-table-column>
|
||||||
|
<el-table-column prop="health_label" label="状态" width="80">
|
||||||
|
<template #default="scope">
|
||||||
|
<el-tag :type="healthTagType(scope.row.health_status)" size="small">
|
||||||
|
{{ scope.row.health_label }}
|
||||||
|
</el-tag>
|
||||||
|
</template>
|
||||||
|
</el-table-column>
|
||||||
|
<el-table-column prop="success_rate" label="成功率" width="90">
|
||||||
|
<template #default="scope">
|
||||||
|
{{ scope.row.success_rate }}%
|
||||||
|
</template>
|
||||||
|
</el-table-column>
|
||||||
|
<el-table-column prop="article_count" label="文章数" width="80" />
|
||||||
|
<el-table-column prop="days_since_fetch" label="未更新(天)" width="100">
|
||||||
|
<template #default="scope">
|
||||||
|
{{ scope.row.days_since_fetch !== null ? scope.row.days_since_fetch : '-' }}
|
||||||
|
</template>
|
||||||
|
</el-table-column>
|
||||||
|
</el-table>
|
||||||
|
</el-card>
|
||||||
|
</el-col>
|
||||||
|
|
||||||
|
<el-col :span="10">
|
||||||
|
<el-card shadow="hover">
|
||||||
|
<template #header>
|
||||||
|
<span>📋 最近抓取活动</span>
|
||||||
|
</template>
|
||||||
|
<el-timeline v-loading="loadingActivity">
|
||||||
|
<el-timeline-item
|
||||||
|
v-for="log in recentActivity"
|
||||||
|
:key="log.id"
|
||||||
|
:type="log.status === 'success' ? 'success' : 'danger'"
|
||||||
|
:icon="log.status === 'success' ? 'CircleCheck' : 'CircleClose'"
|
||||||
|
>
|
||||||
|
<div style="font-size: 13px;">
|
||||||
|
<strong>{{ log.feed_title }}</strong>
|
||||||
|
<el-tag :type="log.status === 'success' ? 'success' : 'danger'" size="small" style="margin-left: 8px;">
|
||||||
|
{{ log.status === 'success' ? '成功' : '失败' }}
|
||||||
|
</el-tag>
|
||||||
|
</div>
|
||||||
|
<div style="font-size: 12px; color: #a0aec0; margin-top: 4px;">
|
||||||
|
{{ log.status === 'success' ? `获取 ${log.articles_fetched} 篇文章` : log.error_message }}
|
||||||
|
· {{ formatTime(log.created_at) }}
|
||||||
|
</div>
|
||||||
|
</el-timeline-item>
|
||||||
|
</el-timeline>
|
||||||
|
</el-card>
|
||||||
|
</el-col>
|
||||||
|
</el-row>
|
||||||
|
|
||||||
|
<!-- 分类分布 -->
|
||||||
|
<el-row style="margin-top: 20px;">
|
||||||
|
<el-col :span="24">
|
||||||
|
<el-card shadow="hover">
|
||||||
|
<template #header>
|
||||||
|
<span>📂 分类分布</span>
|
||||||
|
</template>
|
||||||
|
<div v-if="categoryStats.length === 0" style="text-align: center; padding: 40px; color: #a0aec0;">
|
||||||
|
暂无数据
|
||||||
|
</div>
|
||||||
|
<el-row :gutter="20" v-else>
|
||||||
|
<el-col :span="4" v-for="cat in categoryStats" :key="cat.name">
|
||||||
|
<div style="text-align: center; padding: 16px;">
|
||||||
|
<div style="font-size: 24px; font-weight: bold; color: #63b3ed;">{{ cat.count }}</div>
|
||||||
|
<div style="font-size: 14px; color: #a0aec0; margin-top: 4px;">{{ cat.name || '未分类' }}</div>
|
||||||
|
</div>
|
||||||
|
</el-col>
|
||||||
|
</el-row>
|
||||||
|
</el-card>
|
||||||
|
</el-col>
|
||||||
|
</el-row>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script setup>
|
||||||
|
import { ref, onMounted, computed } from 'vue'
|
||||||
|
import { dashboardApi, feedsApi } from '../api/index.js'
|
||||||
|
|
||||||
|
const stats = ref({})
|
||||||
|
const healthData = ref([])
|
||||||
|
const recentActivity = ref([])
|
||||||
|
const loadingHealth = ref(false)
|
||||||
|
const loadingActivity = ref(false)
|
||||||
|
const categoryStats = ref([])
|
||||||
|
|
||||||
|
const statsCards = computed(() => [
|
||||||
|
{ key: 'feeds', label: 'RSS 源总数', value: stats.value.total_feeds || 0, color: '#63b3ed' },
|
||||||
|
{ key: 'articles', label: '文章总数', value: stats.value.total_articles || 0, color: '#68d391' },
|
||||||
|
{ key: 'healthy', label: '健康源数', value: stats.value.healthy_feeds || 0, color: '#48bb78' },
|
||||||
|
{ key: 'today', label: '今日抓取', value: stats.value.today_fetches || 0, color: '#f6ad55' },
|
||||||
|
])
|
||||||
|
|
||||||
|
const healthTagType = (status) => {
|
||||||
|
const map = { healthy: 'success', warning: 'warning', unhealthy: 'danger', unknown: 'info' }
|
||||||
|
return map[status] || 'info'
|
||||||
|
}
|
||||||
|
|
||||||
|
const formatTime = (iso) => {
|
||||||
|
if (!iso) return '-'
|
||||||
|
const d = new Date(iso)
|
||||||
|
return d.toLocaleString('zh-CN', { month: 'short', day: 'numeric', hour: '2-digit', minute: '2-digit' })
|
||||||
|
}
|
||||||
|
|
||||||
|
const loadStats = async () => {
|
||||||
|
try {
|
||||||
|
stats.value = await dashboardApi.stats()
|
||||||
|
} catch (e) {
|
||||||
|
console.error('加载统计失败', e)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const loadHealth = async () => {
|
||||||
|
loadingHealth.value = true
|
||||||
|
try {
|
||||||
|
const res = await dashboardApi.health({ limit: 10 })
|
||||||
|
healthData.value = res.items || []
|
||||||
|
} catch (e) {
|
||||||
|
console.error('加载健康度失败', e)
|
||||||
|
} finally {
|
||||||
|
loadingHealth.value = false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const loadActivity = async () => {
|
||||||
|
loadingActivity.value = true
|
||||||
|
try {
|
||||||
|
const res = await dashboardApi.recentActivity()
|
||||||
|
recentActivity.value = res.items || []
|
||||||
|
} catch (e) {
|
||||||
|
console.error('加载活动失败', e)
|
||||||
|
} finally {
|
||||||
|
loadingActivity.value = false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const loadCategories = async () => {
|
||||||
|
try {
|
||||||
|
const feeds = await feedsApi.list({ limit: 1000 })
|
||||||
|
const cats = {}
|
||||||
|
for (const f of feeds.items || []) {
|
||||||
|
const c = f.category || '未分类'
|
||||||
|
cats[c] = (cats[c] || 0) + 1
|
||||||
|
}
|
||||||
|
categoryStats.value = Object.entries(cats).map(([name, count]) => ({ name, count }))
|
||||||
|
} catch (e) {
|
||||||
|
console.error('加载分类失败', e)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
onMounted(() => {
|
||||||
|
loadStats()
|
||||||
|
loadHealth()
|
||||||
|
loadActivity()
|
||||||
|
loadCategories()
|
||||||
|
})
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<style scoped>
|
||||||
|
.stats-row {
|
||||||
|
margin-bottom: 10px;
|
||||||
|
}
|
||||||
|
</style>
|
||||||
@@ -0,0 +1,369 @@
|
|||||||
|
<template>
|
||||||
|
<div>
|
||||||
|
<div style="display: flex; justify-content: space-between; align-items: center; margin-bottom: 20px;">
|
||||||
|
<h1 class="page-title">📡 RSS 源管理</h1>
|
||||||
|
<div>
|
||||||
|
<el-button type="primary" @click="showAddDialog = true" :icon="Plus">添加源</el-button>
|
||||||
|
<el-button @click="showImportDialog = true" :icon="Upload">导入 OPML</el-button>
|
||||||
|
<el-button @click="handleExport" :icon="Download">导出 OPML</el-button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- 筛选栏 -->
|
||||||
|
<el-row :gutter="10" style="margin-bottom: 16px;">
|
||||||
|
<el-col :span="6">
|
||||||
|
<el-input v-model="searchQuery" placeholder="搜索源名称或 URL" clearable @change="loadFeeds" :prefix-icon="Search" />
|
||||||
|
</el-col>
|
||||||
|
<el-col :span="4">
|
||||||
|
<el-select v-model="filterCategory" placeholder="分类筛选" clearable @change="loadFeeds">
|
||||||
|
<el-option v-for="cat in categories" :key="cat" :label="cat || '未分类'" :value="cat" />
|
||||||
|
</el-select>
|
||||||
|
</el-col>
|
||||||
|
<el-col :span="4">
|
||||||
|
<el-select v-model="filterStatus" placeholder="状态筛选" clearable @change="loadFeeds">
|
||||||
|
<el-option label="启用" value="active" />
|
||||||
|
<el-option label="禁用" value="inactive" />
|
||||||
|
</el-select>
|
||||||
|
</el-col>
|
||||||
|
<el-col :span="6" style="display: flex; gap: 8px;">
|
||||||
|
<el-tag v-if="stats.total" type="info">共 {{ stats.total }} 个源</el-tag>
|
||||||
|
</el-col>
|
||||||
|
</el-row>
|
||||||
|
|
||||||
|
<!-- 源列表 -->
|
||||||
|
<el-card shadow="hover">
|
||||||
|
<el-table :data="feeds" v-loading="loading" size="small">
|
||||||
|
<el-table-column type="index" width="50" />
|
||||||
|
<el-table-column prop="title" label="源名称" min-width="200" show-overflow-tooltip>
|
||||||
|
<template #default="scope">
|
||||||
|
<el-link :href="scope.row.url" target="_blank" type="primary">{{ scope.row.title || scope.row.url }}</el-link>
|
||||||
|
</template>
|
||||||
|
</el-table-column>
|
||||||
|
<el-table-column prop="category" label="分类" width="120">
|
||||||
|
<template #default="scope">
|
||||||
|
<el-tag v-if="scope.row.category" size="small" type="info">{{ scope.row.category }}</el-tag>
|
||||||
|
<span v-else style="color: #718096;">-</span>
|
||||||
|
</template>
|
||||||
|
</el-table-column>
|
||||||
|
<el-table-column prop="health_status" label="健康度" width="100">
|
||||||
|
<template #default="scope">
|
||||||
|
<el-tag :type="healthTagType(scope.row.health_status)" size="small">
|
||||||
|
{{ healthLabel(scope.row.health_status) }}
|
||||||
|
</el-tag>
|
||||||
|
</template>
|
||||||
|
</el-table-column>
|
||||||
|
<el-table-column prop="article_count" label="文章数" width="80" />
|
||||||
|
<el-table-column prop="last_fetch_at" label="最后抓取" width="160">
|
||||||
|
<template #default="scope">
|
||||||
|
{{ formatTime(scope.row.last_fetch_at) }}
|
||||||
|
</template>
|
||||||
|
</el-table-column>
|
||||||
|
<el-table-column prop="fetch_interval_minutes" label="间隔(分)" width="90" />
|
||||||
|
<el-table-column label="操作" width="200" fixed="right">
|
||||||
|
<template #default="scope">
|
||||||
|
<el-button link type="primary" size="small" @click="triggerFetch(scope.row.id)" :icon="Refresh">抓取</el-button>
|
||||||
|
<el-button link type="primary" size="small" @click="editFeed(scope.row)" :icon="Edit">编辑</el-button>
|
||||||
|
<el-button link type="danger" size="small" @click="deleteFeed(scope.row.id)" :icon="Delete">删除</el-button>
|
||||||
|
</template>
|
||||||
|
</el-table-column>
|
||||||
|
</el-table>
|
||||||
|
|
||||||
|
<el-pagination
|
||||||
|
v-model:current-page="page"
|
||||||
|
v-model:page-size="pageSize"
|
||||||
|
:total="stats.total"
|
||||||
|
:page-sizes="[20, 50, 100]"
|
||||||
|
layout="total, sizes, prev, pager, next"
|
||||||
|
@change="loadFeeds"
|
||||||
|
style="margin-top: 16px; justify-content: flex-end;"
|
||||||
|
/>
|
||||||
|
</el-card>
|
||||||
|
|
||||||
|
<!-- 添加源对话框 -->
|
||||||
|
<el-dialog v-model="showAddDialog" title="添加 RSS 源" width="600px">
|
||||||
|
<el-tabs v-model="addTab">
|
||||||
|
<el-tab-pane label="直接添加" name="direct">
|
||||||
|
<el-form :model="newFeed" label-width="100px">
|
||||||
|
<el-form-item label="RSS 地址">
|
||||||
|
<el-input v-model="newFeed.url" placeholder="https://example.com/feed.xml" />
|
||||||
|
</el-form-item>
|
||||||
|
<el-form-item label="名称">
|
||||||
|
<el-input v-model="newFeed.title" placeholder="自动抓取(可选)" />
|
||||||
|
</el-form-item>
|
||||||
|
<el-form-item label="分类">
|
||||||
|
<el-input v-model="newFeed.category" placeholder="如:科技、新闻" />
|
||||||
|
</el-form-item>
|
||||||
|
<el-form-item label="抓取间隔">
|
||||||
|
<el-input-number v-model="newFeed.fetch_interval_minutes" :min="15" :max="1440" />
|
||||||
|
<span style="margin-left: 8px; color: #a0aec0;">分钟</span>
|
||||||
|
</el-form-item>
|
||||||
|
</el-form>
|
||||||
|
</el-tab-pane>
|
||||||
|
<el-tab-pane label="自动发现" name="discover">
|
||||||
|
<el-input v-model="discoverUrl" placeholder="输入任意网页地址,自动发现 RSS 源">
|
||||||
|
<template #append>
|
||||||
|
<el-button @click="handleDiscover" :loading="discovering">发现</el-button>
|
||||||
|
</template>
|
||||||
|
</el-input>
|
||||||
|
<el-table :data="discoveredFeeds" v-if="discoveredFeeds.length" style="margin-top: 16px;" size="small">
|
||||||
|
<el-table-column prop="url" label="发现的 RSS 源" />
|
||||||
|
<el-table-column width="100">
|
||||||
|
<template #default="scope">
|
||||||
|
<el-button size="small" type="primary" @click="addDiscovered(scope.row.url)">添加</el-button>
|
||||||
|
</template>
|
||||||
|
</el-table-column>
|
||||||
|
</el-table>
|
||||||
|
</el-tab-pane>
|
||||||
|
</el-tabs>
|
||||||
|
<template #footer>
|
||||||
|
<el-button @click="showAddDialog = false">取消</el-button>
|
||||||
|
<el-button type="primary" @click="handleAdd" :loading="adding">添加</el-button>
|
||||||
|
</template>
|
||||||
|
</el-dialog>
|
||||||
|
|
||||||
|
<!-- 编辑对话框 -->
|
||||||
|
<el-dialog v-model="showEditDialog" title="编辑 RSS 源" width="500px">
|
||||||
|
<el-form :model="editForm" label-width="100px">
|
||||||
|
<el-form-item label="名称">
|
||||||
|
<el-input v-model="editForm.title" />
|
||||||
|
</el-form-item>
|
||||||
|
<el-form-item label="分类">
|
||||||
|
<el-input v-model="editForm.category" />
|
||||||
|
</el-form-item>
|
||||||
|
<el-form-item label="抓取间隔">
|
||||||
|
<el-input-number v-model="editForm.fetch_interval_minutes" :min="15" :max="1440" />
|
||||||
|
<span style="margin-left: 8px; color: #a0aec0;">分钟</span>
|
||||||
|
</el-form-item>
|
||||||
|
<el-form-item label="状态">
|
||||||
|
<el-switch v-model="editForm.is_active" active-text="启用" inactive-text="禁用" />
|
||||||
|
</el-form-item>
|
||||||
|
</el-form>
|
||||||
|
<template #footer>
|
||||||
|
<el-button @click="showEditDialog = false">取消</el-button>
|
||||||
|
<el-button type="primary" @click="handleEdit" :loading="editing">保存</el-button>
|
||||||
|
</template>
|
||||||
|
</el-dialog>
|
||||||
|
|
||||||
|
<!-- 导入 OPML -->
|
||||||
|
<el-dialog v-model="showImportDialog" title="导入 OPML" width="500px">
|
||||||
|
<el-input v-model="opmlContent" type="textarea" :rows="10" placeholder="粘贴 OPML 文件内容..." />
|
||||||
|
<template #footer>
|
||||||
|
<el-button @click="showImportDialog = false">取消</el-button>
|
||||||
|
<el-button type="primary" @click="handleImport" :loading="importing">导入</el-button>
|
||||||
|
</template>
|
||||||
|
</el-dialog>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script setup>
|
||||||
|
import { ref, onMounted } from 'vue'
|
||||||
|
import { ElMessage, ElMessageBox } from 'element-plus'
|
||||||
|
import { Plus, Upload, Download, Search, Refresh, Edit, Delete } from '@element-plus/icons-vue'
|
||||||
|
import { feedsApi } from '../api/index.js'
|
||||||
|
|
||||||
|
const feeds = ref([])
|
||||||
|
const stats = ref({ total: 0 })
|
||||||
|
const loading = ref(false)
|
||||||
|
const page = ref(1)
|
||||||
|
const pageSize = ref(20)
|
||||||
|
const searchQuery = ref('')
|
||||||
|
const filterCategory = ref('')
|
||||||
|
const filterStatus = ref('')
|
||||||
|
const categories = ref([])
|
||||||
|
|
||||||
|
// 添加对话框
|
||||||
|
const showAddDialog = ref(false)
|
||||||
|
const addTab = ref('direct')
|
||||||
|
const adding = ref(false)
|
||||||
|
const newFeed = ref({ url: '', title: '', category: '', fetch_interval_minutes: 60 })
|
||||||
|
|
||||||
|
// 自动发现
|
||||||
|
const discoverUrl = ref('')
|
||||||
|
const discovering = ref(false)
|
||||||
|
const discoveredFeeds = ref([])
|
||||||
|
|
||||||
|
// 编辑
|
||||||
|
const showEditDialog = ref(false)
|
||||||
|
const editing = ref(false)
|
||||||
|
const editForm = ref({ id: null, title: '', category: '', fetch_interval_minutes: 60, is_active: true })
|
||||||
|
|
||||||
|
// 导入
|
||||||
|
const showImportDialog = ref(false)
|
||||||
|
const importing = ref(false)
|
||||||
|
const opmlContent = ref('')
|
||||||
|
|
||||||
|
const healthTagType = (status) => {
|
||||||
|
const map = { healthy: 'success', warning: 'warning', unhealthy: 'danger', unknown: 'info' }
|
||||||
|
return map[status] || 'info'
|
||||||
|
}
|
||||||
|
|
||||||
|
const healthLabel = (status) => {
|
||||||
|
const map = { healthy: '健康', warning: '警告', unhealthy: '异常', unknown: '未知' }
|
||||||
|
return map[status] || '未知'
|
||||||
|
}
|
||||||
|
|
||||||
|
const formatTime = (iso) => {
|
||||||
|
if (!iso) return '-'
|
||||||
|
const d = new Date(iso)
|
||||||
|
return d.toLocaleString('zh-CN')
|
||||||
|
}
|
||||||
|
|
||||||
|
const loadFeeds = async () => {
|
||||||
|
loading.value = true
|
||||||
|
try {
|
||||||
|
const params = {
|
||||||
|
skip: (page.value - 1) * pageSize.value,
|
||||||
|
limit: pageSize.value,
|
||||||
|
search: searchQuery.value || undefined,
|
||||||
|
category: filterCategory.value || undefined,
|
||||||
|
}
|
||||||
|
if (filterStatus.value === 'active') params.is_active = true
|
||||||
|
if (filterStatus.value === 'inactive') params.is_active = false
|
||||||
|
|
||||||
|
const res = await feedsApi.list(params)
|
||||||
|
feeds.value = res.items || []
|
||||||
|
stats.value = { total: res.total }
|
||||||
|
} catch (e) {
|
||||||
|
ElMessage.error(e.message)
|
||||||
|
} finally {
|
||||||
|
loading.value = false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const loadCategories = async () => {
|
||||||
|
try {
|
||||||
|
categories.value = await feedsApi.categories()
|
||||||
|
} catch (e) {
|
||||||
|
console.error(e)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const handleAdd = async () => {
|
||||||
|
if (!newFeed.value.url) {
|
||||||
|
ElMessage.warning('请输入 RSS 地址')
|
||||||
|
return
|
||||||
|
}
|
||||||
|
adding.value = true
|
||||||
|
try {
|
||||||
|
await feedsApi.create(newFeed.value)
|
||||||
|
ElMessage.success('添加成功')
|
||||||
|
showAddDialog.value = false
|
||||||
|
newFeed.value = { url: '', title: '', category: '', fetch_interval_minutes: 60 }
|
||||||
|
loadFeeds()
|
||||||
|
loadCategories()
|
||||||
|
} catch (e) {
|
||||||
|
ElMessage.error(e.message)
|
||||||
|
} finally {
|
||||||
|
adding.value = false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const handleDiscover = async () => {
|
||||||
|
if (!discoverUrl.value) return
|
||||||
|
discovering.value = true
|
||||||
|
try {
|
||||||
|
const res = await feedsApi.discover(discoverUrl.value)
|
||||||
|
discoveredFeeds.value = (res.found_feeds || []).map(url => ({ url }))
|
||||||
|
if (discoveredFeeds.value.length === 0) {
|
||||||
|
ElMessage.info('未找到 RSS 源')
|
||||||
|
}
|
||||||
|
} catch (e) {
|
||||||
|
ElMessage.error(e.message)
|
||||||
|
} finally {
|
||||||
|
discovering.value = false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const addDiscovered = async (url) => {
|
||||||
|
try {
|
||||||
|
await feedsApi.create({ url, fetch_interval_minutes: 60 })
|
||||||
|
ElMessage.success('添加成功')
|
||||||
|
loadFeeds()
|
||||||
|
} catch (e) {
|
||||||
|
ElMessage.error(e.message)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const editFeed = (feed) => {
|
||||||
|
editForm.value = {
|
||||||
|
id: feed.id,
|
||||||
|
title: feed.title || '',
|
||||||
|
category: feed.category || '',
|
||||||
|
fetch_interval_minutes: feed.fetch_interval_minutes,
|
||||||
|
is_active: feed.is_active,
|
||||||
|
}
|
||||||
|
showEditDialog.value = true
|
||||||
|
}
|
||||||
|
|
||||||
|
const handleEdit = async () => {
|
||||||
|
editing.value = true
|
||||||
|
try {
|
||||||
|
await feedsApi.update(editForm.value.id, editForm.value)
|
||||||
|
ElMessage.success('更新成功')
|
||||||
|
showEditDialog.value = false
|
||||||
|
loadFeeds()
|
||||||
|
} catch (e) {
|
||||||
|
ElMessage.error(e.message)
|
||||||
|
} finally {
|
||||||
|
editing.value = false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const deleteFeed = async (id) => {
|
||||||
|
try {
|
||||||
|
await ElMessageBox.confirm('确定删除该 RSS 源吗?相关文章也会被删除。', '确认删除', { type: 'warning' })
|
||||||
|
await feedsApi.remove(id)
|
||||||
|
ElMessage.success('删除成功')
|
||||||
|
loadFeeds()
|
||||||
|
} catch (e) {
|
||||||
|
if (e !== 'cancel') ElMessage.error(e.message)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const triggerFetch = async (id) => {
|
||||||
|
try {
|
||||||
|
const res = await feedsApi.fetch(id)
|
||||||
|
ElMessage.success(`抓取完成,新增 ${res.articles_count || 0} 篇文章`)
|
||||||
|
loadFeeds()
|
||||||
|
} catch (e) {
|
||||||
|
ElMessage.error(e.message)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const handleImport = async () => {
|
||||||
|
if (!opmlContent.value.trim()) return
|
||||||
|
importing.value = true
|
||||||
|
try {
|
||||||
|
const res = await feedsApi.importOpml(opmlContent.value)
|
||||||
|
ElMessage.success(res.message)
|
||||||
|
showImportDialog.value = false
|
||||||
|
opmlContent.value = ''
|
||||||
|
loadFeeds()
|
||||||
|
loadCategories()
|
||||||
|
} catch (e) {
|
||||||
|
ElMessage.error(e.message)
|
||||||
|
} finally {
|
||||||
|
importing.value = false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const handleExport = async () => {
|
||||||
|
try {
|
||||||
|
const res = await feedsApi.exportOpml()
|
||||||
|
const blob = new Blob([res.opml], { type: 'text/xml' })
|
||||||
|
const url = URL.createObjectURL(blob)
|
||||||
|
const a = document.createElement('a')
|
||||||
|
a.href = url
|
||||||
|
a.download = 'rsskeeper-feeds.opml'
|
||||||
|
a.click()
|
||||||
|
URL.revokeObjectURL(url)
|
||||||
|
} catch (e) {
|
||||||
|
ElMessage.error(e.message)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
onMounted(() => {
|
||||||
|
loadFeeds()
|
||||||
|
loadCategories()
|
||||||
|
})
|
||||||
|
</script>
|
||||||
@@ -0,0 +1,30 @@
|
|||||||
|
import { defineConfig } from 'vite'
|
||||||
|
import vue from '@vitejs/plugin-vue'
|
||||||
|
import { resolve } from 'path'
|
||||||
|
|
||||||
|
export default defineConfig({
|
||||||
|
plugins: [vue()],
|
||||||
|
base: '/',
|
||||||
|
build: {
|
||||||
|
outDir: 'dist',
|
||||||
|
assetsDir: 'assets',
|
||||||
|
},
|
||||||
|
resolve: {
|
||||||
|
alias: {
|
||||||
|
'@': resolve(__dirname, 'src'),
|
||||||
|
},
|
||||||
|
},
|
||||||
|
server: {
|
||||||
|
port: 5173,
|
||||||
|
proxy: {
|
||||||
|
'/api': {
|
||||||
|
target: 'http://localhost:8000',
|
||||||
|
changeOrigin: true,
|
||||||
|
},
|
||||||
|
'/api/v1': {
|
||||||
|
target: 'http://localhost:8000',
|
||||||
|
changeOrigin: true,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
})
|
||||||
Reference in New Issue
Block a user