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
+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)