Files
vesti/crawler/rss_crawler.py
T

362 lines
16 KiB
Python
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.
# VESTI — RSS/Atom-краулер (feedparser + httpx).
# Читает источники из sources.yaml (crawler: rss), условные GET (etag/modified),
# нормализует URL (UTM/якоря), дотягивает полный текст (trafilatura) для summary-only,
# пишет посты в posts (sha256-дедуп) и состояние ленты в rss_state.
#
# Запуск:
# python -m crawler.rss_crawler --all # все включённые rss-источники
# python -m crawler.rss_crawler --direction linux
# python -m crawler.rss_crawler --source lwn
# python -m crawler.rss_crawler --source lwn --dry-run # печать без записи в БД
import argparse
import hashlib
import html as html_mod
import logging
import re
import sys
import time
from pathlib import Path
from urllib.parse import parse_qsl, urlencode, urlparse, urlunparse
sys.path.insert(0, str(Path(__file__).resolve().parent.parent))
from config import RSS_DEFAULT_TIMEOUT, RSS_USER_AGENT, ensure_dirs
from db.db import db
from sources.sources import (
get_enabled_sources, get_source, load_sources_yaml, sync_sources_to_db,
start_run, finish_run,
)
logging.basicConfig(level=logging.INFO, format="%(asctime)s %(levelname)s %(name)s %(message)s")
log = logging.getLogger("vesti.rss")
MAX_ITEMS = 25 # максимум записей ленты за запуск (остальные — следующими запусками)
RATE_LIMIT_SEC = 1.5 # пауза между источниками (вежливый опрос)
MIN_SUMMARY_LEN = 220 # если выжимка короче — пробуем дотянуть полный текст
ERROR_DEAD_THRESHOLD = 5 # N ошибок подряд → status='dead' (фид-здоровье)
# Параметры запроса, которые режут из URL (трекинг/мусор)
UTM_PARAMS = {
"utm_source", "utm_medium", "utm_campaign", "utm_term", "utm_content",
"fbclid", "gclid", "ref", "ref_src", "mc_cid", "mc_eid",
}
def normalize_url(url):
"""Чистит URL от UTM-параметров, fbclid/gclid/ref и якоря -> canonical_url.
Возвращает None для мусорных ссылок (без схемы/хоста).
"""
if not url:
return None
url = url.strip()
if not url.startswith(("http://", "https://")):
return None
u = urlparse(url)
if not u.netloc:
return None
q = [(k, v) for k, v in parse_qsl(u.query, keep_blank_values=True)
if k.lower() not in UTM_PARAMS]
return urlunparse(u._replace(query=urlencode(q), fragment=""))
def sha256_text(text: str) -> str:
return hashlib.sha256((text or "").encode("utf-8", "ignore")).hexdigest()
# Блочные теги, вокруг которых сохраняем переносы (абзацы/списки/заголовки/цитаты)
_BLOCK_TAGS = [
"p", "div", "li", "h1", "h2", "h3", "h4", "h5", "h6",
"blockquote", "pre", "tr", "section", "article",
]
# Нормализация текста: сохраняем структуру абзацев (\n\n), убираем мусор.
# НЕ схлопываем всё в одну строку — иначе новость в предпросмотре — сплошной текст.
def normalize_text(text):
"""Чистит текст: пробелы по краям строк, 3+ переноса → 2, без изменения слов."""
if not text:
return ""
text = text.replace("\r\n", "\n").replace("\r", "\n")
text = re.sub(r"[ \t]+\n", "\n", text) # пробелы перед концом строки
text = re.sub(r"\n[ \t]+", "\n", text) # пробелы после начала строки
text = re.sub(r"\n{3,}", "\n\n", text) # не больше одной пустой строки
# пробелы по краям каждой строки
text = "\n".join(l.strip() for l in text.split("\n"))
text = re.sub(r"\n{3,}", "\n\n", text)
return text.strip()
def html_to_text(html_str):
"""Раскрывает HTML (из ленты) в читаемый text с сохранением абзацев/списков.
HTML-теги разметки (b/i/a/...) вырезаются, их содержимое остаётся текстом;
блочные теги (p/li/h1-6/blockquote/pre) дают переносы строк, чтобы текст
не превращался в сплошную кашу.
"""
from selectolax.parser import HTMLParser
if not html_str:
return ""
try:
tree = HTMLParser(html_str)
tree.strip_tags(["script", "style", "noscript"])
if tree.body is None:
return ""
for node in tree.css(", ".join(_BLOCK_TAGS)):
node.insert_before("\n\n")
text = tree.body.text()
return normalize_text(text)
except Exception as e:
log.debug("html_to_text failed: %s", e)
return re.sub(r"<[^>]+>", " ", html_mod.unescape(html_str)).strip()
def extract_full_text(url, timeout=15):
"""Полный текст страницы через trafilatura. Возвращает str или None (не падает)."""
import httpx
try:
r = httpx.get(url, headers={"User-Agent": RSS_USER_AGENT},
timeout=timeout, follow_redirects=True)
if r.status_code != 200:
return None
import trafilatura
txt = trafilatura.extract(
r.text,
include_comments=False,
include_tables=False,
favor_precision=True,
# include_formatting=True сохраняет абзацы (\\n\\n) — иначе текст
# схлопывается в сплошную строку и новость нечитаема
include_formatting=True,
)
return (txt or "").strip() or None
except Exception as e:
log.debug("full text %s failed: %s", url, e)
return None
def entry_published_at(entry):
"""published_parsed/updated_parsed -> ISO-строка; None если нет даты."""
for key in ("published_parsed", "updated_parsed"):
st = getattr(entry, key, None)
if st:
try:
import datetime
return datetime.datetime(*st[:6], tzinfo=datetime.timezone.utc).isoformat()
except Exception:
return None
return None
def parse_entries(feed, source, dry_run=False):
"""Превращает записи ленты feedparser в список dict постов.
returns (posts, last_build_date)
"""
posts = []
for entry in feed.entries[:MAX_ITEMS]:
link = getattr(entry, "link", None) or ""
canonical = normalize_url(link)
if not canonical:
continue
title = getattr(entry, "title", "") or ""
# полный текст из ленты (content[].value), иначе summary
content_parts = [c.get("value", "") for c in (getattr(entry, "content", None) or [])]
raw_html = content_parts[0] if content_parts else ""
summary_html = getattr(entry, "summary", "") or ""
text = html_to_text(raw_html or summary_html)
# summary-only лента: пробуем дотянуть полный текст
if (not raw_html or len(text) < MIN_SUMMARY_LEN) and canonical:
full = extract_full_text(canonical)
if full:
text = full
if not text:
text = title # последний рубеж: заголовок
text = normalize_text(text)
posts.append({
"url": canonical,
"title": title,
"text": text,
"published_at": entry_published_at(entry),
"author": getattr(entry, "author", None) or None,
})
last_build = None
fb = getattr(feed, "last_build_date_parsed", None) or getattr(feed, "published_parsed", None)
if fb:
try:
import datetime
last_build = datetime.datetime(*fb[:6], tzinfo=datetime.timezone.utc).isoformat()
except Exception:
last_build = None
return posts, last_build
def store_rss_posts(conn, source_id, posts):
"""Вставка RSS-постов в posts с дедупом по sha256(text) и url.
Возвращает количество НОВЫХ. Повторная вставка той же статьи (даже с другим
URL в ленте) не создаёт второй строки — sha256 от текста.
"""
new = 0
for p in posts:
sha = sha256_text(p["text"])
dup = conn.execute(
"SELECT id FROM posts WHERE sha256=? OR url=?",
(sha, p["url"]),
).fetchone()
if dup:
continue
conn.execute(
"""INSERT INTO posts
(sha256, source_id, url, text, content_type, published_at, status, is_own, is_own_canonical)
VALUES (?,?,?,?, 'text', ?, 'new', 0, 0)""",
(sha, source_id, p["url"], p["text"], p["published_at"]),
)
new += 1
return new
def run_source(source, dry_run=False):
"""Краулинг одного RSS-источника: условный GET, парсинг, запись, состояние."""
slug = source["slug"]
feed_url = source.get("feed_url")
if not feed_url:
log.error("SKIP %s: нет feed_url (crawler=rss, а лента не задана)", slug)
return
run_id = start_run(source["id"], f"rss:{slug}", "cron")
t0 = time.monotonic()
try:
import httpx
# --- состояние ленты ---
with db() as conn:
st = conn.execute("SELECT * FROM rss_state WHERE slug=?", (slug,)).fetchone()
state = dict(st) if st else {}
headers = {"User-Agent": RSS_USER_AGENT, "Accept": "application/rss+xml, application/atom+xml, application/xml;q=0.9, */*;q=0.8"}
if state.get("etag"):
headers["If-None-Match"] = state["etag"]
if state.get("modified"):
headers["If-Modified-Since"] = state["modified"]
resp = httpx.get(feed_url, headers=headers, timeout=RSS_DEFAULT_TIMEOUT,
follow_redirects=True)
# вежливый ретрай на 429/503 (LWN троттлит частые опросы)
for attempt in range(3):
if resp.status_code in (429, 503):
retry_after = 0
ra = resp.headers.get("retry-after")
if ra:
try: retry_after = int(ra)
except ValueError: retry_after = 10
wait = max(retry_after, 5 * (attempt + 1))
log.warning("%s: HTTP %d, retry %d/3 через %ds", slug, resp.status_code, attempt + 1, wait)
time.sleep(wait)
resp = httpx.get(feed_url, headers=headers, timeout=RSS_DEFAULT_TIMEOUT,
follow_redirects=True)
else:
break
if resp.status_code == 304:
# лента не менялась — экономим (0 fetched/0 new, ok)
duration = int((time.monotonic() - t0) * 1000)
if not dry_run:
finish_run(run_id, "ok", 0, 0, None, duration)
log.info("OK %s: 304 (не менялась), %dms", slug, duration)
return
if resp.status_code != 200:
raise RuntimeError(f"HTTP {resp.status_code} for {feed_url}")
import feedparser
feed = feedparser.parse(resp.content, response_headers=resp.headers)
if getattr(feed, "bozo", False) and not feed.entries:
raise RuntimeError(f"bozo feed (не парсится): {getattr(feed, 'bozo_exception', '')}")
posts, last_build = parse_entries(feed, source, dry_run)
if dry_run:
print(f"=== {slug} ({source['name']}) — dry_run, {len(posts)} записей ===")
for p in posts:
print(f" - {p['published_at'] or '????'} | {p['title'][:80]}")
print(f" {p['url']}")
return
with db() as conn:
new = store_rss_posts(conn, source["id"], posts)
new_etag = (resp.headers.get("etag") or "").strip()
new_modified = (resp.headers.get("last-modified") or "").strip()
conn.execute(
"""INSERT INTO rss_state (slug, etag, modified, last_build_date, last_error, error_count, status, updated_at)
VALUES (?,?,?,?, NULL, 0, 'alive', datetime('now'))
ON CONFLICT(slug) DO UPDATE SET
etag=excluded.etag, modified=excluded.modified,
last_build_date=excluded.last_build_date,
last_error=NULL, error_count=0, status='alive',
updated_at=datetime('now')""",
(slug, new_etag or state.get("etag"), new_modified or state.get("modified"), last_build),
)
conn.execute(
"UPDATE sources SET last_fetch=datetime('now'), status='alive', last_error=NULL WHERE id=?",
(source["id"],),
)
duration = int((time.monotonic() - t0) * 1000)
finish_run(run_id, "ok", len(posts), new, None, duration)
log.info("OK %s: fetched=%d new=%d (%.1fs)", slug, len(posts), new, duration / 1000)
except Exception as e:
duration = int((time.monotonic() - t0) * 1000)
if not dry_run:
with db() as conn:
st = conn.execute("SELECT error_count FROM rss_state WHERE slug=?", (slug,)).fetchone()
err_count = (st[0] if st else 0) + 1
status = "dead" if err_count >= ERROR_DEAD_THRESHOLD else "alive"
conn.execute(
"""INSERT INTO rss_state (slug, last_error, error_count, status, updated_at)
VALUES (?,?,?,?, datetime('now'))
ON CONFLICT(slug) DO UPDATE SET
last_error=excluded.last_error, error_count=excluded.error_count,
status=excluded.status, updated_at=datetime('now')""",
(slug, str(e)[:500], err_count, status),
)
conn.execute(
"UPDATE sources SET last_error=?, status=? WHERE id=?",
(str(e)[:500], status, source["id"]),
)
finish_run(run_id, "error", 0, 0, str(e)[:500], duration)
log.error("ERROR %s: %s", slug, e)
def main():
ap = argparse.ArgumentParser(description="VESTI RSS/Atom crawler")
ap.add_argument("--source", help="slug источника (например lwn)")
ap.add_argument("--direction", help="направление (например linux)")
ap.add_argument("--all", action="store_true", help="все включённые rss-источники")
ap.add_argument("--dry-run", action="store_true", help="печать записей без записи в БД")
args = ap.parse_args()
ensure_dirs()
data = load_sources_yaml()
sync_sources_to_db(data)
if args.source:
src = get_source(args.source)
if not src:
log.error("source %s not found", args.source)
sys.exit(1)
sources = [src]
elif args.direction:
sources = get_enabled_sources(crawler="rss", direction=args.direction)
else:
sources = get_enabled_sources(crawler="rss")
if not sources:
log.info("no rss sources to crawl")
return
for src in sources:
run_source(src, dry_run=args.dry_run)
time.sleep(RATE_LIMIT_SEC)
if __name__ == "__main__":
main()