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
+200 -9
View File
@@ -2,30 +2,38 @@
from __future__ import annotations
import asyncio
import json
import logging
import re
from datetime import datetime, timezone
from html import unescape
import httpx
from io import StringIO
import feedparser
from sqlmodel import Session, select
from . import delivery
from .database import engine, get_settings
from .delivery import Message
from .models import Feed, Notification, SeenEntry
from .models import Article, DigestEntry, Feed, Notification, SeenEntry
log = logging.getLogger("checker")
_TAG_RE = re.compile(r"<[^>]+>")
_IMG_RE = re.compile(r'<img[^>]+src=["\']([^"\']+)["\']', re.IGNORECASE)
_VIDEO_RE = re.compile(
r'<(?:video|iframe|source|embed)[^>]*src=["\']([^"\']+)["\']', re.IGNORECASE
)
_ENC_VIDEO_RE = re.compile(r'<(?:video|iframe|source|embed)[^>]*>', re.IGNORECASE)
def _strip_html(text: str, limit: int = 1500) -> str:
text = unescape(_TAG_RE.sub(" ", text or ""))
text = re.sub(r"[ \t]+", " ", text)
text = re.sub(r"\n\s*\n\s*\n+", "\n\n", text).strip()
if len(text) > limit:
if limit and len(text) > limit:
text = text[:limit].rsplit(" ", 1)[0] + ""
return text
@@ -57,6 +65,60 @@ def _extract_image(entry) -> str:
return match.group(1) if match else ""
def _extract_all_images(entry) -> list[str]:
"""Extract ALL image URLs from a feed entry (deduplicated, order preserved)."""
urls: list[str] = []
# media_content / media_thumbnail
for key in ("media_content", "media_thumbnail"):
media = entry.get(key)
if media and isinstance(media, list):
for item in media:
url = item.get("url")
if url:
urls.append(url)
# enclosure links with image/ type
for link in entry.get("links", []):
if link.get("rel") == "enclosure" and str(link.get("type", "")).startswith("image"):
href = link.get("href", "")
if href:
urls.append(href)
# <img> tags in HTML body
html = entry.get("summary") or entry.get("description") or ""
if not html:
content = entry.get("content")
if content and isinstance(content, list):
html = content[0].get("value", "")
urls.extend(_IMG_RE.findall(html or ""))
# deduplicate preserving order
return list(dict.fromkeys(urls))
def _extract_videos(entry) -> list[str]:
"""Extract video/multimedia URLs from a feed entry."""
urls: list[str] = []
# enclosure links with video/ type
for link in entry.get("links", []):
if link.get("rel") == "enclosure" and str(link.get("type", "")).startswith("video"):
href = link.get("href", "")
if href:
urls.append(href)
# <video>, <iframe>, <source>, <embed> tags in HTML body
html = entry.get("summary") or entry.get("description") or ""
if not html:
content = entry.get("content")
if content and isinstance(content, list):
html = content[0].get("value", "")
urls.extend(_VIDEO_RE.findall(html or ""))
return list(dict.fromkeys(urls))
def _passes_filters(feed: Feed, title: str, body: str) -> bool:
"""Keyword include/exclude check (case-insensitive)."""
haystack = f"{title}\n{body}".lower()
@@ -69,9 +131,43 @@ def _passes_filters(feed: Feed, title: str, body: str) -> bool:
return True
def _parse(url: str):
"""Blocking feedparser call (run in a thread)."""
return feedparser.parse(url, agent="rss-ntfy/1.0 (+https://github.com)")
def _parse_raw(xml: str):
"""Blocking feedparser call on XML string (run in a thread)."""
return feedparser.parse(StringIO(xml), agent="rss-ntfy/1.0 (+https://github.com)")
async def _fetch_feed(url: str, proxy: str = "") -> str:
"""Download feed XML via httpx (supports proxy)."""
kw = {"timeout": 30}
if proxy.strip():
kw["proxy"] = proxy.strip()
async with httpx.AsyncClient(**kw) as client:
resp = await client.get(
url,
headers={"User-Agent": "rss-ntfy/1.0 (+https://github.com)"},
)
resp.raise_for_status()
return resp.text
def _extract_full_article(url: str) -> tuple[str, str]:
"""Fetch page and extract main article text via trafilatura.
Returns (plain_text, html) or ("", "") on failure.
"""
try:
import trafilatura
downloaded = trafilatura.fetch_url(url)
if downloaded is None:
return "", ""
plain = trafilatura.extract(
downloaded, output_format="txt", with_metadata=False
) or ""
html = trafilatura.extract(
downloaded, output_format="xml", with_metadata=False
) or ""
return plain.strip(), html.strip()
except Exception:
return "", ""
async def fetch_preview(url: str, include: str = "", exclude: str = "") -> dict:
@@ -79,7 +175,10 @@ async def fetch_preview(url: str, include: str = "", exclude: str = "") -> dict:
Raises ValueError if the feed can't be parsed or has no matching entries.
"""
parsed = await asyncio.to_thread(_parse, url)
with Session(engine) as s:
proxy = get_settings(s).proxy_url
raw_xml = await _fetch_feed(url, proxy=proxy)
parsed = await asyncio.to_thread(_parse_raw, raw_xml)
if getattr(parsed, "bozo", False) and not parsed.entries:
raise ValueError(str(getattr(parsed, "bozo_exception", "parse error")))
if not parsed.entries:
@@ -104,7 +203,13 @@ async def fetch_preview(url: str, include: str = "", exclude: str = "") -> dict:
async def check_feed(feed: Feed) -> str:
"""Check a single feed, dispatch new entries, log history. Returns status."""
parsed = await asyncio.to_thread(_parse, feed.url)
# Load settings early for proxy URL
with Session(engine) as _sess:
_settings = get_settings(_sess)
proxy_url = _settings.proxy_url
raw_xml = await _fetch_feed(feed.url, proxy=proxy_url)
parsed = await asyncio.to_thread(_parse_raw, raw_xml)
if getattr(parsed, "bozo", False) and not parsed.entries:
exc = getattr(parsed, "bozo_exception", "parse error")
@@ -145,7 +250,24 @@ async def check_feed(feed: Feed) -> str:
continue
title = entry.get("title", "(без заголовка)")
body = _strip_html(entry.get("summary") or entry.get("description") or "")
raw_html = entry.get("summary") or entry.get("description") or ""
link = entry.get("link", "")
full = db_feed.send_full_content
fetch_full = db_feed.fetch_full_article
body = _strip_html(raw_html, limit=0 if full else 1500)
# Trafilatura: extract full article text from the link page
if fetch_full and link and len(body) < 500:
try:
extra_text, extra_html = await asyncio.to_thread(
_extract_full_article, link
)
if extra_text:
body = extra_text
if extra_html:
raw_html = extra_html
except Exception:
pass # siliently fall back to RSS body
if not _passes_filters(db_feed, title, body):
skipped += 1
@@ -155,9 +277,78 @@ async def check_feed(feed: Feed) -> str:
source=db_feed.title or feed_title,
title=title,
body=body,
link=entry.get("link", ""),
link=link,
image=_extract_image(entry),
images=_extract_all_images(entry) if full else [],
full_html=raw_html if full else "",
videos=_extract_videos(entry) if full else [],
full_content=full,
)
# Store article for RSS reader (always, including first_run entries)
try:
existing_art = session.exec(
select(Article).where(
Article.feed_id == db_feed.id,
Article.link == link,
)
).first()
pub = entry.get("published_parsed")
pub_dt = None
if pub:
try:
pub_dt = datetime(*pub[:6], tzinfo=timezone.utc)
except Exception:
pass
if existing_art:
existing_art.title = title
existing_art.body = body
existing_art.full_html = raw_html
existing_art.image = msg.image
if pub_dt:
existing_art.published_at = pub_dt
session.add(existing_art)
else:
session.add(Article(
feed_id=db_feed.id,
feed_title=db_feed.title or feed_title,
title=title,
body=body,
full_html=raw_html,
link=link,
image=msg.image,
published_at=pub_dt,
))
except Exception:
pass # article storage is best-effort
# Digest mode: store instead of dispatching
if db_feed.digest_enabled:
session.add(DigestEntry(
feed_id=db_feed.id,
title=title,
link=link,
body=body,
image=msg.image,
full_html=raw_html if full else "",
images=json.dumps(msg.images) if full else "[]",
videos=json.dumps(msg.videos) if full else "[]",
full_content=full,
))
# Record as seen but skip dispatch
sent += 1
session.add(
Notification(
feed_id=db_feed.id,
feed_title=msg.source,
title=title,
link=msg.link,
channels="digest",
ok=True,
detail="queued for digest",
)
)
continue
result = await delivery.dispatch(db_feed, settings, msg)
session.add(
+37 -1
View File
@@ -9,7 +9,7 @@ from sqlmodel import Session, SQLModel, create_engine, select
from . import config
from .auth import hash_password
from .models import Settings, User
from .models import Category, Settings, User
engine = create_engine(
config.DATABASE_URL,
@@ -18,6 +18,33 @@ engine = create_engine(
)
def _setup_fts() -> None:
"""Create FTS5 virtual table and triggers for full-text notification search."""
with engine.begin() as conn:
conn.execute(text(
"CREATE VIRTUAL TABLE IF NOT EXISTS notification_fts "
"USING fts5(title, body, content='notification', content_rowid='id')"
))
# Triggers to keep FTS index in sync
for trigger_sql in [
"""CREATE TRIGGER IF NOT EXISTS ntf_ai AFTER INSERT ON notification BEGIN
INSERT INTO notification_fts(rowid, title, body)
VALUES (new.id, new.title, new.feed_title);
END""",
"""CREATE TRIGGER IF NOT EXISTS ntf_ad AFTER DELETE ON notification BEGIN
INSERT INTO notification_fts(notification_fts, rowid, title, body)
VALUES ('delete', old.id, old.title, old.feed_title);
END""",
"""CREATE TRIGGER IF NOT EXISTS ntf_au AFTER UPDATE ON notification BEGIN
INSERT INTO notification_fts(notification_fts, rowid, title, body)
VALUES ('delete', old.id, old.title, old.feed_title);
INSERT INTO notification_fts(rowid, title, body)
VALUES (new.id, new.title, new.feed_title);
END""",
]:
conn.execute(text(trigger_sql))
def _migrate() -> None:
"""Add any model columns missing from existing tables (SQLite ALTER ADD).
@@ -60,6 +87,7 @@ def init_db() -> None:
"""Create tables, run migration, ensure settings + admin user exist."""
SQLModel.metadata.create_all(engine)
_migrate()
_setup_fts()
with Session(engine) as session:
if session.get(Settings, 1) is None:
session.add(
@@ -83,6 +111,14 @@ def init_db() -> None:
)
session.commit()
# Bootstrap the "Общее" (General) category.
general = session.exec(
select(Category).where(Category.name == "Общее")
).first()
if general is None:
session.add(Category(name="Общее", sort_order=0))
session.commit()
def get_settings(session: Session) -> Settings:
settings = session.get(Settings, 1)
+63 -3
View File
@@ -24,7 +24,11 @@ class Message:
title: str # entry title
body: str # plain-text summary
link: str = ""
image: str = "" # image URL, if any
image: str = "" # first image URL, for the Attach header
images: list[str] = field(default_factory=list) # all images (full_content mode)
full_html: str = "" # raw HTML body (full_content mode)
videos: list[str] = field(default_factory=list) # video URLs (full_content mode)
full_content: bool = False
@dataclass
@@ -51,7 +55,13 @@ async def _send_telegram(settings: Settings, msg: Message) -> None:
if msg.source:
text = f"📡 <i>{_esc(msg.source)}</i>\n{text}"
if msg.body:
text += f"\n\n{_esc(msg.body[:600])}"
limit = 3500 if msg.full_content else 600
text += f"\n\n{_esc(msg.body[:limit])}"
if msg.full_content:
for img_url in msg.images[:5]:
text += f'\n<a href="{_esc(img_url)}">🖼️ image</a>'
for vid_url in msg.videos[:3]:
text += f'\n<a href="{_esc(vid_url)}">🎬 video</a>'
if msg.link:
text += f'\n\n<a href="{_esc(msg.link)}">Открыть →</a>'
@@ -80,6 +90,10 @@ async def _send_webhook(settings: Settings, feed: Feed, msg: Message) -> None:
"link": msg.link,
"image": msg.image,
}
if msg.full_content:
payload["images"] = msg.images
payload["videos"] = msg.videos
payload["full_html"] = msg.full_html
async with httpx.AsyncClient(timeout=20) as client:
resp = await client.post(url, json=payload)
resp.raise_for_status()
@@ -95,6 +109,26 @@ async def dispatch(feed: Feed, settings: Settings, msg: Message) -> DispatchResu
server = feed.ntfy_server.strip() or settings.default_ntfy_server
full_title = f"{msg.source}: {msg.title}" if msg.source else msg.title
# Apply notification template to body
template = settings.notification_template or "{title}\n\n{body}\n\n{link}"
try:
formatted_body = template.replace("\\n", "\n").format(
title=msg.title,
body=msg.body,
link=msg.link,
source=msg.source,
image_url=msg.image,
)
except (KeyError, ValueError):
formatted_body = msg.body # fallback on template error
# Build default actions for ntfy
ntfy_actions = []
if msg.link:
ntfy_actions.append({"action": "view", "label": "Открыть статью", "url": msg.link})
if feed.url:
ntfy_actions.append({"action": "view", "label": "Открыть ленту", "url": feed.url})
# Per-feed auth wins; otherwise fall back to the default-server credentials.
has_feed_auth = bool(feed.ntfy_token.strip() or feed.ntfy_username.strip())
token = feed.ntfy_token if has_feed_auth else settings.default_ntfy_token
@@ -104,11 +138,20 @@ async def dispatch(feed: Feed, settings: Settings, msg: Message) -> DispatchResu
# --- ntfy (default channel; requires a topic) ---
if feed.ntfy_topic.strip():
try:
if msg.full_content:
# Build markdown body with all images inlined + template applied.
md_body = formatted_body
if msg.images:
for img_url in msg.images:
md_body += f"\n\n![image]({img_url})"
if msg.videos:
for vid_url in msg.videos:
md_body += f"\n\n📹 {vid_url}"
await ntfy.publish(
server=server,
topic=feed.ntfy_topic,
title=full_title,
message=msg.body or "(нет описания)",
message=md_body or "(нет описания)",
click=msg.link,
tags=feed.tags,
priority=feed.priority,
@@ -116,6 +159,23 @@ async def dispatch(feed: Feed, settings: Settings, msg: Message) -> DispatchResu
token=token,
username=username,
password=password,
markdown=True,
actions=ntfy_actions,
)
else:
await ntfy.publish(
server=server,
topic=feed.ntfy_topic,
title=full_title,
message=formatted_body or "(нет описания)",
click=msg.link,
tags=feed.tags,
priority=feed.priority,
attach=msg.image if feed.attach_image else "",
token=token,
username=username,
password=password,
actions=ntfy_actions,
)
result.channels.append("ntfy")
except Exception as exc: # noqa: BLE001
+383 -5
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,7 +369,20 @@ def history(
limit = min(500, max(1, limit))
query = select(Notification)
if q.strip():
like = f"%{q.strip()}%"
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)
)
@@ -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")
+60
View File
@@ -44,6 +44,14 @@ class Feed(SQLModel, table=True):
interval: int = 0
enabled: bool = True
send_full_content: bool = False # send entire article: all text, images, videos
fetch_full_article: bool = False # trafilatura: extract full page content from link
digest_enabled: bool = False # accumulate entries instead of real-time dispatch
digest_period_hours: int = 24 # how often to send the digest (1=hourly, 24=daily, 168=weekly)
last_digest_at: Optional[datetime] = None
# --- category ---
category_id: Optional[int] = Field(default=None, foreign_key="category.id")
# --- state ---
last_checked: Optional[datetime] = None
@@ -52,6 +60,46 @@ class Feed(SQLModel, table=True):
created_at: datetime = Field(default_factory=_utcnow)
class Category(SQLModel, table=True):
"""Admin-defined category for organizing feeds."""
id: Optional[int] = Field(default=None, primary_key=True)
name: str = Field(index=True, unique=True)
sort_order: int = 0
class Article(SQLModel, table=True):
"""Stored feed entries for the built-in RSS reader."""
id: Optional[int] = Field(default=None, primary_key=True)
feed_id: int = Field(index=True, foreign_key="feed.id")
feed_title: str = ""
title: str = ""
body: str = ""
full_html: str = ""
link: str = ""
image: str = ""
published_at: Optional[datetime] = None
is_read: bool = False
created_at: datetime = Field(default_factory=_utcnow, index=True)
class DigestEntry(SQLModel, table=True):
"""Pending entries for digest-enabled feeds."""
id: Optional[int] = Field(default=None, primary_key=True)
feed_id: int = Field(index=True, foreign_key="feed.id")
title: str = ""
link: str = ""
body: str = ""
image: str = ""
full_html: str = ""
images: str = "" # JSON array
videos: str = "" # JSON array
full_content: bool = False
created_at: datetime = Field(default_factory=_utcnow)
class SeenEntry(SQLModel, table=True):
"""Tracks which feed entries have already been pushed to avoid duplicates."""
@@ -113,3 +161,15 @@ class Settings(SQLModel, table=True):
alerts_enabled: bool = False
alert_topic: str = "" # ntfy topic to notify when a feed keeps failing
alert_threshold: int = 3 # consecutive failures before alerting
# --- Default values for new feeds (pre-fill the "Add feed" form) ---
default_priority: int = 3
default_tags: str = ""
default_attach_image: bool = True
default_interval: int = 0
# --- Notification template ---
notification_template: str = "**{title}**\n\n{body}\n\n[Открыть \u2192]({link})"
# --- Proxy for RSS fetching ---
proxy_url: str = ""
+13
View File
@@ -36,6 +36,8 @@ async def publish(
token: str = "",
username: str = "",
password: str = "",
markdown: bool = False,
actions: list[dict] | None = None,
) -> None:
"""Send one notification to ntfy. Raises httpx.HTTPStatusError on failure.
@@ -69,6 +71,17 @@ async def publish(
headers["Attach"] = attach
except UnicodeEncodeError:
pass
if markdown:
headers["Content-Type"] = "text/markdown"
if actions:
parts = []
for a in actions:
act = a.get("action", "view")
label = a.get("label", "")
url = a.get("url", "")
clear = ", clear=true" if a.get("clear") else ""
parts.append(f"{act}, {label}, {url}{clear}")
headers["Actions"] = "; ".join(parts)
async with httpx.AsyncClient(timeout=20) as client:
resp = await client.post(url, content=message.encode("utf-8"), headers=headers)
+89
View File
@@ -7,16 +7,98 @@ import logging
from apscheduler.schedulers.asyncio import AsyncIOScheduler
from apscheduler.triggers.interval import IntervalTrigger
from datetime import datetime, timezone
from sqlmodel import Session, select
from . import delivery
from .checker import check_all_feeds
from .database import engine, get_settings
from .delivery import Message
from .models import DigestEntry, Feed, Notification
log = logging.getLogger("scheduler")
_scheduler: AsyncIOScheduler | None = None
_JOB_ID = "check-feeds"
_JOB_DIGEST = "send-digests"
# Fixed tick; per-feed/global intervals are honoured inside check_all_feeds.
_TICK_SECONDS = 60
async def send_due_digests() -> None:
"""Check each digest-enabled feed and send pending entries."""
now = datetime.now(timezone.utc)
with Session(engine) as session:
settings = get_settings(session)
feeds = session.exec(
select(Feed).where(
Feed.enabled == True, # noqa: E712
Feed.digest_enabled == True, # noqa: E712
)
).all()
for feed in feeds:
period_hours = max(1, feed.digest_period_hours)
if feed.last_digest_at is not None:
last = feed.last_digest_at.replace(tzinfo=timezone.utc) if feed.last_digest_at.tzinfo is None else feed.last_digest_at
if (now - last).total_seconds() < period_hours * 3600:
continue
with Session(engine) as session:
entries = session.exec(
select(DigestEntry)
.where(DigestEntry.feed_id == feed.id)
.order_by(DigestEntry.created_at)
).all()
if not entries:
continue
db_feed = session.get(Feed, feed.id)
stg = get_settings(session)
# Build digest message as Markdown list
lines = [f"# 📡 {db_feed.title or 'Дайджест'}\n"]
for e in entries:
lines.append(f"**[{e.title}]({e.link})**")
if e.body:
preview = e.body[:200].replace("\n", " ")
lines.append(f"> {preview}")
lines.append("")
digest_msg = Message(
source=db_feed.title or "",
title=f"Дайджест ({len(entries)} записей)",
body="\n".join(lines),
link="",
image="",
full_content=True,
)
result = await delivery.dispatch(db_feed, stg, digest_msg)
session.add(
Notification(
feed_id=db_feed.id,
feed_title=digest_msg.source,
title=digest_msg.title,
link="",
channels=",".join(result.channels),
ok=result.ok,
detail=result.detail,
)
)
# Clear sent entries
for e in entries:
session.delete(e)
db_feed.last_digest_at = now
db_feed.last_checked = now
db_feed.last_status = f"digest:{len(entries)}"
session.commit()
def start(interval_minutes: int) -> None:
global _scheduler
if _scheduler is not None:
@@ -30,6 +112,13 @@ def start(interval_minutes: int) -> None:
coalesce=True,
replace_existing=True,
)
_scheduler.add_job(
send_due_digests,
trigger=IntervalTrigger(seconds=300), # every 5 minutes
id=_JOB_DIGEST,
max_instances=1,
coalesce=True,
)
_scheduler.start()
log.info("Планировщик запущен (тик 60с), интервал по умолчанию %d мин", interval_minutes)
+35
View File
@@ -23,6 +23,11 @@ class FeedIn(BaseModel):
filter_exclude: str = ""
interval: int = 0
enabled: bool = True
send_full_content: bool = False
fetch_full_article: bool = False
digest_enabled: bool = False
digest_period_hours: int = 24
category_id: Optional[int] = None
@field_validator("url")
@classmethod
@@ -63,6 +68,13 @@ class SettingsIn(BaseModel):
alerts_enabled: bool = False
alert_topic: str = ""
alert_threshold: int = 3
# Default feed values
default_priority: int = 3
default_tags: str = ""
default_attach_image: bool = True
default_interval: int = 0
notification_template: str = "**{title}**\n\n{body}\n\n[Открыть →]({link})"
proxy_url: str = ""
@field_validator("check_interval")
@classmethod
@@ -74,6 +86,16 @@ class SettingsIn(BaseModel):
def _threshold_min(cls, v: int) -> int:
return max(1, v)
@field_validator("default_priority")
@classmethod
def _def_priority_range(cls, v: int) -> int:
return min(5, max(1, v))
@field_validator("default_interval")
@classmethod
def _def_interval_nonneg(cls, v: int) -> int:
return max(0, v)
class TestIn(BaseModel):
server: str = ""
@@ -98,6 +120,19 @@ class PreviewIn(BaseModel):
return v
class CategoryIn(BaseModel):
name: str
sort_order: int = 0
@field_validator("name")
@classmethod
def _name_required(cls, v: str) -> str:
v = v.strip()
if not v:
raise ValueError("Название категории обязательно")
return v
class UserIn(BaseModel):
username: str
password: str = "" # empty on edit = keep existing
+247 -8
View File
@@ -4,6 +4,8 @@ const $ = (sel, root = document) => root.querySelector(sel);
const $$ = (sel, root = document) => [...root.querySelectorAll(sel)];
let ME = { role: "admin", auth_enabled: false };
let SETTINGS = {};
let CATEGORIES = [];
// ---------- API helper ----------
async function api(method, url, body) {
@@ -105,6 +107,87 @@ async function loadActivity() {
`<svg viewBox="0 0 ${W} ${H}" preserveAspectRatio="none" class="chart-svg">${bars}</svg>`;
}
// ---------- Categories ----------
async function loadCategories() {
try { CATEGORIES = await api("GET", "/api/categories"); } catch (e) { CATEGORIES = []; }
var list = document.getElementById("categories-list");
if (!list) return;
list.innerHTML = "";
var empty = document.getElementById("categories-empty");
if (empty) empty.classList.toggle("hidden", CATEGORIES.length > 0);
CATEGORIES.forEach(function(c) { list.appendChild(categoryCard(c)); });
}
function categoryCard(c) {
var el = document.createElement("div");
el.className = "feed-card";
el.innerHTML = '<div class="feed-top"><span class="dot on"></span>' +
'<div style="flex:1">' +
'<div class="feed-title">' + escapeHtml(c.name) + '</div>' +
'<div class="feed-meta"><span class="chip">' + t("cat.sortOrder") + ': ' + c.sort_order + '</span></div>' +
'</div>' +
'<div class="feed-actions">' +
'<button class="btn ghost small" data-act="edit">✎</button>' +
'<button class="btn danger small" data-act="del">🗑</button>' +
'</div></div>';
el.querySelector('[data-act="edit"]').onclick = function() { openCatModal(c); };
el.querySelector('[data-act="del"]').onclick = function() { deleteCategory(c); };
return el;
}
var catModal = document.getElementById("cat-modal");
var catForm = document.getElementById("cat-form");
function openCatModal(cat) {
catForm.reset();
document.getElementById("cat-modal-title").textContent = cat ? t("cat.editTitle") : t("cat.addTitle");
catForm.id.value = cat ? (cat.id || "") : "";
catForm.name.value = cat ? (cat.name || "") : "";
catForm.sort_order.value = (cat && cat.sort_order != null) ? cat.sort_order : 0;
catModal.classList.remove("hidden");
}
function closeCatModal() { catModal.classList.add("hidden"); }
document.getElementById("add-category").onclick = function() { openCatModal(null); };
document.getElementById("cat-modal-close").onclick = closeCatModal;
document.getElementById("cat-modal-cancel").onclick = closeCatModal;
catModal.addEventListener("click", function(e) { if (e.target === catModal) closeCatModal(); });
catForm.addEventListener("submit", async function(e) {
e.preventDefault();
var payload = {
name: catForm.name.value.trim(),
sort_order: parseInt(catForm.sort_order.value, 10) || 0,
};
var id = catForm.id.value;
try {
if (id) await api("PUT", "/api/categories/" + id, payload);
else await api("POST", "/api/categories", payload);
toast(id ? t("toast.categoryUpdated") : t("toast.categoryAdded"));
closeCatModal();
await loadCategories();
loadFeeds().catch(function() {});
} catch (err) { toast(err.message, "err"); }
});
async function deleteCategory(c) {
var feedCount = 0;
try {
var feeds = await api("GET", "/api/feeds");
feedCount = feeds.filter(function(f) { return f.category_id === c.id; }).length;
} catch (e) {}
var msg = feedCount
? t("confirm.deleteCategoryFeeds", { name: c.name, n: feedCount })
: t("confirm.deleteCategory", { name: c.name });
if (!confirm(msg)) return;
try {
await api("DELETE", "/api/categories/" + c.id);
toast(t("toast.categoryDeleted"));
await loadCategories();
loadFeeds().catch(function() {});
} catch (err) { toast(err.message, "err"); }
}
// ---------- Feeds ----------
function feedCard(f) {
const el = document.createElement("div");
@@ -119,6 +202,11 @@ function feedCard(f) {
if (f.to_webhook) chips.push(`<span class="chip">🔗 hook</span>`);
if (f.filter_include || f.filter_exclude) chips.push(`<span class="chip">🧩</span>`);
if (f.tags) chips.push(`<span class="chip">🏷️ ${escapeHtml(f.tags)}</span>`);
if (f.send_full_content) chips.push('<span class="chip full">📄 full</span>');
if (f.fetch_full_article) chips.push('<span class="chip full">📄 trafilatura</span>');
if (f.digest_enabled) chips.push('<span class="chip">📬 digest ' + f.digest_period_hours + 'h</span>');
var cat = CATEGORIES.find(function(c) { return c.id === f.category_id; });
if (cat) chips.push('<span class="chip cat">🏷️ ' + escapeHtml(cat.name) + '</span>');
const admin = ME.role === "admin";
el.innerHTML = `
@@ -185,12 +273,28 @@ function openModal(feed) {
$("#preview-area").innerHTML = "";
$("#modal-title").textContent = feed ? t("modal.editFeed") : t("modal.addFeed");
feedForm.id.value = feed?.id || "";
const f = feed || { attach_image: true, enabled: true, priority: 3, interval: 0 };
const defaults = {
attach_image: SETTINGS.default_attach_image ?? true,
enabled: true,
priority: SETTINGS.default_priority ?? 3,
interval: SETTINGS.default_interval ?? 0,
tags: SETTINGS.default_tags ?? "",
};
const f = feed || defaults;
for (const el of feedForm.elements) {
if (!el.name || el.name === "id") continue;
if (el.type === "checkbox") el.checked = !!f[el.name];
else if (f[el.name] !== undefined) el.value = f[el.name];
}
// Populate category dropdown
const sel = feedForm.category_id;
if (sel) {
sel.innerHTML = '<option value="">' + t("feed.categoryNone") + '</option>';
CATEGORIES.forEach(function(c) {
sel.innerHTML += '<option value="' + c.id + '">' + escapeHtml(c.name) + '</option>';
});
if (feed && feed.category_id) sel.value = feed.category_id;
}
modal.classList.remove("hidden");
}
function closeModal() { modal.classList.add("hidden"); }
@@ -240,9 +344,14 @@ feedForm.addEventListener("submit", async e => {
filter_include: feedForm.filter_include.value.trim(),
filter_exclude: feedForm.filter_exclude.value.trim(),
attach_image: feedForm.attach_image.checked,
send_full_content: feedForm.send_full_content.checked,
fetch_full_article: feedForm.fetch_full_article.checked,
digest_enabled: feedForm.digest_enabled.checked,
digest_period_hours: parseInt(feedForm.digest_period_hours.value, 10) || 24,
to_telegram: feedForm.to_telegram.checked,
to_webhook: feedForm.to_webhook.checked,
enabled: feedForm.enabled.checked,
category_id: feedForm.category_id.value || null,
};
const id = feedForm.id.value;
try {
@@ -267,6 +376,25 @@ $("#check-all").onclick = async (e) => {
};
// ---------- OPML ----------
// --- Backup / Restore ---
$("#backup-btn").onclick = function() { location.href = "/api/backup"; };
$("#restore-btn").onclick = function() { document.getElementById("restore-file").click(); };
$("#restore-file").onchange = async function(e) {
var file = e.target.files[0];
if (!file) return;
if (!confirm(t("confirm.restore"))) { e.target.value = ""; return; }
var fd = new FormData();
fd.append("file", file);
try {
var res = await fetch("/api/backup", { method: "POST", body: fd });
var data = await res.json();
if (!res.ok) throw new Error(data.detail || "Error");
toast(t("toast.restored"));
setTimeout(function() { location.reload(); }, 1500);
} catch (err) { toast(err.message, "err"); }
finally { e.target.value = ""; }
};
$("#export-btn").onclick = () => { location.href = "/api/feeds/export"; };
$("#import-btn").onclick = () => $("#opml-file").click();
$("#opml-file").onchange = async (e) => {
@@ -398,11 +526,11 @@ userForm.addEventListener("submit", async e => {
const sForm = $("#settings-form");
async function loadSettings() {
const s = await api("GET", "/api/settings");
SETTINGS = await api("GET", "/api/settings");
for (const el of sForm.elements) {
if (!el.name) continue;
if (el.type === "checkbox") el.checked = !!s[el.name];
else if (s[el.name] !== undefined) el.value = s[el.name];
if (el.type === "checkbox") el.checked = !!SETTINGS[el.name];
else if (SETTINGS[el.name] !== undefined) el.value = SETTINGS[el.name];
}
}
@@ -423,6 +551,12 @@ sForm.addEventListener("submit", async e => {
alerts_enabled: sForm.alerts_enabled.checked,
alert_topic: sForm.alert_topic.value.trim(),
alert_threshold: parseInt(sForm.alert_threshold.value, 10) || 3,
default_priority: parseInt(sForm.default_priority.value, 10) || 3,
default_tags: sForm.default_tags.value.trim(),
default_attach_image: sForm.default_attach_image.checked,
default_interval: parseInt(sForm.default_interval.value, 10) || 0,
notification_template: sForm.notification_template.value,
proxy_url: sForm.proxy_url.value.trim(),
};
try {
await api("PUT", "/api/settings", payload);
@@ -452,6 +586,8 @@ $$(".tab").forEach(tab => tab.addEventListener("click", () => {
tab.classList.add("active");
$(`#tab-${tab.dataset.tab}`).classList.add("active");
if (tab.dataset.tab === "history") loadHistory().catch(() => {});
if (tab.dataset.tab === "reader") loadReader().catch(() => {});
if (tab.dataset.tab === "categories") loadCategories().catch(() => {});
if (tab.dataset.tab === "users") loadUsers().catch(() => {});
}));
@@ -467,6 +603,8 @@ langSelect.onchange = () => {
// Re-render dynamic content in the new language.
loadFeeds().catch(() => {});
if ($("#tab-history").classList.contains("active")) loadHistory().catch(() => {});
if ($("#tab-reader").classList.contains("active")) loadReader().catch(() => {});
if ($("#tab-categories").classList.contains("active")) loadCategories().catch(() => {});
if ($("#tab-users").classList.contains("active")) loadUsers().catch(() => {});
};
@@ -476,13 +614,114 @@ function renderWhoami() {
}
}
// ---------- Reader ----------
var READER = { cats: [], selectedCat: "all" };
async function loadReaderStats() {
try { READER.cats = await api("GET", "/api/articles/stats"); } catch (e) { READER.cats = []; }
var list = document.getElementById("reader-cat-list");
if (!list) return;
list.innerHTML = '<div class="reader-cat active" data-cat="all"><span>' + t("reader.all") + '</span></div>';
var totalUnread = 0;
READER.cats.forEach(function(c) {
totalUnread += c.unread;
list.innerHTML += '<div class="reader-cat" data-cat="' + c.category_id + '">' +
'<span>' + escapeHtml(c.category_name) + '</span>' +
(c.unread ? '<span class="badge">' + c.unread + '</span>' : '') +
'</div>';
});
var allEl = list.querySelector('[data-cat="all"]');
if (allEl && totalUnread) allEl.innerHTML = '<span>' + t("reader.all") + '</span><span class="badge">' + totalUnread + '</span>';
var cats = list.querySelectorAll('.reader-cat');
cats.forEach(function(el) {
el.onclick = function() {
cats.forEach(function(x) { x.classList.remove("active"); });
el.classList.add("active");
READER.selectedCat = el.dataset.cat;
loadReaderArticles();
};
});
}
async function loadReaderArticles() {
var url = "/api/articles?limit=100";
if (READER.selectedCat !== "all") url += "&category_id=" + READER.selectedCat;
var articles;
try { articles = await api("GET", url); } catch (e) { return; }
var list = document.getElementById("reader-list");
var empty = document.getElementById("reader-empty");
list.innerHTML = "";
if (empty) empty.classList.toggle("hidden", articles.length > 0);
articles.forEach(function(a) {
var el = document.createElement("div");
el.className = "article-row" + (a.is_read ? "" : " unread");
var imgHtml = a.image ? '<img src="' + escapeHtml(a.image) + '" alt="" loading="lazy" style="max-width:80px;max-height:60px;border-radius:6px;flex-shrink:0">' : '';
el.innerHTML = '<span class="article-dot">' + (a.is_read ? '○' : '●') + '</span>' +
'<div class="article-content">' +
'<div class="article-title">' + escapeHtml(a.title) + '</div>' +
'<div class="article-meta">' +
'<span class="chip">' + escapeHtml(a.feed_title) + '</span>' +
'<span class="muted">' + fmtDate(a.created_at) + '</span>' +
'</div>' +
'<div class="article-body-preview">' + escapeHtml(a.body) + '</div>' +
'</div>' +
imgHtml;
el.onclick = async function() {
try {
var full = await api("GET", "/api/articles/" + a.id);
showArticleDetail(full);
} catch (err) { toast(err.message, "err"); }
};
list.appendChild(el);
});
}
function showArticleDetail(article) {
var detail = document.getElementById("reader-detail");
var list = document.getElementById("reader-list");
detail.classList.remove("hidden");
list.classList.add("hidden");
var body = article.full_html || escapeHtml(article.body).replace(/\n/g, '<br>');
detail.innerHTML = '<button class="btn ghost" id="reader-back">← ' + t("reader.back") + '</button>' +
'<h2>' + escapeHtml(article.title) + '</h2>' +
'<div class="article-meta" style="margin:12px 0">' +
'<span class="chip">' + escapeHtml(article.feed_title) + '</span>' +
'<span class="muted">' + fmtDate(article.created_at) + '</span>' +
(article.link ? '<a href="' + escapeHtml(article.link) + '" target="_blank" rel="noopener" class="btn ghost small">' + t("reader.open") + '</a>' : '') +
'</div>' +
'<div class="article-body">' + body + '</div>';
document.getElementById("reader-back").onclick = function() {
detail.classList.add("hidden");
list.classList.remove("hidden");
};
}
async function loadReader() {
await loadReaderStats();
await loadReaderArticles();
}
document.getElementById("reader-mark-all").onclick = async function() {
var url = "/api/articles/read-all";
if (READER.selectedCat !== "all") url += "?category_id=" + READER.selectedCat;
try {
var r = await api("PUT", url);
toast(t("toast.articlesMarked"));
loadReader();
} catch (err) { toast(err.message, "err"); }
};
// ---------- init ----------
async function init() {
applyI18n();
try { ME = await api("GET", "/api/me"); } catch (_) {}
if (ME.role !== "admin") $$(".admin-only").forEach(el => el.classList.add("hidden"));
if (ME.auth_enabled) { $("#logout-btn").style.display = ""; renderWhoami(); }
loadFeeds().catch(e => toast(e.message, "err"));
if (ME.role === "admin") loadSettings().catch(e => toast(e.message, "err"));
if (ME.role !== "admin") $$(".admin-only").forEach(function(el) { el.classList.add("hidden"); });
if (ME.auth_enabled) { document.getElementById("logout-btn").style.display = ""; renderWhoami(); }
if (ME.role === "admin") {
loadCategories().catch(function() {});
loadSettings().catch(function(e) { toast(e.message, "err"); });
}
loadFeeds().catch(function(e) { toast(e.message, "err"); });
}
init();
+108
View File
@@ -142,6 +142,60 @@ const I18N = {
"status.sendError": "Ошибка отправки: {msg}",
"status.dash": "—",
"nav.categories": "Категории",
"categories.heading": "Категории",
"categories.add": "+ Добавить категорию",
"categories.empty": "Категорий пока нет.",
"cat.addTitle": "Добавить категорию",
"cat.editTitle": "Редактировать категорию",
"cat.name": "Название *",
"cat.sortOrder": "Порядок сортировки",
"feed.category": "Категория",
"feed.categoryNone": "— без категории —",
"feed.fullContent": "Отправлять полный контент",
"feed.fullContentHint": "Весь текст, все картинки и видео. Для ntfy — Markdown.",
"settings.feedDefaults": "Значения по умолчанию для новых лент",
"confirm.deleteCategory": "Удалить категорию «{name}»?",
"confirm.deleteCategoryFeeds": "Удалить категорию «{name}»? {n} лент будут откреплены.",
"toast.categoryAdded": "Категория добавлена",
"toast.categoryUpdated": "Категория обновлена",
"toast.categoryDeleted": "Категория удалена",
"nav.reader": "Чтение",
"reader.all": "Все",
"reader.markAll": "Отметить все прочитанными",
"reader.back": "← Назад",
"reader.open": "Открыть оригинал →",
"reader.empty": "Статей пока нет. Добавьте ленты, чтобы начать читать.",
"feed.digest": "Дайджест",
"feed.digestEnable": "Накапливать записи (дайджест)",
"feed.digestPeriod": "Период дайджеста (часы)",
"feed.fetchArticle": "Загружать полную статью (trafilatura)",
"feed.fetchArticleHint": "Загружает страницу статьи и извлекает основной текст.",
"settings.template": "Шаблон уведомлений",
"settings.templateHint": "Переменные: {title}, {body}, {link}, {source}, {image_url}",
"settings.proxyUrl": "URL прокси",
"settings.proxyHint": "Например: http://proxy:8080 или socks5://proxy:1080",
"feeds.backup": "💾 Бэкап",
"feeds.restore": "📥 Восстановить",
"confirm.restore": "Восстановление заменит всю текущую базу данных. Продолжить?",
"toast.restored": "База восстановлена. Перезагрузка...",
"toast.articlesMarked": "Все статьи отмечены прочитанными",
"role.admin": "админ",
"role.viewer": "наблюдатель",
"login.subtitle": "Войдите, чтобы продолжить",
@@ -291,6 +345,60 @@ const I18N = {
"status.sendError": "Send error: {msg}",
"status.dash": "—",
"nav.categories": "Categories",
"categories.heading": "Categories",
"categories.add": "+ Add category",
"categories.empty": "No categories yet.",
"cat.addTitle": "Add category",
"cat.editTitle": "Edit category",
"cat.name": "Name *",
"cat.sortOrder": "Sort order",
"feed.category": "Category",
"feed.categoryNone": "— no category —",
"feed.fullContent": "Send full content",
"feed.fullContentHint": "Full text, all images and videos. For ntfy — Markdown.",
"settings.feedDefaults": "Default values for new feeds",
"confirm.deleteCategory": "Delete category «{name}»?",
"confirm.deleteCategoryFeeds": "Delete category «{name}»? {n} feeds will be uncategorized.",
"toast.categoryAdded": "Category added",
"toast.categoryUpdated": "Category updated",
"toast.categoryDeleted": "Category deleted",
"nav.reader": "Reader",
"reader.all": "All",
"reader.markAll": "Mark all read",
"reader.back": "← Back",
"reader.open": "Open original →",
"reader.empty": "No articles yet. Add feeds to start reading.",
"feed.digest": "Digest",
"feed.digestEnable": "Accumulate entries (digest)",
"feed.digestPeriod": "Digest period (hours)",
"feed.fetchArticle": "Fetch full article (trafilatura)",
"feed.fetchArticleHint": "Fetches the article page and extracts main text.",
"settings.template": "Notification template",
"settings.templateHint": "Variables: {title}, {body}, {link}, {source}, {image_url}",
"settings.proxyUrl": "Proxy URL",
"settings.proxyHint": "Example: http://proxy:8080 or socks5://proxy:1080",
"feeds.backup": "💾 Backup",
"feeds.restore": "📥 Restore",
"confirm.restore": "Restore will replace the entire database. Continue?",
"toast.restored": "Database restored. Reloading...",
"toast.articlesMarked": "All articles marked read",
"role.admin": "admin",
"role.viewer": "viewer",
"login.subtitle": "Sign in to continue",
+51
View File
@@ -133,6 +133,8 @@ h1, h2, h3 { margin: 0; font-weight: 600; }
}
.chip.topic { background: rgba(79, 124, 255, .16); color: #aebfff; }
.chip.tg { background: rgba(34, 158, 217, .18); color: #7fd0f0; }
.chip.cat { background: rgba(168, 85, 247, .18); color: #c9a2f5; }
.chip.full { background: rgba(245, 158, 11, .16); color: #fbbf24; }
.feed-status { font-size: 12.5px; color: var(--muted); }
.feed-status .ok { color: var(--success); }
.feed-status .err { color: var(--danger); }
@@ -316,3 +318,52 @@ details.adv[open] { padding-bottom: 8px; }
.np-title { font-weight: 600; font-size: 15px; margin-bottom: 6px; word-break: break-word; }
.np-body { font-size: 13.5px; color: var(--muted); white-space: pre-wrap; word-break: break-word; }
.ntfy-preview img { max-width: 100%; border-radius: 8px; margin-top: 10px; display: block; }
/* ---------- Reader layout ---------- */
.reader-layout {
display: grid;
grid-template-columns: 200px 1fr;
gap: 20px;
min-height: 60vh;
}
@media (max-width: 700px) {
.reader-layout { grid-template-columns: 1fr; }
}
.reader-sidebar { position: sticky; top: 80px; align-self: start; }
.reader-cats { display: flex; flex-direction: column; gap: 4px; }
.reader-cat {
padding: 8px 14px; border-radius: 8px; cursor: pointer;
font-size: 14px; font-weight: 500; transition: .15s;
display: flex; justify-content: space-between; align-items: center;
}
.reader-cat:hover { background: rgba(255,255,255,.04); }
.reader-cat.active { background: rgba(79,124,255,.16); color: var(--primary); }
.reader-cat .badge {
background: var(--primary); color: #fff; font-size: 11px;
padding: 1px 7px; border-radius: 999px; min-width: 20px; text-align: center;
}
/* Article rows */
.article-row {
display: flex; gap: 12px; padding: 14px 16px;
border-bottom: 1px solid var(--border); cursor: pointer; transition: .1s;
}
.article-row:hover { background: rgba(255,255,255,.03); }
.article-row.unread { border-left: 3px solid var(--primary); }
.article-dot { color: var(--primary); font-size: 12px; flex-shrink: 0; margin-top: 3px; }
.article-content { flex: 1; min-width: 0; }
.article-title { font-weight: 600; font-size: 15px; margin-bottom: 4px; }
.article-meta { display: flex; gap: 8px; align-items: center; margin-bottom: 6px; font-size: 12.5px; }
.article-body-preview {
font-size: 13px; color: var(--muted); white-space: pre-wrap;
display: -webkit-box; -webkit-line-clamp: 3; -webkit-box-orient: vertical; overflow: hidden;
}
/* Article detail (full view) */
.article-body {
font-size: 15px; line-height: 1.7; margin-top: 20px;
padding: 20px; background: var(--bg-soft); border-radius: 12px;
white-space: pre-wrap; word-break: break-word;
}
.article-body img { max-width: 100%; border-radius: 8px; margin: 12px 0; }
#reader-back { margin-bottom: 16px; }
+109
View File
@@ -5,7 +5,9 @@
<div class="brand"><span class="logo">📡</span> RSS&nbsp;&nbsp;ntfy</div>
<nav class="tabs">
<button class="tab active" data-tab="feeds" data-i18n="nav.feeds">Ленты</button>
<button class="tab" data-tab="reader" data-i18n="nav.reader">Чтение</button>
<button class="tab" data-tab="history" data-i18n="nav.history">История</button>
<button class="tab admin-only" data-tab="categories" data-i18n="nav.categories">Категории</button>
<button class="tab admin-only" data-tab="users" data-i18n="nav.users">Пользователи</button>
<button class="tab admin-only" data-tab="settings" data-i18n="nav.settings">Настройки</button>
</nav>
@@ -39,6 +41,9 @@
<div class="panel-head-actions">
<button class="btn ghost" id="check-all" data-i18n="feeds.checkAll">↻ Проверить все</button>
<button class="btn ghost admin-only" id="import-btn" data-i18n="feeds.import">⬆ Импорт OPML</button>
<button class="btn ghost admin-only" id="backup-btn" data-i18n="feeds.backup">💾 Бэкап</button>
<button class="btn ghost admin-only" id="restore-btn" data-i18n="feeds.restore">📥 Восстановить</button>
<input type="file" id="restore-file" accept=".db" hidden>
<button class="btn ghost" id="export-btn" data-i18n="feeds.export">⬇ Экспорт OPML</button>
<button class="btn primary admin-only" id="add-feed" data-i18n="feeds.add">+ Добавить ленту</button>
<input type="file" id="opml-file" accept=".opml,.xml,text/xml" hidden>
@@ -51,6 +56,29 @@
</div>
</section>
<!-- ===================== READER ===================== -->
<section id="tab-reader" class="tab-panel">
<div class="reader-layout">
<aside class="reader-sidebar">
<div class="reader-cats" id="reader-cat-list">
<div class="reader-cat active" data-cat="all" data-i18n="reader.all">Все</div>
</div>
</aside>
<div class="reader-main">
<div class="panel-head">
<h2 data-i18n="nav.reader">Чтение</h2>
<button class="btn ghost" id="reader-mark-all" data-i18n="reader.markAll">Отметить все прочитанными</button>
</div>
<div id="reader-list"></div>
<div id="reader-detail" class="hidden"></div>
<div id="reader-empty" class="empty hidden">
<div class="empty-icon">📖</div>
<p data-i18n="reader.empty">Статей пока нет. Добавьте ленты, чтобы начать читать.</p>
</div>
</div>
</div>
</section>
<!-- ===================== HISTORY ===================== -->
<section id="tab-history" class="tab-panel">
<div class="panel-head">
@@ -72,6 +100,19 @@
</div>
</section>
<!-- ===================== CATEGORIES ===================== -->
<section id="tab-categories" class="tab-panel">
<div class="panel-head">
<h2 data-i18n="categories.heading">Категории</h2>
<button class="btn primary" id="add-category" data-i18n="categories.add">+ Добавить категорию</button>
</div>
<div id="categories-list" class="cards"></div>
<div id="categories-empty" class="empty hidden">
<div class="empty-icon">🏷️</div>
<p data-i18n="categories.empty">Категорий пока нет.</p>
</div>
</section>
<!-- ===================== USERS ===================== -->
<section id="tab-users" class="tab-panel">
<div class="panel-head">
@@ -108,12 +149,39 @@
<button type="button" class="btn ghost" id="test-btn" data-i18n="settings.testBtn">Отправить тест</button>
</div>
<h3 data-i18n="settings.check">Проверка</h3>
<label><span data-i18n="settings.proxyUrl">URL прокси</span> <small class="muted" data-i18n="settings.proxyHint"></small>
<input type="text" name="proxy_url" placeholder="http://proxy:8080"></label>
<h3 data-i18n="settings.template">Шаблон уведомлений</h3>
<label><span data-i18n="settings.templateHint">Переменные: {title}, {body}, {link}, {source}, {image_url}</span>
<textarea name="notification_template" rows="3" style="width:100%;margin-top:4px;padding:8px;background:var(--bg-soft);border:1px solid var(--border);border-radius:8px;color:var(--text);font-family:monospace;font-size:13px;resize:vertical"></textarea></label>
<h3 data-i18n="settings.check">Проверка</h3>
<label><span data-i18n="settings.interval">Интервал проверки по умолчанию (минуты)</span>
<input type="number" name="check_interval" min="1" value="5">
<small class="muted" data-i18n="settings.intervalHint"></small>
</label>
<h3 data-i18n="settings.feedDefaults">Значения по умолчанию для новых лент</h3>
<div class="grid-2">
<label><span data-i18n="feed.priority">Приоритет</span>
<select name="default_priority">
<option value="1" data-i18n="feed.p1"></option>
<option value="2" data-i18n="feed.p2"></option>
<option value="3" selected data-i18n="feed.p3"></option>
<option value="4" data-i18n="feed.p4"></option>
<option value="5" data-i18n="feed.p5"></option>
</select>
</label>
<label><span data-i18n="feed.intervalMin">Интервал, мин</span> <small class="muted" data-i18n="feed.intervalHint"></small>
<input type="number" name="default_interval" min="0" value="0"></label>
</div>
<label><span data-i18n="feed.tags">Теги / эмодзи</span> <small class="muted" data-i18n="feed.commaHint"></small>
<input type="text" name="default_tags" placeholder="newspaper,fire"></label>
<label class="switch-row"><span data-i18n="feed.attach">Прикреплять картинку</span>
<input type="checkbox" name="default_attach_image" class="switch" checked></label>
<h3 data-i18n="settings.telegram">Telegram</h3>
<label class="switch-row"><span data-i18n="settings.tgEnable">Включить доставку в Telegram</span>
<input type="checkbox" name="telegram_enabled" class="switch"></label>
@@ -169,6 +237,11 @@
<label><span data-i18n="feed.title">Название</span> <small class="muted" data-i18n="feed.titleOpt"></small>
<input type="text" name="title">
</label>
<label><span data-i18n="feed.category">Категория</span>
<select name="category_id">
<option value="" data-i18n="feed.categoryNone">— без категории —</option>
</select>
</label>
<div class="grid-2">
<label><span data-i18n="feed.server">Сервер ntfy</span> <small class="muted" data-i18n="feed.serverHint"></small>
<input type="text" name="ntfy_server" placeholder="https://ntfy.sh"></label>
@@ -201,6 +274,15 @@
<label><span data-i18n="feed.intervalMin">Интервал, мин</span> <small class="muted" data-i18n="feed.intervalHint"></small>
<input type="number" name="interval" min="0" value="0"></label>
</div>
<details class="adv">
<summary data-i18n="feed.digest">Дайджест</summary>
<label class="switch-row"><span data-i18n="feed.digestEnable">Накапливать записи (дайджест)</span>
<input type="checkbox" name="digest_enabled" class="switch"></label>
<label><span data-i18n="feed.digestPeriod">Период дайджеста (часы)</span>
<input type="number" name="digest_period_hours" min="1" value="24"></label>
</details>
<label><span data-i18n="feed.tags">Теги / эмодзи</span> <small class="muted" data-i18n="feed.commaHint"></small>
<input type="text" name="tags" placeholder="newspaper,fire"></label>
@@ -214,6 +296,12 @@
<div class="switch-grid">
<label class="switch-row"><span data-i18n="feed.attach">Прикреплять картинку</span>
<input type="checkbox" name="attach_image" class="switch" checked></label>
<label class="switch-row"><span data-i18n="feed.fetchArticle">Загружать полную статью (trafilatura)</span>
<input type="checkbox" name="fetch_full_article" class="switch"></label>
<small class="muted" data-i18n="feed.fetchArticleHint" style="margin-top:-8px;display:block">Загружает страницу статьи и извлекает основной текст.</small>
<label class="switch-row"><span data-i18n="feed.fullContent">Отправлять полный контент</span>
<input type="checkbox" name="send_full_content" class="switch"></label>
<small class="muted" data-i18n="feed.fullContentHint" style="margin-top:-8px;display:block">Весь текст, все картинки и видео. Для ntfy — Markdown.</small>
<label class="switch-row"><span data-i18n="feed.dupTg">Дублировать в Telegram</span>
<input type="checkbox" name="to_telegram" class="switch"></label>
<label class="switch-row"><span data-i18n="feed.toWebhook">Отправлять в webhook</span>
@@ -234,6 +322,27 @@
</form>
</div>
<!-- ===================== CATEGORY MODAL ===================== -->
<div id="cat-modal" class="modal-backdrop hidden">
<form class="modal" id="cat-form">
<div class="modal-head">
<h3 id="cat-modal-title" data-i18n="cat.addTitle">Добавить категорию</h3>
<button type="button" class="icon-btn" id="cat-modal-close"></button>
</div>
<div class="modal-body">
<input type="hidden" name="id">
<label><span data-i18n="cat.name">Название *</span>
<input type="text" name="name" required></label>
<label><span data-i18n="cat.sortOrder">Порядок сортировки</span>
<input type="number" name="sort_order" min="0" value="0"></label>
</div>
<div class="modal-foot">
<button type="button" class="btn ghost" id="cat-modal-cancel" data-i18n="modal.cancel">Отмена</button>
<button type="submit" class="btn primary" data-i18n="modal.save">Сохранить</button>
</div>
</form>
</div>
<!-- ===================== USER MODAL ===================== -->
<div id="user-modal" class="modal-backdrop hidden">
<form class="modal" id="user-form">
+1
View File
@@ -7,3 +7,4 @@ APScheduler==3.11.0
Jinja2==3.1.5
python-multipart==0.0.20
itsdangerous==2.2.0
trafilatura==2.0.0