Files
vesti/openspec/changes/rss-crawler/design.md
T

170 lines
8.4 KiB
Markdown
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
# 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-пост (с направлением после классификации)