Files
creator/backend/crawl.py
2026-06-28 17:05:18 +02:00

149 lines
6.3 KiB
Python

"""Geboundeter Domain-Crawler für Link-Quellen — rendert JS via Playwright (Chromium).
Lädt ab einer Start-URL Seiten + PDFs — NUR dieselbe Domain, begrenzte Tiefe und
Seitenzahl. HTML-Seiten werden im Headless-Browser gerendert (für SPAs nötig), dann
Links + Text aus dem fertigen DOM gezogen. PDFs werden direkt als Bytes geladen.
Deterministisch, gebounded; läuft via asyncio.to_thread (Sync-API, kein Event-Loop).
"""
import hashlib
import logging
import re
from pathlib import Path
from urllib.parse import urldefrag, urlparse
from urllib.request import Request, urlopen
from fsutil import atomic_write_text
log = logging.getLogger("creator.crawl")
MAX_TIEFE = 3
MAX_SEITEN = 500
SEITE_TIMEOUT = 30 # Sekunden pro Seite (Render bzw. PDF-Download)
CRAWL_SETTLE_MS = 3000 # gedeckelter Settle nach domcontentloaded (SPA-Render); kein 30s-networkidle-Hang
MAX_BYTES = 10_000_000 # 10 MB Deckel pro PDF
_UA = "Mozilla/5.0 (creator-lernbot)"
def _fetch_bytes(url: str) -> bytes | None:
"""PDF-Bytes per urllib laden (kein Rendering nötig). None bei Fehler/zu groß."""
try:
req = Request(url, headers={"User-Agent": _UA})
with urlopen(req, timeout=SEITE_TIMEOUT) as resp:
data = resp.read(MAX_BYTES + 1)
return None if len(data) > MAX_BYTES else data
except Exception as e:
log.debug("crawl: PDF-Fetch fehlgeschlagen %s: %s", url, e)
return None
def _name(url: str, ext: str) -> str:
h = hashlib.md5(url.encode("utf-8")).hexdigest()[:8]
slug = re.sub(r"[^a-zA-Z0-9]+", "-", urlparse(url).path).strip("-")[:60] or "index"
return f"{h}-{slug}{ext}"
def _is_pdf(url: str) -> bool:
return url.lower().split("?")[0].rstrip("/").endswith(".pdf")
def _scope_prefix(start_url: str) -> str:
"""Erstes nicht-leeres Pfad-Segment der Start-URL als Crawl-Scope, z.B.
`/learn/path/x` → `/learn`. Ohne Pfad-Segment → `""` (ganze Domain, kein Regress)."""
seg = [s for s in urlparse(start_url).path.split("/") if s]
return f"/{seg[0]}" if seg else ""
def _in_scope(url: str, prefix: str) -> bool:
"""Segment-genauer Prefix-Match (kein `/learn` ⊃ `/learning-x`). Leerer Prefix → alles erlaubt."""
if not prefix:
return True
p = urlparse(url).path
return p == prefix or p.startswith(prefix + "/")
def _seiten_text(page) -> str:
"""Haupttext der gerenderten Seite — Nav/Footer/Boilerplate per trafilatura entfernt.
Fallback auf den rohen Body-Text, wenn die Extraktion leer/zu kurz ausfällt (Nicht-Artikel-Seiten)."""
try:
from trafilatura import extract # lazy: Backend startet auch ohne das Paket
text = extract(page.content(), include_comments=False, include_tables=True) or ""
except Exception:
text = ""
if len(text.strip()) >= 200:
return text.strip()
try:
return page.inner_text("body").strip()
except Exception:
return text.strip()
def crawl(start_url: str, ziel: Path, *, max_tiefe: int = MAX_TIEFE, max_seiten: int = MAX_SEITEN, cancelled=None) -> int:
"""Crawlt ab start_url (nur gleiche Domain), rendert JS und legt Seiten/PDFs in `ziel` ab.
BFS bis `max_tiefe` / `max_seiten`. Fehler einzelner Seiten werden übersprungen.
Schreibt am ENDE einen `.done`-Marker; ein Abbruch (`cancelled()` → True) lässt ihn weg,
sodass ein Neustart neu crawlt. Gibt die Zahl gespeicherter Quellen zurück.
"""
# Lazy: so startet das Backend auch ohne installiertes Playwright; nur das Crawlen schlägt dann fehl.
from playwright.sync_api import sync_playwright
ziel.mkdir(parents=True, exist_ok=True)
domain = urlparse(start_url).netloc
prefix = _scope_prefix(start_url) # nur Links unter diesem Pfad-Segment folgen
gesehen: set[str] = set()
queue: list[tuple[str, int]] = [(urldefrag(start_url)[0], 0)]
gespeichert = 0
with sync_playwright() as pw:
browser = pw.chromium.launch(args=["--no-sandbox"]) # non-root (Docker user app)
page = browser.new_page(user_agent=_UA)
try:
while queue and gespeichert < max_seiten:
if cancelled and cancelled():
return gespeichert # Abbruch → KEIN .done-Marker → Neustart crawlt neu
url, tiefe = queue.pop(0)
if url in gesehen:
continue
gesehen.add(url)
# PDFs brauchen kein Rendering — direkt laden.
if _is_pdf(url):
data = _fetch_bytes(url)
if data:
p = ziel / _name(url, ".pdf")
if not p.exists():
p.write_bytes(data)
gespeichert += 1
continue
try:
page.goto(url, wait_until="domcontentloaded", timeout=SEITE_TIMEOUT * 1000)
except Exception as e:
log.debug("crawl: goto unvollständig %s: %s", url, e) # trotzdem versuchen, Inhalt zu lesen
try:
page.wait_for_load_state("networkidle", timeout=CRAWL_SETTLE_MS)
except Exception:
pass # SPA mit Dauer-Traffic erreicht nie idle → nach Settle weiter, kein 30s-Hang
text = _seiten_text(page) # Haupttext, Nav/Footer entfernt (Fallback: roher Body)
if text:
atomic_write_text(ziel / _name(url, ".txt"), f"QUELLE: {url}\n\n{text}")
gespeichert += 1
if tiefe < max_tiefe:
try:
hrefs = page.eval_on_selector_all("a[href]", "els => els.map(e => e.href)")
except Exception:
hrefs = []
for href in hrefs:
nxt = urldefrag(href)[0]
if (nxt.startswith(("http://", "https://"))
and urlparse(nxt).netloc == domain and _in_scope(nxt, prefix)
and nxt not in gesehen):
queue.append((nxt, tiefe + 1))
finally:
browser.close()
(ziel / ".done").write_text("ok", encoding="utf-8") # sauber durchgelaufen
log.info("crawl %s%d Quellen in %s", start_url, gespeichert, ziel)
return gespeichert