diff --git a/STATUS.md b/STATUS.md index e3ddcc0..912566a 100644 --- a/STATUS.md +++ b/STATUS.md @@ -1,8 +1,12 @@ # VESTI — Статус -Обновлено: 2026-09-16 (fix-sources-add-channel: поле channel в форме добавления TG-источника) +Обновлено: 2026-09-16 (address-field-enhancement: одно поле Address, авто-название из TG/RSS) ## Текущее состояние +- **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 — добавлен. - **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`. diff --git a/openspec/changes/address-field-enhancement/tasks.md b/openspec/changes/address-field-enhancement/tasks.md index 911bec5..0704590 100644 --- a/openspec/changes/address-field-enhancement/tasks.md +++ b/openspec/changes/address-field-enhancement/tasks.md @@ -8,11 +8,11 @@ Direction — datalist из реальных значений БД + свобо ## Tasks -- [ ] sources/sources.py: `parse_address(address, crawler)` — telegram/rss парсинг адреса -- [ ] web/app.py: route GET /sources/preview (Telethon get_entity / feedparser title) -- [ ] web/app.py: _form_source — address вместо url/channel/feed_url; slug из address -- [ ] web/app.py: /sources — DIRECTIONS + уникальные из БД → directions в контекст -- [ ] web/templates/sources.html: одно поле address, кнопка «Найти» → slug/name; datalist direction из данных -- [ ] openspec validate clean -- [ ] рестарт vesti-web; curl preview (telegram+rss); ручная проверка формы -- [ ] STATUS.md актуализирован; commit + push gitverse \ No newline at end of file +- [x] sources/sources.py: `parse_address(address, crawler)` — telegram/rss парсинг адреса +- [x] web/app.py: route GET /sources/preview (Telethon get_entity / feedparser title) +- [x] web/app.py: _form_source — address вместо url/channel/feed_url; slug из address +- [x] web/app.py: /sources — DIRECTIONS + уникальные из БД → directions в контекст +- [x] web/templates/sources.html: одно поле address, кнопка «Найти» → slug/name; datalist direction из данных +- [x] openspec validate clean +- [x] рестарт vesti-web; curl preview (telegram: mknewsru→«Мой Компьютер», linuxklub→«Linux Club»; rss: lwn→«LWN.net») +- [x] STATUS.md актуализирован; commit + push gitverse \ No newline at end of file diff --git a/sources/sources.py b/sources/sources.py index 4b3342f..c52a57a 100644 --- a/sources/sources.py +++ b/sources/sources.py @@ -1,5 +1,7 @@ # VESTI — работа с реестром источников (sources.yaml) и таблицей sources/runs. import datetime +import re +import sqlite3 import sys from pathlib import Path @@ -30,18 +32,20 @@ def sync_sources_to_db(data=None): if not slug: continue cur = conn.execute( - """INSERT INTO sources (slug, name, url, channel, crawler, direction, lang, priority, enabled, status, own) - VALUES (?,?,?,?,?,?,?,?,?, 'new', ?) - ON CONFLICT(slug) DO UPDATE SET - name=excluded.name, url=excluded.url, channel=excluded.channel, - crawler=excluded.crawler, direction=excluded.direction, - lang=excluded.lang, priority=excluded.priority, enabled=excluded.enabled, - own=excluded.own""", - (slug, s.get("name"), s.get("url"), s.get("channel"), - s.get("crawler", "telegram"), s.get("direction"), s.get("lang", "ru"), - s.get("priority", "P1"), int(s.get("enabled", True)), - int(s.get("own", False) or 0)), - ) + """INSERT INTO sources (slug, name, url, channel, crawler, feed_url, direction, lang, priority, enabled, status, own) + VALUES (?,?,?,?,?,?,?,?,?,?, 'new', ?) + ON CONFLICT(slug) DO UPDATE SET + name=excluded.name, url=excluded.url, channel=excluded.channel, + crawler=excluded.crawler, feed_url=excluded.feed_url, + direction=excluded.direction, + lang=excluded.lang, priority=excluded.priority, enabled=excluded.enabled, + own=excluded.own""", + (slug, s.get("name"), s.get("url"), s.get("channel"), + s.get("crawler", "telegram"), s.get("feed_url"), + 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 return mapping @@ -110,4 +114,195 @@ def recent_runs(limit=50, source_id=None): def now_iso(): - return datetime.datetime.now(datetime.timezone.utc).strftime("%Y-%m-%dT%H:%M:%SZ") \ No newline at end of file + 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 \ No newline at end of file diff --git a/web/app.py b/web/app.py index b85f3b1..9ff52d8 100644 --- a/web/app.py +++ b/web/app.py @@ -25,7 +25,7 @@ except ImportError: # CRUD источников: работа с sources.yaml (источник истины) + БД синк from sources.sources import ( 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 @@ -857,32 +857,100 @@ def sources_page(request: Request): FROM sources s LEFT JOIN rss_state rs ON rs.slug=s.slug ORDER BY s.enabled DESC, s.priority, s.slug""" ).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() + directions = sorted(set(DIRECTIONS) | {r[0] for r in dir_rows}) html = tpl.get_template("sources.html").render( - request=request, sources=sources, + request=request, sources=sources, directions=directions, did=request.query_params.get("did", ""), ) 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: - """Собирает источник из формы (str поля, None для пустых).""" + """Собирает источник из формы (str поля, None для пустых). + + Поле `address` — единое: парсится по типу crawler в url/channel/feed_url. + slug: если не заполнен (или ручной) — берётся из адреса. + """ def s(k, default=None): v = form.get(k) return (v or "").strip() if v else default - return { - "slug": s("slug"), + crawler = s("crawler", "telegram") + parsed = parse_address(s("address") or "", crawler) + src = { + "slug": s("slug") or parsed.get("slug"), "name": s("name"), - "url": s("url"), - "channel": s("channel"), - "crawler": s("crawler", "telegram"), - "feed_url": s("feed_url"), + "crawler": crawler, "direction": s("direction"), "lang": s("lang", "ru"), "priority": s("priority", "P1"), "enabled": bool(form.get("enabled")), "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") diff --git a/web/templates/sources.html b/web/templates/sources.html index 58f5c99..9845037 100644 --- a/web/templates/sources.html +++ b/web/templates/sources.html @@ -23,13 +23,13 @@