openspec: архив 14 завершённых change-ов (веб-фиксы, crawler-queue, own-content-hub, publisher-service); спеки влиты в openspec/specs

This commit is contained in:
kpa39l
2026-09-16 17:01:00 +00:00
parent 584582a48c
commit 771f6a8276
88 changed files with 1632 additions and 3 deletions
@@ -0,0 +1,202 @@
# address-field-enhancement
## Дизайн
### 1. Backend: парсер адреса и получение названия
**Новая функция в `sources/sources.py`** (или отдельный модуль — решаю по месту;
парсинг адреса — чистая функция без зависимостей):
```python
_TG_HANDLE_RE = re.compile(r"^(?:https?://)?(?:www\.)?(?:t\.me|telegram\.me)/(?:s/)?([a-zA-Z][a-zA-Z0-9_]{3,31})$")
_RSS_URL_RE = re.compile(r"^https?://", re.I)
def parse_address(address: str, crawler: str) -> dict:
"""Разбирает одно поле Address в словарь полей источника.
telegram: address = @handle | t.me/handle | https://t.me/s/handle | handle
→ {slug: handle, channel: handle, url: https://t.me/handle}
rss: address = https://...feed.xml | http://...
→ {feed_url: address, url: address} (slug — из домена/ручного ввода)
Возвращает dict с заполненными полями, пустые — None. Гарантирует валидность
по _validate_source (slug для telegram; feed_url для rss).
"""
addr = (address or "").strip()
if crawler == "telegram":
m = _TG_HANDLE_RE.match(addr) or _TG_HANDLE_RE.match("https://t.me/" + addr.lstrip("@/"))
if not m:
return {}
handle = m.group(1)
return {"slug": handle, "channel": handle, "url": f"https://t.me/{handle}"}
else: # rss
if not _RSS_URL_RE.match(addr):
return {}
# slug берём из имени файла/домена, чтобы был стабильным и уникальным
from urllib.parse import urlparse
host = urlparse(addr).netloc.replace("www.", "").split(".")[0]
return {"feed_url": addr, "url": addr, "slug": host}
```
**Получение названия (route preview)** — в `web/app.py`:
```python
@app.get("/sources/preview")
async def sources_preview(request: Request, address: str = "", crawler: str = "telegram"):
"""GET /sources/preview?address=t.me/linuxklub&crawler=telegram → JSON.
telegram: get_entity из Telethon → name=title канала.
rss: feedparser на адрес → name=feed.title.
Ошибки (канал не найден, сеть) → {"error": "..."} (200, чтобы JS читал).
"""
_require_auth(request)
if not (address or "").strip():
return {"error": "адрес пуст"}
parsed = parse_address(address, crawler)
if not parsed:
return {"error": "не могу разобрать адрес для crawler=" + crawler}
name = None
if crawler == "telegram":
from crawler.telegram_crawler import make_client
from config import TG_SESSION_DIR
import asyncio
async def _get_title():
client = make_client(TG_SESSION_DIR)
await client.start()
try:
ent = await client.get_entity(parsed["channel"])
return ent.title
finally:
await client.disconnect()
try:
name = asyncio.run(_get_title())
except Exception as e:
return {"error": f"TG: {e}", **parsed}
else:
import feedparser
try:
d = feedparser.parse(parsed["feed_url"])
if d.bozo and not d.feed.get("title"):
return {"error": f"фид не читается: {d.bozo_exception}", **parsed}
name = d.feed.get("title") or None
except Exception as e:
return {"error": f"RSS: {e}", **parsed}
return {"slug": parsed["slug"], "name": name, **parsed}
```
`_form_source` — замена url/channel/feed_url одиночным address:
```python
def _form_source(form) -> dict:
def s(k, default=None):
v = form.get(k)
return (v or "").strip() if v else default
crawler = s("crawler", "telegram")
src = {
"slug": s("slug"),
"name": s("name"),
"crawler": crawler,
"direction": s("direction"),
"lang": s("lang", "ru"),
"priority": s("priority", "P1"),
"enabled": bool(form.get("enabled")),
"own": bool(form.get("own")),
}
parsed = parse_address(s("address"), crawler)
if crawler == "telegram":
src["channel"] = parsed.get("channel") or s("channel")
src["url"] = parsed.get("url") or s("url")
src["feed_url"] = s("feed_url")
else:
src["feed_url"] = parsed.get("feed_url") or s("feed_url")
src["url"] = parsed.get("url") or s("url")
src["channel"] = s("channel")
if not src["slug"] and parsed.get("slug"):
src["slug"] = parsed["slug"]
return src
```
`_validate_source`: для rss достаточно проверки feed_url (уже есть), для
telegram — channel (уже есть). Доп. проверка: если crawler=rss и address не URL
→ _form_source уже вернул пустые, валидатор отдаст 'rss-источнику нужен feed_url'.
Ничего менять не нужно.
**DIRECTION_OPTIONS** — в app.py при рендере /sources:
```python
@app.get("/sources")
async def sources(request: Request, did: str = ""):
...
conn = _db()
rows = conn.execute(
"SELECT DISTINCT direction FROM sources WHERE direction IS NOT NULL AND direction != '' ORDER BY direction"
).fetchall()
directions = sorted(set(DIRECTIONS) | {r[0] for r in rows})
return templates.TemplateResponse("sources.html", {
"sources": srcs, "did": did, "directions": directions,
"request": request,
})
```
### 2. Frontend: форма
Поля `url`, `channel`, `feed_url` из формы удаляются, вместо них:
```html
<div class="col-auto">
<label class="form-label mb-0 small">Address</label>
<input type="text" name="address" class="form-control form-control-sm"
placeholder="@handle, t.me/name, https://t.me/name или https://.../rss" id="add_address">
</div>
```
Кнопка «Найти» (для обоих типов; для telegram — получение названия через TG):
```html
<div class="col-auto">
<button type="button" class="btn btn-sm btn-outline-secondary" id="btn_fetch"
onclick="fetchSourcePreview()">🔎 Найти</button>
</div>
```
JS (замена старого скрипта required):
```html
<script>
const addCrawler = document.querySelector('form[action="/sources/add"] select[name="crawler"]');
const addAddress = document.getElementById('add_address');
const addSlug = document.querySelector('form[action="/sources/add"] input[name="slug"]');
const addName = document.querySelector('form[action="/sources/add"] input[name="name"]');
function toggleAddressPlaceholder() {
addAddress.placeholder = addCrawler.value === 'telegram'
? '@handle, t.me/name, https://t.me/name'
: 'https://.../rss или http://...';
}
addCrawler.addEventListener('change', toggleAddressPlaceholder);
toggleAddressPlaceholder();
async function fetchSourcePreview() {
const addr = addAddress.value.trim();
if (!addr) { alert('Введите адрес'); return; }
const resp = await fetch(`/sources/preview?address=${encodeURIComponent(addr)}&crawler=${addCrawler.value}`);
const data = await resp.json();
if (data.error) { alert(data.error); return; }
if (data.slug) addSlug.value = data.slug;
if (data.name) { addName.value = data.name; }
else if (addName.value === '') addName.placeholder = 'Название не найдено — введите вручную';
}
</script>
```
### 3. Изменения только в форме — edit-форма не трогается
Таблица и edit (изменение существующих) остаются как есть: у источника в строке
показываются channel/feed_url. Для edit поле address не добавляется — там
сохранены отношения url/channel/feed_url.
## Проверка
1. `openspec validate address-field-enhancement` — чисто.
2. `.venv/bin/python -c "from sources.sources import parse_address; print(parse_address('https://t.me/mknewsru','telegram'))"` → slug=mknewsru, channel=mknewsru.
3. `curl 'http://127.0.0.1:8400/sources/preview?address=t.me/linuxklub&crawler=telegram'` (с авторизацией) → {"slug":"linuxklub","name":"Linux Club",...}.
4. Рестарт vesti-web; ручная проверка формы (address → Найти → название появилось).
@@ -0,0 +1,45 @@
# address-field-enhancement
Улучшение формы добавления источника: одно поле Address вместо url/channel/feed_url,
авто-подстановка названия и slug, direction с автодополнением из существующих значений.
## Why
Пользователь: при добавлении telegram-источника хочется вставить адрес типа
`https://t.me/mknewsru`, а название канала получить из TG API автоматически
(«Мой компьютер»), slug — из адреса (`mknewsru`). Сейчас приходится вручную
заполнять 4 поля (url, channel, feed_url, название), часть из которых избыточна.
Также вместо жёсткого списка DIRECTIONS в datalist — подставлять реально
существующие в БД направления + возможность ввода нового.
## What Changes
- **web/templates/sources.html**: поле `channel`/`feed_url`/`url` заменяются
одним полем `address`; JS: при выборе crawler подсказка меняется;
кнопка «Найти название» (для telegram) вызывает GET /sources/preview?address=...
и заполняет slug/name; direction — datalist из `DIRECTIONS` + свободный ввод.
- **web/app.py**: новый route `GET /sources/preview` — принимает address,
определяет тип (telegram/rss), парсит slug, получает название (telegram:
Telethon get_entity; rss: feedparser), возвращает JSON {slug, name, url, channel/feed_url}.
`_form_source`: `address` → парсинг в зависимости от crawler (telegram:
channel из address, url=t.me/...; rss: feed_url=address, url=address).
`_validate_source`: rss проверяет address как URL (непустой), telegram — channel.
- **web/templates/sources.html**: datalist direction строится из DIRECTION_OPTIONS
(переменная из app.py: DIRECTIONS + уникальные из БД), input остаётся свободным.
## Why Not
- Не изменяем БД/схему: `address` — только UI-концепция, в БД по-прежнему
url/channel/feed_url.
- Не трогаем краулеры: они читают channel/feed_url из yaml — парсинг на этапе
добавления в yaml.
## Impact
- Форма проще: одно поле для адреса + crawler.
- Название и slug подставляются автоматически (для telegram — реальное имя
канала; для rss — title фида).
- direction: реальные значения из БД, свободный ввод остаётся.
- Обратная совместимость: старые источники с заполненными url/channel/feed_url
работают как раньше (адрес в форму можно не вводить при edit).
@@ -0,0 +1,18 @@
# address-field-enhancement
## Problem
Форма добавления источника требует url/channel/feed_url/название вручную.
Хочется: одно поле Address, название — из TG API (RSS — из фида), slug — из адреса.
Direction — datalist из реальных значений БД + свободный ввод.
## Tasks
- [ ] 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
@@ -0,0 +1,2 @@
schema: spec-driven
created: 2026-09-15
@@ -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/<slug>` — переименование/правка полей.
- `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-удалений).
@@ -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).
@@ -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`
@@ -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/<slug> — добавление/правка. Валидация, 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 удалён, чисто).
@@ -0,0 +1,2 @@
schema: spec-driven
created: 2026-09-15
@@ -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
@@ -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-краулер уже работает.
@@ -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) — они уже работают.
- НЕ выносим воркеры в отдельный процесс/сервис — модуль в том же проекте.
@@ -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).
@@ -0,0 +1,3 @@
schema: spec-driven
created: 2026-09-16
skip_specs: true
@@ -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 (что сделано, как запускать, питфолы)
Проверка: документы отражают новое состояние (обновлено при закрытии сессии)
@@ -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-запуске (нужен маппинг путей или передача имени файла)
- [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/
@@ -0,0 +1,3 @@
schema: spec-driven
created: 2026-09-15
skip_specs: true
@@ -0,0 +1,52 @@
# Design: rewrite-as-author
## 1. candidates.html (блок пересказа)
```diff
{% if selected.rewritten_text %}
<div class="mt-2">
- <label ...>✍️ Черновик пересказа (редактируйте и копируйте):</label>
- <textarea class="form-control form-control-sm" rows="4" readonly>{{ selected.rewritten_text }}</textarea>
+ <label ...>✍️ Пересказ (редактируйте и сохраните — он пойдёт в канал от вашего имени):</label>
+ <form method="post" action="/posts/{{ selected.id }}/rewrite-save" class="row g-1">
+ {{ hid }}
+ <textarea class="form-control form-control-sm" name="rewritten_text" rows="5">{{ selected.rewritten_text }}</textarea>
+ <div class="col-auto mt-1"><button class="btn btn-sm btn-outline-primary" type="submit">💾 Сохранить пересказ</button></div>
+ </form>
</div>
{% 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 сохраняется (если есть).
@@ -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) · Источник: <url>» вместо `🔗 Оригинал: <url>` (публикация от имени автора).
## Why Not
- Не делаем отдельного поля «автор» и персистенции источника пересказа — пересказ всегда от имени владельца канала; ссылка на оригинал остаётся строкой «Источник: …» для атрибуции.
## Impact
- Кандидат: textarea редактируемо, кнопка save. Публикация: пересказ приоритетнее оригинала, подпись от имени канала.
- Обратная совместимость: посты без rewritten_text публикуются как раньше (полный текст + 🔗 Оригинал).
@@ -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 (после подтверждения пользователя)
@@ -0,0 +1,2 @@
schema: spec-driven
created: 2026-09-15
+170
View File
@@ -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-пост (с направлением после классификации)
+85
View File
@@ -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 <slug>`.
- Новая таблица `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.
@@ -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 <slug>). Каждый запуск источника MUST записываться в таблицу `runs`
(start_run/finish_run) как задача `rss:<slug>`.
#### 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).
+48
View File
@@ -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; в отсутствие ошибок не трогаем.