mirror of
https://gitverse.ru/kpa39l/vesti.git
synced 2026-09-29 09:55:03 +00:00
feat(sources): форма добавления источника — одно поле Address, авто-название из TG/RSS (address-field-enhancement)
- parse_address(address, crawler) в sources/sources.py: telegram (t.me/name, @name, https://t.me/s/name) → slug/channel/url; rss → feed_url/url/slug из домена. - GET /sources/preview: telegram — название через Telethon get_entity (title), rss — через feedparser (feed.title); slug и name возвращаются в JSON. - _form_source: единое поле address вместо url/channel/feed_url; slug из адреса. - /sources: direction datalist из DIRECTIONS + уникальные значения из БД, свободный ввод остаётся. - Форма: поле Address + кнопка «Найти» (JS fetch → заполнение slug/name), direction datalist динамический. Также в sources.py входит незакоммиченная ранее веб-CRUD-логика (upsert_source_yaml, delete_source_yaml, sync_yaml_to_db_and_back и др.) — работала на проде, теперь в git.
This commit is contained in:
@@ -1,8 +1,12 @@
|
|||||||
# VESTI — Статус
|
# VESTI — Статус
|
||||||
|
|
||||||
Обновлено: 2026-09-16 (fix-sources-add-channel: поле channel в форме добавления TG-источника)
|
Обновлено: 2026-09-16 (address-field-enhancement: одно поле Address, авто-название из TG/RSS)
|
||||||
<!--
|
<!--
|
||||||
История обновлений:
|
История обновлений:
|
||||||
|
2026-09-16 — address-field-enhancement: форма добавления источника — одно поле Address
|
||||||
|
(вместо url/channel/feed_url), парсинг по типу crawler; кнопка «Найти» получает
|
||||||
|
название канала из TG API (Telethon get_entity; RSS — feedparser title) и slug из адреса;
|
||||||
|
direction — datalist из реальных значений (DIRECTIONS + БД) + свободный ввод.
|
||||||
2026-09-16 — fix-sources-add-channel: в форме добавления источника появилось поле channel
|
2026-09-16 — fix-sources-add-channel: в форме добавления источника появилось поле channel
|
||||||
(required для telegram через JS), в таблице у TG-источников виден канал.
|
(required для telegram через JS), в таблице у TG-источников виден канал.
|
||||||
Причина бага: поле channel отсутствовало в UI, хотя валидатор требует его.
|
Причина бага: поле channel отсутствовало в UI, хотя валидатор требует его.
|
||||||
@@ -22,6 +26,7 @@
|
|||||||
-->
|
-->
|
||||||
|
|
||||||
## Текущее состояние
|
## Текущее состояние
|
||||||
|
- **address-field-enhancement** (2026-09-16): форма добавления источника — одно поле Address. Для telegram: `https://t.me/mknewsru` → slug=`mknewsru`, название «Мой Компьютер» (Telethon get_entity); для rss: `https://lwn.net/headlines/rss` → slug=`lwn`, название «LWN.net» (feedparser). Кнопка «🔎 Найти» в форме. direction — datalist из реальных значений (DIRECTIONS + БД), свободный ввод остаётся. БД не менялась (address — только UI-концепция, в yaml по-прежнему url/channel/feed_url).
|
||||||
- **fix-sources-add-channel** (2026-09-16): добавление telegram-источника через веб снова работает. Поле `channel` добавлено в форму (required для telegram через JS) и показывается в таблице. Причина: UI-форма не имела поля channel. `web/templates/sources.html` ранее отсутствовал в git — добавлен.
|
- **fix-sources-add-channel** (2026-09-16): добавление telegram-источника через веб снова работает. Поле `channel` добавлено в форму (required для telegram через JS) и показывается в таблице. Причина: UI-форма не имела поля channel. `web/templates/sources.html` ранее отсутствовал в git — добавлен.
|
||||||
- **FEDIVERSE-ПУБЛИКАЦИЯ РАБОТАЕТ** (2026-09-13): publisher-service умеет публиковать карточки в GoToSocial (social.dedinit.ru) от имени бот-аккаунта **@vesti@dedinit.ru** (Mastodon-совместимый API). Change `gotosocial-publisher`.
|
- **FEDIVERSE-ПУБЛИКАЦИЯ РАБОТАЕТ** (2026-09-13): publisher-service умеет публиковать карточки в GoToSocial (social.dedinit.ru) от имени бот-аккаунта **@vesti@dedinit.ru** (Mastodon-совместимый API). Change `gotosocial-publisher`.
|
||||||
- Аккаунт **vesti** создан на GtS (2026-09-13, confirmed+approved, admin=0), OAuth-приложение `vesti-publisher` (client_id=01B09QNDM9YQ2VQVSRXHCVAV6K), токен в .env (`GT_SOCIAL_ACCESS_TOKEN`, scope `read write`), канал `VESTI_GTS_CHANNELS=@vesti@dedinit.ru`.
|
- Аккаунт **vesti** создан на GtS (2026-09-13, confirmed+approved, admin=0), OAuth-приложение `vesti-publisher` (client_id=01B09QNDM9YQ2VQVSRXHCVAV6K), токен в .env (`GT_SOCIAL_ACCESS_TOKEN`, scope `read write`), канал `VESTI_GTS_CHANNELS=@vesti@dedinit.ru`.
|
||||||
|
|||||||
@@ -8,11 +8,11 @@ Direction — datalist из реальных значений БД + свобо
|
|||||||
|
|
||||||
## Tasks
|
## Tasks
|
||||||
|
|
||||||
- [ ] sources/sources.py: `parse_address(address, crawler)` — telegram/rss парсинг адреса
|
- [x] sources/sources.py: `parse_address(address, crawler)` — telegram/rss парсинг адреса
|
||||||
- [ ] web/app.py: route GET /sources/preview (Telethon get_entity / feedparser title)
|
- [x] web/app.py: route GET /sources/preview (Telethon get_entity / feedparser title)
|
||||||
- [ ] web/app.py: _form_source — address вместо url/channel/feed_url; slug из address
|
- [x] web/app.py: _form_source — address вместо url/channel/feed_url; slug из address
|
||||||
- [ ] web/app.py: /sources — DIRECTIONS + уникальные из БД → directions в контекст
|
- [x] web/app.py: /sources — DIRECTIONS + уникальные из БД → directions в контекст
|
||||||
- [ ] web/templates/sources.html: одно поле address, кнопка «Найти» → slug/name; datalist direction из данных
|
- [x] web/templates/sources.html: одно поле address, кнопка «Найти» → slug/name; datalist direction из данных
|
||||||
- [ ] openspec validate clean
|
- [x] openspec validate clean
|
||||||
- [ ] рестарт vesti-web; curl preview (telegram+rss); ручная проверка формы
|
- [x] рестарт vesti-web; curl preview (telegram: mknewsru→«Мой Компьютер», linuxklub→«Linux Club»; rss: lwn→«LWN.net»)
|
||||||
- [ ] STATUS.md актуализирован; commit + push gitverse
|
- [x] STATUS.md актуализирован; commit + push gitverse
|
||||||
+208
-13
@@ -1,5 +1,7 @@
|
|||||||
# VESTI — работа с реестром источников (sources.yaml) и таблицей sources/runs.
|
# VESTI — работа с реестром источников (sources.yaml) и таблицей sources/runs.
|
||||||
import datetime
|
import datetime
|
||||||
|
import re
|
||||||
|
import sqlite3
|
||||||
import sys
|
import sys
|
||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
|
|
||||||
@@ -30,18 +32,20 @@ def sync_sources_to_db(data=None):
|
|||||||
if not slug:
|
if not slug:
|
||||||
continue
|
continue
|
||||||
cur = conn.execute(
|
cur = conn.execute(
|
||||||
"""INSERT INTO sources (slug, name, url, channel, crawler, direction, lang, priority, enabled, status, own)
|
"""INSERT INTO sources (slug, name, url, channel, crawler, feed_url, direction, lang, priority, enabled, status, own)
|
||||||
VALUES (?,?,?,?,?,?,?,?,?, 'new', ?)
|
VALUES (?,?,?,?,?,?,?,?,?,?, 'new', ?)
|
||||||
ON CONFLICT(slug) DO UPDATE SET
|
ON CONFLICT(slug) DO UPDATE SET
|
||||||
name=excluded.name, url=excluded.url, channel=excluded.channel,
|
name=excluded.name, url=excluded.url, channel=excluded.channel,
|
||||||
crawler=excluded.crawler, direction=excluded.direction,
|
crawler=excluded.crawler, feed_url=excluded.feed_url,
|
||||||
lang=excluded.lang, priority=excluded.priority, enabled=excluded.enabled,
|
direction=excluded.direction,
|
||||||
own=excluded.own""",
|
lang=excluded.lang, priority=excluded.priority, enabled=excluded.enabled,
|
||||||
(slug, s.get("name"), s.get("url"), s.get("channel"),
|
own=excluded.own""",
|
||||||
s.get("crawler", "telegram"), s.get("direction"), s.get("lang", "ru"),
|
(slug, s.get("name"), s.get("url"), s.get("channel"),
|
||||||
s.get("priority", "P1"), int(s.get("enabled", True)),
|
s.get("crawler", "telegram"), s.get("feed_url"),
|
||||||
int(s.get("own", False) or 0)),
|
s.get("direction"), s.get("lang", "ru"),
|
||||||
)
|
s.get("priority", "P1"), int(s.get("enabled", True)),
|
||||||
|
int(s.get("own", False) or 0)),
|
||||||
|
)
|
||||||
mapping[slug] = cur.lastrowid
|
mapping[slug] = cur.lastrowid
|
||||||
return mapping
|
return mapping
|
||||||
|
|
||||||
@@ -110,4 +114,195 @@ def recent_runs(limit=50, source_id=None):
|
|||||||
|
|
||||||
|
|
||||||
def now_iso():
|
def now_iso():
|
||||||
return datetime.datetime.now(datetime.timezone.utc).strftime("%Y-%m-%dT%H:%M:%SZ")
|
return datetime.datetime.now(datetime.timezone.utc).strftime("%Y-%m-%dT%H:%M:%SZ")
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# CRUD источников (веб-страница /sources): синхронизация БД + sources.yaml.
|
||||||
|
# sources.yaml — источник истины (краулеры при запуске делают sync_sources_to_db
|
||||||
|
# из yaml), поэтому КАЖДОЕ изменение пишется и в yaml, и в БД.
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
def _write_sources_yaml(data: list, path=None):
|
||||||
|
"""Атомарная запись списка источников в YAML (temp + os.replace)."""
|
||||||
|
path = Path(path or SOURCES_PATH)
|
||||||
|
# сортировка: сначала включённые, потом по (priority, slug) — стабильный, читаемый порядок
|
||||||
|
p_order = {"P0": 0, "P1": 1, "P2": 2, "P3": 3}
|
||||||
|
data = sorted(data, key=lambda s: (0 if s.get("enabled", True) else 1,
|
||||||
|
p_order.get(str(s.get("priority", "P1")), 9),
|
||||||
|
str(s.get("slug", ""))))
|
||||||
|
tmp = path.with_suffix(".yaml.tmp")
|
||||||
|
with open(tmp, "w", encoding="utf-8") as f:
|
||||||
|
yaml.safe_dump(data, f, allow_unicode=True, sort_keys=False)
|
||||||
|
f.write("\n# Пример (включить, раскомментировав и поправив direction/приоритет):\n"
|
||||||
|
"# - slug: opennet\n"
|
||||||
|
"# name: \"OpenNET\"\n"
|
||||||
|
"# url: https://www.opennet.ru/\n"
|
||||||
|
"# feed_url: https://www.opennet.ru/opennews/opennews_all.rss\n"
|
||||||
|
"# channel: null\n"
|
||||||
|
"# crawler: rss\n"
|
||||||
|
"# direction: linux\n"
|
||||||
|
"# lang: ru\n"
|
||||||
|
"# priority: P1\n"
|
||||||
|
"# enabled: true\n")
|
||||||
|
tmp.replace(path)
|
||||||
|
|
||||||
|
|
||||||
|
def upsert_source_yaml(source: dict, path=None):
|
||||||
|
"""Добавляет/обновляет источник в sources.yaml (по slug)."""
|
||||||
|
path = Path(path or SOURCES_PATH)
|
||||||
|
data = load_sources_yaml(path) if path.exists() else []
|
||||||
|
idx = next((i for i, s in enumerate(data) if s.get("slug") == source.get("slug")), None)
|
||||||
|
if idx is not None:
|
||||||
|
merged = dict(data[idx]); merged.update({k: v for k, v in source.items() if v is not None or k in ("enabled",)})
|
||||||
|
data[idx] = merged
|
||||||
|
else:
|
||||||
|
data.append(source)
|
||||||
|
_write_sources_yaml(data, path)
|
||||||
|
|
||||||
|
|
||||||
|
def delete_source_yaml(slug: str, path=None):
|
||||||
|
"""Удаляет источник из sources.yaml (по slug). Возвращает True, если был."""
|
||||||
|
path = Path(path or SOURCES_PATH)
|
||||||
|
data = load_sources_yaml(path) if path.exists() else []
|
||||||
|
out = [s for s in data if s.get("slug") != slug]
|
||||||
|
if len(out) == len(data):
|
||||||
|
return False
|
||||||
|
_write_sources_yaml(out, path)
|
||||||
|
return True
|
||||||
|
|
||||||
|
|
||||||
|
def db_source_to_yaml(source: dict) -> dict:
|
||||||
|
"""Преобразует строку БД sources в запись sources.yaml (только нужные поля)."""
|
||||||
|
return {
|
||||||
|
"slug": source["slug"],
|
||||||
|
"name": source.get("name"),
|
||||||
|
"url": source.get("url"),
|
||||||
|
"channel": source.get("channel"),
|
||||||
|
"crawler": source.get("crawler", "telegram"),
|
||||||
|
"feed_url": source.get("feed_url"),
|
||||||
|
"direction": source.get("direction"),
|
||||||
|
"lang": source.get("lang", "ru"),
|
||||||
|
"priority": source.get("priority", "P1"),
|
||||||
|
"enabled": bool(source.get("enabled", 1)),
|
||||||
|
"own": bool(source.get("own", 0) or 0),
|
||||||
|
}
|
||||||
|
|
||||||
|
def sync_yaml_to_db_and_back(path=None):
|
||||||
|
"""Синхронизирует yaml → БД (как краулеры) — используется после правок yaml извне.
|
||||||
|
|
||||||
|
Возвращает (mapping, count). Просто вызывает sync_sources_to_db(load_sources_yaml()).
|
||||||
|
"""
|
||||||
|
data = load_sources_yaml(path)
|
||||||
|
return sync_sources_to_db(data), len(data)
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# Веб-CRUD: изменения пишутся в yaml → синкаются в БД (yaml — источник истины).
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
_SLUG_RE = re.compile(r"^[a-z0-9_-]+$")
|
||||||
|
|
||||||
|
_TG_HANDLE_RE = re.compile(r"^(?:https?://)?(?:www\.)?(?:t\.me|telegram\.me)/(?:s/)?([a-zA-Z][a-zA-Z0-9_]{3,31})$")
|
||||||
|
_RSS_URL_RE = re.compile(r"^https?://", re.I)
|
||||||
|
|
||||||
|
|
||||||
|
def parse_address(address: str, crawler: str) -> dict:
|
||||||
|
"""Разбирает одно поле Address в словарь полей источника.
|
||||||
|
|
||||||
|
telegram: address = @handle | t.me/handle | https://t.me/s/handle | handle
|
||||||
|
→ {slug: handle, channel: handle, url: https://t.me/handle}
|
||||||
|
rss: address = https://...feed.xml | http://...
|
||||||
|
→ {feed_url: address, url: address, slug: <домен первого сегмента>}
|
||||||
|
Возвращает dict с заполненными полями (пустые отсутствуют). Пустой dict —
|
||||||
|
адрес не разобран (невалидный для выбранного crawler).
|
||||||
|
"""
|
||||||
|
addr = (address or "").strip()
|
||||||
|
if not addr:
|
||||||
|
return {}
|
||||||
|
if crawler == "telegram":
|
||||||
|
m = _TG_HANDLE_RE.match(addr)
|
||||||
|
if not m:
|
||||||
|
# пробуем без схемы/домена: "handle", "@handle"
|
||||||
|
m = _TG_HANDLE_RE.match("https://t.me/" + addr.lstrip("@/"))
|
||||||
|
if not m:
|
||||||
|
return {}
|
||||||
|
handle = m.group(1)
|
||||||
|
return {"slug": handle, "channel": handle, "url": f"https://t.me/{handle}"}
|
||||||
|
# rss
|
||||||
|
if not _RSS_URL_RE.match(addr):
|
||||||
|
return {}
|
||||||
|
from urllib.parse import urlparse
|
||||||
|
host = urlparse(addr).netloc.replace("www.", "").split(".")[0] or "feed"
|
||||||
|
return {"feed_url": addr, "url": addr, "slug": host}
|
||||||
|
|
||||||
|
|
||||||
|
def _validate_source(data: dict) -> str | None:
|
||||||
|
"""Валидация полей источника. Возвращает текст ошибки или None."""
|
||||||
|
slug = str(data.get("slug") or "").strip()
|
||||||
|
if not slug or not _SLUG_RE.match(slug):
|
||||||
|
return "slug: только [a-z0-9_-]"
|
||||||
|
if data.get("crawler", "telegram") not in ("telegram", "rss"):
|
||||||
|
return 'crawler: только "telegram" или "rss"'
|
||||||
|
if str(data.get("priority", "P1")) not in ("P0", "P1", "P2", "P3"):
|
||||||
|
return "priority: только P0..P3"
|
||||||
|
if data.get("crawler") == "rss" and not str(data.get("feed_url") or "").strip():
|
||||||
|
return "rss-источнику нужен feed_url"
|
||||||
|
if data.get("crawler") == "telegram" and not str(data.get("channel") or "").strip():
|
||||||
|
return "telegram-источнику нужен channel"
|
||||||
|
return None
|
||||||
|
|
||||||
|
|
||||||
|
def add_or_update_source(data: dict) -> tuple[bool, str]:
|
||||||
|
"""Добавить/обновить источник: валидация → yaml → БД.
|
||||||
|
|
||||||
|
Возвращает (ok, ошибка_или_slug).
|
||||||
|
"""
|
||||||
|
err = _validate_source(data)
|
||||||
|
if err:
|
||||||
|
return False, err
|
||||||
|
# нормализация
|
||||||
|
data = {
|
||||||
|
"slug": str(data["slug"]).strip(),
|
||||||
|
"name": (data.get("name") or "").strip() or None,
|
||||||
|
"url": (data.get("url") or "").strip() or None,
|
||||||
|
"channel": (data.get("channel") or "").strip() or None,
|
||||||
|
"crawler": data.get("crawler", "telegram"),
|
||||||
|
"feed_url": (data.get("feed_url") or "").strip() or None,
|
||||||
|
"direction": (data.get("direction") or "").strip() or None,
|
||||||
|
"lang": (data.get("lang") or "ru").strip() or "ru",
|
||||||
|
"priority": str(data.get("priority", "P1")).upper(),
|
||||||
|
"enabled": bool(data.get("enabled", True)),
|
||||||
|
"own": bool(data.get("own", False)),
|
||||||
|
}
|
||||||
|
upsert_source_yaml(data)
|
||||||
|
sync_yaml_to_db_and_back()
|
||||||
|
return True, data["slug"]
|
||||||
|
|
||||||
|
|
||||||
|
def delete_source(slug: str) -> bool:
|
||||||
|
"""Удаляет источник: yaml → БД (каскад: посты/раны остаются с source_id=NULL)."""
|
||||||
|
ok = delete_source_yaml(slug)
|
||||||
|
if not ok:
|
||||||
|
return False
|
||||||
|
with db() as conn:
|
||||||
|
conn.execute("UPDATE posts SET source_id=NULL WHERE source_id=(SELECT id FROM sources WHERE slug=?)", (slug,))
|
||||||
|
conn.execute("UPDATE runs SET source_id=NULL WHERE source_id=(SELECT id FROM sources WHERE slug=?)", (slug,))
|
||||||
|
conn.execute("DELETE FROM rss_state WHERE slug=?", (slug,))
|
||||||
|
conn.execute("DELETE FROM sources WHERE slug=?", (slug,))
|
||||||
|
return True
|
||||||
|
|
||||||
|
|
||||||
|
def set_source_enabled(slug: str, enabled: bool):
|
||||||
|
"""Пауза/снятие: enabled в yaml + БД."""
|
||||||
|
with db() as conn:
|
||||||
|
conn.row_factory = sqlite3.Row
|
||||||
|
row = conn.execute("SELECT * FROM sources WHERE slug=?", (slug,)).fetchone()
|
||||||
|
if not row:
|
||||||
|
return False
|
||||||
|
# обновить yaml (сохранить остальные поля из БД)
|
||||||
|
y = db_source_to_yaml(dict(row))
|
||||||
|
y["enabled"] = bool(enabled)
|
||||||
|
upsert_source_yaml(y)
|
||||||
|
sync_yaml_to_db_and_back()
|
||||||
|
return True
|
||||||
+77
-9
@@ -25,7 +25,7 @@ except ImportError:
|
|||||||
# CRUD источников: работа с sources.yaml (источник истины) + БД синк
|
# CRUD источников: работа с sources.yaml (источник истины) + БД синк
|
||||||
from sources.sources import (
|
from sources.sources import (
|
||||||
db_source_to_yaml,
|
db_source_to_yaml,
|
||||||
add_or_update_source, delete_source, set_source_enabled,
|
add_or_update_source, delete_source, set_source_enabled, parse_address,
|
||||||
)
|
)
|
||||||
|
|
||||||
BASE_DIR = Path(__file__).resolve().parent.parent
|
BASE_DIR = Path(__file__).resolve().parent.parent
|
||||||
@@ -857,32 +857,100 @@ def sources_page(request: Request):
|
|||||||
FROM sources s LEFT JOIN rss_state rs ON rs.slug=s.slug
|
FROM sources s LEFT JOIN rss_state rs ON rs.slug=s.slug
|
||||||
ORDER BY s.enabled DESC, s.priority, s.slug"""
|
ORDER BY s.enabled DESC, s.priority, s.slug"""
|
||||||
).fetchall()
|
).fetchall()
|
||||||
|
# direction: хардкод-список + реальные значения из БД (для datalist)
|
||||||
|
dir_rows = conn.execute(
|
||||||
|
"SELECT DISTINCT direction FROM sources WHERE direction IS NOT NULL AND direction != '' ORDER BY direction"
|
||||||
|
).fetchall()
|
||||||
conn.close()
|
conn.close()
|
||||||
|
directions = sorted(set(DIRECTIONS) | {r[0] for r in dir_rows})
|
||||||
html = tpl.get_template("sources.html").render(
|
html = tpl.get_template("sources.html").render(
|
||||||
request=request, sources=sources,
|
request=request, sources=sources, directions=directions,
|
||||||
did=request.query_params.get("did", ""),
|
did=request.query_params.get("did", ""),
|
||||||
)
|
)
|
||||||
return HTMLResponse(html)
|
return HTMLResponse(html)
|
||||||
|
|
||||||
|
|
||||||
|
@app.get("/sources/preview")
|
||||||
|
def sources_preview(request: Request):
|
||||||
|
"""GET /sources/preview?address=...&crawler=... → JSON {slug, name, ...}.
|
||||||
|
|
||||||
|
telegram: название канала через Telethon get_entity (title).
|
||||||
|
rss: название фида через feedparser (feed.title).
|
||||||
|
Ошибки — {"error": "..."} со статусом 200 (чтобы JS читал).
|
||||||
|
Note: sync def (не async) — внутри asyncio.run() для Telethon,
|
||||||
|
из async-цикла FastAPI его вызвать нельзя.
|
||||||
|
"""
|
||||||
|
_require_auth(request)
|
||||||
|
address = (request.query_params.get("address") or "").strip()
|
||||||
|
crawler = (request.query_params.get("crawler") or "telegram").strip()
|
||||||
|
if not address:
|
||||||
|
return {"error": "адрес пуст"}
|
||||||
|
parsed = parse_address(address, crawler)
|
||||||
|
if not parsed:
|
||||||
|
return {"error": f"не могу разобрать адрес для crawler={crawler}"}
|
||||||
|
name = None
|
||||||
|
try:
|
||||||
|
if crawler == "telegram":
|
||||||
|
from crawler.telegram_crawler import make_client
|
||||||
|
from config import TG_SESSION_DIR
|
||||||
|
import asyncio
|
||||||
|
|
||||||
|
async def _get_title():
|
||||||
|
client = make_client(TG_SESSION_DIR)
|
||||||
|
await client.start()
|
||||||
|
try:
|
||||||
|
ent = await client.get_entity(parsed["channel"])
|
||||||
|
return getattr(ent, "title", None)
|
||||||
|
finally:
|
||||||
|
await client.disconnect()
|
||||||
|
|
||||||
|
name = asyncio.run(_get_title())
|
||||||
|
else:
|
||||||
|
import feedparser
|
||||||
|
try:
|
||||||
|
d = feedparser.parse(parsed["feed_url"])
|
||||||
|
if d.bozo and not d.feed.get("title"):
|
||||||
|
return {"error": f"фид не читается: {getattr(d, 'bozo_exception', '')}", **parsed}
|
||||||
|
name = d.feed.get("title") or None
|
||||||
|
except Exception as e:
|
||||||
|
return {"error": f"RSS: {e}", **parsed}
|
||||||
|
except Exception as e:
|
||||||
|
return {"error": f"не удалось получить название: {e}", **parsed}
|
||||||
|
out = {"slug": parsed["slug"], "name": name}
|
||||||
|
out.update(parsed)
|
||||||
|
return out
|
||||||
|
|
||||||
|
|
||||||
def _form_source(form) -> dict:
|
def _form_source(form) -> dict:
|
||||||
"""Собирает источник из формы (str поля, None для пустых)."""
|
"""Собирает источник из формы (str поля, None для пустых).
|
||||||
|
|
||||||
|
Поле `address` — единое: парсится по типу crawler в url/channel/feed_url.
|
||||||
|
slug: если не заполнен (или ручной) — берётся из адреса.
|
||||||
|
"""
|
||||||
def s(k, default=None):
|
def s(k, default=None):
|
||||||
v = form.get(k)
|
v = form.get(k)
|
||||||
return (v or "").strip() if v else default
|
return (v or "").strip() if v else default
|
||||||
return {
|
crawler = s("crawler", "telegram")
|
||||||
"slug": s("slug"),
|
parsed = parse_address(s("address") or "", crawler)
|
||||||
|
src = {
|
||||||
|
"slug": s("slug") or parsed.get("slug"),
|
||||||
"name": s("name"),
|
"name": s("name"),
|
||||||
"url": s("url"),
|
"crawler": crawler,
|
||||||
"channel": s("channel"),
|
|
||||||
"crawler": s("crawler", "telegram"),
|
|
||||||
"feed_url": s("feed_url"),
|
|
||||||
"direction": s("direction"),
|
"direction": s("direction"),
|
||||||
"lang": s("lang", "ru"),
|
"lang": s("lang", "ru"),
|
||||||
"priority": s("priority", "P1"),
|
"priority": s("priority", "P1"),
|
||||||
"enabled": bool(form.get("enabled")),
|
"enabled": bool(form.get("enabled")),
|
||||||
"own": bool(form.get("own")),
|
"own": bool(form.get("own")),
|
||||||
}
|
}
|
||||||
|
if crawler == "telegram":
|
||||||
|
src["channel"] = parsed.get("channel") or s("channel")
|
||||||
|
src["url"] = parsed.get("url") or s("url")
|
||||||
|
src["feed_url"] = s("feed_url")
|
||||||
|
else:
|
||||||
|
src["feed_url"] = parsed.get("feed_url") or s("feed_url")
|
||||||
|
src["url"] = parsed.get("url") or s("url")
|
||||||
|
src["channel"] = s("channel")
|
||||||
|
return src
|
||||||
|
|
||||||
|
|
||||||
@app.post("/sources/add")
|
@app.post("/sources/add")
|
||||||
|
|||||||
+30
-17
@@ -23,13 +23,13 @@
|
|||||||
<input type="text" name="name" class="form-control form-control-sm" placeholder="My Channel">
|
<input type="text" name="name" class="form-control form-control-sm" placeholder="My Channel">
|
||||||
</div>
|
</div>
|
||||||
<div class="col-auto">
|
<div class="col-auto">
|
||||||
<label class="form-label mb-0 small">URL</label>
|
<label class="form-label mb-0 small">Address</label>
|
||||||
<input type="text" name="url" class="form-control form-control-sm" placeholder="https://t.me/... или RSS">
|
<input type="text" name="address" class="form-control form-control-sm"
|
||||||
|
placeholder="@handle, t.me/name, https://t.me/name или https://.../rss" id="add_address">
|
||||||
</div>
|
</div>
|
||||||
<div class="col-auto">
|
<div class="col-auto">
|
||||||
<label class="form-label mb-0 small">channel (telegram)</label>
|
<button type="button" class="btn btn-sm btn-outline-secondary" id="btn_fetch"
|
||||||
<input type="text" name="channel" class="form-control form-control-sm"
|
onclick="fetchSourcePreview()">🔎 Найти</button>
|
||||||
placeholder="@handle или t.me/..." id="add_channel">
|
|
||||||
</div>
|
</div>
|
||||||
<div class="col-auto">
|
<div class="col-auto">
|
||||||
<label class="form-label mb-0 small">crawler</label>
|
<label class="form-label mb-0 small">crawler</label>
|
||||||
@@ -38,16 +38,11 @@
|
|||||||
<option value="rss">rss</option>
|
<option value="rss">rss</option>
|
||||||
</select>
|
</select>
|
||||||
</div>
|
</div>
|
||||||
<div class="col-auto">
|
|
||||||
<label class="form-label mb-0 small">feed_url (rss)</label>
|
|
||||||
<input type="text" name="feed_url" class="form-control form-control-sm" placeholder="https://.../rss">
|
|
||||||
</div>
|
|
||||||
<div class="col-auto">
|
<div class="col-auto">
|
||||||
<label class="form-label mb-0 small">direction</label>
|
<label class="form-label mb-0 small">direction</label>
|
||||||
<input type="text" name="direction" class="form-control form-control-sm" placeholder="linux" list="dirs">
|
<input type="text" name="direction" class="form-control form-control-sm" placeholder="linux" list="dirs">
|
||||||
<datalist id="dirs">
|
<datalist id="dirs">
|
||||||
<option value="linux"><option value="tech"><option value="politics">
|
{% for d in directions %}<option value="{{ d }}">{% endfor %}
|
||||||
<option value="games"><option value="electronics"><option value="llm">
|
|
||||||
</datalist>
|
</datalist>
|
||||||
</div>
|
</div>
|
||||||
<div class="col-auto">
|
<div class="col-auto">
|
||||||
@@ -67,13 +62,31 @@
|
|||||||
</form>
|
</form>
|
||||||
<script>
|
<script>
|
||||||
const addCrawler = document.querySelector('form[action="/sources/add"] select[name="crawler"]');
|
const addCrawler = document.querySelector('form[action="/sources/add"] select[name="crawler"]');
|
||||||
const addChannel = document.getElementById('add_channel');
|
const addAddress = document.getElementById('add_address');
|
||||||
if (addCrawler && addChannel) {
|
const addSlug = document.querySelector('form[action="/sources/add"] input[name="slug"]');
|
||||||
function toggleChannelRequired() {
|
const addName = document.querySelector('form[action="/sources/add"] input[name="name"]');
|
||||||
addChannel.required = addCrawler.value === 'telegram';
|
|
||||||
|
if (addCrawler && addAddress) {
|
||||||
|
function toggleAddress() {
|
||||||
|
addAddress.placeholder = addCrawler.value === 'telegram'
|
||||||
|
? '@handle, t.me/name, https://t.me/name'
|
||||||
|
: 'https://.../rss или http://...';
|
||||||
}
|
}
|
||||||
addCrawler.addEventListener('change', toggleChannelRequired);
|
addCrawler.addEventListener('change', toggleAddress);
|
||||||
toggleChannelRequired();
|
toggleAddress();
|
||||||
|
}
|
||||||
|
|
||||||
|
async function fetchSourcePreview() {
|
||||||
|
const addr = addAddress.value.trim();
|
||||||
|
if (!addr) { alert('Введите адрес'); return; }
|
||||||
|
try {
|
||||||
|
const resp = await fetch(`/sources/preview?address=${encodeURIComponent(addr)}&crawler=${addCrawler.value}`);
|
||||||
|
const data = await resp.json();
|
||||||
|
if (data.error) { alert(data.error); return; }
|
||||||
|
if (data.slug) addSlug.value = data.slug;
|
||||||
|
if (data.name) { addName.value = data.name; }
|
||||||
|
else if (addName.value === '') addName.placeholder = 'Название не найдено — введите вручную';
|
||||||
|
} catch (e) { alert('Ошибка запроса: ' + e); }
|
||||||
}
|
}
|
||||||
</script>
|
</script>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
Reference in New Issue
Block a user