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:
@@ -0,0 +1,12 @@
|
||||
data/
|
||||
.git/
|
||||
.gitignore
|
||||
__pycache__/
|
||||
*.pyc
|
||||
*.pyo
|
||||
.venv/
|
||||
venv/
|
||||
.env
|
||||
.idea/
|
||||
.vscode/
|
||||
README.md
|
||||
+12
@@ -0,0 +1,12 @@
|
||||
data/
|
||||
__pycache__/
|
||||
*.pyc
|
||||
*.pyo
|
||||
.venv/
|
||||
venv/
|
||||
.env
|
||||
.idea/
|
||||
.vscode/
|
||||
*.db
|
||||
*.db-wal
|
||||
*.db-shm
|
||||
+32
@@ -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"]
|
||||
@@ -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).
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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()
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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; }
|
||||
}
|
||||
@@ -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) =>
|
||||
({ "&": "&", "<": "<", ">": ">", '"': """ }[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);
|
||||
@@ -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: "Приоритет (1–5)",
|
||||
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 (1–5)",
|
||||
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();
|
||||
@@ -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})
|
||||
@@ -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">Приоритет (1–5)</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>
|
||||
@@ -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
|
||||
Reference in New Issue
Block a user