From 771f6a827637735a056f23901686511cef046dc4 Mon Sep 17 00:00:00 2001 From: kpa39l Date: Wed, 16 Sep 2026 17:01:00 +0000 Subject: [PATCH] =?UTF-8?q?openspec:=20=D0=B0=D1=80=D1=85=D0=B8=D0=B2=2014?= =?UTF-8?q?=20=D0=B7=D0=B0=D0=B2=D0=B5=D1=80=D1=88=D1=91=D0=BD=D0=BD=D1=8B?= =?UTF-8?q?=D1=85=20change-=D0=BE=D0=B2=20(=D0=B2=D0=B5=D0=B1-=D1=84=D0=B8?= =?UTF-8?q?=D0=BA=D1=81=D1=8B,=20crawler-queue,=20own-content-hub,=20publi?= =?UTF-8?q?sher-service);=20=D1=81=D0=BF=D0=B5=D0=BA=D0=B8=20=D0=B2=D0=BB?= =?UTF-8?q?=D0=B8=D1=82=D1=8B=20=D0=B2=20openspec/specs?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../.openspec.yaml | 0 .../address-field-enhancement/design.md | 202 ++++++++++++++++++ .../address-field-enhancement/proposal.md | 45 ++++ .../address-field-enhancement/tasks.md | 18 ++ .../2026-09-15-sources-admin/.openspec.yaml | 2 + .../2026-09-15-sources-admin/design.md | 51 +++++ .../2026-09-15-sources-admin/proposal.md | 32 +++ .../specs/sources-admin/spec.md | 92 ++++++++ .../archive/2026-09-15-sources-admin/tasks.md | 35 +++ .../.openspec.yaml | 0 .../design.md | 0 .../proposal.md | 0 .../tasks.md | 0 .../.openspec.yaml | 0 .../design.md | 0 .../proposal.md | 0 .../tasks.md | 0 .../.openspec.yaml | 0 .../design.md | 0 .../proposal.md | 0 .../2026-09-16-candidates-read-flag}/tasks.md | 0 .../2026-09-16-crawler-queue/.openspec.yaml | 2 + .../2026-09-16-crawler-queue/design.md | 107 ++++++++++ .../2026-09-16-crawler-queue/proposal.md | 46 ++++ .../specs/crawler-queue/spec.md | 74 +++++++ .../archive/2026-09-16-crawler-queue/tasks.md | 40 ++++ .../.openspec.yaml | 0 .../design.md | 0 .../proposal.md | 0 .../tasks.md | 0 .../.openspec.yaml | 0 .../2026-09-16-fix-media-mount}/design.md | 0 .../2026-09-16-fix-media-mount}/proposal.md | 0 .../2026-09-16-fix-media-mount}/tasks.md | 0 .../.openspec.yaml | 0 .../2026-09-16-fix-published-page}/design.md | 0 .../proposal.md | 0 .../2026-09-16-fix-published-page}/tasks.md | 0 .../.openspec.yaml | 3 + .../design.md | 0 .../proposal.md | 0 .../tasks.md | 0 .../.openspec.yaml | 0 .../design.md | 0 .../proposal.md | 0 .../tasks.md | 0 .../.openspec.yaml | 0 .../2026-09-16-own-content-hub}/design.md | 0 .../2026-09-16-own-content-hub}/proposal.md | 0 .../specs/classifier/spec.md | 0 .../specs/news-store/spec.md | 0 .../specs/tg-crawler/spec.md | 0 .../specs/tg-publisher/spec.md | 0 .../specs/vesti-web/spec.md | 0 .../2026-09-16-own-content-hub}/tasks.md | 4 +- .../2026-09-16-publisher-service}/design.md | 0 .../2026-09-16-publisher-service}/proposal.md | 0 .../specs/tg-publisher-service/spec.md | 0 .../2026-09-16-publisher-service}/tasks.md | 2 +- .../.openspec.yaml | 0 .../design.md | 0 .../proposal.md | 0 .../tasks.md | 0 .../.openspec.yaml | 0 .../design.md | 0 .../proposal.md | 0 .../tasks.md | 0 .../2026-09-16-selected-list}/.openspec.yaml | 0 .../2026-09-16-selected-list}/design.md | 0 .../2026-09-16-selected-list}/proposal.md | 0 .../2026-09-16-selected-list}/tasks.md | 0 .../changes/rewrite-as-author/.openspec.yaml | 3 + openspec/changes/rewrite-as-author/design.md | 52 +++++ .../changes/rewrite-as-author/proposal.md | 25 +++ openspec/changes/rewrite-as-author/tasks.md | 23 ++ openspec/changes/rss-crawler/.openspec.yaml | 2 + openspec/changes/rss-crawler/design.md | 170 +++++++++++++++ openspec/changes/rss-crawler/proposal.md | 85 ++++++++ .../rss-crawler/specs/rss-crawler/spec.md | 90 ++++++++ openspec/changes/rss-crawler/tasks.md | 48 +++++ openspec/specs/classifier/spec.md | 31 +++ openspec/specs/crawler-queue/spec.md | 66 ++++++ openspec/specs/news-store/spec.md | 22 ++ openspec/specs/sources-admin/spec.md | 92 ++++++++ openspec/specs/tg-crawler/spec.md | 33 +++ openspec/specs/tg-publisher-service/spec.md | 65 ++++++ openspec/specs/tg-publisher/spec.md | 44 ++++ openspec/specs/vesti-web/spec.md | 29 +++ 88 files changed, 1632 insertions(+), 3 deletions(-) rename openspec/changes/{fix-sources-add-channel => address-field-enhancement}/.openspec.yaml (100%) create mode 100644 openspec/changes/address-field-enhancement/design.md create mode 100644 openspec/changes/address-field-enhancement/proposal.md create mode 100644 openspec/changes/address-field-enhancement/tasks.md create mode 100644 openspec/changes/archive/2026-09-15-sources-admin/.openspec.yaml create mode 100644 openspec/changes/archive/2026-09-15-sources-admin/design.md create mode 100644 openspec/changes/archive/2026-09-15-sources-admin/proposal.md create mode 100644 openspec/changes/archive/2026-09-15-sources-admin/specs/sources-admin/spec.md create mode 100644 openspec/changes/archive/2026-09-15-sources-admin/tasks.md rename openspec/changes/{candidates-buttons-order => archive/2026-09-16-candidates-buttons-order}/.openspec.yaml (100%) rename openspec/changes/{candidates-buttons-order => archive/2026-09-16-candidates-buttons-order}/design.md (100%) rename openspec/changes/{candidates-buttons-order => archive/2026-09-16-candidates-buttons-order}/proposal.md (100%) rename openspec/changes/{candidates-buttons-order => archive/2026-09-16-candidates-buttons-order}/tasks.md (100%) rename openspec/changes/{candidates-only-external-new => archive/2026-09-16-candidates-only-external-new}/.openspec.yaml (100%) rename openspec/changes/{candidates-only-external-new => archive/2026-09-16-candidates-only-external-new}/design.md (100%) rename openspec/changes/{candidates-only-external-new => archive/2026-09-16-candidates-only-external-new}/proposal.md (100%) rename openspec/changes/{candidates-only-external-new => archive/2026-09-16-candidates-only-external-new}/tasks.md (100%) rename openspec/changes/{candidates-read-flag => archive/2026-09-16-candidates-read-flag}/.openspec.yaml (100%) rename openspec/changes/{candidates-read-flag => archive/2026-09-16-candidates-read-flag}/design.md (100%) rename openspec/changes/{candidates-read-flag => archive/2026-09-16-candidates-read-flag}/proposal.md (100%) rename openspec/changes/{candidates-read-flag => archive/2026-09-16-candidates-read-flag}/tasks.md (100%) create mode 100644 openspec/changes/archive/2026-09-16-crawler-queue/.openspec.yaml create mode 100644 openspec/changes/archive/2026-09-16-crawler-queue/design.md create mode 100644 openspec/changes/archive/2026-09-16-crawler-queue/proposal.md create mode 100644 openspec/changes/archive/2026-09-16-crawler-queue/specs/crawler-queue/spec.md create mode 100644 openspec/changes/archive/2026-09-16-crawler-queue/tasks.md rename openspec/changes/{fix-date-group-status => archive/2026-09-16-fix-date-group-status}/.openspec.yaml (100%) rename openspec/changes/{fix-date-group-status => archive/2026-09-16-fix-date-group-status}/design.md (100%) rename openspec/changes/{fix-date-group-status => archive/2026-09-16-fix-date-group-status}/proposal.md (100%) rename openspec/changes/{fix-date-group-status => archive/2026-09-16-fix-date-group-status}/tasks.md (100%) rename openspec/changes/{fix-media-mount => archive/2026-09-16-fix-media-mount}/.openspec.yaml (100%) rename openspec/changes/{fix-media-mount => archive/2026-09-16-fix-media-mount}/design.md (100%) rename openspec/changes/{fix-media-mount => archive/2026-09-16-fix-media-mount}/proposal.md (100%) rename openspec/changes/{fix-media-mount => archive/2026-09-16-fix-media-mount}/tasks.md (100%) rename openspec/changes/{fix-published-page => archive/2026-09-16-fix-published-page}/.openspec.yaml (100%) rename openspec/changes/{fix-published-page => archive/2026-09-16-fix-published-page}/design.md (100%) rename openspec/changes/{fix-published-page => archive/2026-09-16-fix-published-page}/proposal.md (100%) rename openspec/changes/{fix-published-page => archive/2026-09-16-fix-published-page}/tasks.md (100%) create mode 100644 openspec/changes/archive/2026-09-16-fix-sources-add-channel/.openspec.yaml rename openspec/changes/{fix-sources-add-channel => archive/2026-09-16-fix-sources-add-channel}/design.md (100%) rename openspec/changes/{fix-sources-add-channel => archive/2026-09-16-fix-sources-add-channel}/proposal.md (100%) rename openspec/changes/{fix-sources-add-channel => archive/2026-09-16-fix-sources-add-channel}/tasks.md (100%) rename openspec/changes/{keep-candidates-context => archive/2026-09-16-keep-candidates-context}/.openspec.yaml (100%) rename openspec/changes/{keep-candidates-context => archive/2026-09-16-keep-candidates-context}/design.md (100%) rename openspec/changes/{keep-candidates-context => archive/2026-09-16-keep-candidates-context}/proposal.md (100%) rename openspec/changes/{keep-candidates-context => archive/2026-09-16-keep-candidates-context}/tasks.md (100%) rename openspec/changes/{own-content-hub => archive/2026-09-16-own-content-hub}/.openspec.yaml (100%) rename openspec/changes/{own-content-hub => archive/2026-09-16-own-content-hub}/design.md (100%) rename openspec/changes/{own-content-hub => archive/2026-09-16-own-content-hub}/proposal.md (100%) rename openspec/changes/{own-content-hub => archive/2026-09-16-own-content-hub}/specs/classifier/spec.md (100%) rename openspec/changes/{own-content-hub => archive/2026-09-16-own-content-hub}/specs/news-store/spec.md (100%) rename openspec/changes/{own-content-hub => archive/2026-09-16-own-content-hub}/specs/tg-crawler/spec.md (100%) rename openspec/changes/{own-content-hub => archive/2026-09-16-own-content-hub}/specs/tg-publisher/spec.md (100%) rename openspec/changes/{own-content-hub => archive/2026-09-16-own-content-hub}/specs/vesti-web/spec.md (100%) rename openspec/changes/{own-content-hub => archive/2026-09-16-own-content-hub}/tasks.md (93%) rename openspec/changes/{publisher-service => archive/2026-09-16-publisher-service}/design.md (100%) rename openspec/changes/{publisher-service => archive/2026-09-16-publisher-service}/proposal.md (100%) rename openspec/changes/{publisher-service => archive/2026-09-16-publisher-service}/specs/tg-publisher-service/spec.md (100%) rename openspec/changes/{publisher-service => archive/2026-09-16-publisher-service}/tasks.md (94%) rename openspec/changes/{reject-stay-candidates => archive/2026-09-16-reject-stay-candidates}/.openspec.yaml (100%) rename openspec/changes/{reject-stay-candidates => archive/2026-09-16-reject-stay-candidates}/design.md (100%) rename openspec/changes/{reject-stay-candidates => archive/2026-09-16-reject-stay-candidates}/proposal.md (100%) rename openspec/changes/{reject-stay-candidates => archive/2026-09-16-reject-stay-candidates}/tasks.md (100%) rename openspec/changes/{rss-formatting-preserve => archive/2026-09-16-rss-formatting-preserve}/.openspec.yaml (100%) rename openspec/changes/{rss-formatting-preserve => archive/2026-09-16-rss-formatting-preserve}/design.md (100%) rename openspec/changes/{rss-formatting-preserve => archive/2026-09-16-rss-formatting-preserve}/proposal.md (100%) rename openspec/changes/{rss-formatting-preserve => archive/2026-09-16-rss-formatting-preserve}/tasks.md (100%) rename openspec/changes/{selected-list => archive/2026-09-16-selected-list}/.openspec.yaml (100%) rename openspec/changes/{selected-list => archive/2026-09-16-selected-list}/design.md (100%) rename openspec/changes/{selected-list => archive/2026-09-16-selected-list}/proposal.md (100%) rename openspec/changes/{selected-list => archive/2026-09-16-selected-list}/tasks.md (100%) create mode 100644 openspec/changes/rewrite-as-author/.openspec.yaml create mode 100644 openspec/changes/rewrite-as-author/design.md create mode 100644 openspec/changes/rewrite-as-author/proposal.md create mode 100644 openspec/changes/rewrite-as-author/tasks.md create mode 100644 openspec/changes/rss-crawler/.openspec.yaml create mode 100644 openspec/changes/rss-crawler/design.md create mode 100644 openspec/changes/rss-crawler/proposal.md create mode 100644 openspec/changes/rss-crawler/specs/rss-crawler/spec.md create mode 100644 openspec/changes/rss-crawler/tasks.md create mode 100644 openspec/specs/classifier/spec.md create mode 100644 openspec/specs/crawler-queue/spec.md create mode 100644 openspec/specs/news-store/spec.md create mode 100644 openspec/specs/sources-admin/spec.md create mode 100644 openspec/specs/tg-crawler/spec.md create mode 100644 openspec/specs/tg-publisher-service/spec.md create mode 100644 openspec/specs/tg-publisher/spec.md create mode 100644 openspec/specs/vesti-web/spec.md diff --git a/openspec/changes/fix-sources-add-channel/.openspec.yaml b/openspec/changes/address-field-enhancement/.openspec.yaml similarity index 100% rename from openspec/changes/fix-sources-add-channel/.openspec.yaml rename to openspec/changes/address-field-enhancement/.openspec.yaml diff --git a/openspec/changes/address-field-enhancement/design.md b/openspec/changes/address-field-enhancement/design.md new file mode 100644 index 0000000..e2567a8 --- /dev/null +++ b/openspec/changes/address-field-enhancement/design.md @@ -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 +
+ + +
+``` + +Кнопка «Найти» (для обоих типов; для telegram — получение названия через TG): + +```html +
+ +
+``` + +JS (замена старого скрипта required): + +```html + +``` + +### 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 → Найти → название появилось). \ No newline at end of file diff --git a/openspec/changes/address-field-enhancement/proposal.md b/openspec/changes/address-field-enhancement/proposal.md new file mode 100644 index 0000000..4230ea3 --- /dev/null +++ b/openspec/changes/address-field-enhancement/proposal.md @@ -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). \ No newline at end of file diff --git a/openspec/changes/address-field-enhancement/tasks.md b/openspec/changes/address-field-enhancement/tasks.md new file mode 100644 index 0000000..911bec5 --- /dev/null +++ b/openspec/changes/address-field-enhancement/tasks.md @@ -0,0 +1,18 @@ +# address-field-enhancement + +## Problem + +Форма добавления источника требует url/channel/feed_url/название вручную. +Хочется: одно поле Address, название — из TG API (RSS — из фида), slug — из адреса. +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 diff --git a/openspec/changes/archive/2026-09-15-sources-admin/.openspec.yaml b/openspec/changes/archive/2026-09-15-sources-admin/.openspec.yaml new file mode 100644 index 0000000..96db9a4 --- /dev/null +++ b/openspec/changes/archive/2026-09-15-sources-admin/.openspec.yaml @@ -0,0 +1,2 @@ +schema: spec-driven +created: 2026-09-15 diff --git a/openspec/changes/archive/2026-09-15-sources-admin/design.md b/openspec/changes/archive/2026-09-15-sources-admin/design.md new file mode 100644 index 0000000..871a22d --- /dev/null +++ b/openspec/changes/archive/2026-09-15-sources-admin/design.md @@ -0,0 +1,51 @@ +# Design: sources-admin + +## Модель данных и источник истины + +- `sources/sources.yaml` — **источник истины** (в Git). Краулеры при запуске + вызывают `sync_sources_to_db()` → таблица `sources`. +- Любое веб-изменение: сначала пишем в yaml (`upsert_source_yaml` / + `delete_source_yaml` / toggle enabled), затем синкаем yaml → БД + (`sync_yaml_to_db_and_back`), чтобы бд-строка не расходилась. + +## Изменения в sources/sources.py + +1. `delete_source_db(slug)` — удаляет строку из `sources` по slug. Связанные + данные: `posts` (FK source_id) — задаём `source_id=NULL` (посты остаются + историей, без источника); `runs` — `source_id=NULL`; `rss_state` — DELETE; + `classifications` — не трогаем (привязаны к posts). + Порядок: `delete_source_yaml` (yaml) → `delete_source_db` (БД). +2. `set_source_enabled(slug, enabled)` — обновляет enabled в yaml + (через upsert_source_yaml с ключом enabled) и в БД (`sync_yaml_to_db_and_back`). + Пауза: enabled=false; снятие: enabled=true. +3. `add_or_update_source(data)` — валидация (slug обязателен, уникален; + crawler ∈ {telegram, rss}; priority ∈ {P0..P3}; для rss — feed_url обязателен) + → upsert_source_yaml → sync_yaml_to_db_and_back → возврат slug. + +## Веб-слой (web/app.py) + +- `GET /sources` — страница (за auth): таблица всех источников (slug, name, + crawler, direction, lang, priority, enabled, url/feed_url, last_fetch/status), + форма добавления, кнопки действий. Шаблон `web/templates/sources.html`. +- `POST /sources/add` — добавление (форма). Redirect на /sources. +- `POST /sources/update/` — переименование/правка полей. +- `POST /sources/{slug}/toggle` — пауза/снятие с паузы. +- `POST /sources/{slug}/delete` — удаление (с подтверждением на стороне формы: + `onclick="return confirm(...)"`). +- Все POST — с `_require_auth`, следуют паттерну /crawlers (async + form). +- Валидация ошибок → flash message + редирект (не 500). +- Навбар: ссылка «Источники» между «Краулеры» и «Выйти». + +## Шаблон sources.html + +- Таблица + модальный диалог (Bootstrap) для add/edit (одна форма). +- Кнопки: Изменить (заполняет модалку), Удалить (confirm), Пауза/Снять (toggle). +- Приоритет — селект P0/P1/P2/P3; crawler — селект telegram/rss; direction — + селект из DIRECTIONS_CANON (keywords) + «—». +- Для rss: поле feed_url; для telegram: channel. + +## Безопасность + +- Пароль ADMIN_PASSWORD, как на /crawlers. +- slug — белый список [a-z0-9_-], валидация на добавление (иначе 400). +- Удаление — только POST + confirm (никаких GET-удалений). \ No newline at end of file diff --git a/openspec/changes/archive/2026-09-15-sources-admin/proposal.md b/openspec/changes/archive/2026-09-15-sources-admin/proposal.md new file mode 100644 index 0000000..1543b92 --- /dev/null +++ b/openspec/changes/archive/2026-09-15-sources-admin/proposal.md @@ -0,0 +1,32 @@ +# Proposal: sources-admin + +## Why + +Сейчас источники редактируются вручную в `sources/sources.yaml` (Git-файл). +Веба для управления нет: на /crawlers источники только читаются. Пользователь +хочет отдельную страницу управления источниками: + +- добавлять новые источники (telegram/rss), указывая slug, name, url/feed_url, направление, приоритет; +- переименовывать (name), менять slug при необходимости; +- удалять (с подтверждением); +- ставить на паузу (enabled=false) и снимать с паузы; +- менять приоритет (priority P0–P3). + +`sources.yaml` остаётся источником истины (краулеры при запуске делают +`sync_sources_to_db`). CRUD-функции в sources.py уже есть (upsert_source_yaml, +delete_source_yaml, db_source_to_yaml) — не хватает удаления/паузы в БД и +веб-слоя. Проблема: удаление источника из yaml оставляет его в БД (сирота с +постами/ранами); пауза = enabled=0 в yaml+БД. + +## Success criteria + +- Страница /sources (за auth): таблица всех источников, кнопки Добавить / Изменить / Удалить / Пауза / Снять с паузы. +- Добавление/изменение через модальную форму (slug, name, crawler, url/feed_url, direction, lang, priority). +- Удаление с подтверждением (confirm) и каскадной чисткой связанных данных. +- Каждое изменение пишется в sources.yaml И БД атомарно (yaml — источник истины). +- /crawlers продолжает работать (читает ту же таблицу). + +## Out of scope + +- Тестирование краулеров из веба (запуск оставлен на /crawlers). +- Управление направлениями (направления — код в keywords.py, не CRUD). \ No newline at end of file diff --git a/openspec/changes/archive/2026-09-15-sources-admin/specs/sources-admin/spec.md b/openspec/changes/archive/2026-09-15-sources-admin/specs/sources-admin/spec.md new file mode 100644 index 0000000..f2de8f8 --- /dev/null +++ b/openspec/changes/archive/2026-09-15-sources-admin/specs/sources-admin/spec.md @@ -0,0 +1,92 @@ +# Spec: sources-admin + +## Purpose +Страница управления источниками в веб-интерфейсе VESTI: добавление, изменение, +удаление, пауза и смена приоритета источников без ручной правки YAML. +`sources.yaml` остаётся источником истины, веб пишет в него и синкает БД. + +## ADDED Requirements + +### Requirement: Просмотр списка источников +Страница /sources показывает таблицу всех источников с их полями и статусом. + +#### Scenario: просмотр списка источников +- **Given** веб запущен, пользователь авторизован +- **When** он открывает `/sources` +- **Then** страница показывает таблицу всех источников: slug, name, crawler, + direction, lang, priority, enabled (пауза), url/feed_url, статус (last_fetch/error) + +### Requirement: Добавление нового источника +Форма «Добавить» создаёт запись в sources.yaml и БД. + +#### Scenario: добавление нового источника +- **Given** страница /sources +- **When** форма «Добавить» заполнена (slug, name, crawler=rss, feed_url, direction=tech, priority=P1) +- **And** отправлена +- **Then** запись появляется в `sources/sources.yaml` и таблице `sources` БД +- **And** страница показывает новый источник в таблице + +#### Scenario: валидация добавления +- **Given** форма добавления с невалидным slug (`my source!`) +- **When** отправлена +- **Then** добавление отклонено, показана ошибка (flash), ничего не записано +- **And** slug принимается только `[a-z0-9_-]+` + +### Requirement: Изменение полей источника +Форма «Изменить» обновляет name/priority/направление и пр. в yaml и БД. + +#### Scenario: переименование / изменение полей +- **Given** существующий источник `lwn` +- **When** форма «Изменить» меняет `name` на «LWN Tech» и `priority` на P2 +- **Then** изменения применяются в yaml и БД, таблица обновляется + +### Requirement: Пауза и снятие с паузы +Переключатель enabled=false останавливает сбор источника, enabled=true возобновляет. + +#### Scenario: пауза и снятие с паузы +- **Given** источник `lwn` (enabled=true) +- **When** пользователь нажимает «Пауза» +- **Then** `enabled=false` в yaml и БД +- **And** краулеры больше не собирают этот источник (`get_enabled_sources` исключает) +- **When** пользователь нажимает «Снять с паузы» +- **Then** `enabled=true`, сбор возобновляется + +### Requirement: Удаление источника +Удаление (с подтверждением) убирает источник из yaml и БД, сохраняя его посты. + +#### Scenario: удаление источника +- **Given** источник `opennet` с постами в БД +- **When** пользователь подтверждает удаление (confirm) +- **Then** запись удалена из yaml и sources БД +- **And** его посты НЕ удалены: `source_id=NULL` (история сохраняется) +- **And** rss_state для него удалён + +#### Scenario: удаление без подтверждения +- **Given** форма удаления +- **When** confirm отклонён (Cancel) +- **Then** ничего не удалено, данные не меняются + +### Requirement: Доступ без авторизации +Страница /sources защищена паролем, как остальные админ-страницы. + +#### Scenario: доступ без авторизации +- **Given** пользователь не авторизован +- **When** он открывает `/sources` +- **Then** происходит редирект на /login (303) + +### Requirement: Приоритет источника +Приоритет P0–P3 влияет на порядок обработки источников. + +#### Scenario: приоритет +- **Given** источники с priority P0–P3 +- **When** страница /sources загружена +- **Then** приоритеты отображаются и доступны для изменения (P0–P3) +- **And** /crawlers сортирует по (enabled, priority, slug) с учётом нового приоритета + +### Requirement: Навигация +Ссылка «Источники» присутствует в навбаре. + +#### Scenario: навигация +- **Given** любая страница веб (base.html) +- **When** открыт навбар +- **Then** есть ссылка «Источники» на `/sources` \ No newline at end of file diff --git a/openspec/changes/archive/2026-09-15-sources-admin/tasks.md b/openspec/changes/archive/2026-09-15-sources-admin/tasks.md new file mode 100644 index 0000000..b42a973 --- /dev/null +++ b/openspec/changes/archive/2026-09-15-sources-admin/tasks.md @@ -0,0 +1,35 @@ +# Tasks: sources-admin + +## 1. sources.py — CRUD для БД + валидация + +- [x] 1.1 `delete_source_db(slug)`: UPDATE posts/runs SET source_id=NULL, DELETE rss_state, DELETE sources. + Проверка: юнит-тест — после delete_source_db строки sources нет, rss_state нет, posts.source_id=NULL. +- [x] 1.2 `set_source_enabled(slug, enabled)`: обновить yaml (upsert с enabled) + sync_yaml_to_db_and_back. + Проверка: юнит — после toggle enabled в БД = 0/1, в yaml = false/true. +- [x] 1.3 `add_or_update_source(data)`: валидация (slug regex, crawler ∈ {telegram,rss}, priority ∈ P0..P3, rss→feed_url обязателен) → upsert yaml → sync. + Проверка: юнит — add 'opennet' (rss) → есть в yaml и БД; невалидный 400. + +## 2. Веб-слой + +- [x] 2.1 GET /sources — страница (таблица + форма). Шаблон sources.html. + Проверка: GET с auth → 200, в таблице все источники (10). +- [x] 2.2 POST /sources/add, /sources/update/ — добавление/правка. Валидация, flash, редирект. + Проверка: POST add opennet → в БД/yaml появился; POST update — name изменился. +- [x] 2.3 POST /sources/{slug}/toggle — пауза (enabled 0) / снятие (enabled 1). + Проверка: toggle lwn → enabled=0 в БД и yaml; в /crawlers lwn помечен disabled. +- [x] 2.4 POST /sources/{slug}/delete — удаление с confirm. Каскад: posts/runs source_id=NULL, rss_state удалён. + Проверка: delete тестового источника → из yaml и БД исчез, посты не потеряны (source_id NULL). +- [x] 2.5 Навбар: ссылка «Источники» (была добавлена — проверена в base.html). + +## 3. Интеграция, доки + +- [x] 3.1 AGENT.MD / STATUS.md / TODO.md — /sources (добавлено/закрыто). +- [x] 3.2 Ссылка /sources в nav (проверка вёрстки). +- [x] 3.3 `openspec validate sources-admin` → valid. +- [x] 3.4 Рестарт vesti-web, ручная проверка GET /sources 200. + +## Примечания +- sources.yaml — источник истины; каждое изменение пишется в yaml И БД. +- Удалённые посты НЕ удаляются (история), только source_id=NULL. +- Пауза не трогает уже собранные посты — только прекращает сбор (enabled=0). +- HTTP-тест: add→toggle(pause)→toggle(resume)→delete прошёл end-to-end (тест. источник test_tmp_src удалён, чисто). \ No newline at end of file diff --git a/openspec/changes/candidates-buttons-order/.openspec.yaml b/openspec/changes/archive/2026-09-16-candidates-buttons-order/.openspec.yaml similarity index 100% rename from openspec/changes/candidates-buttons-order/.openspec.yaml rename to openspec/changes/archive/2026-09-16-candidates-buttons-order/.openspec.yaml diff --git a/openspec/changes/candidates-buttons-order/design.md b/openspec/changes/archive/2026-09-16-candidates-buttons-order/design.md similarity index 100% rename from openspec/changes/candidates-buttons-order/design.md rename to openspec/changes/archive/2026-09-16-candidates-buttons-order/design.md diff --git a/openspec/changes/candidates-buttons-order/proposal.md b/openspec/changes/archive/2026-09-16-candidates-buttons-order/proposal.md similarity index 100% rename from openspec/changes/candidates-buttons-order/proposal.md rename to openspec/changes/archive/2026-09-16-candidates-buttons-order/proposal.md diff --git a/openspec/changes/candidates-buttons-order/tasks.md b/openspec/changes/archive/2026-09-16-candidates-buttons-order/tasks.md similarity index 100% rename from openspec/changes/candidates-buttons-order/tasks.md rename to openspec/changes/archive/2026-09-16-candidates-buttons-order/tasks.md diff --git a/openspec/changes/candidates-only-external-new/.openspec.yaml b/openspec/changes/archive/2026-09-16-candidates-only-external-new/.openspec.yaml similarity index 100% rename from openspec/changes/candidates-only-external-new/.openspec.yaml rename to openspec/changes/archive/2026-09-16-candidates-only-external-new/.openspec.yaml diff --git a/openspec/changes/candidates-only-external-new/design.md b/openspec/changes/archive/2026-09-16-candidates-only-external-new/design.md similarity index 100% rename from openspec/changes/candidates-only-external-new/design.md rename to openspec/changes/archive/2026-09-16-candidates-only-external-new/design.md diff --git a/openspec/changes/candidates-only-external-new/proposal.md b/openspec/changes/archive/2026-09-16-candidates-only-external-new/proposal.md similarity index 100% rename from openspec/changes/candidates-only-external-new/proposal.md rename to openspec/changes/archive/2026-09-16-candidates-only-external-new/proposal.md diff --git a/openspec/changes/candidates-only-external-new/tasks.md b/openspec/changes/archive/2026-09-16-candidates-only-external-new/tasks.md similarity index 100% rename from openspec/changes/candidates-only-external-new/tasks.md rename to openspec/changes/archive/2026-09-16-candidates-only-external-new/tasks.md diff --git a/openspec/changes/candidates-read-flag/.openspec.yaml b/openspec/changes/archive/2026-09-16-candidates-read-flag/.openspec.yaml similarity index 100% rename from openspec/changes/candidates-read-flag/.openspec.yaml rename to openspec/changes/archive/2026-09-16-candidates-read-flag/.openspec.yaml diff --git a/openspec/changes/candidates-read-flag/design.md b/openspec/changes/archive/2026-09-16-candidates-read-flag/design.md similarity index 100% rename from openspec/changes/candidates-read-flag/design.md rename to openspec/changes/archive/2026-09-16-candidates-read-flag/design.md diff --git a/openspec/changes/candidates-read-flag/proposal.md b/openspec/changes/archive/2026-09-16-candidates-read-flag/proposal.md similarity index 100% rename from openspec/changes/candidates-read-flag/proposal.md rename to openspec/changes/archive/2026-09-16-candidates-read-flag/proposal.md diff --git a/openspec/changes/candidates-read-flag/tasks.md b/openspec/changes/archive/2026-09-16-candidates-read-flag/tasks.md similarity index 100% rename from openspec/changes/candidates-read-flag/tasks.md rename to openspec/changes/archive/2026-09-16-candidates-read-flag/tasks.md diff --git a/openspec/changes/archive/2026-09-16-crawler-queue/.openspec.yaml b/openspec/changes/archive/2026-09-16-crawler-queue/.openspec.yaml new file mode 100644 index 0000000..96db9a4 --- /dev/null +++ b/openspec/changes/archive/2026-09-16-crawler-queue/.openspec.yaml @@ -0,0 +1,2 @@ +schema: spec-driven +created: 2026-09-15 diff --git a/openspec/changes/archive/2026-09-16-crawler-queue/design.md b/openspec/changes/archive/2026-09-16-crawler-queue/design.md new file mode 100644 index 0000000..6daf022 --- /dev/null +++ b/openspec/changes/archive/2026-09-16-crawler-queue/design.md @@ -0,0 +1,107 @@ +# Design: crawler-queue + +## Approach + +Двухфазный пайплайн поверх существующей модели данных (БЕЗ новой таблицы-очереди): + +``` +Фаза 1 (сбор) Фаза 3 (обработка, ПАРАЛЛЕЛЬНО) +sources.yaml ──► ┌──────────────┐ + telegram_crawler ──► │ ThreadPool │──► posts(classified=0) + rss_crawler ──► │ (8 воркеров)│──► keywords → Ollama → direction + │ └──────────────┘ + trafilatura (полный текст) + ▼ +posts (status='new', classified=0) = ОЧЕРЕДЬ +``` + +- **Очередь = таблица `posts`** уже существует (`status='new'` + `classified=0`). + Размер очереди: `SELECT COUNT(*) FROM posts WHERE classified IS NULL OR classified=0`. +- **Фаза 3** — новый `crawler/worker.py`: ThreadPoolExecutor(8), каждый воркер + claim'ит посты (`UPDATE posts SET classified=-1 WHERE id=? AND classified=0` + → атомарный claim, без дублей), классифицирует (keywords → Ollama), пишет + direction/relevance/interest/summary, ставит classified=1. Упавшие → classified=0 + обратно (ретрай). +- **Фаза 1** — `crawler/crawl_sources.py`: обёртка, запускающая telegram_crawler + и rss_crawler (сбор источников → посты в очередь). Раздельные запуски. +- **Страница `/crawlers`** — FastAPI route в `web/app.py` + шаблон + `web/templates/crawlers.html`: таблица источников (из `sources` + `rss_state`), + размер очереди (pending/processing/done), кнопки «Запустить сейчас» + (POST /crawlers/run) и «Сбросить dead» (POST /crawlers/reset). + +## Files + +```bash +# Новое +crawler/worker.py # фаза 3: ThreadPoolExecutor, claim, классификация +crawler/crawl_sources.py # фаза 1: запуск telegram+rss краулеров (сбор в очередь) +web/templates/crawlers.html # страница /crawlers + +# Изменения +web/app.py # routes: GET /crawlers, POST /crawlers/run, POST /crawlers/reset +sources/sources.py # + get_rss_state(), + reset_source_status() +AGENT.MD / STATUS.md / TODO.md / WALKTHROUGH.md # доки +openspec/changes/crawler-queue/ # этот change +``` + +## Worker (фаза 3) + +```python +# crawler/worker.py +from concurrent.futures import ThreadPoolExecutor, as_completed +import sqlite3 + +def claim_post(conn, worker_id) -> row | None: + # атомарный claim: одна строка — один воркер + conn.execute("BEGIN IMMEDIATE") + row = conn.execute( + "SELECT id, text, views, reactions_total, is_own FROM posts " + "WHERE classified IS NULL OR classified=0 " + "ORDER BY is_own DESC, id LIMIT 1").fetchone() + if row: + conn.execute("UPDATE posts SET classified=-1 WHERE id=?", (row["id"],)) + conn.commit() + return row + +def process_one(row) -> dict: # classify_text из classifier.classify + ... + +def run(workers: int = 8, limit: int = 200): + with ThreadPoolExecutor(max_workers=workers) as ex: + futs = [ex.submit(work_loop, worker_id=i) for i in range(workers)] + for f in as_completed(futs): + ... +``` + +Каждый воркер: +1. `claim_post` → строка post (classified=0 → -1). +2. `classify_text` (keywords → Ollama; трафилатура для summary-only). +3. UPDATE posts SET direction=?, relevance=?, interest=?, summary=?, classified=1 WHERE id=? +4. При исключении → UPDATE posts SET classified=0 WHERE id=? (вернуть в очередь). + +## Страница /crawlers + +| Источник | slug | name | crawler | status | last_fetch | last_error | Приоритет | Действия | +Снизу — карточки очереди: +- Pending: COUNT(classified IS NULL OR 0) +- Processing: COUNT(classified=-1) +- Done (сегодня): COUNT(classified=1 AND fetched_at >= today) + +Кнопки: +- «Запустить сейчас» → POST /crawlers/run → запускает worker.run(8) синхронно (или + через subprocess в фоне), редирект на /crawlers. +- «Сбросить dead» → POST /crawlers/reset → UPDATE sources SET status='alive', error_count=0 + WHERE status='dead'. + +## CLI + +```bash +.venv/bin/python -m crawler.worker --workers 8 --limit 200 # фаза 3, параллельно +.venv/bin/python -m crawler.crawl_sources --all # фаза 1, сбор в очередь +``` + +## Verification + +- [ ] `openspec validate crawler-queue` → 0 ошибок +- [ ] `worker.run(8)` обрабатывает ≥5 постов из очереди; ПОВТОРНЫЙ запуск — 0 новых (все classified=1) +- [ ] Дубли не возникают: 100 постов, 8 воркеров → 100 строк обновлены, 0 пропущено +- [ ] Страница /crawlers: показывает таблицу источников, размер очереди; кнопка «Сбросить dead» обнуляет error_count \ No newline at end of file diff --git a/openspec/changes/archive/2026-09-16-crawler-queue/proposal.md b/openspec/changes/archive/2026-09-16-crawler-queue/proposal.md new file mode 100644 index 0000000..92541e2 --- /dev/null +++ b/openspec/changes/archive/2026-09-16-crawler-queue/proposal.md @@ -0,0 +1,46 @@ +# Proposal: crawler-queue + +## Why + +Сейчас фазы сбора и обработки не разделены: каждый краулер (telegram/rss) сам +собирает посты и сам же (через `classifier.classify`) разбирает их на +классификацию. Это последовательно: весь прогон — один поток, один источник за +раз. При этом самая дорогая часть — фаза 3 (классификация через Ollama, +дотягивание текста трафилатурой) — выполняется последовательно и без видимости +процесса: непонятно, сколько кандидатов в очереди, какие источники живы, что +упало. + +Пользователь хочет: +1. Отдельная страница `/crawlers` — статус работы краулеров (alive/dead, + last_fetch, ошибки) и размер очереди. +2. Двухфазный сбор: (1) краулеры проходят по источникам → ищут новых кандидатов; + (2) найденное кладётся в очередь; (3) воркеры разбирают очередь в НЕСКОЛЬКО + ПОТОКОВ. + +Анализ текущего кода: +- Очередь фазы 2 уже существует — это `posts` со `status='new'` и + `classified IS NULL OR classified=0` (классификатор выбирает именно их, + `SELECT ... WHERE classified IS NULL OR classified=0 LIMIT ?`). +- Отдельная таблица `crawl_queue` НЕ нужна — она дублировала бы `posts`. + «Размер очереди» = `COUNT(*) FROM posts WHERE classified IS NULL OR classified=0`. +- Чего нет: (а) параллельной фазы 3 (ThreadPoolExecutor), (б) страницы `/crawlers`, + (в) разделения «сбор» и «обработка» как независимых запусков. + +## Goal + +- Параллельная фаза 3: воркеры (N потоков) разбирают очередь + `posts(classified=0)` — классификация (keywords → Ollama) + дотягивание текста + (trafilatura) по пайплайну `classifier.classify`. +- Страница `/crawlers` (веб, за auth): таблица источников (slug, name, crawler, + status, last_fetch, last_error, error_count, priority), размер очереди + (pending/processing/done), кнопки «Запустить сейчас» и «Сбросить dead». +- Разделение: `crawler/worker.py` (фаза 3, N потоков) и `crawler/crawl_sources.py` + (фаза 1: собрать новых кандидатов в очередь) — независимые запуски. +- Терминология (для доков): true-конкурентность на IO-bound задачах через потоки; + GIL не мешает, т.к. фаза 3 — ожидание сети. + +## Non-goals + +- Не вводим Redis/RabbitMQ/брокеры — SQLite-очередь (claim по `posts`) достаточна. +- Не выносим в микросервисы — всё в рамках существующего веба/краулера. +- Не переписываем telegram_crawler; RSS-краулер уже работает. \ No newline at end of file diff --git a/openspec/changes/archive/2026-09-16-crawler-queue/specs/crawler-queue/spec.md b/openspec/changes/archive/2026-09-16-crawler-queue/specs/crawler-queue/spec.md new file mode 100644 index 0000000..ca62ef0 --- /dev/null +++ b/openspec/changes/archive/2026-09-16-crawler-queue/specs/crawler-queue/spec.md @@ -0,0 +1,74 @@ +# Spec: crawler-queue + +## Purpose + +Двухфазный пайплайн сбора и обработки новостей: краулеры (фаза 1) собирают +кандидатов в очередь (`posts` со `status='new'` и неклассифицированные), воркеры +(фаза 3) разбирают её параллельно (ThreadPoolExecutor). Отдельная страница +`/crawlers` показывает статус источников и размер очереди. + +## ADDED Requirements + +### Requirement: Параллельная фаза 3 (воркеры) +Система MUST предоставлять `crawler/worker.py` с пулом `ThreadPoolExecutor(max_workers=N)`, +где каждый воркер атомарно забирает пост из очереди (`UPDATE posts SET classified=-1 +WHERE id=? AND classified=0`), классифицирует его (`classify_text`: keywords → Ollama, +trafilatura для summary-only) и пишет результат (direction/relevance/interest/summary, +classified=1). При исключении посте MUST возвращаться в очередь (classified=0). + +#### Scenario: Параллельная обработка очереди +- **GIVEN** 100 постов со `classified=0` +- **WHEN** `python -m crawler.worker --workers 8 --limit 200` +- **THEN** все 100 постов обработаны (classified=1), дублей нет (каждый обработан ровно 1 раз) + +#### Scenario: Сбой воркера +- **GIVEN** пост, у которого `classify_text` бросает исключение (Ollama недоступна) +- **WHEN** воркер обрабатывает пост +- **THEN** пост возвращается в очередь (classified=0), воркер продолжает работу, запуск не падает + +### Requirement: Очередь на основе posts (без новой таблицы) +«Размер очереди» MUST вычисляться из существующей таблицы `posts`: +`SELECT COUNT(*) FROM posts WHERE classified IS NULL OR classified=0` (pending), +`classified=-1` (processing), `classified=1 AND fetched_at >= date('now')` (done today). +Новая таблица для очереди НЕ создаётся — она дублировала бы `posts`. + +#### Scenario: Размер очереди +- **GIVEN** в posts 10 новых (classified=0) и 2 в обработке (classified=-1) +- **WHEN** страница /crawlers запрашивает размер очереди +- **THEN** pending=10, processing=2, done today=0 + +### Requirement: Страница /crawlers +Веб MUST предоставлять `GET /crawlers` (за аутентификацией): таблица источников +(slug, name, crawler, status, last_fetch, last_error за 200 симв., error_count, +priority; для rss — etag/modified/last_build_date из rss_state) и карточки очереди +(pending/processing/done). Кнопка «Сбросить dead» (POST /crawlers/reset) MUST +устанавливать sources.status='alive', error_count=0, last_error=NULL для всех +источников со status='dead'. + +#### Scenario: Просмотр статуса +- **GIVEN** источник lwn (rss, alive) и 15 новых постов в очереди +- **WHEN** GET /crawlers +- **THEN** страница показывает lwn с статусом alive, размер очереди 15 + +#### Scenario: Сброс dead-источников +- **GIVEN** источник со status='dead', error_count=7 +- **WHEN** POST /crawlers/reset +- **THEN** источник становится alive, error_count=0, last_error=NULL + +### Requirement: Фаза 1 (сбор) отдельно от фазы 3 (обработка) +Система MUST предоставлять `crawler/crawl_sources.py` — запуск сбора всех +включённых источников (telegram_crawler + rss_crawler) без классификации; +новые посты попадают в очередь (posts, classified=0). Обработка (фаза 3) +запускается отдельно (`crawler/worker.py`). + +#### Scenario: Сбор без классификации +- **GIVEN** включённые источники telegram и rss +- **WHEN** `python -m crawler.crawl_sources --all` +- **THEN** новые посты добавлены в posts с classified=0, классификация НЕ запущена + +## NOT Requirements + +- НЕ создаём отдельную таблицу очереди (`crawl_queue`) — используется `posts`. +- НЕ вводим Redis/RabbitMQ/брокеры — атомарный claim через SQLite достаточен. +- НЕ переписываем telegram_crawler/rss_crawler (фаза 1) — они уже работают. +- НЕ выносим воркеры в отдельный процесс/сервис — модуль в том же проекте. \ No newline at end of file diff --git a/openspec/changes/archive/2026-09-16-crawler-queue/tasks.md b/openspec/changes/archive/2026-09-16-crawler-queue/tasks.md new file mode 100644 index 0000000..16f5846 --- /dev/null +++ b/openspec/changes/archive/2026-09-16-crawler-queue/tasks.md @@ -0,0 +1,40 @@ +# Tasks: crawler-queue + +## 1. Воркер (фаза 3) + +- [x] 1.1 crawler/worker.py: claim_post (атомарный claim: classified=0 → -1, BEGIN IMMEDIATE) + Проверка: in-memory тест — два вызова claim_post дают разные id (атомарно) +- [x] 1.2 process_post: classify_text + трафилатура для summary-only; UPDATE posts (direction/relevance/interest/summary/classified=1) + Проверка: in-memory — пост «linux» → tech, classified=1; «игры» → games +- [x] 1.3 Исключение → classified=0 (вернуть в очередь) + Проверка: try/except в work_loop, UPDATE classified=0; реализовано +- [x] 1.4 run(workers=8, limit=200): ThreadPoolExecutor, as_completed, счётчики (processed/classified/llm_ok) + Проверка: `python -m crawler.worker --workers 4 --limit 2` → 8 обработано; без ошибок + +## 2. Сбор (фаза 1) + +- [x] 2.1 crawler/crawl_sources.py: запуск telegram_crawler + rss_crawler (сбор новых кандидатов в очередь) + Проверка: `python -m crawler.crawl_sources --all` → запускает оба; `--crawler rss` — только RSS + +## 3. Страница /crawlers + +- [x] 3.1 web/app.py: GET /crawlers (за auth) — таблица источников (slug, name, crawler, status, last_fetch, last_error, error_count, priority) + карточки очереди (pending/processing/done) + Проверка: GET /crawlers с auth → 200, таблица с lwn (проверено httpx) +- [x] 3.2 web/templates/crawlers.html — шаблон (таблица + карточки + кнопки) +- [x] 3.3 POST /crawlers/run → запуск worker.run (фаза 3); POST /crawlers/reset → sources.status='alive', error_count=0 + Проверка: POST /crawlers/run → воркер реально стартует (logs/worker.log), 200/302; reset — UPDATE + +## 4. Интеграция и доки + +- [x] 4.1 AGENT.MD — команды worker/crawl_sources (добавлены) +- [x] 4.2 STATUS.md / TODO.md — страница /crawlers, параллельная фаза 3 (обновлены) +- [x] 4.3 `openspec validate crawler-queue` → 0 ошибок +- [x] 4.4 TODO.md: задача «crawler-очередь» закрыта ✅ + +## Примечания (найденные при реализации) + +- SQLite: соединение привязано к потоку — создаётся ВНУТРИ work_loop (нельзя делить между потоками). +- Локальная Ollama (qwen3:8b) держит 1 слот — 8 параллельных LLM-запросов → таймауты (посты возвращаются в очередь, не теряются). Добавлен `LLM_SEM` (threading.BoundedSemaphore, MAX_CONCURRENT_LLM=2). +- Очередь = posts (classified IS NULL OR 0) — отдельная таблица НЕ нужна (дублирование). +- error_count/etag для RSS — из rss_state (в sources их нет); запрос /crawlers использует COALESCE. +- **SOURCE_RULES (classifier/keywords.py)**: правило источника с приоритетом над словарём и LLM. `lwn → tech` — все посты LWN (технологическое СМИ) получают направление tech детерминированно, без LLM (method='source-rule'). Работает в worker.py и старом CLI classifier.classify (оба JOIN sources → source_slug). \ No newline at end of file diff --git a/openspec/changes/fix-date-group-status/.openspec.yaml b/openspec/changes/archive/2026-09-16-fix-date-group-status/.openspec.yaml similarity index 100% rename from openspec/changes/fix-date-group-status/.openspec.yaml rename to openspec/changes/archive/2026-09-16-fix-date-group-status/.openspec.yaml diff --git a/openspec/changes/fix-date-group-status/design.md b/openspec/changes/archive/2026-09-16-fix-date-group-status/design.md similarity index 100% rename from openspec/changes/fix-date-group-status/design.md rename to openspec/changes/archive/2026-09-16-fix-date-group-status/design.md diff --git a/openspec/changes/fix-date-group-status/proposal.md b/openspec/changes/archive/2026-09-16-fix-date-group-status/proposal.md similarity index 100% rename from openspec/changes/fix-date-group-status/proposal.md rename to openspec/changes/archive/2026-09-16-fix-date-group-status/proposal.md diff --git a/openspec/changes/fix-date-group-status/tasks.md b/openspec/changes/archive/2026-09-16-fix-date-group-status/tasks.md similarity index 100% rename from openspec/changes/fix-date-group-status/tasks.md rename to openspec/changes/archive/2026-09-16-fix-date-group-status/tasks.md diff --git a/openspec/changes/fix-media-mount/.openspec.yaml b/openspec/changes/archive/2026-09-16-fix-media-mount/.openspec.yaml similarity index 100% rename from openspec/changes/fix-media-mount/.openspec.yaml rename to openspec/changes/archive/2026-09-16-fix-media-mount/.openspec.yaml diff --git a/openspec/changes/fix-media-mount/design.md b/openspec/changes/archive/2026-09-16-fix-media-mount/design.md similarity index 100% rename from openspec/changes/fix-media-mount/design.md rename to openspec/changes/archive/2026-09-16-fix-media-mount/design.md diff --git a/openspec/changes/fix-media-mount/proposal.md b/openspec/changes/archive/2026-09-16-fix-media-mount/proposal.md similarity index 100% rename from openspec/changes/fix-media-mount/proposal.md rename to openspec/changes/archive/2026-09-16-fix-media-mount/proposal.md diff --git a/openspec/changes/fix-media-mount/tasks.md b/openspec/changes/archive/2026-09-16-fix-media-mount/tasks.md similarity index 100% rename from openspec/changes/fix-media-mount/tasks.md rename to openspec/changes/archive/2026-09-16-fix-media-mount/tasks.md diff --git a/openspec/changes/fix-published-page/.openspec.yaml b/openspec/changes/archive/2026-09-16-fix-published-page/.openspec.yaml similarity index 100% rename from openspec/changes/fix-published-page/.openspec.yaml rename to openspec/changes/archive/2026-09-16-fix-published-page/.openspec.yaml diff --git a/openspec/changes/fix-published-page/design.md b/openspec/changes/archive/2026-09-16-fix-published-page/design.md similarity index 100% rename from openspec/changes/fix-published-page/design.md rename to openspec/changes/archive/2026-09-16-fix-published-page/design.md diff --git a/openspec/changes/fix-published-page/proposal.md b/openspec/changes/archive/2026-09-16-fix-published-page/proposal.md similarity index 100% rename from openspec/changes/fix-published-page/proposal.md rename to openspec/changes/archive/2026-09-16-fix-published-page/proposal.md diff --git a/openspec/changes/fix-published-page/tasks.md b/openspec/changes/archive/2026-09-16-fix-published-page/tasks.md similarity index 100% rename from openspec/changes/fix-published-page/tasks.md rename to openspec/changes/archive/2026-09-16-fix-published-page/tasks.md diff --git a/openspec/changes/archive/2026-09-16-fix-sources-add-channel/.openspec.yaml b/openspec/changes/archive/2026-09-16-fix-sources-add-channel/.openspec.yaml new file mode 100644 index 0000000..63c288a --- /dev/null +++ b/openspec/changes/archive/2026-09-16-fix-sources-add-channel/.openspec.yaml @@ -0,0 +1,3 @@ +schema: spec-driven +created: 2026-09-16 +skip_specs: true \ No newline at end of file diff --git a/openspec/changes/fix-sources-add-channel/design.md b/openspec/changes/archive/2026-09-16-fix-sources-add-channel/design.md similarity index 100% rename from openspec/changes/fix-sources-add-channel/design.md rename to openspec/changes/archive/2026-09-16-fix-sources-add-channel/design.md diff --git a/openspec/changes/fix-sources-add-channel/proposal.md b/openspec/changes/archive/2026-09-16-fix-sources-add-channel/proposal.md similarity index 100% rename from openspec/changes/fix-sources-add-channel/proposal.md rename to openspec/changes/archive/2026-09-16-fix-sources-add-channel/proposal.md diff --git a/openspec/changes/fix-sources-add-channel/tasks.md b/openspec/changes/archive/2026-09-16-fix-sources-add-channel/tasks.md similarity index 100% rename from openspec/changes/fix-sources-add-channel/tasks.md rename to openspec/changes/archive/2026-09-16-fix-sources-add-channel/tasks.md diff --git a/openspec/changes/keep-candidates-context/.openspec.yaml b/openspec/changes/archive/2026-09-16-keep-candidates-context/.openspec.yaml similarity index 100% rename from openspec/changes/keep-candidates-context/.openspec.yaml rename to openspec/changes/archive/2026-09-16-keep-candidates-context/.openspec.yaml diff --git a/openspec/changes/keep-candidates-context/design.md b/openspec/changes/archive/2026-09-16-keep-candidates-context/design.md similarity index 100% rename from openspec/changes/keep-candidates-context/design.md rename to openspec/changes/archive/2026-09-16-keep-candidates-context/design.md diff --git a/openspec/changes/keep-candidates-context/proposal.md b/openspec/changes/archive/2026-09-16-keep-candidates-context/proposal.md similarity index 100% rename from openspec/changes/keep-candidates-context/proposal.md rename to openspec/changes/archive/2026-09-16-keep-candidates-context/proposal.md diff --git a/openspec/changes/keep-candidates-context/tasks.md b/openspec/changes/archive/2026-09-16-keep-candidates-context/tasks.md similarity index 100% rename from openspec/changes/keep-candidates-context/tasks.md rename to openspec/changes/archive/2026-09-16-keep-candidates-context/tasks.md diff --git a/openspec/changes/own-content-hub/.openspec.yaml b/openspec/changes/archive/2026-09-16-own-content-hub/.openspec.yaml similarity index 100% rename from openspec/changes/own-content-hub/.openspec.yaml rename to openspec/changes/archive/2026-09-16-own-content-hub/.openspec.yaml diff --git a/openspec/changes/own-content-hub/design.md b/openspec/changes/archive/2026-09-16-own-content-hub/design.md similarity index 100% rename from openspec/changes/own-content-hub/design.md rename to openspec/changes/archive/2026-09-16-own-content-hub/design.md diff --git a/openspec/changes/own-content-hub/proposal.md b/openspec/changes/archive/2026-09-16-own-content-hub/proposal.md similarity index 100% rename from openspec/changes/own-content-hub/proposal.md rename to openspec/changes/archive/2026-09-16-own-content-hub/proposal.md diff --git a/openspec/changes/own-content-hub/specs/classifier/spec.md b/openspec/changes/archive/2026-09-16-own-content-hub/specs/classifier/spec.md similarity index 100% rename from openspec/changes/own-content-hub/specs/classifier/spec.md rename to openspec/changes/archive/2026-09-16-own-content-hub/specs/classifier/spec.md diff --git a/openspec/changes/own-content-hub/specs/news-store/spec.md b/openspec/changes/archive/2026-09-16-own-content-hub/specs/news-store/spec.md similarity index 100% rename from openspec/changes/own-content-hub/specs/news-store/spec.md rename to openspec/changes/archive/2026-09-16-own-content-hub/specs/news-store/spec.md diff --git a/openspec/changes/own-content-hub/specs/tg-crawler/spec.md b/openspec/changes/archive/2026-09-16-own-content-hub/specs/tg-crawler/spec.md similarity index 100% rename from openspec/changes/own-content-hub/specs/tg-crawler/spec.md rename to openspec/changes/archive/2026-09-16-own-content-hub/specs/tg-crawler/spec.md diff --git a/openspec/changes/own-content-hub/specs/tg-publisher/spec.md b/openspec/changes/archive/2026-09-16-own-content-hub/specs/tg-publisher/spec.md similarity index 100% rename from openspec/changes/own-content-hub/specs/tg-publisher/spec.md rename to openspec/changes/archive/2026-09-16-own-content-hub/specs/tg-publisher/spec.md diff --git a/openspec/changes/own-content-hub/specs/vesti-web/spec.md b/openspec/changes/archive/2026-09-16-own-content-hub/specs/vesti-web/spec.md similarity index 100% rename from openspec/changes/own-content-hub/specs/vesti-web/spec.md rename to openspec/changes/archive/2026-09-16-own-content-hub/specs/vesti-web/spec.md diff --git a/openspec/changes/own-content-hub/tasks.md b/openspec/changes/archive/2026-09-16-own-content-hub/tasks.md similarity index 93% rename from openspec/changes/own-content-hub/tasks.md rename to openspec/changes/archive/2026-09-16-own-content-hub/tasks.md index c02b040..b18f580 100644 --- a/openspec/changes/own-content-hub/tasks.md +++ b/openspec/changes/archive/2026-09-16-own-content-hub/tasks.md @@ -15,7 +15,7 @@ Проверка: код реализован (store_posts/is_own_source, py_compile OK); интеграционная проверка ждёт бэкфилла 2.3 - [x] 2.2 Форварды в своём канале: чужой форвард сохраняется с is_own=1 + fwd-полями, медиа НЕ скачивается Проверка: код реализован (skip_media для форвардов); интеграционная проверка после бэкфилла -- [ ] 2.3 Бэкфилл своего канала: первый прогон ~1039 постов (медиа по возможности; при лимите — текст без медиа, бэкфилл-флаг) +- [x] 2.3 Бэкфилл своего канала: первый прогон ~1039 постов (медиа по возможности; при лимите — текст без медиа, бэкфилл-флаг) — СДЕЛАНО 2026-09-10 (backfill_dedinit.py, fetched=1040, max_post_id=1092, 846 в БД; ныне 848 is_own) Проверка: `sqlite3 db/vesti.db "SELECT COUNT(*) FROM posts WHERE is_own=1"` > 0 (до ~1039) - [x] 2.4 Дедуп: тот же sha256/url во внешнем канале → is_own=0 (упоминание), канонический остаётся is_own_canonical=1 Проверка: код реализован (канон vs упоминание); проверка на реальных данных после бэкфилла @@ -54,7 +54,7 @@ ## 7. Проверка интеграции и документация -- [ ] 7.1 Полный прогон: синк → краулер dedinit → классификатор → веб (approve с fan-out) → бандлы; внешние источники не затронуты (is_own=0 по умолчанию) +- [x] 7.1 Полный прогон: синк → краулер dedinit → классификатор → веб (approve с fan-out) → бандлы; внешние источники не затронуты (is_own=0 по умолчанию) — СДЕЛАНО (реальные approve 136/137, 2026-09-09; бэкфилл 2026-09-10) Проверка: counts по is_own в БД, бандлы, /published, runs ok — ждёт бэкфилла 2.3 (реальная сеть) - [x] 7.2 Обновить STATUS.md / TODO.md / WALKTHROUGH.md (что сделано, как запускать, питфолы) Проверка: документы отражают новое состояние (обновлено при закрытии сессии) \ No newline at end of file diff --git a/openspec/changes/publisher-service/design.md b/openspec/changes/archive/2026-09-16-publisher-service/design.md similarity index 100% rename from openspec/changes/publisher-service/design.md rename to openspec/changes/archive/2026-09-16-publisher-service/design.md diff --git a/openspec/changes/publisher-service/proposal.md b/openspec/changes/archive/2026-09-16-publisher-service/proposal.md similarity index 100% rename from openspec/changes/publisher-service/proposal.md rename to openspec/changes/archive/2026-09-16-publisher-service/proposal.md diff --git a/openspec/changes/publisher-service/specs/tg-publisher-service/spec.md b/openspec/changes/archive/2026-09-16-publisher-service/specs/tg-publisher-service/spec.md similarity index 100% rename from openspec/changes/publisher-service/specs/tg-publisher-service/spec.md rename to openspec/changes/archive/2026-09-16-publisher-service/specs/tg-publisher-service/spec.md diff --git a/openspec/changes/publisher-service/tasks.md b/openspec/changes/archive/2026-09-16-publisher-service/tasks.md similarity index 94% rename from openspec/changes/publisher-service/tasks.md rename to openspec/changes/archive/2026-09-16-publisher-service/tasks.md index d5ca554..73d972a 100644 --- a/openspec/changes/publisher-service/tasks.md +++ b/openspec/changes/archive/2026-09-16-publisher-service/tasks.md @@ -48,4 +48,4 @@ - [x] 6.3 Обновить STATUS.md / TODO.md — сделано; WALKTHROUGH/PRD — обновлено (см. PRD.md) ## Открытые пункты -- [ ] publisher: медиа из card.media — путь в БД /opt/vesti/media/... не совпадает с монтированием в контейнере (/srv/publisher/media) → send_photo не уходит при Docker-запуске (нужен маппинг путей или передача имени файла) \ No newline at end of file +- [x] publisher: медиа из card.media — путь в БД /opt/vesti/media/... не совпадает с монтированием в контейнере (/srv/publisher/media) → send_photo не уходит при Docker-запуске — ЗАКРЫТО change fix-media-mount (2026-09-14): контейнер монтирует весь каталог media/, publisher резолвит media/ и media/media/ \ No newline at end of file diff --git a/openspec/changes/reject-stay-candidates/.openspec.yaml b/openspec/changes/archive/2026-09-16-reject-stay-candidates/.openspec.yaml similarity index 100% rename from openspec/changes/reject-stay-candidates/.openspec.yaml rename to openspec/changes/archive/2026-09-16-reject-stay-candidates/.openspec.yaml diff --git a/openspec/changes/reject-stay-candidates/design.md b/openspec/changes/archive/2026-09-16-reject-stay-candidates/design.md similarity index 100% rename from openspec/changes/reject-stay-candidates/design.md rename to openspec/changes/archive/2026-09-16-reject-stay-candidates/design.md diff --git a/openspec/changes/reject-stay-candidates/proposal.md b/openspec/changes/archive/2026-09-16-reject-stay-candidates/proposal.md similarity index 100% rename from openspec/changes/reject-stay-candidates/proposal.md rename to openspec/changes/archive/2026-09-16-reject-stay-candidates/proposal.md diff --git a/openspec/changes/reject-stay-candidates/tasks.md b/openspec/changes/archive/2026-09-16-reject-stay-candidates/tasks.md similarity index 100% rename from openspec/changes/reject-stay-candidates/tasks.md rename to openspec/changes/archive/2026-09-16-reject-stay-candidates/tasks.md diff --git a/openspec/changes/rss-formatting-preserve/.openspec.yaml b/openspec/changes/archive/2026-09-16-rss-formatting-preserve/.openspec.yaml similarity index 100% rename from openspec/changes/rss-formatting-preserve/.openspec.yaml rename to openspec/changes/archive/2026-09-16-rss-formatting-preserve/.openspec.yaml diff --git a/openspec/changes/rss-formatting-preserve/design.md b/openspec/changes/archive/2026-09-16-rss-formatting-preserve/design.md similarity index 100% rename from openspec/changes/rss-formatting-preserve/design.md rename to openspec/changes/archive/2026-09-16-rss-formatting-preserve/design.md diff --git a/openspec/changes/rss-formatting-preserve/proposal.md b/openspec/changes/archive/2026-09-16-rss-formatting-preserve/proposal.md similarity index 100% rename from openspec/changes/rss-formatting-preserve/proposal.md rename to openspec/changes/archive/2026-09-16-rss-formatting-preserve/proposal.md diff --git a/openspec/changes/rss-formatting-preserve/tasks.md b/openspec/changes/archive/2026-09-16-rss-formatting-preserve/tasks.md similarity index 100% rename from openspec/changes/rss-formatting-preserve/tasks.md rename to openspec/changes/archive/2026-09-16-rss-formatting-preserve/tasks.md diff --git a/openspec/changes/selected-list/.openspec.yaml b/openspec/changes/archive/2026-09-16-selected-list/.openspec.yaml similarity index 100% rename from openspec/changes/selected-list/.openspec.yaml rename to openspec/changes/archive/2026-09-16-selected-list/.openspec.yaml diff --git a/openspec/changes/selected-list/design.md b/openspec/changes/archive/2026-09-16-selected-list/design.md similarity index 100% rename from openspec/changes/selected-list/design.md rename to openspec/changes/archive/2026-09-16-selected-list/design.md diff --git a/openspec/changes/selected-list/proposal.md b/openspec/changes/archive/2026-09-16-selected-list/proposal.md similarity index 100% rename from openspec/changes/selected-list/proposal.md rename to openspec/changes/archive/2026-09-16-selected-list/proposal.md diff --git a/openspec/changes/selected-list/tasks.md b/openspec/changes/archive/2026-09-16-selected-list/tasks.md similarity index 100% rename from openspec/changes/selected-list/tasks.md rename to openspec/changes/archive/2026-09-16-selected-list/tasks.md diff --git a/openspec/changes/rewrite-as-author/.openspec.yaml b/openspec/changes/rewrite-as-author/.openspec.yaml new file mode 100644 index 0000000..04dfde5 --- /dev/null +++ b/openspec/changes/rewrite-as-author/.openspec.yaml @@ -0,0 +1,3 @@ +schema: spec-driven +created: 2026-09-15 +skip_specs: true \ No newline at end of file diff --git a/openspec/changes/rewrite-as-author/design.md b/openspec/changes/rewrite-as-author/design.md new file mode 100644 index 0000000..3535b0d --- /dev/null +++ b/openspec/changes/rewrite-as-author/design.md @@ -0,0 +1,52 @@ +# Design: rewrite-as-author + +## 1. candidates.html (блок пересказа) + +```diff + {% if selected.rewritten_text %} +
+- +- ++ ++
++ {{ hid }} ++ ++
++
+
+ {% endif %} +``` + +## 2. app.py — новый эндпоинт + +```python +@app.post("/posts/{post_id}/rewrite-save") +async def post_rewrite_save(post_id, request, group_by="", direction="", own="", q=""): + _require_auth(request) + conn = _db() + p = conn.execute("SELECT id FROM posts WHERE id=?", (post_id,)).fetchone() + if not p: return RedirectResponse(..., status_code=302) + form = await request.form() + draft = str(form.get("rewritten_text") or "").strip() + conn.execute("UPDATE posts SET rewritten_text=? WHERE id=?", (draft or None, post_id)) + conn.commit(); conn.close() + return RedirectResponse(..., status_code=302) +``` + +## 3. card.py — тело и подпись + +```python +body = (post.get("rewritten_text") or post.get("text") or "").strip() +rewritten = bool((post.get("rewritten_text") or "").strip()) +... +if rewritten: + tail = "✍️ Дед в АйТи (@dedinit)" + (f" · Источник: {orig}" if orig else "") +elif is_own: ... # как было +elif orig: tail = f"🔗 Оригинал: {orig}" +``` + +## Верификация + +- `py_compile web/app.py publisher/card.py` +- Реальный кандидат с rewritten_text (id=4): GET /candidates?selected=4 → textarea без readonly, есть «Сохранить пересказ». +- `make_card` (read-only БД) с постом id=4 → текст начинается с пересказа, содержит «Дед в АйТи», не содержит «🔗 Оригинал»; media сохраняется (если есть). \ No newline at end of file diff --git a/openspec/changes/rewrite-as-author/proposal.md b/openspec/changes/rewrite-as-author/proposal.md new file mode 100644 index 0000000..a6698e6 --- /dev/null +++ b/openspec/changes/rewrite-as-author/proposal.md @@ -0,0 +1,25 @@ +# Proposal: rewrite-as-author + +## Why + +Функция «✍️ Переписать» в карточке кандидата: + +1. textarea с черновиком была `readonly` — пользователь не мог отредактировать сгенерированный LLM пересказ (генерируем 2000 символов, а в канал уходил исходный текст поста как «богатый репост» с `🔗 Оригинал`). +2. Даже после «Переписать» публикация шла с `post["text"]` (исходник), а не с `rewritten_text` — пересказ игнорировался. + +Ожидание пользователя: пересказ — это его авторский текст, публикуемый ОТ ИМЕНИ канала «Дед в АйТи», а не пересылка чужого сообщения. + +## What Changes + +- `web/templates/candidates.html`: textarea пересказа становится редактируемой, добавляется кнопка «💾 Сохранить пересказ» → `POST /posts/{id}/rewrite-save`. +- `web/app.py`: новый эндпоинт `rewrite-save` (сохраняет отредактированный `rewritten_text`). +- `publisher/card.py make_card`: при наличии непустого `rewritten_text` тело карточки = пересказ; подпись «✍️ Дед в АйТи (@dedinit) · Источник: » вместо `🔗 Оригинал: ` (публикация от имени автора). + +## Why Not + +- Не делаем отдельного поля «автор» и персистенции источника пересказа — пересказ всегда от имени владельца канала; ссылка на оригинал остаётся строкой «Источник: …» для атрибуции. + +## Impact + +- Кандидат: textarea редактируемо, кнопка save. Публикация: пересказ приоритетнее оригинала, подпись от имени канала. +- Обратная совместимость: посты без rewritten_text публикуются как раньше (полный текст + 🔗 Оригинал). \ No newline at end of file diff --git a/openspec/changes/rewrite-as-author/tasks.md b/openspec/changes/rewrite-as-author/tasks.md new file mode 100644 index 0000000..85dd09b --- /dev/null +++ b/openspec/changes/rewrite-as-author/tasks.md @@ -0,0 +1,23 @@ +# Tasks: rewrite-as-author + +## 1. Шаблон (web/templates/candidates.html) + +- [x] 1.1 Убрать `readonly` у textarea пересказа, переделать блок: label «редактируйте и сохраните», форма → `/posts/{id}/rewrite-save`, кнопка «💾 Сохранить пересказ» +- [x] 1.2 Проверка: GET /candidates?selected=4 (пост с пересказом) — textarea редактируема, кнопка на месте + +## 2. Эндпоинт (web/app.py) + +- [x] 2.1 `POST /posts/{post_id}/rewrite-save`: сохраняет отредактированный textarea в `posts.rewritten_text` (async, str-коэршн), редирект обратно с selected +- [x] 2.2 Компиляция + рестарт vesti-web (active) + +## 3. Карточка (publisher/card.py) + +- [x] 3.1 `make_card`: при непустом rewritten_text тело = пересказ, подпись «✍️ Дед в АйТи (@dedinit) · Источник: …» вместо «🔗 Оригинал» +- [x] 3.2 Проверка на реальном посте id=4 (read-only): текст начинается с пересказа, есть «Дед в АйТи», нет «🔗 Оригинал» +- [x] 3.3 Посты без пересказа — регресс (поведение не изменилось): тело = text, подпись 🔗 Оригинал + +## 4. Документация + +- [ ] 4.1 STATUS.md обновлён +- [ ] 4.2 openspec validate rewrite-as-author — чисто +- [ ] 4.3 git commit + push (после подтверждения пользователя) \ No newline at end of file diff --git a/openspec/changes/rss-crawler/.openspec.yaml b/openspec/changes/rss-crawler/.openspec.yaml new file mode 100644 index 0000000..96db9a4 --- /dev/null +++ b/openspec/changes/rss-crawler/.openspec.yaml @@ -0,0 +1,2 @@ +schema: spec-driven +created: 2026-09-15 diff --git a/openspec/changes/rss-crawler/design.md b/openspec/changes/rss-crawler/design.md new file mode 100644 index 0000000..8bcd206 --- /dev/null +++ b/openspec/changes/rss-crawler/design.md @@ -0,0 +1,170 @@ +# Design: rss-crawler + +## Approach + +Синхронный краулер RSS/Atom-лент, следующий общему паттерну telegram_crawler: +реестр источников в `sources.yaml` → выборка включённых → запуск по каждому +(с записью в `runs`) → нормализация → дедуп → вставка в `posts`. + +Отличия от telegram_crawler: +- транспорт — HTTP (httpx), а не MTProto; +- состояние ленты — `rss_state` (etag/modified), а не `tg_state` (last_post_id); +- условные GET (304 = пропуск) вместо инкрементального обхода истории; +- извлечение полного текста (trafilatura) для summary-only записей; +- данные ленты (summary/links/author) кладутся в текст записи, sha256 берётся + с канонического текста — поэтому дубль статьи в двух лентах схлопнется. + +Схема потока: +``` +sources.yaml ──► rss_crawler.py ──► HTTP GET (If-None-Match/If-Modified-Since) + │ 304 → skip (экономно) + ▼ + feedparser.parse + │ + ├─► нормализация URL (UTM/якоря) → canonical_url + ├─► трафилатура (полный текст) если summary-only + ▼ + posts (sha256-дедуп) + rss_state (etag/modified/status) +``` + +## Files + +```bash +# Новый модуль +crawler/rss_crawler.py # краулер: CLI + run_source + fetch_feed + store + +# Изменения +db/schema.sql # + CREATE TABLE rss_state; sources + feed_url +db/db.py # + миграция: ALTER TABLE sources ADD COLUMN feed_url (если нет) +sources/sources.py # + синк feed_url в БД +requirements.txt # + trafilatura +sources/sources.yaml # + lwn (enabled), пример opennet (закомментирован) +config.py # + RSS_USER_AGENT (env, default), RSS_DEFAULT_TIMEOUT +.env.example # + RSS_USER_AGENT +STATUS.md / TODO.md / WALKTHROUGH.md / AGENT.MD # статус и команды +``` + +## Data / Config + +```bash +# .env (опционально, честный User-Agent с контактом) +RSS_USER_AGENT=vesti-rss/0.1 (+https://vesti.nixg.ru) +``` + +`sources.yaml` — формат записи (продолжает существующий): +```yaml +- slug: lwn + name: "LWN.net" + url: https://lwn.net/ + feed_url: https://lwn.net/headlines/rss + channel: null # только для telegram + crawler: rss # rss | telegram + direction: linux # linux | tech | politics | games | electronics | llm + lang: en + priority: P0 # P0 (15 мин) | P1 (1 ч) | P2 (4 ч) + enabled: true +``` + +```sql +CREATE TABLE IF NOT EXISTS sources ( + ... + feed_url TEXT, -- только для crawler: rss — URL ленты + ... +); +``` + +Миграция существующей БД (CREATE IF NOT EXISTS не меняет таблицу): +`db/db.py` init_db — после executescript проверить `PRAGMA table_info(sources)`, +при отсутствии `feed_url` выполнить `ALTER TABLE sources ADD COLUMN feed_url TEXT`. +`sources.py` sync_sources_to_db — добавить `feed_url` в INSERT/ON CONFLICT UPDATE. + +## rss_state (новая таблица) + +```sql +CREATE TABLE IF NOT EXISTS rss_state ( + slug TEXT PRIMARY KEY, -- = sources.slug + etag TEXT, + modified TEXT, + last_build_date TEXT, + last_error TEXT, + error_count INTEGER DEFAULT 0, + status TEXT DEFAULT 'alive', -- alive | dead | new + updated_at TEXT DEFAULT (datetime('now')) +); +``` + +## Алгоритм run_source + +1. `start_run(source_id, f"rss:{slug}", trigger)`. +2. Прочитать `rss_state` по slug; собрать заголовки `If-None-Match`/`If-Modified-Since`, если есть. +3. `httpx.get(feed_url, headers={If-None-Match, If-Modified-Since, User-Agent}, timeout=20)`. +4. `304` → `finish_run(ok, 0, 0)` (экономия трафика); не трогаем status. +5. `200` → `feedparser.parse(resp.content)`: + - если в контенте указано `etag`/`modified` — сохранить в rss_state; + - для каждой записи (первые MAX_ITEMS=25, см. лимит ниже): + - `canonical_url = normalize_url(entry.link)` (UTM/якоря), + - `sha = sha256(canonical text)` (текст см. ниже), + - `text = entry.summary или выжимка полного контента`, + - если запись summary-only — попробовать полный текст через trafilatura + (timeout, не ронять источник при сбое), + - `published_at` из `published_parsed`/`updated_parsed` (dateutil) или None, + - вставка через `store_rss_post` (дедуп: sha256 уникальна; url — доп. ключ). +6. `finish_run(ok, fetched, new)`; `rss_state.status='alive'`, `last_error=NULL`, + `error_count=0`; `last_build_date` из ленты. +7. Ошибка сети/парсинга → `finish_run(error)`, `last_error`, `error_count+1`; + при `error_count>=5` → `status='dead'`. `sources.status` (alive/dead) — как у TG. + +### Текст записи (что идёт в posts.text) +- Если есть `content[0].value` — раскрываем HTML в plain text (selectolax), берём его. +- Иначе `summary` — раскрываем в plain text. +- Если записи нет полного текста, а выжимка короткая (< 220 симв.) ИЛИ в ленте нет + `content` вовсе — пробуем trafilatura по `canonical_url`. +- Итог: `text` = полный текст (если дотянут) или выжимка; `content_type` = 'text'. +- sha256 считаем от финального `text` (после нормализации пробелов) — это делает + дедуп устойчивым к мелким различиям лент. + +## Стандартизация URL (canonical_url) + +```python +def normalize_url(url): + if not url: return url + u = urlparse(url) + q = parse_qsl(u.query, keep_blank_values=True) + q = [(k, v) for k, v in q if k.lower() not in UTM_PARAMS] # utm_*, fbclid, gclid + return urlunparse(u._replace(query=urlencode(q), fragment="")) +``` +UTM_PARAMS = {utm_source, utm_medium, utm_campaign, utm_term, utm_content, fbclid, gclid, ref}. + +## CLI + +```bash +.venv/bin/python -m crawler.rss_crawler --all # все включённые rss-источники +.venv/bin/python -m crawler.rss_crawler --direction linux +.venv/bin/python -m crawler.rss_crawler --source lwn +.venv/bin/python -m crawler.rss_crawler --dry-run # не писать в БД, только печать +``` + +## Команды + +```bash +cd /opt/vesti +.venv/bin/pip install -r requirements.txt # + trafilatura +.venv/bin/python -m crawler.rss_crawler --all --dry-run +.venv/bin/python -m crawler.rss_crawler --source lwn +# БД: проверить +.venv/bin/python - <<'EOF' +import sqlite3 +c = sqlite3.connect('db/vesti.db') +print(c.execute("SELECT slug,status,error_count FROM rss_state").fetchall()) +print(c.execute("SELECT COUNT(*) FROM posts p JOIN sources s ON s.id=p.source_id WHERE s.crawler='rss'").fetchone()) +EOF +``` + +## Verification + +- [ ] `openspec validate rss-crawler` → 0 ошибок +- [ ] `--dry-run` против lwn: печатает ≥5 записей, ничего не пишет в БД +- [ ] Реальный прогон `--source lwn`: posts.rss_count>0, в rss_state появились etag/last_build_date, status=alive +- [ ] Повторный прогон: 304 (вторичный запуск) или 0 новых (если лента без etag); runs — ok +- [ ] Повторная вставка той же записи (та же лента дважды) → новых 0, дублей 0 +- [ ] Веб-кандидаты показывают RSS-пост (с направлением после классификации) \ No newline at end of file diff --git a/openspec/changes/rss-crawler/proposal.md b/openspec/changes/rss-crawler/proposal.md new file mode 100644 index 0000000..24f571d --- /dev/null +++ b/openspec/changes/rss-crawler/proposal.md @@ -0,0 +1,85 @@ +# Proposal: rss-crawler + +## Why + +В VESTI сейчас есть только telegram_crawler (Telethon, MTProto). RSS-источники — +самый массовый и дешёвый класс источников (по оценке PRD — ~80% объёма новостей): +веб-СМИ, блоги, IT-порталы публикуют RSS/Atom-ленты. Без RSS-краулера они недоступны. + +Проблемы, которые решает модуль: +- **нет спроса на RSS вовсе**: в источники (sources.yaml) нельзя добавить ни одного + RSS-канала — краулер просто не умеет их обрабатывать (`get_enabled_sources(crawler="rss")` вернёт пусто); +- **негражданный режим опроса**: без If-None-Match/If-Modified-Since каждый запуск + тянет полные ленты — это лишний трафик для издателей и для нас; +- **мусорные ссылки**: URL из лент часто содержат UTM-метки, якоря, трекеры — + дедупликация по сырому URL даёт дубли одной статьи; +- **обрывки вместо текста**: многие ленты отдают summary вместо полного текста — + без дотягивания полного текста карточки кандидатов бедные; +- **нет состояния/здоровья**: нет etag/modified/last_error/status для RSS-источников. + +Для пользователя это значит: можно добавить в VESTI новостные сайты/блоги и получать +их посты в конвейере (классификация → кандидаты → публикация) наравне с Telegram-каналами. + +## What Changes + +- Новый модуль краулера `crawler/rss_crawler.py` (feedparser + httpx): + - читает `sources.yaml` (crawler: rss), группирует по priority; + - HTTP GET c `If-None-Match`/`If-Modified-Since` из таблицы `rss_state` → 304 = пропуск; + - нормализует URL (UTM-параметры, якоря) → `canonical_url`, дедуп по sha256; + - извлекает полный текст через trafilatura, если запись — обрывок; + - пишет посты в `posts` через существующий `store_posts`-механизм (дедуп по sha256 текста); + - пишет метаданные ленты в `rss_state` (etag/modified/last_error/status, last_build_date); + - ведёт `runs` (source_id, task, trigger) — как telegram_crawler; + - CLI: `python -m crawler.rss_crawler --all | --direction | --source `. +- Новая таблица `rss_state` (etag, modified, last_error, status, last_build_date) — + аналог `tg_state` для RSS-лент. +- `requirements.txt`: + trafilatura (извлечение полного текста). +- `sources.yaml`: первый реальный RSS-источник для проверки — LWN (lwn.net) — + и один закомментированный пример (opennet.ru). +- `.env.example`: + `RSS_USER_AGENT` (информативный User-Agent). +- Cron (Hermes): `vesti-rss-crawler` (каждые 15 мин, P0) — тихий, no_agent, как telegram. + +### Не меняется + +- Дедуп и схема `posts` — как у telegram_crawler (sha256 текста; url — дополнительный ключ). +- Классификатор, веб, publisher — не затрагиваются (RSS-посты идут в тот же `posts`). +- `tg_state` — остаётся для Telegram; RSS использует `rss_state`. +- Никаких автопубликаций: посты попадают в кандидаты, подтверждение — человеком. + +## Capabilities + +### New Capabilities +- `rss-crawler`: Синхронный краулер RSS/Atom-лент: реестр из sources.yaml, + условные GET (etag/modified), нормализация URL, извлечение полного текста + (trafilatura), состояние и здоровье ленты в БД, интеграция с runs/posts. + +### Modified Capabilities +- `news-store` (таблица `posts`): без изменений схемы; RSS-посты используют её как есть. +- `sources.yaml`: новые источники с `crawler: rss`. +- `vesti-web`: без изменений (RSS-посты автоматически видны как кандидаты). + +## Impact + +- Файлы: + - новый: `crawler/rss_crawler.py` (модуль-краулер); + - изменён: `db/schema.sql` (+CREATE TABLE rss_state), `requirements.txt` + (+ trafilatura), `sources/sources.yaml` (+lwn, пример opennet), `.env.example` + (+RSS_USER_AGENT), `config.py` (+RSS_USER_AGENT, +RSS_*); + - доки: STATUS.md, TODO.md, WALKTHROUGH.md, AGENT.MD (команда запуска). +- Данные: миграция — `CREATE TABLE IF NOT EXISTS`; существующие данные не трогаются. +- Секреты: не требует (RSS публичные). User-Agent — из .env. +- Сеть: исходящие HTTP-запросы к сайтам издателей (только к лентам, учтиво: + условные GET, рейт-лимит 1–2 с между источниками). +- Rollback: модуль не вызывается cron — просто не добавлять источники/не запускать. + +## Risks + +- Издатели режут по User-Agent / отдают капчу — лечится `RSS_USER_AGENT` + (честный, с контактом) и `enabled: false` для проблемных. +- Ленты с кривой датой (`published_parsed` None) — ставим `fetched_at`, не падаем. +- summary-only ленты — trafilatura может не найти полный текст; тогда сохраняем summary, + пост всё равно попадает в конвейер (пометка в content_type/text). +- Огромные ленты (мега-фиды): ограничение — первые N записей, остальные догонятся + следующими запусками (инкрементально), как в telegram_crawler. +- Троттлинг издателя: проставляем last_error и status='dead' после N=5 ошибок подряд + (правило фид-здоровья), watchdog молчит до перехода alive↔dead. \ No newline at end of file diff --git a/openspec/changes/rss-crawler/specs/rss-crawler/spec.md b/openspec/changes/rss-crawler/specs/rss-crawler/spec.md new file mode 100644 index 0000000..b265dab --- /dev/null +++ b/openspec/changes/rss-crawler/specs/rss-crawler/spec.md @@ -0,0 +1,90 @@ +# Spec: rss-crawler + +## Purpose + +Краулер RSS/Atom-лент с реестра источников (sources.yaml, crawler: rss): условные +GET (etag/modified), нормализация URL, извлечение полного текста (trafilatura), +состояние и здоровье ленты в БД (rss_state), интеграция с posts/runs. RSS-посты +попадают в общий конвейер (классификация → кандидаты → публикация) наравне с +Telegram-постами. + +## ADDED Requirements + +### Requirement: Чтение RSS-источников из реестра +Система MUST поддерживать источники с `crawler: rss` в sources.yaml и запускать +их краулинг через `python -m crawler.rss_crawler` (фильтры: --all / --direction / +--source ). Каждый запуск источника MUST записываться в таблицу `runs` +(start_run/finish_run) как задача `rss:`. + +#### Scenario: Список RSS-источников +- **GIVEN** sources.yaml содержит источник `lwn` с `crawler: rss`, `enabled: true` +- **WHEN** выполняется `python -m crawler.rss_crawler --all` +- **THEN** краулер обрабатывает источник lwn, в `runs` появляется запись задачи `rss:lwn` + +### Requirement: Условные GET (etag/modified) +Краулер MUST отправлять `If-None-Match` (etag) и `If-Modified-Since` (modified) из +`rss_state` при запросе ленты. При ответе 304 краулер MUST НЕ парсить ленту и НЕ +добавлять посты (запуск завершается ok с 0 fetched/0 new). Полученные etag/modified +из ответа MUST сохраняться в `rss_state`. + +#### Scenario: Неизменённая лента +- **GIVEN** rss_state для lwn содержит etag "xyz" и лента не менялась +- **WHEN** запускается краулер +- **THEN** сервер отвечает 304, новых постов нет, запуск в runs имеет status ok + +### Requirement: Нормализация URL и дедупликация +Краулер MUST нормализовать URL записи перед использованием в качестве ключа: +удалять UTM-параметры (utm_*), fbclid/gclid/ref и якоря (#). Дедупликация MUST +использовать sha256 от текста записи (как в telegram_crawler); повторная вставка +той же статьи MUST не создавать второй строки в `posts`. + +#### Scenario: Дубликат: та же статья, разные URL +- **GIVEN** статья с URL вида `https://site/a?utm_source=rss&utm_medium=feed#top` +- **WHEN** краулер обрабатывает запись +- **THEN** canonical_url = `https://site/a` (без утм и якоря), sha256 уникален, + вторая идентичная запись из другой ленты не создаёт дубль + +### Requirement: Полный текст (trafilatura) +Краулер MUST извлекать полный текст страницы через trafilatura для записей, +у которых нет полного текста в ленте (только summary). Сбой извлечения MUST НЕ +ронять источник: в `posts.text` сохраняется доступный текст (summary), запуск +продолжается. + +#### Scenario: Summary-only лента +- **GIVEN** лента отдаёт краткое описание (summary) без полного контента +- **WHEN** краулер обрабатывает запись +- **THEN** текст записи дотягивается trafilatura; при неудаче сохраняется summary, + запуск завершается ok, ошибка логгируется + +### Requirement: Здоровье и состояние ленты (rss_state) +Система MUST хранить состояние ленты в таблице `rss_state` (slug PK, etag, modified, +last_build_date, last_error, error_count, status). После успешного прогона +`status='alive'`, error_count=0, last_error=NULL. При ошибке error_count +инкрементируется; при error_count>=5 подряд `status='dead'` (фид-здоровье), +sources.status синхронизируется. + +#### Scenario: Лента постоянно падает +- **GIVEN** лента возвращает 500/таймаут 5 раз подряд +- **WHEN** краулер завершает 5-й неудачный запуск +- **THEN** rss_state.status='dead', sources.status='dead', last_error непуст; + watchdog (cron) уведомит при переходе alive→dead + +### Requirement: Запуск по приоритетам и CLI +Краулер MUST запускаться из cron с каденцией по приоритету источника (P0 раз в +15 мин, P1 раз в час, P2 раз в 4 часа). Ручной запуск MUST поддерживать +`--dry-run` (печать записей без записи в БД). + +#### Scenario: Dry-run +- **GIVEN** выполняется `python -m crawler.rss_crawler --source lwn --dry-run` +- **THEN** краулер печатает записи ленты, но НЕ пишет в posts/rss_state/runs; + БД остаётся неизменной + +## NOT Requirements + +- Не реализуем инкрементальный обход истории (как telegram_crawler по last_post_id): + RSS-ленты — это «последние N записей», состояние — через etag/modified. +- Не пишем полный контент в отдельную таблицу/колонку (text в posts уже достаточен; + bundles создаёт веб при approve). +- Не скачиваем медиа из RSS (картинки-иконки ленты) — контент текстовый. +- Не делаем автопостинг: RSS-посты проходят тот же путь подтверждения человеком. +- Не затрагиваем tg_state/telegram_crawler (отдельный механизм для Telegram). \ No newline at end of file diff --git a/openspec/changes/rss-crawler/tasks.md b/openspec/changes/rss-crawler/tasks.md new file mode 100644 index 0000000..e28b7e5 --- /dev/null +++ b/openspec/changes/rss-crawler/tasks.md @@ -0,0 +1,48 @@ +# Tasks: rss-crawler + +## 1. Схема и конфиг + +- [x] 1.1 db/schema.sql: + CREATE TABLE rss_state; sources + feed_url; db/db.py — миграция ALTER TABLE (feed_url); sources.py — синк feed_url + Проверка: `python -m db.db` (init_db) выполняется без ошибок; таблица rss_state и колонка sources.feed_url появляются +- [x] 1.2 config.py: + RSS_USER_AGENT (env, default "vesti-rss/0.1"), RSS_DEFAULT_TIMEOUT=20 + Проверка: `python -c "from config import RSS_USER_AGENT; print(RSS_USER_AGENT)"` → значение из .env +- [x] 1.3 requirements.txt: + trafilatura, selectolax + Проверка: `.venv/bin/pip install -r requirements.txt` без ошибок + +## 2. Модуль краулера + +- [x] 2.1 crawler/rss_crawler.py: normalize_url (UTM/якоря), sha256_text + Проверка: изолированный тест normalize_url: utm-параметры удаляются, якорь удаляется, остальное сохраняется +- [x] 2.2 fetch_feed: httpx.GET с If-None-Match/If-Modified-Since/User-Agent, таймаут, ретрай 429/503; 304 → пусто + Проверка: повторный запрос к ленте с etag → 304 (сервер LWN поддерживает) +- [x] 2.3 parse_entries: feedparser, извлечение etag/modified из ответа, лимит записей (MAX_ITEMS=25) + Проверка: запись → dict {url, title, text, published_at, author} +- [x] 2.4 full_text: trafilatura для summary-only (selectolax для HTML→text) + Проверка: запись с summary → в text полный текст или summary (не падает) +- [x] 2.5 store_rss_post: дедуп по sha256; вставка в posts (source_id, url, canonical_url, content_type='text', published_at) + Проверка: две идентичные записи → одна строка в posts +- [x] 2.6 run_source: start_run/finish_run, обновление rss_state (etag/modified/status/error_count), status alive/dead при error_count>=5 + Проверка: успешный прогон → rss_state.status='alive', runs=ok; имитация ошибки → error_count растёт +- [x] 2.7 CLI: --all / --direction / --source / --dry-run + Проверка: `--source lwn --dry-run` печатает записи, БД не меняется + +## 3. Источники и интеграция + +- [x] 3.1 sources.yaml: + lwn (crawler: rss, direction: linux, priority: P0, enabled: true); пример opennet закомментирован + Проверка: `sync_sources_to_db()` — в БД появляется источник lwn c crawler='rss' +- [x] 3.2 .env.example: + RSS_USER_AGENT, RSS_DEFAULT_TIMEOUT +- [x] 3.3 Реальный прогон `--source lwn` → посты в posts, rss_state настроена + Проверка: SQL-запросы из design.md (Verification) + +## 4. Cron и доки + +- [ ] 4.1 Hermes cron: vesti-rss-crawler (`*/15`, P0; no_agent, тихий, deliver=local) + Проверка: cron list показывает job; ручной запуск проходит +- [x] 4.2 STATUS.md / AGENT.MD обновлены (команды запуска, порты/доступы) + Проверка: grep rss в доках +- [x] 4.3 `openspec validate rss-crawler` → 0 ошибок + Проверка: команда выше → valid +- [ ] 4.4 TODO.md: + задача веб-скедулинга RSS (веб-админ: кнопка запуска краулера) + Проверка: задача видна в планах + +Открытый вопрос: состояние таблицы rss_state ERROR (после 429 и ретрая, error_count=1) обновится при следующем удачном 200; в отсутствие ошибок не трогаем. \ No newline at end of file diff --git a/openspec/specs/classifier/spec.md b/openspec/specs/classifier/spec.md new file mode 100644 index 0000000..e542635 --- /dev/null +++ b/openspec/specs/classifier/spec.md @@ -0,0 +1,31 @@ +# classifier Specification + +## Purpose +Классификация постов по направлениям локальным LLM + словарный фильтр. Дополняется +приоритетом «своего контента» и поддержкой мультинаправлений для fan-out. + +## Requirements + +### Requirement: Приоритет своего контента +Классификатор MUST обрабатывать посты с `is_own=1` как «сильные кандидаты»: при наличии +направления по словарю — relevance=critical, classified=True — даже если LLM недоступна +(фолбэк без LLM, пост не теряется). Для внешних постов поведение без изменений. + +#### Scenario: Свой пост, LLM недоступна +- **WHEN** пост is_own=1, словарь дал направление, но Ollama недоступна +- **THEN** пост получает direction (словарь), relevance=critical, classified=True, + method='dict-own' (не требует LLM) + +#### Scenario: Свой пост без направления по словарю +- **WHEN** пост is_own=1, словарь не дал направление +- **THEN** классификации нет (classified=False) до LLM; пост не теряется (остаётся в очереди) + +### Requirement: Мультинаправления +Классификатор MUST уметь возвращать несколько направлений для поста (для маппинга fan-out); +основное направление хранится в posts.direction, дополнительные — в classifications +(таблица уже позволяет несколько классификаций на пост). + +#### Scenario: Пост про Linux + AI +- **WHEN** пост упоминает и линукс, и нейросети +- **THEN** в classifications может быть несколько записей (linux, ai); fan-out использует + оба при подтверждении diff --git a/openspec/specs/crawler-queue/spec.md b/openspec/specs/crawler-queue/spec.md new file mode 100644 index 0000000..2806f6e --- /dev/null +++ b/openspec/specs/crawler-queue/spec.md @@ -0,0 +1,66 @@ +# crawler-queue Specification + +## Purpose +Двухфазный пайплайн сбора и обработки новостей: краулеры (фаза 1) собирают +кандидатов в очередь (`posts` со `status='new'` и неклассифицированные), воркеры +(фаза 3) разбирают её параллельно (ThreadPoolExecutor). Отдельная страница +`/crawlers` показывает статус источников и размер очереди. + +## Requirements + +### Requirement: Параллельная фаза 3 (воркеры) +Система MUST предоставлять `crawler/worker.py` с пулом `ThreadPoolExecutor(max_workers=N)`, +где каждый воркер атомарно забирает пост из очереди (`UPDATE posts SET classified=-1 +WHERE id=? AND classified=0`), классифицирует его (`classify_text`: keywords → Ollama, +trafilatura для summary-only) и пишет результат (direction/relevance/interest/summary, +classified=1). При исключении посте MUST возвращаться в очередь (classified=0). + +#### Scenario: Параллельная обработка очереди +- **GIVEN** 100 постов со `classified=0` +- **WHEN** `python -m crawler.worker --workers 8 --limit 200` +- **THEN** все 100 постов обработаны (classified=1), дублей нет (каждый обработан ровно 1 раз) + +#### Scenario: Сбой воркера +- **GIVEN** пост, у которого `classify_text` бросает исключение (Ollama недоступна) +- **WHEN** воркер обрабатывает пост +- **THEN** пост возвращается в очередь (classified=0), воркер продолжает работу, запуск не падает + +### Requirement: Очередь на основе posts (без новой таблицы) +«Размер очереди» MUST вычисляться из существующей таблицы `posts`: +`SELECT COUNT(*) FROM posts WHERE classified IS NULL OR classified=0` (pending), +`classified=-1` (processing), `classified=1 AND fetched_at >= date('now')` (done today). +Новая таблица для очереди НЕ создаётся — она дублировала бы `posts`. + +#### Scenario: Размер очереди +- **GIVEN** в posts 10 новых (classified=0) и 2 в обработке (classified=-1) +- **WHEN** страница /crawlers запрашивает размер очереди +- **THEN** pending=10, processing=2, done today=0 + +### Requirement: Страница /crawlers +Веб MUST предоставлять `GET /crawlers` (за аутентификацией): таблица источников +(slug, name, crawler, status, last_fetch, last_error за 200 симв., error_count, +priority; для rss — etag/modified/last_build_date из rss_state) и карточки очереди +(pending/processing/done). Кнопка «Сбросить dead» (POST /crawlers/reset) MUST +устанавливать sources.status='alive', error_count=0, last_error=NULL для всех +источников со status='dead'. + +#### Scenario: Просмотр статуса +- **GIVEN** источник lwn (rss, alive) и 15 новых постов в очереди +- **WHEN** GET /crawlers +- **THEN** страница показывает lwn с статусом alive, размер очереди 15 + +#### Scenario: Сброс dead-источников +- **GIVEN** источник со status='dead', error_count=7 +- **WHEN** POST /crawlers/reset +- **THEN** источник становится alive, error_count=0, last_error=NULL + +### Requirement: Фаза 1 (сбор) отдельно от фазы 3 (обработка) +Система MUST предоставлять `crawler/crawl_sources.py` — запуск сбора всех +включённых источников (telegram_crawler + rss_crawler) без классификации; +новые посты попадают в очередь (posts, classified=0). Обработка (фаза 3) +запускается отдельно (`crawler/worker.py`). + +#### Scenario: Сбор без классификации +- **GIVEN** включённые источники telegram и rss +- **WHEN** `python -m crawler.crawl_sources --all` +- **THEN** новые посты добавлены в posts с classified=0, классификация НЕ запущена diff --git a/openspec/specs/news-store/spec.md b/openspec/specs/news-store/spec.md new file mode 100644 index 0000000..070ba8e --- /dev/null +++ b/openspec/specs/news-store/spec.md @@ -0,0 +1,22 @@ +# news-store Specification + +## Purpose +Банк статей: markdown-бандлы с frontmatter + медиа. Дополняется пометкой происхождения +своего контента и ссылкой на оригинал. + +## Requirements + +### Requirement: Атрибуция в бандле +Бандл поста с is_own=1 MUST содержать в frontmatter `origin: own`, ссылку на оригинал +(`source_url` = t.me/dedinit/) и имя автора («Дед в АйТи»). Бандл внешнего поста — +как раньше (origin: external, source_url=url источника). + +#### Scenario: Бандл своего поста +- **WHEN** create_bundle вызывается для поста is_own=1 +- **THEN** frontmatter содержит origin: own, source: dedinit, source_url: + https://t.me/dedinit/, author: Дед в АйТи + +#### Scenario: Бандл внешнего поста в нескольких направлениях +- **WHEN** пост (свой или внешний) опубликован в несколько направлений +- **THEN** бандл создаётся по каждому направлению (bundles///.md), + обе записи ссылаются на один и тот же original post_id diff --git a/openspec/specs/sources-admin/spec.md b/openspec/specs/sources-admin/spec.md new file mode 100644 index 0000000..b446678 --- /dev/null +++ b/openspec/specs/sources-admin/spec.md @@ -0,0 +1,92 @@ +# sources-admin Specification + +## Purpose +Страница управления источниками в веб-интерфейсе VESTI: добавление, изменение, +удаление, пауза и смена приоритета источников без ручной правки YAML. +`sources.yaml` остаётся источником истины, веб пишет в него и синкает БД. + +## Requirements + +### Requirement: Просмотр списка источников +Страница /sources показывает таблицу всех источников с их полями и статусом. + +#### Scenario: просмотр списка источников +- **Given** веб запущен, пользователь авторизован +- **When** он открывает `/sources` +- **Then** страница показывает таблицу всех источников: slug, name, crawler, + direction, lang, priority, enabled (пауза), url/feed_url, статус (last_fetch/error) + +### Requirement: Добавление нового источника +Форма «Добавить» создаёт запись в sources.yaml и БД. + +#### Scenario: добавление нового источника +- **Given** страница /sources +- **When** форма «Добавить» заполнена (slug, name, crawler=rss, feed_url, direction=tech, priority=P1) +- **And** отправлена +- **Then** запись появляется в `sources/sources.yaml` и таблице `sources` БД +- **And** страница показывает новый источник в таблице + +#### Scenario: валидация добавления +- **Given** форма добавления с невалидным slug (`my source!`) +- **When** отправлена +- **Then** добавление отклонено, показана ошибка (flash), ничего не записано +- **And** slug принимается только `[a-z0-9_-]+` + +### Requirement: Изменение полей источника +Форма «Изменить» обновляет name/priority/направление и пр. в yaml и БД. + +#### Scenario: переименование / изменение полей +- **Given** существующий источник `lwn` +- **When** форма «Изменить» меняет `name` на «LWN Tech» и `priority` на P2 +- **Then** изменения применяются в yaml и БД, таблица обновляется + +### Requirement: Пауза и снятие с паузы +Переключатель enabled=false останавливает сбор источника, enabled=true возобновляет. + +#### Scenario: пауза и снятие с паузы +- **Given** источник `lwn` (enabled=true) +- **When** пользователь нажимает «Пауза» +- **Then** `enabled=false` в yaml и БД +- **And** краулеры больше не собирают этот источник (`get_enabled_sources` исключает) +- **When** пользователь нажимает «Снять с паузы» +- **Then** `enabled=true`, сбор возобновляется + +### Requirement: Удаление источника +Удаление (с подтверждением) убирает источник из yaml и БД, сохраняя его посты. + +#### Scenario: удаление источника +- **Given** источник `opennet` с постами в БД +- **When** пользователь подтверждает удаление (confirm) +- **Then** запись удалена из yaml и sources БД +- **And** его посты НЕ удалены: `source_id=NULL` (история сохраняется) +- **And** rss_state для него удалён + +#### Scenario: удаление без подтверждения +- **Given** форма удаления +- **When** confirm отклонён (Cancel) +- **Then** ничего не удалено, данные не меняются + +### Requirement: Доступ без авторизации +Страница /sources защищена паролем, как остальные админ-страницы. + +#### Scenario: доступ без авторизации +- **Given** пользователь не авторизован +- **When** он открывает `/sources` +- **Then** происходит редирект на /login (303) + +### Requirement: Приоритет источника +Приоритет P0–P3 влияет на порядок обработки источников. + +#### Scenario: приоритет +- **Given** источники с priority P0–P3 +- **When** страница /sources загружена +- **Then** приоритеты отображаются и доступны для изменения (P0–P3) +- **And** /crawlers сортирует по (enabled, priority, slug) с учётом нового приоритета + +### Requirement: Навигация +Ссылка «Источники» присутствует в навбаре. + +#### Scenario: навигация +- **Given** любая страница веб (base.html) +- **When** открыт навбар +- **Then** есть ссылка «Источники» на `/sources` diff --git a/openspec/specs/tg-crawler/spec.md b/openspec/specs/tg-crawler/spec.md new file mode 100644 index 0000000..92b17c0 --- /dev/null +++ b/openspec/specs/tg-crawler/spec.md @@ -0,0 +1,33 @@ +# tg-crawler Specification + +## Purpose +Чтение публичных Telegram-каналов через Telethon (MTProto) для сбора новостей с метриками +популярности. Дополняется поддержкой «своих» источников (own: true) — контент пользователя. + +## Requirements + +### Requirement: Источники своего контента +Краулер MUST распознавать источники с `own: true` в sources.yaml и для их постов +проставлять `is_own=1`, `is_own_canonical=1`. Для таких источников медиа MUST +скачиваться (контент принадлежит пользователю). + +#### Scenario: Краулинг своего канала +- **WHEN** источник slug=dedinit имеет own: true +- **THEN** посты сохраняются с is_own=1 и is_own_canonical=1; медиа скачивается; + инкрементальный обход и дедуп работают как обычно + +#### Scenario: Чужой форвард в своём канале +- **WHEN** пост в своём канале — форвард из чужого канала +- **THEN** пост сохраняется с is_own=1 (это пост пользователя, он его переслал) и с + fwd_from_channel_id/fwd_from_post_id; медиа не скачивается (содержимое чужое), + атрибуция оригинала сохраняется в fwd-полях + +### Requirement: Канонический экземпляр своего контента +Если пост пользователя (is_own=1) позже встречается во внешнем канале (тот же sha256/url), +внешний экземпляр MUST сохраняться как «упоминание» с is_own=0; каноническим остаётся +первичный (is_own_canonical=1). + +#### Scenario: Свой пост запостили в чужой канал +- **WHEN** краулер находит в чужом канале пост с текстом, совпадающим с is_own-постом +- **THEN** создаётся запись is_own=0 (упоминание) без дублирования контента; веб видит + оба экземпляра, но кандидатом на публикацию считается канонический (is_own_canonical) diff --git a/openspec/specs/tg-publisher-service/spec.md b/openspec/specs/tg-publisher-service/spec.md new file mode 100644 index 0000000..006b6ba --- /dev/null +++ b/openspec/specs/tg-publisher-service/spec.md @@ -0,0 +1,65 @@ +# tg-publisher-service Specification + +## Purpose +Изолированный FastAPI-сервис публикации карточек в Telegram-каналы через Bot API. +Единая точка вызова для всех компонентов (веб, cron, будущие боты). Один бот-контроллер +публикует во все каналы, в которые добавлен администратором. Telegram доступен только +через SOCKS5-прокси (127.0.0.1:1080). + +## Requirements + +### Requirement: Изолированный сервис публикации +Сервис MUST быть отдельным FastAPI-приложением (контейнер), НЕ импортируемым модулем веба. +Он MUST предоставлять `GET /healthz` (статус, бот, прокси, каналы) и +`POST /api/v1/publish` (публикация карточки). + +#### Scenario: Healthcheck +- **GIVEN** сервис запущен +- **THEN** `GET /healthz` возвращает 200 с `{"status":"ok","bot":...,"channels":[...]}` + и healthcheck в docker-compose (`curl -f`) проходит + +#### Scenario: Публикация карточки +- **GIVEN** POST /api/v1/publish c `card: {text, direction, lang}` и `channels: ["@dedinit_vesti"]` +- **THEN** сервис отправляет сообщение через Bot API в каждый канал и возвращает + `{"ok":true,"results":{"@dedinit_vesti":{"message_id":,"views":0}}}` + +### Requirement: Один бот, несколько каналов (конфиг) +Сервис MUST поддерживать список каналов из конфигурации (`VESTI_BOT_CHANNELS`, через запятую). +`channels` в запросе MAY переопределять список. Если бот добавлен в канал администратором — +публикация MUST работать; иначе сервис MUST вернуть понятную ошибку (403). + +#### Scenario: Несколько каналов +- **GIVEN** `VESTI_BOT_CHANNELS=@dedinit_vesti,@other_channel` (оба добавлены боту) +- **WHEN** POST /api/v1/publish без поля channels +- **THEN** карточка публикуется в оба канала; results содержит оба message_id + +#### Scenario: Бот не админ канала +- **GIVEN** канал, в котором бот не администратор +- **WHEN** публикация в него +- **THEN** сервис возвращает 403 с текстом ошибки Bot API (sendMessage → Forbidden) + +### Requirement: Прокси SOCKS5 для Bot API +Все запросы к api.telegram.org MUST идти через прокси из `TG_PROXY=socks5://127.0.0.1:1080`. +Если прокси недоступен — сервис MUST вернуть 502 (не падать). + +#### Scenario: Прокси недоступен +- **GIVEN** прокси 127.0.0.1:1080 выключен +- **WHEN** POST /api/v1/publish +- **THEN** сервис возвращает 502 с ошибкой подключения (не 500, не краш) + +### Requirement: Сбор views +Сервис MUST предоставлять способ получения просмотров для опубликованных сообщений +(через Bot API getMessage), чтобы веб показывал метрики. + +#### Scenario: Views после публикации +- **GIVEN** сообщение опубликовано (message_id получен) +- **WHEN** запрос views для этого message_id +- **THEN** возвращается число просмотров (0 если ещё нет) + +### Requirement: Режим подтверждения +Сервис MUST НЕ публиковать автоматически: вызывается только по HTTP-запросу (из веба +после клика «Опубликовать»). Автопостинга по таймеру внутри сервиса НЕТ. + +#### Scenario: Нет автопостинга +- **GIVEN** сервис запущен без входящих запросов +- **THEN** ничего не публикуется (процесс только слушает HTTP) diff --git a/openspec/specs/tg-publisher/spec.md b/openspec/specs/tg-publisher/spec.md new file mode 100644 index 0000000..f748b69 --- /dev/null +++ b/openspec/specs/tg-publisher/spec.md @@ -0,0 +1,44 @@ +# tg-publisher Specification + +## Purpose +Публикация отобранных новостей через Bot API в тематические Telegram-каналы. Дополняется +автораспространением своего контента (fan-out) по нескольким направлениям. + +## Requirements + +### Requirement: Автораспространение (fan-out) +Подтверждение СВОЕГО поста (is_own=1) MUST публиковать карточку во ВСЕ тематические +каналы @dedinit_vesti___bot, соответствующие выбранным направлениям +(по умолчанию — все направления классификации поста). Каждая карточка MUST содержать +атрибуцию «Дед в АйТи» (@dedinit) и ссылку на оригинал t.me/dedinit/. + +#### Scenario: Мульти-публикация +- **WHEN** подтверждается свой пост с направлениями [linux, ai] +- **THEN** карточка отправляется в @dedinit_vesti_linux_ru_bot и @dedinit_vesti_ai_ru_bot; + обе содержат ссылку на оригинал + +#### Scenario: Чужой пост — без fan-out +- **WHEN** подтверждается внешний пост (is_own=0) +- **THEN** публикуется только в канал своего направления, без атрибуции автора + +### Requirement: Публикация по списку направлений +Публикатор MUST поддерживать `publish_multi(directions)` — публикацию карточки по списку +направлений, возвращающую map {direction: message_id}. При пустом списке направлений +MUST публиковать в направление по умолчанию (direction поста). + +#### Scenario: Список направлений рассылки +- **WHEN** publish_multi вызывается с directions=[linux, ai] +- **THEN** возвращается {linux: message_id1, ai: message_id2}; каждая публикация + записывается в published с distributed_dirs + +#### Scenario: Пустой список направлений +- **WHEN** publish_multi вызывается без directions +- **THEN** публикация идёт в канал направления поста (direction по умолчанию), без fan-out + +### Requirement: Метрики по каждому направлению +Для опубликованных карточек MUST собираться views через Bot API по каждому направлению +(каждому message_id), чтобы веб показывал эффективность рассылки по лентам. + +#### Scenario: Метрики fan-out +- **WHEN** пост разослан в [linux, ai] и каналы набирают просмотры +- **THEN** get_views вызывается для каждого message_id; views хранятся по направлению diff --git a/openspec/specs/vesti-web/spec.md b/openspec/specs/vesti-web/spec.md new file mode 100644 index 0000000..03d6ba3 --- /dev/null +++ b/openspec/specs/vesti-web/spec.md @@ -0,0 +1,29 @@ +# vesti-web Specification + +## Purpose +Веб-интерфейс управления VESTI. Дополняется фильтром «Свои», бейджем и выбором +направлений рассылки для своего контента. + +## Requirements + +### Requirement: Фильтр «Свои» +Веб MUST давать фильтр постов по `is_own` (все/только свои/только внешние) и показывать +бейдж «СВОЙ» у постов is_own=1. Для своего поста при подтверждении MUST отображаться +выбор направлений рассылки (по умолчанию — все направления классификации поста). + +#### Scenario: Фильтр своих постов +- **WHEN** админ выбирает фильтр «Свои» +- **THEN** показываются только посты is_own=1 с бейджем «СВОЙ» и чекбоксами направлений + +#### Scenario: Подтверждение своего поста +- **WHEN** админ подтверждает свой пост с выбранными направлениями [linux, ai] +- **THEN** публикация идёт в оба канала (fan-out), результат виден в опубликованных с + distributed_dirs + +### Requirement: Список распространения +Веб MUST показывать для опубликованного поста, в какие направления/каналы он был +разослан (distributed_dirs) и метрики (views) по каждому каналу. + +#### Scenario: Просмотр распространения +- **WHEN** админ открывает опубликованный пост (свой) +- **THEN** видит список @dedinit_vesti___bot с views по каждому