#!/usr/bin/env python3 """ WebsiteLoader by Marius Lädt eine Website samt Unterseiten, Bildern, CSS und Schriften auf deinen Rechner und schreibt die Verweise so um, dass alles lokal funktioniert. Aufruf: python3 websiteloader.py https://beispiel.de python3 websiteloader.py https://beispiel.de --tiefe 3 --max 80 Ohne Zusatzpakete — reines Python. Läuft auf Mac und Windows gleich. Nur für Referenzzwecke: Es werden ausschließlich Seiten der angegebenen Domain geladen, und die Inhalte bleiben auf deinem Rechner. """ import argparse import html as html_mod import os import time import re import sys import urllib.error import urllib.parse import urllib.request from html.parser import HTMLParser KOPF = {"User-Agent": "Mozilla/5.0 (kompatibel; WebsiteLoader by Marius; Lernzwecke)"} # Attribute, in denen Verweise stecken VERWEISE = { "a": ["href"], "link": ["href"], "script": ["src"], "img": ["src", "data-src", "srcset"], "source": ["src", "srcset"], "video": ["src", "poster"], "iframe": ["src"], } # Endungen, an denen man Bilder und Ähnliches erkennt, wenn die Adresse # selbst keine hergibt (z. B. /_next/image?url=…) TYP_ENDUNG = { "image/jpeg": ".jpg", "image/png": ".png", "image/gif": ".gif", "image/webp": ".webp", "image/avif": ".avif", "image/svg+xml": ".svg", "image/x-icon": ".ico", "text/css": ".css", "text/javascript": ".js", "application/javascript": ".js", "font/woff2": ".woff2", "font/woff": ".woff", "font/ttf": ".ttf", "application/json": ".json", } def beschreibe(e) -> str: """Aus einem Fehler einen Satz machen, mit dem ein Mensch etwas anfangen kann.""" if isinstance(e, urllib.error.HTTPError): return f"Server antwortete mit {e.code}" if isinstance(e, urllib.error.URLError): grund = getattr(e, "reason", e) if "CERTIFICATE" in str(grund).upper(): return ("Zertifikatsfehler — auf dem Mac einmal " "'Install Certificates.command' ausfuehren") return f"keine Verbindung ({grund})" return f"{e.__class__.__name__}: {e}" def ist_seite(url: str) -> bool: """Ist das eine Unterseite (und kein Bild, CSS, Skript …)?""" p = urllib.parse.urlparse(url).path if p in ("", "/") or p.endswith("/"): return True basis = os.path.basename(p) if "." not in basis: return True return basis.endswith((".html", ".htm", ".php", ".asp", ".aspx")) # Dateiendungen, die im Volltext-Durchgang als Assets gelten ASSET_ENDUNGEN = ( ".js", ".mjs", ".css", ".png", ".jpg", ".jpeg", ".webp", ".avif", ".gif", ".svg", ".ico", ".woff2", ".woff", ".ttf", ".otf", ".mp4", ".webm", ".json", ) class Sammler(HTMLParser): """Zieht alle Verweise aus einer Seite.""" def __init__(self): super().__init__(convert_charrefs=True) self.treffer = [] # (tag, attribut, wert) def handle_starttag(self, tag, attrs): felder = VERWEISE.get(tag) if not felder: return d = dict(attrs) if tag == "link": # preconnect, dns-prefetch & Co. sind keine Dateien rel = (d.get("rel") or "").lower() if not any(w in rel for w in ("stylesheet", "icon", "preload", "manifest", "apple-touch")): return for f in felder: wert = d.get(f) if wert: self.treffer.append((tag, f, wert)) class Sauger: def __init__(self, start, tiefe, maximum, ordner=None): self.start = start self.tiefe = tiefe self.maximum = maximum teile = urllib.parse.urlparse(start) self.host = teile.netloc self.ordner = ordner or teile.netloc.replace("www.", "").replace(":", "_") self.gesehen = set() self.merker = {} # Adresse -> lokaler Pfad, damit nichts doppelt geladen wird self.seiten = 0 self.dateien = 0 self.fehler = [] self.pause = 0.25 # Sekunden zwischen zwei Zugriffen, damit der Server mitkommt # ---------------------------------------------------------------- Helfer def gleiche_domain(self, url): return urllib.parse.urlparse(url).netloc in ("", self.host) def lokaler_pfad(self, url, als_seite=None, typ=None): """Aus einer Adresse einen Pfad im Zielordner machen.""" t = urllib.parse.urlparse(url) pfad = t.path if t.netloc and t.netloc != self.host: # Datei von einem fremden Server (CDN) — sortiert nach Host ablegen pfad = "/extern/" + t.netloc + pfad if als_seite is None: als_seite = ist_seite(url) if pfad.endswith("/") or pfad == "": pfad += "index.html" elif als_seite and not pfad.endswith((".html", ".htm")): pfad += ".html" elif not als_seite and "." not in os.path.basename(pfad): # Bild ohne Endung in der Adresse — Endung aus dem Typ ableiten pfad += TYP_ENDUNG.get(typ or "", ".bin") pfad = pfad.lstrip("/") if t.query: wurzel, endung = os.path.splitext(pfad) kurz = re.sub(r"[^A-Za-z0-9]+", "-", t.query)[:40] pfad = f"{wurzel}--{kurz}{endung}" # Windows-verbotene Zeichen entschärfen pfad = re.sub(r'[<>:"|?*]', "-", pfad) return os.path.join(self.ordner, pfad) def relativ(self, von_datei, ziel_datei): rel = os.path.relpath(ziel_datei, os.path.dirname(von_datei)) return rel.replace(os.sep, "/") def holen(self, url, versuche=3): """Holt eine Adresse. Wartet kurz zwischen den Zugriffen und probiert es bei Verbindungsabbrüchen erneut — viele Server drosseln sonst.""" letzter = None for versuch in range(versuche): try: time.sleep(self.pause) anfrage = urllib.request.Request(url, headers=KOPF) with urllib.request.urlopen(anfrage, timeout=30) as antwort: typ = antwort.headers.get_content_type() return antwort.read(), typ except urllib.error.HTTPError as e: if e.code in (403, 404, 410): raise letzter = e except Exception as e: letzter = e if versuch < versuche - 1: time.sleep(1.5 * (versuch + 1)) # bei Drosselung länger warten raise letzter def schreiben(self, pfad, daten): os.makedirs(os.path.dirname(pfad) or ".", exist_ok=True) with open(pfad, "wb") as f: f.write(daten) # ------------------------------------------------------------- Bausteine def asset_laden(self, url, von_datei): """Bild, CSS, Schrift o. Ä. holen und den lokalen Verweis zurückgeben.""" if url in self.merker: return self.relativ(von_datei, self.merker[url]) try: daten, typ = self.holen(url) except Exception as e: self.fehler.append(f"{url} — {beschreibe(e)}") return url ziel = self.lokaler_pfad(url, als_seite=False, typ=typ) self.merker[url] = ziel if typ == "text/css": daten = self.css_verarbeiten(daten.decode("utf-8", "replace"), url, ziel).encode() self.schreiben(ziel, daten) self.dateien += 1 print(f" · {os.path.relpath(ziel, self.ordner)}") return self.relativ(von_datei, ziel) def css_verarbeiten(self, text, css_url, css_datei): """url(...) in Stylesheets mitnehmen (Hintergrundbilder, Schriften).""" def ersetzen(m): roh = m.group(1).strip("'\"") if roh.startswith(("data:", "#")): return m.group(0) voll = urllib.parse.urljoin(css_url, roh) neu = self.asset_laden(voll.split("#")[0], css_datei) return f"url({neu})" return re.sub(r"url\(([^)]+)\)", ersetzen, text) def seite_laden(self, url, tiefe): if self.seiten >= self.maximum or url in self.gesehen: return self.gesehen.add(url) try: daten, typ = self.holen(url) except Exception as e: self.fehler.append(f"{url} — {beschreibe(e)}") return if not typ.startswith("text/html"): return self.seiten += 1 ziel = self.lokaler_pfad(url, als_seite=True) print(f"\n [{self.seiten}] {url}") text = daten.decode("utf-8", "replace") sammler = Sammler() sammler.feed(text) weiter = [] for tag, attribut, wert in sammler.treffer: if wert.startswith(("data:", "#", "mailto:", "tel:", "javascript:")): continue if attribut == "srcset": # srcset enthält mehrere Adressen: "bild.jpg 640w, gross.jpg 1x" teile = [] for eintrag in wert.split(","): e = eintrag.strip() if not e: continue stuecke = e.split() if stuecke[0].startswith("data:"): teile.append(e) continue voll = urllib.parse.urljoin(url, stuecke[0]).split("#")[0] teile.append(" ".join([self.asset_laden(voll, ziel)] + stuecke[1:])) neu = ", ".join(teile) text = text.replace(f'{attribut}="{wert}"', f'{attribut}="{neu}"') text = text.replace(f"{attribut}='{wert}'", f"{attribut}='{neu}'") continue voll = urllib.parse.urljoin(url, wert).split("#")[0] if tag == "a": # Unterseiten nur von der eigenen Domain if not self.gleiche_domain(voll): continue if tiefe > 0 and ist_seite(voll) and voll not in self.gesehen: weiter.append(voll) neu = self.relativ(ziel, self.lokaler_pfad(voll, als_seite=True)) else: # Bilder, CSS, Schriften: auch von fremden Servern (CDN) holen neu = self.asset_laden(voll, ziel) text = text.replace(f'{attribut}="{wert}"', f'{attribut}="{neu}"') text = text.replace(f"{attribut}='{wert}'", f"{attribut}='{neu}'") # Zweiter Durchgang: absolute Adressen ÜBERALL im Quelltext — # auch in srcset, Inline-Skripten, JSON und