RSS/Atom -> ntfy bridge with web UI, OPML import/export and RU/EN localization

Web-managed fork of nurefexc/rss-bridge-ntfy: Flask UI + REST API, background
sync engine (SQLite dedup, quiet hours, filters, flood protection, images),
OPML import/export and switchable interface/notification language.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-06-06 19:34:53 +08:00
commit 3f9b108482
15 changed files with 2076 additions and 0 deletions
+12
View File
@@ -0,0 +1,12 @@
data/
.git/
.gitignore
__pycache__/
*.pyc
*.pyo
.venv/
venv/
.env
.idea/
.vscode/
README.md
+12
View File
@@ -0,0 +1,12 @@
data/
__pycache__/
*.pyc
*.pyo
.venv/
venv/
.env
.idea/
.vscode/
*.db
*.db-wal
*.db-shm
+32
View File
@@ -0,0 +1,32 @@
FROM python:3.12-slim
LABEL org.opencontainers.image.title="rss-bridge-ntfy-web"
LABEL org.opencontainers.image.description="RSS/Atom -> ntfy bridge with a web UI"
ENV PYTHONDONTWRITEBYTECODE=1 \
PYTHONUNBUFFERED=1 \
DATA_DIR=/data \
PORT=8080 \
TZ=UTC
WORKDIR /app
# Install dependencies first to leverage layer caching.
COPY requirements.txt .
RUN pip install --no-cache-dir -r requirements.txt
# Application code.
COPY main.py engine.py store.py webapp.py opml.py ./
COPY templates ./templates
COPY static ./static
# Persistent data (settings, feeds, history db, logs) lives here.
RUN mkdir -p /data
VOLUME ["/data"]
EXPOSE 8080
HEALTHCHECK --interval=30s --timeout=5s --start-period=10s --retries=3 \
CMD python -c "import urllib.request,os; urllib.request.urlopen('http://127.0.0.1:'+os.environ.get('PORT','8080')+'/api/health')" || exit 1
CMD ["python", "main.py"]
+236
View File
@@ -0,0 +1,236 @@
# RSS → ntfy Bridge (с веб-интерфейсом)
Мост, который следит за RSS/Atom-лентами и отправляет новые записи как push-уведомления
в [ntfy](https://ntfy.sh). В отличие от
[оригинального проекта](https://github.com/nurefexc/rss-bridge-ntfy), где ленты
настраивались через JSON-файлы и переменные окружения, **здесь всё управление
происходит через веб-интерфейс** — добавление лент, топиков, расписаний и
глобальных настроек, запуск синхронизации, просмотр журнала и отправка тестовых
уведомлений.
Проект упакован в Docker и запускается одной командой.
---
## Возможности
- 🌐 **Полное управление через браузер** — фиды, топики, настройки, без правки файлов.
- 🔔 **Отправка в ntfy** с заголовком, ссылкой (Click), Markdown, иконкой и картинкой (Attach).
- 🧠 **Дедупликация** через SQLite (WAL) — одна запись не приходит дважды.
- 🌙 **Тихие часы** — пониженный приоритет в заданном интервале (напр. `22-7`).
- 🧹 **Фильтры** include/exclude по регулярным выражениям.
- 🌊 **Флуд-защита** — ступенчатая задержка доставки для записей с низким приоритетом.
- 🖼️ **Картинки и описание** автоматически извлекаются из HTML записи.
- 📜 **Живой журнал** и дашборд со статусом прямо в интерфейсе.
- ▶️ **Кнопки** «Синхронизировать сейчас», «Пауза/Возобновить», «Тестовое уведомление».
- 📥 **Импорт/экспорт OPML** — перенос списка лент между ридерами (с сохранением ntfy-параметров).
- 🌍 **Локализация интерфейса** — переключение языка (RU/EN) прямо в шапке; влияет и на текст уведомлений.
- 💾 **Все данные в одном томе** `./data` (настройки, фиды, история, логи).
---
## Быстрый старт (Docker Compose) — рекомендуется
Требуется установленный **Docker** и **Docker Compose**.
```bash
# 1. Перейти в каталог проекта
cd rss-bridge-ntfy
# 2. Собрать образ и запустить контейнер
docker compose up -d --build
# 3. Открыть веб-интерфейс
# http://localhost:8080
```
Готово. Откройте **http://localhost:8080**, перейдите на вкладку **«Фиды»** и
добавьте первую ленту.
Полезные команды:
```bash
docker compose logs -f # смотреть логи
docker compose restart # перезапустить
docker compose down # остановить и удалить контейнер (данные в ./data сохранятся)
```
### Настройка перед запуском (необязательно)
В файле `docker-compose.yml` можно поменять:
- **Порт.** `"8080:8080"` → например `"9000:8080"`, тогда интерфейс будет на `:9000`.
- **Часовой пояс.** `TZ: "Europe/Moscow"` — влияет на тихие часы и время в логах.
> Адрес ntfy-сервера и токен задаются **не здесь, а в самом интерфейсе**
> (вкладка «Настройки»). По умолчанию используется публичный `https://ntfy.sh`.
---
## Запуск через `docker run` (без Compose)
```bash
docker build -t rss-bridge-ntfy-web .
docker run -d \
--name rss-bridge-ntfy \
-p 8080:8080 \
-e TZ=Europe/Moscow \
-v "$(pwd)/data:/data" \
--restart unless-stopped \
rss-bridge-ntfy-web
```
---
## Запуск без Docker (для разработки)
Требуется **Python 3.11+**.
```bash
pip install -r requirements.txt
python main.py
# Интерфейс: http://localhost:8080
```
Переменные окружения (необязательно): `PORT` (по умолчанию `8080`),
`HOST` (`0.0.0.0`), `DATA_DIR` (`data`), `TZ` (`UTC`).
---
## Как пользоваться интерфейсом
Интерфейс состоит из трёх вкладок.
### 1. Дашборд
- Карточки со статистикой: число фидов, топиков, отправленных уведомлений, записей в истории.
- Состояние движка, время последней и следующей синхронизации.
- **Тестовое уведомление** — введите топик и текст, проверьте доставку в ntfy.
- **Журнал** работы в реальном времени и кнопка очистки истории дедупликации.
- В шапке: **«Синхронизировать»** (запустить цикл немедленно) и **«Пауза/Возобновить»**.
### 2. Фиды
Кнопка **«+ Добавить фид»** открывает форму. Поля:
| Поле | Описание |
|------|----------|
| **Название** | Подпись источника в уведомлении. |
| **URL фида*** | Ссылка на RSS/Atom. |
| **Топик ntfy*** | Топик, в который уйдут уведомления (напр. `news`). |
| **Приоритет** | 1–5 (базовый приоритет уведомления). |
| **Иконка (URL)** | Картинка-иконка уведомления (заголовок `Icon`). |
| **Тихие часы** | Интервал вида `22-7`; в это время используется приоритет ниже. |
| **Приоритет в тихие часы** | Какой приоритет применять в тихие часы (по умолчанию 1). |
| **Include regex** | Показывать только записи, совпадающие с выражением. |
| **Exclude regex** | Отбрасывать записи, совпадающие с выражением. |
| **Включён** | Переключатель активности фида. |
Кнопка **«Проверить фид»** в форме загружает ленту и показывает несколько свежих
заголовков — удобно убедиться, что URL рабочий, ещё до сохранения.
Каждую ленту в списке можно включить/выключить тумблером, отредактировать (✎) или удалить (🗑).
**Импорт/экспорт OPML.** В шапке вкладки «Фиды» есть кнопки:
- **Экспорт OPML** — скачивает файл `feeds.opml` со всеми лентами (ntfy-параметры —
топик, приоритет, тихие часы, фильтры — сохраняются в кастомных атрибутах `ntfy*`).
- **Импорт OPML** — загружает `.opml`-файл и добавляет ленты. Дубликаты (совпадение
по URL и топику) пропускаются. У стандартных OPML-файлов из других ридеров топик
берётся из родительской группы (или `rss`, если её нет), затем его можно поправить.
### Локализация
Язык интерфейса переключается выпадающим списком **RU/EN** в правом верхнем углу.
Выбор сохраняется в настройках на сервере и влияет также на язык ссылки
«Читать на сайте» / «Read on website» в самих уведомлениях.
### 3. Настройки (глобальные)
| Настройка | Описание |
|-----------|----------|
| **ntfy сервер (URL)** | Адрес сервера ntfy (по умолчанию `https://ntfy.sh`; можно указать свой self-hosted). |
| **ntfy токен** | Токен доступа (`Bearer`) для приватных серверов/топиков. Необязательно. |
| **Интервал синхронизации** | Как часто опрашивать ленты, в секундах (по умолчанию 600). |
| **Часовой пояс (IANA)** | Напр. `Europe/Moscow`. Используется для тихих часов. |
| **Лимит новых записей за цикл** | Максимум новых уведомлений на ленту за один проход (по умолчанию 3). |
| **Макс. длина описания** | До скольких символов обрезать текст уведомления. |
| **User-Agent** | Заголовок User-Agent при запросе лент. |
| **Флуд-защита** | Ступенчатые задержки доставки для записей с приоритетом < 4. |
| **Язык (RU/EN)** | Переключается в шапке; влияет на интерфейс и текст уведомлений. |
После сохранения настроек запускается синхронизация.
---
## Получение уведомлений на телефоне
1. Установите приложение **ntfy** ([Android](https://play.google.com/store/apps/details?id=io.heckel.ntfy) / [iOS](https://apps.apple.com/app/ntfy/id1625396347)) или откройте веб-клиент https://ntfy.sh/app.
2. Подпишитесь на топик, который вы указали у фида (например `news`).
3. Новые записи из ленты будут приходить push-уведомлениями.
> Топик в ntfy.sh — это, по сути, публичный канал. Используйте длинное,
> труднодоступное имя топика или собственный сервер ntfy с токеном для приватности.
---
## Где хранятся данные
Всё лежит в каталоге `./data` (примонтирован как том `/data` в контейнере):
| Файл | Назначение |
|------|------------|
| `settings.json` | Глобальные настройки. |
| `feeds.json` | Список лент и их параметры. |
| `history.db` | SQLite-база отправленных записей (дедупликация). |
| `bridge.log` | Файл журнала. |
Резервная копия = копия каталога `data/`. Удаление `history.db` приведёт к
повторной отправке последних записей.
---
## Структура проекта
```
rss-bridge-ntfy/
├── main.py # точка входа: поднимает движок и веб-сервер (waitress)
├── engine.py # ядро: парсинг лент, дедуп, фильтры, отправка в ntfy
├── store.py # потокобезопасное хранилище настроек и фидов (JSON)
├── webapp.py # Flask: REST API + отдача интерфейса
├── opml.py # импорт/экспорт OPML
├── templates/index.html
├── static/css/style.css
├── static/js/app.js
├── static/js/i18n.js # словарь локализации (RU/EN)
├── requirements.txt
├── Dockerfile
├── docker-compose.yml
└── data/ # создаётся при запуске (том с данными)
```
---
## REST API
Интерфейс работает поверх простого API — им можно пользоваться и напрямую:
| Метод | Путь | Назначение |
|-------|------|------------|
| `GET` | `/api/status` | Статус движка и статистика. |
| `POST` | `/api/sync` | Запустить синхронизацию немедленно. |
| `POST` | `/api/engine` | `{"action":"pause"|"resume"}` — пауза/возобновление. |
| `GET` | `/api/logs` | Последние строки журнала. |
| `POST` | `/api/history/clear` | Очистить историю дедупликации. |
| `GET`/`PUT` | `/api/settings` | Получить/обновить глобальные настройки. |
| `GET`/`POST` | `/api/feeds` | Список фидов / создать фид. |
| `PUT`/`DELETE` | `/api/feeds/<id>` | Изменить / удалить фид. |
| `POST` | `/api/feeds/preview` | `{"url":"..."}` — проверить ленту. |
| `GET` | `/api/export/opml` | Скачать все ленты в формате OPML. |
| `POST` | `/api/import/opml` | Загрузить OPML (multipart `file` или тело запроса). |
| `POST` | `/api/test-notify` | `{"topic":"...","message":"..."}` — тест. |
---
## Лицензия
MIT. Основано на идее проекта
[nurefexc/rss-bridge-ntfy](https://github.com/nurefexc/rss-bridge-ntfy).
+16
View File
@@ -0,0 +1,16 @@
services:
rss-bridge-ntfy:
build: .
image: rss-bridge-ntfy-web:latest
container_name: rss-bridge-ntfy
restart: unless-stopped
ports:
- "8080:8080"
environment:
# Часовой пояс контейнера (для тихих часов и логов)
TZ: "Europe/Moscow"
# Порт веб-интерфейса внутри контейнера
PORT: "8080"
volumes:
# Все настройки, фиды, история и логи хранятся здесь
- ./data:/data
+353
View File
@@ -0,0 +1,353 @@
"""RSS/Atom -> ntfy bridge engine.
A single background thread runs sync cycles on an interval. For every enabled
feed it parses entries, de-duplicates them via a SQLite history table, applies
include/exclude filters, computes a (possibly quiet-hours adjusted) priority and
posts a Markdown notification to the feed's ntfy topic.
The engine exposes status and an in-memory log ring buffer so the web UI can
display what is happening without reading files.
"""
import hashlib
import logging
import re
import sqlite3
import threading
import time
from collections import deque
from datetime import datetime, timezone
from urllib.parse import urlparse, urlunparse
from zoneinfo import ZoneInfo
import feedparser
import requests
from bs4 import BeautifulSoup
import store as store_mod
logger = logging.getLogger("bridge")
class RingBufferHandler(logging.Handler):
"""Keeps the most recent log records in memory for the web UI."""
def __init__(self, capacity=300):
super().__init__()
self.buffer = deque(maxlen=capacity)
def emit(self, record):
self.buffer.append(
{
"time": datetime.fromtimestamp(record.created, tz=timezone.utc).isoformat(),
"level": record.levelname,
"message": record.getMessage(),
}
)
def records(self):
return list(self.buffer)
class Engine:
def __init__(self, store: store_mod.Store):
self.store = store
self._stop = threading.Event()
self._wake = threading.Event()
self._force = False
self._thread = None
self._sync_lock = threading.Lock()
self.ring = RingBufferHandler()
self.ring.setFormatter(logging.Formatter("%(message)s"))
self.status = {
"running": False,
"syncing": False,
"last_sync": None,
"last_sync_ok": None,
"next_sync": None,
"last_error": None,
"sent_total": 0,
"sent_last_cycle": 0,
}
self._init_db()
# ---- lifecycle ---------------------------------------------------------
def start(self):
if self._thread and self._thread.is_alive():
return
self._stop.clear()
self._thread = threading.Thread(target=self._loop, name="engine", daemon=True)
self._thread.start()
self.status["running"] = True
logger.info("Engine started")
def stop(self):
self._stop.set()
self._wake.set()
if self._thread:
self._thread.join(timeout=10)
self.status["running"] = False
logger.info("Engine stopped")
def trigger_sync(self):
"""Request an immediate sync cycle even if the engine is paused."""
self._force = True
self._wake.set()
# ---- main loop ---------------------------------------------------------
def _loop(self):
while not self._stop.is_set():
settings = self.store.get_settings()
if settings["enabled"] or self._force:
self._force = False
try:
self.sync(settings)
except Exception as exc: # never let the loop die
logger.exception("Sync cycle crashed: %s", exc)
self.status["last_error"] = str(exc)
interval = max(30, int(settings.get("sync_interval", 600)))
self.status["next_sync"] = datetime.now(timezone.utc).timestamp() + interval
self._wake.wait(timeout=interval)
self._wake.clear()
# ---- database ----------------------------------------------------------
def _connect(self):
conn = sqlite3.connect(store_mod.DB_PATH, timeout=30)
conn.execute("PRAGMA journal_mode=WAL")
return conn
def _init_db(self):
with self._connect() as conn:
conn.execute(
"""
CREATE TABLE IF NOT EXISTS seen_entries (
hash TEXT PRIMARY KEY,
topic TEXT,
created_at TEXT
)
"""
)
def _seen(self, conn, entry_hash):
cur = conn.execute("SELECT 1 FROM seen_entries WHERE hash=?", (entry_hash,))
return cur.fetchone() is not None
def _mark_seen(self, conn, entry_hash, topic):
conn.execute(
"INSERT OR IGNORE INTO seen_entries (hash, topic, created_at) VALUES (?,?,?)",
(entry_hash, topic, datetime.now(timezone.utc).isoformat()),
)
conn.commit()
def history_count(self):
try:
with self._connect() as conn:
return conn.execute("SELECT COUNT(*) FROM seen_entries").fetchone()[0]
except sqlite3.Error:
return 0
def clear_history(self):
with self._connect() as conn:
conn.execute("DELETE FROM seen_entries")
conn.commit()
# ---- helpers -----------------------------------------------------------
@staticmethod
def clean_url(url):
"""Strip query/params/fragment so tracking params don't change the hash."""
try:
parts = urlparse(url)
return urlunparse((parts.scheme, parts.netloc, parts.path, "", "", ""))
except ValueError:
return url
def _entry_hash(self, topic, entry):
ident = entry.get("id") or self.clean_url(entry.get("link", ""))
raw = f"{topic}_{ident}"
return hashlib.sha256(raw.encode("utf-8")).hexdigest()
def _tz(self, settings):
try:
return ZoneInfo(settings.get("tz", "UTC"))
except Exception:
return timezone.utc
def get_dynamic_priority(self, feed, settings):
base = int(feed.get("priority", 3))
quiet = (feed.get("quiet_hours") or "").strip()
if not quiet:
return base
nums = re.findall(r"\d+", quiet)
if len(nums) < 2:
logger.warning("Invalid quiet_hours '%s' for feed %s", quiet, feed.get("name"))
return base
start, end = int(nums[0]) % 24, int(nums[1]) % 24
hour = datetime.now(self._tz(settings)).hour
if start > end: # overnight window, e.g. 22-7
is_quiet = hour >= start or hour < end
else:
is_quiet = start <= hour < end
if is_quiet:
return int(feed.get("quiet_priority", 1))
return base
@staticmethod
def should_filter(feed, title, summary):
"""Return True if the entry must be dropped."""
text = f"{title} {summary}".lower()
exclude = (feed.get("exclude_regex") or "").strip()
include = (feed.get("include_regex") or "").strip()
try:
if exclude and re.search(exclude, text):
return True
if include and not re.search(include, text):
return True
except re.error as exc:
logger.warning("Bad regex on feed %s: %s", feed.get("name"), exc)
return False
def clean_html_content(self, html, max_len):
"""Return (text, image_url) extracted from an HTML snippet."""
if not html:
return "", None
soup = BeautifulSoup(html, "lxml")
image = None
img = soup.find("img")
if img and img.get("src"):
image = img["src"]
text = soup.get_text(separator=" ", strip=True)
if len(text) > max_len:
text = text[:max_len].rstrip() + ""
return text, image
# ---- ntfy --------------------------------------------------------------
def send_ntfy(self, settings, topic, title, message, link,
priority, icon=None, attach=None, delay=None):
url = settings["ntfy_url"].rstrip("/") + "/" + topic
headers = {
"User-Agent": settings.get("user_agent", store_mod.DEFAULT_USER_AGENT),
"Title": title.encode("utf-8"), # bytes -> ntfy decodes UTF-8
"Priority": str(priority),
"Tags": "newspaper",
"Markdown": "yes",
}
if link:
headers["Click"] = link
if settings.get("ntfy_token"):
headers["Authorization"] = f"Bearer {settings['ntfy_token']}"
if icon:
headers["Icon"] = icon
if attach:
headers["Attach"] = attach
if delay:
headers["Delay"] = delay
resp = requests.post(url, data=message.encode("utf-8"),
headers=headers, timeout=30)
resp.raise_for_status()
return resp
def send_test(self, topic, title="Test", message="rss-bridge-ntfy test notification"):
settings = self.store.get_settings()
self.send_ntfy(settings, topic, title, message, link=None, priority=3)
# ---- sync --------------------------------------------------------------
def sync(self, settings=None):
if not self._sync_lock.acquire(blocking=False):
logger.info("Sync already in progress, skipping trigger")
return
try:
settings = settings or self.store.get_settings()
self.status["syncing"] = True
self.status["last_error"] = None
sent_cycle = 0
feeds = [f for f in self.store.get_feeds() if f["enabled"] and f["url"] and f["topic"]]
logger.info("Sync started: %d active feed(s)", len(feeds))
with self._connect() as conn:
for feed in feeds:
sent_cycle += self._process_feed(conn, settings, feed)
self.status["sent_last_cycle"] = sent_cycle
self.status["sent_total"] += sent_cycle
self.status["last_sync"] = datetime.now(timezone.utc).isoformat()
self.status["last_sync_ok"] = True
logger.info("Sync finished: %d notification(s) sent", sent_cycle)
except Exception as exc:
self.status["last_sync_ok"] = False
self.status["last_error"] = str(exc)
logger.exception("Sync failed: %s", exc)
finally:
self.status["syncing"] = False
self._sync_lock.release()
def _process_feed(self, conn, settings, feed):
name = feed.get("name") or feed.get("url")
try:
parsed = feedparser.parse(
feed["url"], agent=settings.get("user_agent", store_mod.DEFAULT_USER_AGENT)
)
except Exception as exc:
logger.warning("Failed to fetch feed %s: %s", name, exc)
return 0
if parsed.bozo and not parsed.entries:
logger.warning("Feed %s returned no usable entries (%s)", name, parsed.get("bozo_exception"))
return 0
batch_limit = int(settings.get("batch_limit", 3))
max_len = int(settings.get("max_desc_length", 250))
flood = bool(settings.get("flood_protection", True))
# New (unseen) entries, oldest first, capped to the batch limit.
new_entries = []
for entry in parsed.entries:
entry_hash = self._entry_hash(feed["topic"], entry)
if not self._seen(conn, entry_hash):
new_entries.append((entry_hash, entry))
new_entries = list(reversed(new_entries))[:batch_limit]
sent = 0
for index, (entry_hash, entry) in enumerate(new_entries):
title = entry.get("title", "(no title)")
link = entry.get("link", "")
raw_html = ""
if entry.get("content"):
raw_html = entry["content"][0].get("value", "")
raw_html = raw_html or entry.get("summary", "")
description, image = self.clean_html_content(raw_html, max_len)
if self.should_filter(feed, title, description):
self._mark_seen(conn, entry_hash, feed["topic"]) # drop quietly
continue
priority = self.get_dynamic_priority(feed, settings)
# Flood protection: stagger low priority items within the batch.
delay = None
if flood and priority < 4 and index > 0:
delay = f"{index * 5}m"
read_more = {"ru": "Читать на сайте", "en": "Read on website"}.get(
settings.get("language", "ru"), "Читать на сайте")
body = f"**{feed.get('name', '')}**\n\n{description}"
if link:
body += f"\n\n[{read_more}]({link})"
try:
self.send_ntfy(
settings, feed["topic"], title, body, link,
priority, icon=feed.get("icon") or None,
attach=image, delay=delay,
)
self._mark_seen(conn, entry_hash, feed["topic"])
sent += 1
logger.info("Sent '%s' -> topic '%s' (priority %s)", title, feed["topic"], priority)
except Exception as exc:
logger.warning("Failed to send '%s' to '%s': %s", title, feed["topic"], exc)
return sent
+61
View File
@@ -0,0 +1,61 @@
"""Entry point: wire up the store, the background engine and the web server.
A single process hosts both the Flask web UI/API and the RSS->ntfy engine
(running in its own daemon thread). Served by waitress so it works the same on
Linux containers and Windows.
"""
import logging
import os
import signal
import sys
from waitress import serve
import store as store_mod
from engine import Engine
from webapp import create_app
def setup_logging(engine: Engine):
logger = logging.getLogger("bridge")
logger.setLevel(logging.INFO)
fmt = logging.Formatter("%(asctime)s [%(levelname)s] %(message)s", "%Y-%m-%d %H:%M:%S")
stream = logging.StreamHandler(sys.stdout)
stream.setFormatter(fmt)
logger.addHandler(stream)
file_handler = logging.FileHandler(store_mod.LOG_PATH, encoding="utf-8")
file_handler.setFormatter(fmt)
logger.addHandler(file_handler)
logger.addHandler(engine.ring) # in-memory buffer for the web UI
logger.propagate = False
def main():
host = os.environ.get("HOST", "0.0.0.0")
port = int(os.environ.get("PORT", "8080"))
store = store_mod.Store()
engine = Engine(store)
setup_logging(engine)
engine.start()
app = create_app(store, engine)
def shutdown(signum, frame): # noqa: ARG001
logging.getLogger("bridge").info("Shutting down (signal %s)", signum)
engine.stop()
sys.exit(0)
signal.signal(signal.SIGINT, shutdown)
signal.signal(signal.SIGTERM, shutdown)
logging.getLogger("bridge").info("Web UI on http://%s:%s", host, port)
serve(app, host=host, port=port, threads=8)
if __name__ == "__main__":
main()
+86
View File
@@ -0,0 +1,86 @@
"""OPML import/export.
Feeds are exported as an OPML 2.0 outline grouped by ntfy topic. ntfy-specific
options are stored as custom ``ntfy*`` attributes so an export/import round-trip
preserves everything. Import also accepts plain OPML files from other readers:
feeds without an ``ntfyTopic`` attribute inherit the parent outline's text as the
topic (falling back to ``rss``).
"""
import xml.etree.ElementTree as ET
def _int(value, default):
try:
return int(value)
except (TypeError, ValueError):
return default
def feeds_to_opml(feeds):
opml = ET.Element("opml", version="2.0")
head = ET.SubElement(opml, "head")
ET.SubElement(head, "title").text = "rss-bridge-ntfy feeds"
body = ET.SubElement(opml, "body")
# Group feeds by topic, preserving insertion order.
groups = {}
for feed in feeds:
groups.setdefault(feed.get("topic", "") or "feeds", []).append(feed)
for topic, items in groups.items():
group = ET.SubElement(body, "outline", text=topic, title=topic)
for feed in items:
label = feed.get("name") or feed.get("url", "")
ET.SubElement(group, "outline", **{
"type": "rss",
"text": label,
"title": label,
"xmlUrl": feed.get("url", ""),
"htmlUrl": feed.get("url", ""),
"ntfyTopic": feed.get("topic", "") or "",
"ntfyPriority": str(feed.get("priority", 3)),
"ntfyIcon": feed.get("icon", "") or "",
"ntfyQuietHours": feed.get("quiet_hours", "") or "",
"ntfyQuietPriority": str(feed.get("quiet_priority", 1)),
"ntfyInclude": feed.get("include_regex", "") or "",
"ntfyExclude": feed.get("exclude_regex", "") or "",
"ntfyEnabled": "true" if feed.get("enabled", True) else "false",
})
xml = ET.tostring(opml, encoding="unicode")
return '<?xml version="1.0" encoding="UTF-8"?>\n' + xml
def opml_to_feeds(xml_str):
root = ET.fromstring(xml_str)
body = root.find("body")
if body is None:
return []
feeds = []
def walk(node, parent_topic):
for outline in node.findall("outline"):
xml_url = outline.get("xmlUrl") or outline.get("xmlurl")
text = outline.get("text") or outline.get("title") or ""
if xml_url:
topic = outline.get("ntfyTopic") or parent_topic or text or "rss"
feeds.append({
"name": text or xml_url,
"url": xml_url,
"topic": topic,
"priority": _int(outline.get("ntfyPriority"), 3),
"icon": outline.get("ntfyIcon", "") or "",
"quiet_hours": outline.get("ntfyQuietHours", "") or "",
"quiet_priority": _int(outline.get("ntfyQuietPriority"), 1),
"include_regex": outline.get("ntfyInclude", "") or "",
"exclude_regex": outline.get("ntfyExclude", "") or "",
"enabled": (outline.get("ntfyEnabled", "true").lower() != "false"),
})
# A container outline (no xmlUrl) defines the topic for its children.
child_topic = text if (not xml_url and text) else parent_topic
walk(outline, child_topic)
walk(body, "")
return feeds
+6
View File
@@ -0,0 +1,6 @@
feedparser==6.0.11
requests==2.31.0
beautifulsoup4==4.12.3
lxml==5.1.0
Flask==3.0.3
waitress==3.0.0
+159
View File
@@ -0,0 +1,159 @@
:root {
--bg: #0f1419;
--panel: #1a212b;
--panel-2: #232c38;
--border: #2c3543;
--text: #e6edf3;
--muted: #8b97a7;
--accent: #3b82f6;
--accent-2: #2563eb;
--green: #22c55e;
--red: #ef4444;
--yellow: #eab308;
}
* { box-sizing: border-box; }
body {
margin: 0;
font-family: -apple-system, "Segoe UI", Roboto, Helvetica, Arial, sans-serif;
background: var(--bg);
color: var(--text);
font-size: 14px;
}
.topbar {
display: flex; align-items: center; justify-content: space-between;
padding: 0.8rem 1.4rem;
background: var(--panel);
border-bottom: 1px solid var(--border);
position: sticky; top: 0; z-index: 10;
}
.brand { display: flex; align-items: center; gap: 0.8rem; }
.logo { font-size: 1.8rem; }
.brand h1 { margin: 0; font-size: 1.1rem; }
.brand small { color: var(--muted); }
.topbar-actions { display: flex; align-items: center; gap: 0.6rem; }
.lang-select { width: auto; margin-top: 0; padding: 0.45rem 0.5rem; cursor: pointer; }
.feed-toolbar { display: flex; gap: 0.5rem; flex-wrap: wrap; }
.tabs { display: flex; gap: 0.3rem; padding: 0 1.4rem; background: var(--panel); border-bottom: 1px solid var(--border); }
.tab {
background: none; border: none; color: var(--muted);
padding: 0.8rem 1rem; cursor: pointer; font-size: 0.95rem;
border-bottom: 2px solid transparent;
}
.tab:hover { color: var(--text); }
.tab.active { color: var(--text); border-bottom-color: var(--accent); }
main { padding: 1.4rem; max-width: 1100px; margin: 0 auto; }
.tab-panel { display: none; }
.tab-panel.active { display: block; }
.cards { display: grid; grid-template-columns: repeat(4, 1fr); gap: 1rem; margin-bottom: 1.4rem; }
.card { background: var(--panel); border: 1px solid var(--border); border-radius: 10px; padding: 1.1rem; }
.card-val { font-size: 1.8rem; font-weight: 700; }
.card-lbl { color: var(--muted); margin-top: 0.2rem; }
.grid-2 { display: grid; grid-template-columns: 1fr 1fr; gap: 1rem; }
.panel { background: var(--panel); border: 1px solid var(--border); border-radius: 10px; padding: 1.2rem; }
.panel h2 { margin: 0 0 0.8rem; font-size: 1rem; }
.panel-head { display: flex; align-items: center; justify-content: space-between; margin-bottom: 1rem; }
.panel-head h2 { margin: 0; }
.kv { width: 100%; border-collapse: collapse; }
.kv td { padding: 0.4rem 0; border-bottom: 1px solid var(--border); }
.kv td:first-child { color: var(--muted); width: 45%; }
.log {
background: #0b0e13; border: 1px solid var(--border); border-radius: 8px;
padding: 0.6rem; height: 360px; overflow-y: auto;
font-family: "SFMono-Regular", Consolas, monospace; font-size: 12px; line-height: 1.5;
}
.log .line { white-space: pre-wrap; word-break: break-word; padding: 1px 0; }
.log .INFO { color: var(--text); }
.log .WARNING { color: var(--yellow); }
.log .ERROR { color: var(--red); }
.log .time { color: var(--muted); }
.btn {
background: var(--panel-2); color: var(--text); border: 1px solid var(--border);
padding: 0.5rem 0.9rem; border-radius: 8px; cursor: pointer; font-size: 0.9rem;
}
.btn:hover { background: #2b3543; }
.btn-primary { background: var(--accent); border-color: var(--accent); color: #fff; }
.btn-primary:hover { background: var(--accent-2); }
.btn-danger { color: #fff; background: var(--red); border-color: var(--red); }
.btn-sm { padding: 0.3rem 0.6rem; font-size: 0.8rem; }
.pill { padding: 0.25rem 0.7rem; border-radius: 999px; font-size: 0.8rem; font-weight: 600; }
.pill-on { background: rgba(34,197,94,0.15); color: var(--green); }
.pill-off { background: rgba(234,179,8,0.15); color: var(--yellow); }
.pill-muted { background: var(--panel-2); color: var(--muted); }
.row { display: flex; gap: 0.8rem; }
.row > * { flex: 1; }
.row input { width: 100%; }
label { display: block; margin-bottom: 0.8rem; color: var(--muted); font-size: 0.85rem; }
label.check { display: flex; align-items: center; gap: 0.5rem; color: var(--text); }
label.check input { width: auto; }
input, select {
width: 100%; margin-top: 0.3rem; padding: 0.55rem 0.7rem;
background: #0e131a; border: 1px solid var(--border); border-radius: 8px;
color: var(--text); font-size: 0.9rem;
}
input:focus { outline: none; border-color: var(--accent); }
/* Feeds list */
.feed-row {
background: var(--panel); border: 1px solid var(--border); border-radius: 10px;
padding: 0.9rem 1.1rem; margin-bottom: 0.7rem;
display: flex; align-items: center; gap: 1rem;
}
.feed-main { flex: 1; min-width: 0; }
.feed-name { font-weight: 600; }
.feed-url { color: var(--muted); font-size: 0.8rem; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
.feed-badges { display: flex; gap: 0.4rem; flex-wrap: wrap; margin-top: 0.3rem; }
.badge { background: var(--panel-2); color: var(--muted); padding: 0.1rem 0.5rem; border-radius: 6px; font-size: 0.72rem; }
.badge.topic { background: rgba(59,130,246,0.18); color: #93c5fd; }
.feed-actions { display: flex; gap: 0.4rem; }
.icon-btn { background: none; border: none; color: var(--muted); cursor: pointer; font-size: 1rem; }
.icon-btn:hover { color: var(--text); }
.switch { position: relative; display: inline-block; width: 40px; height: 22px; }
.switch input { display: none; }
.slider { position: absolute; inset: 0; background: var(--border); border-radius: 999px; transition: .2s; cursor: pointer; }
.slider::before { content: ""; position: absolute; height: 16px; width: 16px; left: 3px; top: 3px; background: #fff; border-radius: 50%; transition: .2s; }
.switch input:checked + .slider { background: var(--green); }
.switch input:checked + .slider::before { transform: translateX(18px); }
/* Modal */
.modal { position: fixed; inset: 0; background: rgba(0,0,0,0.6); display: flex; align-items: flex-start; justify-content: center; padding: 3rem 1rem; z-index: 50; overflow-y: auto; }
.modal.hidden { display: none; }
.modal-box { background: var(--panel); border: 1px solid var(--border); border-radius: 12px; width: 100%; max-width: 560px; padding: 1.4rem; }
.modal-head { display: flex; justify-content: space-between; align-items: center; margin-bottom: 1rem; }
.modal-head h2 { margin: 0; }
.modal-actions { display: flex; align-items: center; gap: 0.6rem; margin-top: 0.5rem; }
.spacer { flex: 1; }
.preview { margin-top: 1rem; font-size: 0.82rem; color: var(--muted); }
.preview .ok { color: var(--green); }
.preview .err { color: var(--red); }
.preview ul { margin: 0.4rem 0 0; padding-left: 1.1rem; }
.toast {
position: fixed; bottom: 1.5rem; left: 50%; transform: translateX(-50%);
background: var(--panel-2); border: 1px solid var(--border); color: var(--text);
padding: 0.7rem 1.2rem; border-radius: 10px; z-index: 100;
}
.toast.hidden { display: none; }
.toast.err { border-color: var(--red); }
.toast.ok { border-color: var(--green); }
.hidden { display: none; }
.empty { color: var(--muted); text-align: center; padding: 2rem; }
@media (max-width: 760px) {
.cards { grid-template-columns: repeat(2, 1fr); }
.grid-2 { grid-template-columns: 1fr; }
.row { flex-direction: column; }
}
+326
View File
@@ -0,0 +1,326 @@
"use strict";
const $ = (sel) => document.querySelector(sel);
const $$ = (sel) => Array.from(document.querySelectorAll(sel));
async function api(path, opts = {}) {
const res = await fetch(path, {
headers: { "Content-Type": "application/json" },
...opts,
});
let data = null;
try { data = await res.json(); } catch (_) { /* no body */ }
if (!res.ok) {
const msg = (data && (data.error || data.message)) || res.statusText;
throw new Error(msg);
}
return data;
}
function toast(message, kind = "") {
const el = $("#toast");
el.textContent = message;
el.className = "toast " + kind;
setTimeout(() => el.classList.add("hidden"), 3000);
}
function fmtTime(iso) {
if (!iso) return "—";
try { return new Date(iso).toLocaleString(locale()); }
catch (_) { return iso; }
}
function fmtTs(ts) {
if (!ts) return "—";
return new Date(ts * 1000).toLocaleString(locale());
}
function escapeHtml(s) {
return String(s).replace(/[&<>"]/g, (c) =>
({ "&": "&amp;", "<": "&lt;", ">": "&gt;", '"': "&quot;" }[c]));
}
/* ---------------- Language ---------------- */
const langSel = $("#lang-select");
langSel.value = LANG;
langSel.addEventListener("change", async () => {
setLang(langSel.value);
refreshStatus();
refreshLogs();
if ($("#tab-feeds").classList.contains("active")) loadFeeds();
try {
await api("/api/settings", { method: "PUT", body: JSON.stringify({ language: LANG }) });
} catch (_) { /* ignore */ }
});
/* ---------------- Tabs ---------------- */
$$(".tab").forEach((tab) => {
tab.addEventListener("click", () => {
$$(".tab").forEach((t) => t.classList.remove("active"));
$$(".tab-panel").forEach((p) => p.classList.remove("active"));
tab.classList.add("active");
$("#tab-" + tab.dataset.tab).classList.add("active");
if (tab.dataset.tab === "feeds") loadFeeds();
if (tab.dataset.tab === "settings") loadSettings();
});
});
/* ---------------- Status / dashboard ---------------- */
async function refreshStatus() {
try {
const s = await api("/api/status");
$("#stat-feeds").textContent = `${s.feed_active}/${s.feed_total}`;
$("#stat-topics").textContent = s.topics.length;
$("#stat-sent").textContent = s.sent_total;
$("#stat-history").textContent = s.history_count;
const enabled = s.enabled;
const pill = $("#engine-pill");
if (s.syncing) {
pill.textContent = t("pill_syncing"); pill.className = "pill pill-on";
} else if (enabled) {
pill.textContent = t("pill_running"); pill.className = "pill pill-on";
} else {
pill.textContent = t("pill_paused"); pill.className = "pill pill-off";
}
$("#btn-toggle").textContent = enabled ? t("pause") : t("resume");
$("#dash-engine").textContent = s.syncing ? t("st_syncing") : (enabled ? t("st_running") : t("st_paused"));
$("#dash-last").textContent = fmtTime(s.last_sync) +
(s.last_sync_ok === false ? t("err_suffix_sync_failed") : "");
$("#dash-next").textContent = enabled ? fmtTs(s.next_sync) : "—";
$("#dash-interval").textContent = s.sync_interval + " " + t("sec");
$("#dash-error").textContent = s.last_error || "—";
} catch (e) {
$("#engine-pill").textContent = t("no_connection");
}
}
async function refreshLogs() {
try {
const { logs } = await api("/api/logs");
const box = $("#log");
box.innerHTML = logs.map((l) => {
const time = new Date(l.time).toLocaleTimeString(locale());
return `<div class="line ${l.level}"><span class="time">${time}</span> ${escapeHtml(l.message)}</div>`;
}).join("");
} catch (_) { /* ignore */ }
}
/* ---------------- Engine controls ---------------- */
$("#btn-sync").addEventListener("click", async () => {
try { await api("/api/sync", { method: "POST" }); toast(t("sync_started"), "ok"); }
catch (e) { toast(e.message, "err"); }
setTimeout(refreshStatus, 500);
setTimeout(refreshLogs, 1500);
});
$("#btn-toggle").addEventListener("click", async () => {
const s = await api("/api/status");
const action = s.enabled ? "pause" : "resume";
await api("/api/engine", { method: "POST", body: JSON.stringify({ action }) });
refreshStatus();
});
$("#btn-clear-history").addEventListener("click", async () => {
if (!confirm(t("confirm_clear_history"))) return;
await api("/api/history/clear", { method: "POST" });
toast(t("history_cleared"), "ok");
refreshStatus();
});
$("#btn-test").addEventListener("click", async () => {
const topic = $("#test-topic").value.trim();
const message = $("#test-msg").value.trim();
if (!topic) { toast(t("need_topic"), "err"); return; }
try {
await api("/api/test-notify", { method: "POST", body: JSON.stringify({ topic, message }) });
toast(t("notify_sent"), "ok");
} catch (e) { toast(t("err_prefix") + e.message, "err"); }
});
/* ---------------- OPML import / export ---------------- */
$("#btn-export-opml").addEventListener("click", () => {
window.location.href = "/api/export/opml";
});
$("#btn-import-opml").addEventListener("click", () => $("#opml-file").click());
$("#opml-file").addEventListener("change", async (e) => {
const file = e.target.files[0];
if (!file) return;
const form = new FormData();
form.append("file", file);
try {
const res = await fetch("/api/import/opml", { method: "POST", body: form });
const data = await res.json();
if (!res.ok || !data.ok) throw new Error(data.error || res.statusText);
toast(t("import_done").replace("{n}", data.added).replace("{total}", data.total), "ok");
loadFeeds(); refreshStatus();
} catch (err) {
toast(t("err_prefix") + err.message, "err");
} finally {
e.target.value = "";
}
});
/* ---------------- Feeds ---------------- */
async function loadFeeds() {
const { feeds } = await api("/api/feeds");
const list = $("#feeds-list");
if (!feeds.length) {
list.innerHTML = `<div class="empty">${escapeHtml(t("feeds_empty"))}</div>`;
return;
}
list.innerHTML = feeds.map(feedRow).join("");
$$(".feed-toggle").forEach((el) => el.addEventListener("change", onToggleFeed));
$$(".feed-edit").forEach((el) => el.addEventListener("click", onEditFeed));
$$(".feed-del").forEach((el) => el.addEventListener("click", onDeleteFeed));
}
function feedRow(f) {
const badges = [`<span class="badge topic">→ ${escapeHtml(f.topic)}</span>`,
`<span class="badge">${t("priority")} ${f.priority}</span>`];
if (f.quiet_hours) badges.push(`<span class="badge">${t("quiet")} ${escapeHtml(f.quiet_hours)}</span>`);
if (f.include_regex) badges.push(`<span class="badge">include</span>`);
if (f.exclude_regex) badges.push(`<span class="badge">exclude</span>`);
return `
<div class="feed-row">
<label class="switch">
<input type="checkbox" class="feed-toggle" data-id="${f.id}" ${f.enabled ? "checked" : ""}>
<span class="slider"></span>
</label>
<div class="feed-main">
<div class="feed-name">${escapeHtml(f.name || t("no_name"))}</div>
<div class="feed-url">${escapeHtml(f.url)}</div>
<div class="feed-badges">${badges.join("")}</div>
</div>
<div class="feed-actions">
<button class="icon-btn feed-edit" data-id="${f.id}" title="${escapeHtml(t("edit"))}">✎</button>
<button class="icon-btn feed-del" data-id="${f.id}" title="${escapeHtml(t("delete"))}">🗑</button>
</div>
</div>`;
}
async function onToggleFeed(e) {
const id = e.target.dataset.id;
await api(`/api/feeds/${id}`, { method: "PUT", body: JSON.stringify({ enabled: e.target.checked }) });
toast(t("saved"), "ok");
refreshStatus();
}
async function onEditFeed(e) {
const id = e.target.dataset.id;
const { feeds } = await api("/api/feeds");
const feed = feeds.find((f) => f.id === id);
openModal(feed);
}
async function onDeleteFeed(e) {
const id = e.target.dataset.id;
if (!confirm(t("confirm_delete_feed"))) return;
await api(`/api/feeds/${id}`, { method: "DELETE" });
toast(t("feed_deleted"), "ok");
loadFeeds(); refreshStatus();
}
/* ---------------- Feed modal ---------------- */
const modal = $("#modal");
const feedForm = $("#feed-form");
function openModal(feed) {
$("#modal-title").textContent = feed ? t("feed_edit") : t("feed_new");
$("#preview-result").innerHTML = "";
feedForm.reset();
const data = feed || { priority: 3, quiet_priority: 1, enabled: true };
for (const [k, v] of Object.entries(data)) {
const el = feedForm.elements[k];
if (!el) continue;
if (el.type === "checkbox") el.checked = !!v;
else el.value = v ?? "";
}
if (!feed) feedForm.elements["enabled"].checked = true;
modal.classList.remove("hidden");
}
function closeModal() { modal.classList.add("hidden"); }
$("#btn-add-feed").addEventListener("click", () => openModal(null));
$("#modal-close").addEventListener("click", closeModal);
modal.addEventListener("click", (e) => { if (e.target === modal) closeModal(); });
feedForm.addEventListener("submit", async (e) => {
e.preventDefault();
const payload = collectForm(feedForm);
const id = payload.id;
delete payload.id;
try {
if (id) await api(`/api/feeds/${id}`, { method: "PUT", body: JSON.stringify(payload) });
else await api("/api/feeds", { method: "POST", body: JSON.stringify(payload) });
toast(t("feed_saved"), "ok");
closeModal(); loadFeeds(); refreshStatus();
} catch (err) { toast(t("err_prefix") + err.message, "err"); }
});
$("#btn-preview").addEventListener("click", async () => {
const url = feedForm.elements["url"].value.trim();
const box = $("#preview-result");
if (!url) { box.innerHTML = `<span class="err">${escapeHtml(t("need_url"))}</span>`; return; }
box.textContent = t("checking");
try {
const r = await api("/api/feeds/preview", { method: "POST", body: JSON.stringify({ url }) });
if (r.error && !r.entries.length) {
box.innerHTML = `<span class="err">${escapeHtml(t("err_prefix") + r.error)}</span>`;
return;
}
const items = r.entries.map((it) => `<li>${escapeHtml(it.title)}</li>`).join("");
box.innerHTML = `<span class="ok">${escapeHtml(t("preview_ok"))}</span> «${escapeHtml(r.title)}», ` +
`${escapeHtml(t("preview_entries"))} ${r.count}<ul>${items}</ul>`;
} catch (err) { box.innerHTML = `<span class="err">${escapeHtml(t("err_prefix") + err.message)}</span>`; }
});
function collectForm(form) {
const out = {};
for (const el of form.elements) {
if (!el.name) continue;
if (el.type === "checkbox") out[el.name] = el.checked;
else if (el.type === "number") out[el.name] = el.value === "" ? null : Number(el.value);
else out[el.name] = el.value;
}
return out;
}
/* ---------------- Settings ---------------- */
async function loadSettings() {
const s = await api("/api/settings");
const form = $("#settings-form");
for (const [k, v] of Object.entries(s)) {
const el = form.elements[k];
if (!el) continue;
if (el.type === "checkbox") el.checked = !!v;
else el.value = v ?? "";
}
}
$("#settings-form").addEventListener("submit", async (e) => {
e.preventDefault();
const payload = collectForm(e.target);
try {
await api("/api/settings", { method: "PUT", body: JSON.stringify(payload) });
toast(t("saved"), "ok");
refreshStatus();
} catch (err) { toast(t("err_prefix") + err.message, "err"); }
});
/* ---------------- Init ---------------- */
(async () => {
try {
const s = await api("/api/settings");
if (s.language && s.language !== LANG) {
setLang(s.language);
langSel.value = s.language;
}
} catch (_) { /* ignore */ }
refreshStatus();
refreshLogs();
})();
setInterval(refreshStatus, 5000);
setInterval(refreshLogs, 5000);
+229
View File
@@ -0,0 +1,229 @@
"use strict";
// Translation dictionary. Add a language by adding a key here and an <option>
// to #lang-select in index.html.
const I18N = {
ru: {
subtitle: "веб-панель управления мостом",
sync_now: "⟳ Синхронизировать",
pause: "Пауза",
resume: "Возобновить",
tab_dashboard: "Дашборд",
tab_feeds: "Фиды",
tab_settings: "Настройки",
// dashboard cards
card_feeds: "Фидов (активно)",
card_topics: "Топиков",
card_sent: "Отправлено всего",
card_history: "В истории",
// status panel
status: "Состояние",
engine: "Движок",
last_sync: "Последняя синхронизация",
next_sync: "Следующая",
interval: "Интервал",
error: "Ошибка",
test_notification: "Тестовое уведомление",
ph_topic: "топик (напр. news)",
ph_message: "сообщение",
send: "Отправить",
// log panel
log: "Журнал",
clear_history: "Очистить историю",
// feeds
feeds: "Фиды",
add_feed: "+ Добавить фид",
import_opml: "Импорт OPML",
export_opml: "Экспорт OPML",
feeds_empty: "Фидов пока нет. Нажмите «Добавить фид».",
no_name: "(без названия)",
priority: "приоритет",
quiet: "тихо",
edit: "Изменить",
delete: "Удалить",
// settings
settings_global: "Глобальные настройки",
s_ntfy_url: "ntfy сервер (URL)",
s_ntfy_token: "ntfy токен (необязательно)",
s_interval: "Интервал синхронизации (сек)",
s_tz: "Часовой пояс (IANA)",
s_batch: "Лимит новых записей за цикл",
s_maxdesc: "Макс. длина описания",
s_ua: "User-Agent",
s_flood: "Флуд-защита (задержки для низкого приоритета)",
save: "Сохранить",
// feed modal
feed_new: "Новый фид",
feed_edit: "Изменить фид",
f_name: "Название",
f_url: "URL фида *",
f_topic: "Топик ntfy *",
f_priority: "Приоритет (15)",
f_icon: "Иконка (URL)",
f_quiet_hours: "Тихие часы (напр. 22-7)",
f_quiet_priority: "Приоритет в тихие часы",
f_include: "Include regex (показывать только совпадения)",
f_exclude: "Exclude regex (отбрасывать совпадения)",
f_enabled: "Включён",
check_feed: "Проверить фид",
// dynamic / toasts
sync_started: "Синхронизация запущена",
history_cleared: "История очищена",
need_topic: "Укажите топик",
notify_sent: "Уведомление отправлено",
saved: "Сохранено",
feed_deleted: "Фид удалён",
feed_saved: "Фид сохранён",
confirm_delete_feed: "Удалить этот фид?",
confirm_clear_history: "Очистить историю отправленных записей? Старые записи могут прийти повторно.",
checking: "Проверяю…",
need_url: "Укажите URL",
preview_ok: "OK:",
preview_entries: "записей:",
err_prefix: "Ошибка: ",
no_connection: "нет связи",
st_syncing: "синхронизация…",
st_running: "работает",
st_paused: "на паузе",
pill_syncing: "● синхронизация",
pill_running: "● работает",
pill_paused: "‖ пауза",
sec: "сек",
err_suffix_sync_failed: " (ошибка)",
import_done: "Импорт OPML: добавлено {n} из {total}",
import_choose: "Выберите .opml файл",
},
en: {
subtitle: "web control panel for the bridge",
sync_now: "⟳ Sync now",
pause: "Pause",
resume: "Resume",
tab_dashboard: "Dashboard",
tab_feeds: "Feeds",
tab_settings: "Settings",
card_feeds: "Feeds (active)",
card_topics: "Topics",
card_sent: "Sent total",
card_history: "In history",
status: "Status",
engine: "Engine",
last_sync: "Last sync",
next_sync: "Next",
interval: "Interval",
error: "Error",
test_notification: "Test notification",
ph_topic: "topic (e.g. news)",
ph_message: "message",
send: "Send",
log: "Log",
clear_history: "Clear history",
feeds: "Feeds",
add_feed: "+ Add feed",
import_opml: "Import OPML",
export_opml: "Export OPML",
feeds_empty: "No feeds yet. Click \"Add feed\".",
no_name: "(no name)",
priority: "priority",
quiet: "quiet",
edit: "Edit",
delete: "Delete",
settings_global: "Global settings",
s_ntfy_url: "ntfy server (URL)",
s_ntfy_token: "ntfy token (optional)",
s_interval: "Sync interval (sec)",
s_tz: "Timezone (IANA)",
s_batch: "New items per cycle limit",
s_maxdesc: "Max description length",
s_ua: "User-Agent",
s_flood: "Flood protection (delay low-priority items)",
save: "Save",
feed_new: "New feed",
feed_edit: "Edit feed",
f_name: "Name",
f_url: "Feed URL *",
f_topic: "ntfy topic *",
f_priority: "Priority (15)",
f_icon: "Icon (URL)",
f_quiet_hours: "Quiet hours (e.g. 22-7)",
f_quiet_priority: "Priority during quiet hours",
f_include: "Include regex (only matching items)",
f_exclude: "Exclude regex (drop matching items)",
f_enabled: "Enabled",
check_feed: "Check feed",
sync_started: "Sync started",
history_cleared: "History cleared",
need_topic: "Enter a topic",
notify_sent: "Notification sent",
saved: "Saved",
feed_deleted: "Feed deleted",
feed_saved: "Feed saved",
confirm_delete_feed: "Delete this feed?",
confirm_clear_history: "Clear the history of sent items? Old items may be delivered again.",
checking: "Checking…",
need_url: "Enter a URL",
preview_ok: "OK:",
preview_entries: "items:",
err_prefix: "Error: ",
no_connection: "no connection",
st_syncing: "syncing…",
st_running: "running",
st_paused: "paused",
pill_syncing: "● syncing",
pill_running: "● running",
pill_paused: "‖ paused",
sec: "sec",
err_suffix_sync_failed: " (error)",
import_done: "OPML import: added {n} of {total}",
import_choose: "Choose an .opml file",
},
};
let LANG = localStorage.getItem("lang") || "ru";
function t(key) {
const dict = I18N[LANG] || I18N.ru;
return (key in dict) ? dict[key] : (I18N.ru[key] || key);
}
function locale() {
return LANG === "ru" ? "ru-RU" : "en-US";
}
function applyI18n() {
document.documentElement.lang = LANG;
document.querySelectorAll("[data-i18n]").forEach((el) => {
el.textContent = t(el.dataset.i18n);
});
document.querySelectorAll("[data-i18n-ph]").forEach((el) => {
el.placeholder = t(el.dataset.i18nPh);
});
document.querySelectorAll("[data-i18n-title]").forEach((el) => {
el.title = t(el.dataset.i18nTitle);
});
const sel = document.getElementById("lang-select");
if (sel) sel.value = LANG;
}
function setLang(lang) {
LANG = lang;
localStorage.setItem("lang", lang);
applyI18n();
}
// DOM is fully parsed (scripts sit at the end of <body>).
applyI18n();
+177
View File
@@ -0,0 +1,177 @@
"""Thread-safe persistent store for settings and feeds.
Everything the web UI manages lives in two JSON files inside DATA_DIR:
- settings.json : global settings (ntfy server, interval, timezone, ...)
- feeds.json : list of feeds, each bound to an ntfy topic
The SQLite de-duplication database and the log file also live in DATA_DIR.
"""
import json
import os
import threading
import uuid
DATA_DIR = os.environ.get("DATA_DIR", "data")
SETTINGS_FILE = os.path.join(DATA_DIR, "settings.json")
FEEDS_FILE = os.path.join(DATA_DIR, "feeds.json")
DB_PATH = os.path.join(DATA_DIR, "history.db")
LOG_PATH = os.path.join(DATA_DIR, "bridge.log")
DEFAULT_USER_AGENT = (
"Mozilla/5.0 (compatible; rss-bridge-ntfy/2.0; +https://github.com/nurefexc/rss-bridge-ntfy)"
)
DEFAULT_SETTINGS = {
"ntfy_url": "https://ntfy.sh",
"ntfy_token": "",
"sync_interval": 600, # seconds between sync cycles
"tz": "UTC", # timezone name (IANA), e.g. Europe/Moscow
"user_agent": DEFAULT_USER_AGENT,
"batch_limit": 3, # max new items per feed per cycle
"max_desc_length": 250, # truncate description to N chars
"flood_protection": True, # stagger low-priority notifications
"enabled": True, # is the background engine active
"language": "ru", # UI + notification language (ru / en)
}
# Default values applied to every feed object on save (keeps records complete).
FEED_DEFAULTS = {
"name": "",
"url": "",
"topic": "",
"priority": 3,
"icon": "",
"enabled": True,
"quiet_hours": "", # e.g. "22-7" -> quiet between 22:00 and 07:00
"quiet_priority": 1,
"include_regex": "",
"exclude_regex": "",
}
class Store:
"""JSON-backed configuration store guarded by a re-entrant lock."""
def __init__(self):
self._lock = threading.RLock()
os.makedirs(DATA_DIR, exist_ok=True)
self._ensure_files()
# ---- low level helpers -------------------------------------------------
def _ensure_files(self):
if not os.path.exists(SETTINGS_FILE):
self._write(SETTINGS_FILE, DEFAULT_SETTINGS)
if not os.path.exists(FEEDS_FILE):
self._write(FEEDS_FILE, {"feeds": []})
@staticmethod
def _read(path, fallback):
try:
with open(path, "r", encoding="utf-8") as fh:
return json.load(fh)
except (OSError, json.JSONDecodeError):
return json.loads(json.dumps(fallback))
@staticmethod
def _write(path, data):
tmp = f"{path}.tmp"
with open(tmp, "w", encoding="utf-8") as fh:
json.dump(data, fh, ensure_ascii=False, indent=2)
os.replace(tmp, path)
# ---- settings ----------------------------------------------------------
def get_settings(self):
with self._lock:
data = self._read(SETTINGS_FILE, DEFAULT_SETTINGS)
merged = dict(DEFAULT_SETTINGS)
merged.update({k: v for k, v in data.items() if k in DEFAULT_SETTINGS})
return merged
def update_settings(self, patch):
with self._lock:
current = self.get_settings()
for key, value in patch.items():
if key in DEFAULT_SETTINGS:
current[key] = self._coerce(key, value, current[key])
self._write(SETTINGS_FILE, current)
return current
@staticmethod
def _coerce(key, value, previous):
"""Coerce incoming JSON values to the type of the existing default."""
target = type(DEFAULT_SETTINGS.get(key, previous))
try:
if target is bool:
if isinstance(value, str):
return value.strip().lower() in ("1", "true", "yes", "on")
return bool(value)
if target is int:
return int(value)
if target is float:
return float(value)
return str(value)
except (TypeError, ValueError):
return previous
# ---- feeds -------------------------------------------------------------
def get_feeds(self):
with self._lock:
data = self._read(FEEDS_FILE, {"feeds": []})
feeds = data.get("feeds", [])
return [self._normalize_feed(f) for f in feeds]
def _normalize_feed(self, feed):
result = dict(FEED_DEFAULTS)
result.update({k: v for k, v in feed.items() if k in FEED_DEFAULTS})
result["id"] = feed.get("id") or uuid.uuid4().hex
# Type fixes
try:
result["priority"] = int(result["priority"])
except (TypeError, ValueError):
result["priority"] = 3
try:
result["quiet_priority"] = int(result["quiet_priority"])
except (TypeError, ValueError):
result["quiet_priority"] = 1
result["enabled"] = bool(result["enabled"])
result["topic"] = str(result["topic"]).strip()
return result
def add_feed(self, feed):
with self._lock:
feeds = self.get_feeds()
new_feed = self._normalize_feed(feed)
new_feed["id"] = uuid.uuid4().hex
feeds.append(new_feed)
self._save_feeds(feeds)
return new_feed
def update_feed(self, feed_id, patch):
with self._lock:
feeds = self.get_feeds()
updated = None
for idx, feed in enumerate(feeds):
if feed["id"] == feed_id:
merged = dict(feed)
merged.update({k: v for k, v in patch.items() if k in FEED_DEFAULTS})
merged["id"] = feed_id
feeds[idx] = self._normalize_feed(merged)
updated = feeds[idx]
break
if updated is not None:
self._save_feeds(feeds)
return updated
def delete_feed(self, feed_id):
with self._lock:
feeds = self.get_feeds()
remaining = [f for f in feeds if f["id"] != feed_id]
if len(remaining) == len(feeds):
return False
self._save_feeds(remaining)
return True
def _save_feeds(self, feeds):
self._write(FEEDS_FILE, {"feeds": feeds})
+184
View File
@@ -0,0 +1,184 @@
<!DOCTYPE html>
<html lang="ru">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>RSS → ntfy bridge</title>
<link rel="stylesheet" href="/static/css/style.css">
</head>
<body>
<header class="topbar">
<div class="brand">
<span class="logo">📡</span>
<div>
<h1>RSS → ntfy</h1>
<small data-i18n="subtitle">веб-панель управления мостом</small>
</div>
</div>
<div class="topbar-actions">
<span id="engine-pill" class="pill pill-muted"></span>
<button id="btn-sync" class="btn btn-primary" data-i18n="sync_now">⟳ Синхронизировать</button>
<button id="btn-toggle" class="btn" data-i18n="pause">Пауза</button>
<select id="lang-select" class="lang-select" title="Language">
<option value="ru">RU</option>
<option value="en">EN</option>
</select>
</div>
</header>
<nav class="tabs">
<button class="tab active" data-tab="dashboard" data-i18n="tab_dashboard">Дашборд</button>
<button class="tab" data-tab="feeds" data-i18n="tab_feeds">Фиды</button>
<button class="tab" data-tab="settings" data-i18n="tab_settings">Настройки</button>
</nav>
<main>
<!-- DASHBOARD -->
<section id="tab-dashboard" class="tab-panel active">
<div class="cards">
<div class="card"><div class="card-val" id="stat-feeds"></div><div class="card-lbl" data-i18n="card_feeds">Фидов (активно)</div></div>
<div class="card"><div class="card-val" id="stat-topics"></div><div class="card-lbl" data-i18n="card_topics">Топиков</div></div>
<div class="card"><div class="card-val" id="stat-sent"></div><div class="card-lbl" data-i18n="card_sent">Отправлено всего</div></div>
<div class="card"><div class="card-val" id="stat-history"></div><div class="card-lbl" data-i18n="card_history">В истории</div></div>
</div>
<div class="grid-2">
<div class="panel">
<h2 data-i18n="status">Состояние</h2>
<table class="kv">
<tr><td data-i18n="engine">Движок</td><td id="dash-engine"></td></tr>
<tr><td data-i18n="last_sync">Последняя синхронизация</td><td id="dash-last"></td></tr>
<tr><td data-i18n="next_sync">Следующая</td><td id="dash-next"></td></tr>
<tr><td data-i18n="interval">Интервал</td><td id="dash-interval"></td></tr>
<tr><td data-i18n="error">Ошибка</td><td id="dash-error"></td></tr>
</table>
<h2 style="margin-top:1.5rem" data-i18n="test_notification">Тестовое уведомление</h2>
<div class="row">
<input id="test-topic" data-i18n-ph="ph_topic" placeholder="топик (напр. news)">
<input id="test-msg" data-i18n-ph="ph_message" placeholder="сообщение">
<button id="btn-test" class="btn" data-i18n="send">Отправить</button>
</div>
</div>
<div class="panel">
<div class="panel-head">
<h2 data-i18n="log">Журнал</h2>
<button id="btn-clear-history" class="btn btn-sm btn-danger" data-i18n="clear_history">Очистить историю</button>
</div>
<div id="log" class="log"></div>
</div>
</div>
</section>
<!-- FEEDS -->
<section id="tab-feeds" class="tab-panel">
<div class="panel-head">
<h2 data-i18n="feeds">Фиды</h2>
<div class="feed-toolbar">
<button id="btn-import-opml" class="btn" data-i18n="import_opml">Импорт OPML</button>
<button id="btn-export-opml" class="btn" data-i18n="export_opml">Экспорт OPML</button>
<button id="btn-add-feed" class="btn btn-primary" data-i18n="add_feed">+ Добавить фид</button>
<input id="opml-file" type="file" accept=".opml,.xml,text/x-opml,application/xml" class="hidden">
</div>
</div>
<div id="feeds-list"></div>
</section>
<!-- SETTINGS -->
<section id="tab-settings" class="tab-panel">
<div class="panel" style="max-width:680px">
<h2 data-i18n="settings_global">Глобальные настройки</h2>
<form id="settings-form">
<label><span data-i18n="s_ntfy_url">ntfy сервер (URL)</span>
<input name="ntfy_url" placeholder="https://ntfy.sh">
</label>
<label><span data-i18n="s_ntfy_token">ntfy токен (необязательно)</span>
<input name="ntfy_token" type="password" placeholder="tk_...">
</label>
<div class="row">
<label><span data-i18n="s_interval">Интервал синхронизации (сек)</span>
<input name="sync_interval" type="number" min="30">
</label>
<label><span data-i18n="s_tz">Часовой пояс (IANA)</span>
<input name="tz" placeholder="Europe/Moscow">
</label>
</div>
<div class="row">
<label><span data-i18n="s_batch">Лимит новых записей за цикл</span>
<input name="batch_limit" type="number" min="1">
</label>
<label><span data-i18n="s_maxdesc">Макс. длина описания</span>
<input name="max_desc_length" type="number" min="50">
</label>
</div>
<label><span data-i18n="s_ua">User-Agent</span>
<input name="user_agent">
</label>
<label class="check">
<input name="flood_protection" type="checkbox"> <span data-i18n="s_flood">Флуд-защита (задержки для низкого приоритета)</span>
</label>
<button class="btn btn-primary" type="submit" data-i18n="save">Сохранить</button>
</form>
</div>
</section>
</main>
<!-- Feed editor modal -->
<div id="modal" class="modal hidden">
<div class="modal-box">
<div class="modal-head">
<h2 id="modal-title" data-i18n="feed_edit">Фид</h2>
<button id="modal-close" class="icon-btn"></button>
</div>
<form id="feed-form">
<input type="hidden" name="id">
<label><span data-i18n="f_name">Название</span>
<input name="name" placeholder="Example News">
</label>
<label><span data-i18n="f_url">URL фида *</span>
<input name="url" placeholder="https://example.com/rss" required>
</label>
<div class="row">
<label><span data-i18n="f_topic">Топик ntfy *</span>
<input name="topic" placeholder="news" required>
</label>
<label><span data-i18n="f_priority">Приоритет (15)</span>
<input name="priority" type="number" min="1" max="5" value="3">
</label>
</div>
<label><span data-i18n="f_icon">Иконка (URL)</span>
<input name="icon" placeholder="https://example.com/icon.png">
</label>
<div class="row">
<label><span data-i18n="f_quiet_hours">Тихие часы (напр. 22-7)</span>
<input name="quiet_hours" placeholder="">
</label>
<label><span data-i18n="f_quiet_priority">Приоритет в тихие часы</span>
<input name="quiet_priority" type="number" min="1" max="5" value="1">
</label>
</div>
<label><span data-i18n="f_include">Include regex</span>
<input name="include_regex" placeholder="">
</label>
<label><span data-i18n="f_exclude">Exclude regex</span>
<input name="exclude_regex" placeholder="">
</label>
<label class="check">
<input name="enabled" type="checkbox" checked> <span data-i18n="f_enabled">Включён</span>
</label>
<div class="modal-actions">
<button type="button" id="btn-preview" class="btn" data-i18n="check_feed">Проверить фид</button>
<div class="spacer"></div>
<button type="submit" class="btn btn-primary" data-i18n="save">Сохранить</button>
</div>
<div id="preview-result" class="preview"></div>
</form>
</div>
</div>
<div id="toast" class="toast hidden"></div>
<script src="/static/js/i18n.js"></script>
<script src="/static/js/app.js"></script>
</body>
</html>
+187
View File
@@ -0,0 +1,187 @@
"""Flask web application: serves the UI and the REST API used to manage the
bridge (feeds, settings, engine control) entirely from the browser."""
import logging
from datetime import datetime, timezone
import feedparser
from flask import Flask, Response, jsonify, render_template, request
import opml
import store as store_mod
from engine import Engine
logger = logging.getLogger("bridge")
def create_app(store: store_mod.Store, engine: Engine) -> Flask:
app = Flask(__name__, template_folder="templates", static_folder="static")
# ---- UI ----------------------------------------------------------------
@app.get("/")
def index():
return render_template("index.html")
@app.get("/api/health")
def health():
return jsonify({"ok": True})
# ---- status / control --------------------------------------------------
@app.get("/api/status")
def status():
settings = store.get_settings()
st = dict(engine.status)
st["enabled"] = settings["enabled"]
st["sync_interval"] = settings["sync_interval"]
st["history_count"] = engine.history_count()
feeds = store.get_feeds()
st["feed_total"] = len(feeds)
st["feed_active"] = len([f for f in feeds if f["enabled"]])
st["topics"] = sorted({f["topic"] for f in feeds if f["topic"]})
st["server_time"] = datetime.now(timezone.utc).isoformat()
return jsonify(st)
@app.post("/api/sync")
def sync_now():
engine.trigger_sync()
return jsonify({"ok": True, "message": "Синхронизация запущена"})
@app.post("/api/engine")
def engine_control():
data = request.get_json(force=True, silent=True) or {}
action = data.get("action")
if action == "pause":
store.update_settings({"enabled": False})
elif action == "resume":
store.update_settings({"enabled": True})
engine.trigger_sync()
else:
return jsonify({"ok": False, "error": "unknown action"}), 400
return jsonify({"ok": True})
@app.get("/api/logs")
def logs():
return jsonify({"logs": list(reversed(engine.ring.records()))})
@app.post("/api/history/clear")
def clear_history():
engine.clear_history()
return jsonify({"ok": True})
# ---- settings ----------------------------------------------------------
@app.get("/api/settings")
def get_settings():
return jsonify(store.get_settings())
@app.put("/api/settings")
def put_settings():
data = request.get_json(force=True, silent=True) or {}
updated = store.update_settings(data)
engine.trigger_sync()
return jsonify(updated)
# ---- feeds -------------------------------------------------------------
@app.get("/api/feeds")
def get_feeds():
return jsonify({"feeds": store.get_feeds()})
@app.post("/api/feeds")
def create_feed():
data = request.get_json(force=True, silent=True) or {}
if not data.get("url") or not data.get("topic"):
return jsonify({"ok": False, "error": "url и topic обязательны"}), 400
feed = store.add_feed(data)
return jsonify(feed), 201
@app.put("/api/feeds/<feed_id>")
def edit_feed(feed_id):
data = request.get_json(force=True, silent=True) or {}
feed = store.update_feed(feed_id, data)
if feed is None:
return jsonify({"ok": False, "error": "not found"}), 404
return jsonify(feed)
@app.delete("/api/feeds/<feed_id>")
def remove_feed(feed_id):
ok = store.delete_feed(feed_id)
return (jsonify({"ok": True}) if ok
else (jsonify({"ok": False, "error": "not found"}), 404))
@app.post("/api/feeds/preview")
def preview_feed():
"""Fetch a feed URL and return a few recent entries (for the UI)."""
data = request.get_json(force=True, silent=True) or {}
url = data.get("url", "").strip()
if not url:
return jsonify({"ok": False, "error": "url обязателен"}), 400
settings = store.get_settings()
parsed = feedparser.parse(url, agent=settings.get("user_agent"))
entries = [
{"title": e.get("title", "(no title)"), "link": e.get("link", "")}
for e in parsed.entries[:5]
]
return jsonify({
"ok": True,
"title": parsed.feed.get("title", ""),
"count": len(parsed.entries),
"entries": entries,
"error": str(parsed.get("bozo_exception")) if parsed.bozo else None,
})
# ---- OPML import / export ----------------------------------------------
@app.get("/api/export/opml")
def export_opml():
xml = opml.feeds_to_opml(store.get_feeds())
return Response(
xml,
mimetype="text/x-opml",
headers={"Content-Disposition": "attachment; filename=feeds.opml"},
)
@app.post("/api/import/opml")
def import_opml():
if "file" in request.files:
content = request.files["file"].read().decode("utf-8", "replace")
else:
content = request.get_data(as_text=True)
if not content.strip():
return jsonify({"ok": False, "error": "empty file"}), 400
try:
parsed = opml.opml_to_feeds(content)
except Exception as exc: # noqa: BLE001
return jsonify({"ok": False, "error": f"OPML parse error: {exc}"}), 400
existing = {(f["url"], f["topic"]) for f in store.get_feeds()}
added = 0
for feed in parsed:
if not feed.get("url"):
continue
key = (feed["url"], feed["topic"])
if key in existing:
continue
store.add_feed(feed)
existing.add(key)
added += 1
if added:
engine.trigger_sync()
logger.info("OPML import: %d new feed(s) of %d", added, len(parsed))
return jsonify({"ok": True, "added": added, "total": len(parsed)})
# ---- test notification -------------------------------------------------
@app.post("/api/test-notify")
def test_notify():
data = request.get_json(force=True, silent=True) or {}
topic = (data.get("topic") or "").strip()
if not topic:
return jsonify({"ok": False, "error": "topic обязателен"}), 400
try:
engine.send_test(
topic,
title=data.get("title") or "rss-bridge-ntfy",
message=data.get("message") or "Тестовое уведомление ✅",
)
return jsonify({"ok": True})
except Exception as exc: # noqa: BLE001
return jsonify({"ok": False, "error": str(exc)}), 502
return app