✨ 8 major features: trafilatura, digest, ntfy actions, templates, FTS5 search, backup/restore, proxy, RSS reader
build-and-push / docker (push) Has been cancelled
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:
+386
-8
@@ -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")
|
||||
|
||||
Reference in New Issue
Block a user