"""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) 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 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 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="networkidle", timeout=SEITE_TIMEOUT * 1000) except Exception as e: log.debug("crawl: goto unvollständig %s: %s", url, e) # trotzdem versuchen, Inhalt zu lesen try: text = page.inner_text("body").strip() except Exception: text = "" 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 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