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

8.4 KiB
Raw Blame History

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

# Новый модуль
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

# .env (опционально, честный User-Agent с контактом)
RSS_USER_AGENT=vesti-rss/0.1 (+https://vesti.nixg.ru)

sources.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
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 (новая таблица)

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)

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

.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      # не писать в БД, только печать

Команды

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-пост (с направлением после классификации)