Files
vesti/openspec/changes/archive/2026-09-16-address-field-enhancement/design.md
T

8.6 KiB

address-field-enhancement

Дизайн

1. Backend: парсер адреса и получение названия

Новая функция в sources/sources.py (или отдельный модуль — решаю по месту; парсинг адреса — чистая функция без зависимостей):

_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 с заполненными полями, пустые — None. Гарантирует валидность
    по _validate_source (slug для telegram; feed_url для rss).
    """
    addr = (address or "").strip()
    if crawler == "telegram":
        m = _TG_HANDLE_RE.match(addr) or _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}"}
    else:  # rss
        if not _RSS_URL_RE.match(addr):
            return {}
        # slug берём из имени файла/домена, чтобы был стабильным и уникальным
        from urllib.parse import urlparse
        host = urlparse(addr).netloc.replace("www.", "").split(".")[0]
        return {"feed_url": addr, "url": addr, "slug": host}

Получение названия (route preview) — в web/app.py:

@app.get("/sources/preview")
async def sources_preview(request: Request, address: str = "", crawler: str = "telegram"):
    """GET /sources/preview?address=t.me/linuxklub&crawler=telegram → JSON.

    telegram: get_entity из Telethon → name=title канала.
    rss: feedparser на адрес → name=feed.title.
    Ошибки (канал не найден, сеть) → {"error": "..."} (200, чтобы JS читал).
    """
    _require_auth(request)
    if not (address or "").strip():
        return {"error": "адрес пуст"}
    parsed = parse_address(address, crawler)
    if not parsed:
        return {"error": "не могу разобрать адрес для crawler=" + crawler}
    name = None
    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 ent.title
            finally:
                await client.disconnect()
        try:
            name = asyncio.run(_get_title())
        except Exception as e:
            return {"error": f"TG: {e}", **parsed}
    else:
        import feedparser
        try:
            d = feedparser.parse(parsed["feed_url"])
            if d.bozo and not d.feed.get("title"):
                return {"error": f"фид не читается: {d.bozo_exception}", **parsed}
            name = d.feed.get("title") or None
        except Exception as e:
            return {"error": f"RSS: {e}", **parsed}
    return {"slug": parsed["slug"], "name": name, **parsed}

_form_source — замена url/channel/feed_url одиночным address:

def _form_source(form) -> dict:
    def s(k, default=None):
        v = form.get(k)
        return (v or "").strip() if v else default
    crawler = s("crawler", "telegram")
    src = {
        "slug": s("slug"),
        "name": s("name"),
        "crawler": crawler,
        "direction": s("direction"),
        "lang": s("lang", "ru"),
        "priority": s("priority", "P1"),
        "enabled": bool(form.get("enabled")),
        "own": bool(form.get("own")),
    }
    parsed = parse_address(s("address"), crawler)
    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")
    if not src["slug"] and parsed.get("slug"):
        src["slug"] = parsed["slug"]
    return src

_validate_source: для rss достаточно проверки feed_url (уже есть), для telegram — channel (уже есть). Доп. проверка: если crawler=rss и address не URL → _form_source уже вернул пустые, валидатор отдаст 'rss-источнику нужен feed_url'. Ничего менять не нужно.

DIRECTION_OPTIONS — в app.py при рендере /sources:

@app.get("/sources")
async def sources(request: Request, did: str = ""):
    ...
    conn = _db()
    rows = conn.execute(
        "SELECT DISTINCT direction FROM sources WHERE direction IS NOT NULL AND direction != '' ORDER BY direction"
    ).fetchall()
    directions = sorted(set(DIRECTIONS) | {r[0] for r in rows})
    return templates.TemplateResponse("sources.html", {
        "sources": srcs, "did": did, "directions": directions,
        "request": request,
    })

2. Frontend: форма

Поля url, channel, feed_url из формы удаляются, вместо них:

<div class="col-auto">
  <label class="form-label mb-0 small">Address</label>
  <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>

Кнопка «Найти» (для обоих типов; для telegram — получение названия через TG):

<div class="col-auto">
  <button type="button" class="btn btn-sm btn-outline-secondary" id="btn_fetch"
          onclick="fetchSourcePreview()">🔎 Найти</button>
</div>

JS (замена старого скрипта required):

<script>
  const addCrawler = document.querySelector('form[action="/sources/add"] select[name="crawler"]');
  const addAddress = document.getElementById('add_address');
  const addSlug = document.querySelector('form[action="/sources/add"] input[name="slug"]');
  const addName = document.querySelector('form[action="/sources/add"] input[name="name"]');

  function toggleAddressPlaceholder() {
    addAddress.placeholder = addCrawler.value === 'telegram'
      ? '@handle, t.me/name, https://t.me/name'
      : 'https://.../rss или http://...';
  }
  addCrawler.addEventListener('change', toggleAddressPlaceholder);
  toggleAddressPlaceholder();

  async function fetchSourcePreview() {
    const addr = addAddress.value.trim();
    if (!addr) { alert('Введите адрес'); return; }
    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 = 'Название не найдено — введите вручную';
  }
</script>

3. Изменения только в форме — edit-форма не трогается

Таблица и edit (изменение существующих) остаются как есть: у источника в строке показываются channel/feed_url. Для edit поле address не добавляется — там сохранены отношения url/channel/feed_url.

Проверка

  1. openspec validate address-field-enhancement — чисто.
  2. .venv/bin/python -c "from sources.sources import parse_address; print(parse_address('https://t.me/mknewsru','telegram'))" → slug=mknewsru, channel=mknewsru.
  3. curl 'http://127.0.0.1:8400/sources/preview?address=t.me/linuxklub&crawler=telegram' (с авторизацией) → {"slug":"linuxklub","name":"Linux Club",...}.
  4. Рестарт vesti-web; ручная проверка формы (address → Найти → название появилось).