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