mirror of
https://gitverse.ru/kpa39l/vesti.git
synced 2026-09-29 09:55:03 +00:00
8.4 KiB
8.4 KiB
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
start_run(source_id, f"rss:{slug}", trigger).- Прочитать
rss_stateпо slug; собрать заголовкиIf-None-Match/If-Modified-Since, если есть. httpx.get(feed_url, headers={If-None-Match, If-Modified-Since, User-Agent}, timeout=20).304→finish_run(ok, 0, 0)(экономия трафика); не трогаем status.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 — доп. ключ).
- если в контенте указано
finish_run(ok, fetched, new);rss_state.status='alive',last_error=NULL,error_count=0;last_build_dateиз ленты.- Ошибка сети/парсинга →
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-пост (с направлением после классификации)