openspec: архив address-field-enhancement (одно поле Address, авто-название из TG/RSS)

This commit is contained in:
kpa39l
2026-09-16 23:37:03 +00:00
parent 48c25f13d4
commit 8777a0c4a4
5 changed files with 3 additions and 3 deletions
@@ -0,0 +1,3 @@
schema: spec-driven
created: 2026-09-16
skip_specs: true
@@ -0,0 +1,202 @@
# address-field-enhancement
## Дизайн
### 1. Backend: парсер адреса и получение названия
**Новая функция в `sources/sources.py`** (или отдельный модуль — решаю по месту;
парсинг адреса — чистая функция без зависимостей):
```python
_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`:
```python
@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:
```python
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:
```python
@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` из формы удаляются, вместо них:
```html
<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):
```html
<div class="col-auto">
<button type="button" class="btn btn-sm btn-outline-secondary" id="btn_fetch"
onclick="fetchSourcePreview()">🔎 Найти</button>
</div>
```
JS (замена старого скрипта required):
```html
<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 → Найти → название появилось).
@@ -0,0 +1,45 @@
# address-field-enhancement
Улучшение формы добавления источника: одно поле Address вместо url/channel/feed_url,
авто-подстановка названия и slug, direction с автодополнением из существующих значений.
## Why
Пользователь: при добавлении telegram-источника хочется вставить адрес типа
`https://t.me/mknewsru`, а название канала получить из TG API автоматически
(«Мой компьютер»), slug — из адреса (`mknewsru`). Сейчас приходится вручную
заполнять 4 поля (url, channel, feed_url, название), часть из которых избыточна.
Также вместо жёсткого списка DIRECTIONS в datalist — подставлять реально
существующие в БД направления + возможность ввода нового.
## What Changes
- **web/templates/sources.html**: поле `channel`/`feed_url`/`url` заменяются
одним полем `address`; JS: при выборе crawler подсказка меняется;
кнопка «Найти название» (для telegram) вызывает GET /sources/preview?address=...
и заполняет slug/name; direction — datalist из `DIRECTIONS` + свободный ввод.
- **web/app.py**: новый route `GET /sources/preview` — принимает address,
определяет тип (telegram/rss), парсит slug, получает название (telegram:
Telethon get_entity; rss: feedparser), возвращает JSON {slug, name, url, channel/feed_url}.
`_form_source`: `address` → парсинг в зависимости от crawler (telegram:
channel из address, url=t.me/...; rss: feed_url=address, url=address).
`_validate_source`: rss проверяет address как URL (непустой), telegram — channel.
- **web/templates/sources.html**: datalist direction строится из DIRECTION_OPTIONS
(переменная из app.py: DIRECTIONS + уникальные из БД), input остаётся свободным.
## Why Not
- Не изменяем БД/схему: `address` — только UI-концепция, в БД по-прежнему
url/channel/feed_url.
- Не трогаем краулеры: они читают channel/feed_url из yaml — парсинг на этапе
добавления в yaml.
## Impact
- Форма проще: одно поле для адреса + crawler.
- Название и slug подставляются автоматически (для telegram — реальное имя
канала; для rss — title фида).
- direction: реальные значения из БД, свободный ввод остаётся.
- Обратная совместимость: старые источники с заполненными url/channel/feed_url
работают как раньше (адрес в форму можно не вводить при edit).
@@ -0,0 +1,18 @@
# address-field-enhancement
## Problem
Форма добавления источника требует url/channel/feed_url/название вручную.
Хочется: одно поле Address, название — из TG API (RSS — из фида), slug — из адреса.
Direction — datalist из реальных значений БД + свободный ввод.
## Tasks
- [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