update
This commit is contained in:
100
backend/crawl.py
100
backend/crawl.py
@@ -1,9 +1,9 @@
|
||||
"""Geboundeter Domain-Crawler für Link-Quellen — rendert JS via Playwright (Chromium).
|
||||
"""Bounded domain crawler for link sources — renders 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).
|
||||
Loads pages + PDFs starting from a start URL — ONLY the same domain, limited depth
|
||||
and page count. HTML pages are rendered in a headless browser (needed for SPAs), then
|
||||
links + text are pulled from the finished DOM. PDFs are loaded directly as bytes.
|
||||
Deterministic, bounded; runs via asyncio.to_thread (sync API, no event loop).
|
||||
"""
|
||||
|
||||
import hashlib
|
||||
@@ -17,23 +17,23 @@ 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
|
||||
MAX_DEPTH = 3
|
||||
MAX_PAGES = 500
|
||||
PAGE_TIMEOUT = 30 # seconds per page (render or PDF download)
|
||||
CRAWL_SETTLE_MS = 3000 # capped settle after domcontentloaded (SPA render); no 30s networkidle hang
|
||||
MAX_BYTES = 10_000_000 # 10 MB cap per 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ß."""
|
||||
"""Load PDF bytes via urllib (no rendering needed). None on error/too large."""
|
||||
try:
|
||||
req = Request(url, headers={"User-Agent": _UA})
|
||||
with urlopen(req, timeout=SEITE_TIMEOUT) as resp:
|
||||
with urlopen(req, timeout=PAGE_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)
|
||||
log.debug("crawl: PDF fetch failed %s: %s", url, e)
|
||||
return None
|
||||
|
||||
|
||||
@@ -48,25 +48,25 @@ def _is_pdf(url: str) -> bool:
|
||||
|
||||
|
||||
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)."""
|
||||
"""First non-empty path segment of the start URL as the crawl scope, e.g.
|
||||
`/learn/path/x` → `/learn`. No path segment → `""` (whole domain, no narrowing)."""
|
||||
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."""
|
||||
"""Segment-exact prefix match (no `/learn` ⊃ `/learning-x`). Empty prefix → everything allowed."""
|
||||
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)."""
|
||||
def _page_text(page) -> str:
|
||||
"""Main text of the rendered page — nav/footer/boilerplate removed via trafilatura.
|
||||
Falls back to the raw body text when extraction is empty/too short (non-article pages)."""
|
||||
try:
|
||||
from trafilatura import extract # lazy: Backend startet auch ohne das Paket
|
||||
from trafilatura import extract # lazy: the backend starts even without the package
|
||||
text = extract(page.content(), include_comments=False, include_tables=True) or ""
|
||||
except Exception:
|
||||
text = ""
|
||||
@@ -78,58 +78,58 @@ def _seiten_text(page) -> str:
|
||||
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.
|
||||
def crawl(start_url: str, target: Path, *, max_depth: int = MAX_DEPTH, max_pages: int = MAX_PAGES, cancelled=None) -> int:
|
||||
"""Crawl from start_url (same domain only), render JS and store pages/PDFs in `target`.
|
||||
|
||||
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.
|
||||
BFS up to `max_depth` / `max_pages`. Errors on individual pages are skipped.
|
||||
Writes a `.done` marker at the END; an abort (`cancelled()` → True) omits it,
|
||||
so a restart crawls again. Returns the number of saved sources.
|
||||
"""
|
||||
# Lazy: so startet das Backend auch ohne installiertes Playwright; nur das Crawlen schlägt dann fehl.
|
||||
# Lazy: this way the backend starts even without Playwright installed; only crawling then fails.
|
||||
from playwright.sync_api import sync_playwright
|
||||
|
||||
ziel.mkdir(parents=True, exist_ok=True)
|
||||
target.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()
|
||||
prefix = _scope_prefix(start_url) # only follow links under this path segment
|
||||
seen: set[str] = set()
|
||||
queue: list[tuple[str, int]] = [(urldefrag(start_url)[0], 0)]
|
||||
gespeichert = 0
|
||||
saved = 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:
|
||||
while queue and saved < max_pages:
|
||||
if cancelled and cancelled():
|
||||
return gespeichert # Abbruch → KEIN .done-Marker → Neustart crawlt neu
|
||||
url, tiefe = queue.pop(0)
|
||||
if url in gesehen:
|
||||
return saved # abort → NO .done marker → restart crawls again
|
||||
url, depth = queue.pop(0)
|
||||
if url in seen:
|
||||
continue
|
||||
gesehen.add(url)
|
||||
seen.add(url)
|
||||
|
||||
# PDFs brauchen kein Rendering — direkt laden.
|
||||
# PDFs need no rendering — load directly.
|
||||
if _is_pdf(url):
|
||||
data = _fetch_bytes(url)
|
||||
if data:
|
||||
p = ziel / _name(url, ".pdf")
|
||||
p = target / _name(url, ".pdf")
|
||||
if not p.exists():
|
||||
p.write_bytes(data)
|
||||
gespeichert += 1
|
||||
saved += 1
|
||||
continue
|
||||
|
||||
try:
|
||||
page.goto(url, wait_until="domcontentloaded", timeout=SEITE_TIMEOUT * 1000)
|
||||
page.goto(url, wait_until="domcontentloaded", timeout=PAGE_TIMEOUT * 1000)
|
||||
except Exception as e:
|
||||
log.debug("crawl: goto unvollständig %s: %s", url, e) # trotzdem versuchen, Inhalt zu lesen
|
||||
log.debug("crawl: goto incomplete %s: %s", url, e) # still try to read the content
|
||||
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)
|
||||
pass # an SPA with constant traffic never reaches idle → continue after settle, no 30s hang
|
||||
text = _page_text(page) # main text, nav/footer removed (fallback: raw body)
|
||||
if text:
|
||||
atomic_write_text(ziel / _name(url, ".txt"), f"QUELLE: {url}\n\n{text}")
|
||||
gespeichert += 1
|
||||
if tiefe < max_tiefe:
|
||||
atomic_write_text(target / _name(url, ".txt"), f"QUELLE: {url}\n\n{text}")
|
||||
saved += 1
|
||||
if depth < max_depth:
|
||||
try:
|
||||
hrefs = page.eval_on_selector_all("a[href]", "els => els.map(e => e.href)")
|
||||
except Exception:
|
||||
@@ -138,11 +138,11 @@ def crawl(start_url: str, ziel: Path, *, max_tiefe: int = MAX_TIEFE, max_seiten:
|
||||
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))
|
||||
and nxt not in seen):
|
||||
queue.append((nxt, depth + 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
|
||||
(target / ".done").write_text("ok", encoding="utf-8") # ran through cleanly
|
||||
log.info("crawl %s → %d sources in %s", start_url, saved, target)
|
||||
return saved
|
||||
|
||||
Reference in New Issue
Block a user