8 major features: trafilatura, digest, ntfy actions, templates, FTS5 search, backup/restore, proxy, RSS reader
build-and-push / docker (push) Has been cancelled

- Full article extraction via trafilatura (fetch_full_article)
- Digest mode with configurable period (digest_enabled, digest_period_hours)
- ntfy Actions buttons (Open article, Open feed)
- Notification templates with {title}, {body}, {link}, {source}, {image_url}
- FTS5 full-text search in notification history
- Database backup/restore (download/upload .db)
- HTTP/SOCKS proxy for RSS feed fetching (proxy_url setting)
- Built-in RSS reader tab with categories, unread counts, article detail view
- Auto-category 'Общее' for feeds without a category
- Article storage (Article table) for reader
- DigestEntry model for pending digest entries

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
dimon
2026-06-03 20:47:46 +08:00
parent f8d2c31658
commit 834092a3ec
13 changed files with 1414 additions and 44 deletions
+386 -8
View File
@@ -8,6 +8,7 @@ from pathlib import Path
from fastapi import Depends, FastAPI, Form, HTTPException, Request, UploadFile
from fastapi.responses import (
FileResponse,
HTMLResponse,
JSONResponse,
PlainTextResponse,
@@ -16,7 +17,7 @@ from fastapi.responses import (
)
from fastapi.staticfiles import StaticFiles
from fastapi.templating import Jinja2Templates
from sqlalchemy import Integer
from sqlalchemy import Integer, text
from sqlmodel import Session, func, select
from starlette.middleware.sessions import SessionMiddleware
@@ -24,8 +25,8 @@ from . import config, ntfy, opml, scheduler
from .auth import hash_password, verify_password
from .checker import check_feed, fetch_preview
from .database import engine, get_session, get_settings, init_db
from .models import Feed, Notification, SeenEntry, User
from .schemas import FeedIn, PreviewIn, SettingsIn, TestIn, UserIn
from .models import Article, Category, Feed, Notification, SeenEntry, User
from .schemas import CategoryIn, FeedIn, PreviewIn, SettingsIn, TestIn, UserIn
logging.basicConfig(
level=logging.INFO,
@@ -160,6 +161,11 @@ def _feed_dict(feed: Feed) -> dict:
"filter_exclude": feed.filter_exclude,
"interval": feed.interval,
"enabled": feed.enabled,
"send_full_content": feed.send_full_content,
"fetch_full_article": feed.fetch_full_article,
"digest_enabled": feed.digest_enabled,
"digest_period_hours": feed.digest_period_hours,
"category_id": feed.category_id,
"last_checked": feed.last_checked.isoformat() if feed.last_checked else None,
"last_status": feed.last_status,
"error_streak": feed.error_streak,
@@ -178,7 +184,14 @@ def create_feed(
session: Session = Depends(get_session),
_: User = Depends(require_admin),
):
feed = Feed(**data.model_dump())
feed_data = data.model_dump()
if feed_data.get("category_id") is None:
general = session.exec(
select(Category).where(Category.name == "Общее")
).first()
if general:
feed_data["category_id"] = general.id
feed = Feed(**feed_data)
session.add(feed)
session.commit()
session.refresh(feed)
@@ -269,6 +282,79 @@ async def import_feeds(
return {"ok": True, "added": added, "total": len(items)}
# --------------------------------------------------------------------------- #
# API: categories
# --------------------------------------------------------------------------- #
def _category_dict(c: Category) -> dict:
return {"id": c.id, "name": c.name, "sort_order": c.sort_order}
@app.get("/api/categories")
def list_categories(
session: Session = Depends(get_session), _: User = Depends(require_auth)
):
cats = session.exec(select(Category).order_by(Category.sort_order, Category.id)).all()
return [_category_dict(c) for c in cats]
@app.post("/api/categories")
def create_category(
data: CategoryIn,
session: Session = Depends(get_session),
_: User = Depends(require_admin),
):
if session.exec(select(Category).where(Category.name == data.name)).first():
raise HTTPException(400, "Категория с таким названием уже существует")
cat = Category(**data.model_dump())
session.add(cat)
session.commit()
session.refresh(cat)
return _category_dict(cat)
@app.put("/api/categories/{cat_id}")
def update_category(
cat_id: int,
data: CategoryIn,
session: Session = Depends(get_session),
_: User = Depends(require_admin),
):
cat = session.get(Category, cat_id)
if cat is None:
raise HTTPException(404, "Категория не найдена")
dup = session.exec(
select(Category).where(Category.name == data.name, Category.id != cat_id)
).first()
if dup:
raise HTTPException(400, "Категория с таким названием уже существует")
cat.name = data.name
cat.sort_order = data.sort_order
session.add(cat)
session.commit()
session.refresh(cat)
return _category_dict(cat)
@app.delete("/api/categories/{cat_id}")
def delete_category(
cat_id: int,
session: Session = Depends(get_session),
_: User = Depends(require_admin),
):
cat = session.get(Category, cat_id)
if cat is None:
raise HTTPException(404, "Категория не найдена")
# Unlink feeds from this category before deleting.
for feed in session.exec(
select(Feed).where(Feed.category_id == cat_id)
).all():
feed.category_id = None
session.add(feed)
session.delete(cat)
session.commit()
return {"ok": True}
# --------------------------------------------------------------------------- #
# API: history & stats
# --------------------------------------------------------------------------- #
@@ -283,10 +369,23 @@ def history(
limit = min(500, max(1, limit))
query = select(Notification)
if q.strip():
like = f"%{q.strip()}%"
query = query.where(
Notification.title.ilike(like) | Notification.feed_title.ilike(like)
)
qs = q.strip()
# Try FTS5 full-text search first
try:
fts_rows = session.exec(
text("SELECT rowid FROM notification_fts WHERE notification_fts MATCH :q"),
{"q": qs},
).all()
if fts_rows:
matched_ids = [r[0] for r in fts_rows]
query = query.where(Notification.id.in_(matched_ids))
else:
raise ValueError # force fallback
except Exception:
like = f"%{qs}%"
query = query.where(
Notification.title.ilike(like) | Notification.feed_title.ilike(like)
)
if only_errors:
query = query.where(Notification.ok == False) # noqa: E712
notes = session.exec(
@@ -376,6 +475,78 @@ async def preview(
raise HTTPException(502, f"Не удалось загрузить ленту: {exc}")
# --------------------------------------------------------------------------- #
# API: backup / restore (admin only)
# --------------------------------------------------------------------------- #
@app.get("/api/backup")
def download_backup(_: User = Depends(require_admin)):
db_path = config.DATA_DIR / "app.db"
if not db_path.exists():
raise HTTPException(404, "База данных не найдена")
now = datetime.now().strftime("%Y-%m-%d")
return FileResponse(
db_path,
media_type="application/octet-stream",
filename=f"rss-ntfy-backup-{now}.db",
)
@app.post("/api/backup")
async def upload_backup(
file: UploadFile,
_: User = Depends(require_admin),
):
import shutil
if not file.filename or not file.filename.endswith(".db"):
raise HTTPException(400, "Ожидается файл .db")
tmp_path = config.DATA_DIR / "restore_tmp.db"
content_bytes = await file.read()
tmp_path.write_bytes(content_bytes)
try:
import sqlite3
conn = sqlite3.connect(str(tmp_path))
tables = {
row[0]
for row in conn.execute(
"SELECT name FROM sqlite_master WHERE type='table'"
).fetchall()
}
required = {"feed", "settings", "seenentry"}
missing = required - tables
if missing:
raise HTTPException(
400, f"В файле не хватает таблиц: {', '.join(sorted(missing))}"
)
conn.close()
db_path = config.DATA_DIR / "app.db"
backup_path = (
config.DATA_DIR
/ f"app.db.bak-{datetime.now().strftime('%Y%m%d%H%M%S')}"
)
if db_path.exists():
shutil.copy2(db_path, backup_path)
shutil.copy2(tmp_path, db_path)
except HTTPException:
raise
except Exception as exc:
raise HTTPException(400, f"Невалидный файл: {exc}")
finally:
if tmp_path.exists():
tmp_path.unlink(missing_ok=True)
scheduler.shutdown()
with Session(engine) as session:
interval = get_settings(session).check_interval
scheduler.start(interval)
return {"ok": True}
# --------------------------------------------------------------------------- #
# API: settings
# --------------------------------------------------------------------------- #
@@ -397,6 +568,12 @@ def read_settings(session: Session = Depends(get_session), _: User = Depends(req
"alerts_enabled": s.alerts_enabled,
"alert_topic": s.alert_topic,
"alert_threshold": s.alert_threshold,
"default_priority": s.default_priority,
"notification_template": s.notification_template,
"proxy_url": s.proxy_url,
"default_tags": s.default_tags,
"default_attach_image": s.default_attach_image,
"default_interval": s.default_interval,
}
@@ -426,6 +603,12 @@ def write_settings(
s.alerts_enabled = data.alerts_enabled
s.alert_topic = data.alert_topic.strip()
s.alert_threshold = data.alert_threshold
s.default_priority = data.default_priority
s.default_tags = data.default_tags.strip()
s.default_attach_image = data.default_attach_image
s.default_interval = data.default_interval
s.notification_template = data.notification_template
s.proxy_url = data.proxy_url.strip()
session.add(s)
session.commit()
@@ -514,6 +697,201 @@ def delete_user(
# --------------------------------------------------------------------------- #
# --------------------------------------------------------------------------- #
# API: articles (RSS reader)
# --------------------------------------------------------------------------- #
@app.get("/api/articles")
def list_articles(
category_id: int | None = None,
feed_id: int | None = None,
unread: bool = False,
q: str = "",
limit: int = 50,
offset: int = 0,
session: Session = Depends(get_session),
_: User = Depends(require_auth),
):
limit = min(200, max(1, limit))
query = select(Article)
if category_id is not None:
feed_ids = [
f.id
for f in session.exec(
select(Feed).where(Feed.category_id == category_id)
).all()
]
# "Общее" category also includes feeds with NULL category
general = session.exec(
select(Category).where(Category.name == "Общее")
).first()
if general and category_id == general.id:
feeds_no_cat = session.exec(
select(Feed).where(Feed.category_id == None) # noqa: E711
).all()
feed_ids.extend(f.id for f in feeds_no_cat)
if feed_ids:
query = query.where(Article.feed_id.in_(feed_ids))
else:
return []
if feed_id is not None:
query = query.where(Article.feed_id == feed_id)
if unread:
query = query.where(Article.is_read == False) # noqa: E712
if q.strip():
like = f"%{q.strip()}%"
query = query.where(
Article.title.ilike(like) | Article.body.ilike(like)
)
articles = session.exec(
query.order_by(Article.created_at.desc())
.offset(offset)
.limit(limit)
).all()
return [
{
"id": a.id,
"feed_id": a.feed_id,
"feed_title": a.feed_title,
"title": a.title,
"body": a.body[:300] + ("..." if a.body and len(a.body) > 300 else ""),
"link": a.link,
"image": a.image,
"published_at": a.published_at.isoformat() if a.published_at else None,
"is_read": a.is_read,
"created_at": a.created_at.isoformat(),
}
for a in articles
]
@app.get("/api/articles/stats")
def article_stats(
session: Session = Depends(get_session),
_: User = Depends(require_auth),
):
"""Unread article counts per category."""
cats = session.exec(
select(Category).order_by(Category.sort_order, Category.id)
).all()
result = []
for cat in cats:
feed_ids = [
f.id
for f in session.exec(
select(Feed).where(Feed.category_id == cat.id)
).all()
]
if cat.name == "Общее":
feeds_no_cat = session.exec(
select(Feed).where(Feed.category_id == None) # noqa: E711
).all()
feed_ids.extend(f.id for f in feeds_no_cat)
count = 0
if feed_ids:
count = session.exec(
select(func.count())
.select_from(Article)
.where(
Article.feed_id.in_(feed_ids),
Article.is_read == False, # noqa: E712
)
).one()
result.append(
{"category_id": cat.id, "category_name": cat.name, "unread": count}
)
return result
@app.put("/api/articles/read-all")
def mark_all_read(
category_id: int | None = None,
feed_id: int | None = None,
session: Session = Depends(get_session),
_: User = Depends(require_auth),
):
query = select(Article).where(Article.is_read == False) # noqa: E712
if category_id is not None:
feed_ids = [
f.id
for f in session.exec(
select(Feed).where(Feed.category_id == category_id)
).all()
]
general = session.exec(
select(Category).where(Category.name == "Общее")
).first()
if general and category_id == general.id:
feeds_no_cat = session.exec(
select(Feed).where(Feed.category_id == None) # noqa: E711
).all()
feed_ids.extend(f.id for f in feeds_no_cat)
if feed_ids:
query = query.where(Article.feed_id.in_(feed_ids))
else:
return {"marked": 0}
if feed_id is not None:
query = query.where(Article.feed_id == feed_id)
articles = session.exec(query).all()
for a in articles:
a.is_read = True
session.add(a)
session.commit()
return {"marked": len(articles)}
@app.get("/api/articles/{article_id}")
def get_article(
article_id: int,
session: Session = Depends(get_session),
_: User = Depends(require_auth),
):
a = session.get(Article, article_id)
if a is None:
raise HTTPException(404, "Статья не найдена")
# Mark as read on view
if not a.is_read:
a.is_read = True
session.add(a)
session.commit()
session.refresh(a)
return {
"id": a.id,
"feed_id": a.feed_id,
"feed_title": a.feed_title,
"title": a.title,
"body": a.body,
"full_html": a.full_html,
"link": a.link,
"image": a.image,
"published_at": a.published_at.isoformat() if a.published_at else None,
"is_read": a.is_read,
"created_at": a.created_at.isoformat(),
}
@app.put("/api/articles/{article_id}/read")
def mark_read(
article_id: int,
session: Session = Depends(get_session),
_: User = Depends(require_auth),
):
a = session.get(Article, article_id)
if a is None:
raise HTTPException(404, "Статья не найдена")
a.is_read = True
session.add(a)
session.commit()
return {"ok": True}
# API: test notification
# --------------------------------------------------------------------------- #
@app.post("/api/test")