mirror of
https://gitverse.ru/kpa39l/vesti.git
synced 2026-09-29 09:55:03 +00:00
170 lines
8.4 KiB
Markdown
170 lines
8.4 KiB
Markdown
# 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-пост (с направлением после классификации) |