mirror of
https://gitverse.ru/kpa39l/vesti.git
synced 2026-09-29 09:55:03 +00:00
openspec: архив 14 завершённых change-ов (веб-фиксы, crawler-queue, own-content-hub, publisher-service); спеки влиты в openspec/specs
This commit is contained in:
@@ -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
|
||||
+2
-2
@@ -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 (что сделано, как запускать, питфолы)
|
||||
Проверка: документы отражают новое состояние (обновлено при закрытии сессии)
|
||||
+1
-1
@@ -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
|
||||
@@ -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-пост (с направлением после классификации)
|
||||
@@ -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).
|
||||
@@ -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; в отсутствие ошибок не трогаем.
|
||||
Reference in New Issue
Block a user