🚀 Massenscraping von Pressemitteilungen#

🔔 Feinlernziel(e) dieses Kapitels
Die Lernenden können mit Hilfe eines Jupyter Notebooks Python-Code zur Extraktion des Website-Texts ausfĂŒhren.

Hinweise zur AusfĂŒhrung des Notebooks#

Dieses Notebook kann auf unterschiedlichen Levels erarbeitet werden (siehe Abschnitt “Technische Voraussetzungen”):

  1. Book-Only Mode

  2. Cloud Mode: DafĂŒr auf 🚀 klicken und z.B. in Colab ausfĂŒhren.

  3. Local Mode: DafĂŒr auf Herunterladen ↓ klicken und “.ipynb” wĂ€hlen.

Übersicht#

Im Folgenden werden alle Pressemitteilungen der Berliner Staatskanzlei gescraped

DafĂŒr werden folgendene Schritte durchgefĂŒhrt:

  1. Wir werden die Struktur des Teils der Website untersuchen, der alle Pressemitteilungen enthÀlt.

  2. Wir werden die URL-Links zu allen Pressemitteilungen abrufen.

  3. Abschließend werden wir alle Pressemitteilungen scrapen.

Hide code cell content
# 🚀 Install libraries 
!pip install requests tqdm lxml
Requirement already satisfied: requests in /opt/hostedtoolcache/Python/3.11.13/x64/lib/python3.11/site-packages (2.32.3)
Collecting tqdm
  Downloading tqdm-4.67.1-py3-none-any.whl.metadata (57 kB)
Collecting lxml
  Downloading lxml-6.0.0-cp311-cp311-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl.metadata (6.6 kB)
Requirement already satisfied: charset-normalizer<4,>=2 in /opt/hostedtoolcache/Python/3.11.13/x64/lib/python3.11/site-packages (from requests) (3.3.2)
Requirement already satisfied: idna<4,>=2.5 in /opt/hostedtoolcache/Python/3.11.13/x64/lib/python3.11/site-packages (from requests) (3.8)
Requirement already satisfied: urllib3<3,>=1.21.1 in /opt/hostedtoolcache/Python/3.11.13/x64/lib/python3.11/site-packages (from requests) (2.2.2)
Requirement already satisfied: certifi>=2017.4.17 in /opt/hostedtoolcache/Python/3.11.13/x64/lib/python3.11/site-packages (from requests) (2024.8.30)
Downloading tqdm-4.67.1-py3-none-any.whl (78 kB)
Downloading lxml-6.0.0-cp311-cp311-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl (5.2 MB)
?25l   ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━ 0.0/5.2 MB ? eta -:--:--
   ━━━━━━━━━━━━━━━━━━━━━━━━━━â•ș━━━━━━━━━━━━━ 3.4/5.2 MB 18.5 MB/s eta 0:00:01
   ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━ 5.2/5.2 MB 21.0 MB/s eta 0:00:00
?25h
Installing collected packages: tqdm, lxml
?25l
   ━━━━━━━━━━━━━━━━━━━━â•ș━━━━━━━━━━━━━━━━━━━ 1/2 [lxml]
   ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━ 2/2 [lxml]
?25h

Successfully installed lxml-6.0.0 tqdm-4.67.1
Hide code cell content
import requests, pathlib, time, re, logging, textwrap, csv
from bs4 import BeautifulSoup
import pandas as pd
from tqdm import tqdm

1. Abruf und Analyse der Suchseite fĂŒr Pressemitteilungen#

  1. Wir wissen bereits, dass das SuchmenĂŒ auf der Website Berlin.de gezielt die Auswahl der fĂŒr uns interessanten Abteilungen ermöglicht.

selection

  1. Anschließend können wir mit den ausgewĂ€hlten Abteilungen und einer leeren Suchanfrage suchen und so alle Pressemitteilungen dieser Abteilungen abrufen:

suchergebnisse

Wir sehen, dass die Links hier in einer Tabelle gespeichert sind. In HTML wird eine Tabelle mit dem <table>-Element dargestellt. Wenn wir den Quellcode dieser Seite betrachten, stellen wir fest, dass sie eine Tabelle enthĂ€lt, in der alle Links aufgefĂŒhrt sind:

selection

Um diese Links zu durchsuchen, können wir die grundlegenden HTML-Abfragefunktionen der bereits bekannten Bibliothek BeautifulSoup verwenden. Das machen wir im nÀchsten Abschnitt.

2. Suchergebnisse scrapen und Pressemitteilungen extrahieren (auf einer Seite):#

2.1. Organisation der Ordnerstruktur#

# -- organise data ----------------------------------------------------
FIRST_OUTPUT_PAGE = (
    "https://www.berlin.de/presse/pressemitteilungen/index/search/?searchtext=&boolean=0&startdate=&enddate=&alle-senatsverwaltungen=on&institutions%5B%5D=Presse-+und+Informationsamt+des+Landes+Berlin&institutions%5B%5D=Senatsverwaltung+fĂŒr+Bildung%2C+Jugend+und+Familie&institutions%5B%5D=Senatsverwaltung+fĂŒr+Finanzen&institutions%5B%5D=Senatsverwaltung+fĂŒr+Inneres+und+Sport&institutions%5B%5D=Senatsverwaltung+fĂŒr+Arbeit%2C+Soziales%2C+Gleichstellung%2C+Integration%2C+Vielfalt+und+Antidiskriminierung&institutions%5B%5D=Senatsverwaltung+fĂŒr+Justiz+und+Verbraucherschutz&institutions%5B%5D=Senatsverwaltung+fĂŒr+Kultur+und+Gesellschaftlichen+Zusammenhalt&institutions%5B%5D=Senatsverwaltung+fĂŒr+Stadtentwicklung%2C+Bauen+und+Wohnen&institutions%5B%5D=Senatsverwaltung+fĂŒr+MobilitĂ€t%2C+Verkehr%2C+Klimaschutz+und+Umwelt&institutions%5B%5D=Senatsverwaltung+fĂŒr+Wirtschaft%2C+Energie+und+Betriebe&institutions%5B%5D=Senatsverwaltung+fĂŒr+Wissenschaft%2C+Gesundheit+und+Pflege&alle-bezirksamt=on&institutions%5B%5D=Bezirksamt+Charlottenburg-Wilmersdorf&institutions%5B%5D=Bezirksamt+Friedrichshain-Kreuzberg&institutions%5B%5D=Bezirksamt+Lichtenberg&institutions%5B%5D=Bezirksamt+Marzahn-Hellersdorf&institutions%5B%5D=Bezirksamt+Mitte&institutions%5B%5D=Bezirksamt+Neukölln&institutions%5B%5D=Bezirksamt+Pankow&institutions%5B%5D=Bezirksamt+Reinickendorf&institutions%5B%5D=Bezirksamt+Spandau&institutions%5B%5D=Bezirksamt+Steglitz-Zehlendorf&institutions%5B%5D=Bezirksamt+Tempelhof-Schöneberg&institutions%5B%5D=Bezirksamt+Treptow-Köpenick&alle-landesbeauftragte=on&institutions%5B%5D=Beauftragte+des+Senats+fĂŒr+Integration+und+Migration&institutions%5B%5D=Beauftragter+zur+Aufarbeitung+der+SED-Diktatur&institutions%5B%5D=BĂŒrger-+und+Polizeibeauftragter+des+Landes+Berlin&institutions%5B%5D=Pflegebeauftragte+des+Landes+Berlin&institutions%5B%5D=Landestierschutzbeauftragte&institutions%5B%5D=Landeswahlleitung&bt="
) 
DATA_DIR   = pathlib.Path("../data")         
HTML_DIR   = DATA_DIR / "html"
TXT_DIR    = DATA_DIR / "txt"
HTML_DIR.mkdir(parents=True, exist_ok=True)
TXT_DIR.mkdir(parents=True,  exist_ok=True)
logging.basicConfig(level=logging.INFO, format="%(levelname)s: %(message)s")

2.2. Vorbereitung der Funktion, die die HTTP-Anfrage ausfĂŒhrt und die Antwort verarbeitet#

# -- helper -----------------------------------------------------------------
def get_soup(
    url: str,
    *,
    max_retries: int = 3,
    sleep_s: float = 10,
) -> BeautifulSoup | None:
    """
    LĂ€dt eine URL und gibt BeautifulSoup zurĂŒck.
    - Bei 404/410 wird nach *max_retries* Versuchen endgĂŒltig None geliefert.
    - Bei allen anderen Fehlern wird bis *max_retries* weiterprobiert.
    """
    for attempt in range(1, max_retries + 1):
        try:
            r = requests.get(
                url,
                timeout=20,
                headers={"User-Agent": "Mozilla/5.0 (QuadrigaScraper/1.0)"},
            )
        except requests.RequestException as err:
            logging.warning("Netzwerkfehler %s – Versuch %d/%d", err, attempt, max_retries)
            time.sleep(sleep_s)
            continue

        if r.status_code == 200:
            return BeautifulSoup(r.text, "lxml")

        # Dauerhafte Fehler: 404 (Not Found) oder 410 (Gone)
        if r.status_code in (404, 410):
            logging.info("❌ %s liefert %s – ĂŒberspringe.", url, r.status_code)
            return None

        logging.warning(
            "Status %s auf %s – Versuch %d/%d, Wartezeit %ss",
            r.status_code, url, attempt, max_retries, sleep_s
        )
        time.sleep(sleep_s)

    logging.error("🚹 %s nach %d Versuchen nicht erreichbar – ĂŒberspringe.", url, max_retries)
    return None


def slugify(text_: str, maxlen: int = 60) -> str:
    """Rough filename-safe slug for headlines."""
    text_ = re.sub(r"\W+", "-", text_.lower()).strip("-")
    return text_[:maxlen] or "untitled"

đŸ€” Was passiert in diesem Codeblock oben?#

Klicken Sie unten, um die ErklÀrung zu lesen:

Was passiert in diesem Codeblock oben?

  1. get_soup()

    • Ziel: Eine URL abrufen und – falls erfolgreich – als BeautifulSoup-Objekt zurĂŒckliefern.

    • Konfigurierbare Robustheit

      • max_retries (Std.: 3): Wie oft probiert das Skript es insgesamt?

      • sleep_s (Std.: 10 s): Wartezeit zwischen den Versuchen (Schutz vor Überlastung & Rate Limits).

    • Ablauf pro Versuch

      1. HTTP-Request mit

        • 20 Sekunden Timeout und

        • eigenem User-Agent „QuadrigaScraper/1.0“.

      2. Status 200 → Seite wird geparst und sofort zurĂŒckgegeben.

      3. Status 404 oder 410 → gelten als endgĂŒltig („Seite existiert nicht“). Nach bis zu max_retries Versuchen gibt die Funktion None zurĂŒck und der Aufrufer kann den Datensatz ĂŒberspringen.

      4. Andere Fehler (5xx, 429 usw.) oder Netzwerk-Ausnahmen lösen eine Warnung aus. Nach sleep_s Sekunden wird erneut versucht, bis das Limit max_retries erreicht ist.

    • Ergebnis:

      • Erfolgreicher Abruf → BeautifulSoup

      • Dauerhafter Fehler → None (der Scraper fĂ€hrt fort, ohne zu hĂ€ngen)

  2. slugify()

    • Konvertiert beliebigen Text in einen dateinamen-tauglichen “Slug”:

      1. Kleinschreibung

      2. Nicht-alphanumerische Zeichen durch Bindestriche ersetzen

      3. fĂŒhrende/abschließende Bindestriche entfernen

      4. auf 60 Zeichen kĂŒrzen

    • Liefert bei komplett leerem Ergebnis den Fallback „untitled“.

2.3. Extrahieren von Pressemitteilungen aus einer Seite mit Suchausgabe#

# -- Schritt 1: genau **eine** Ergebnisseite parsen -----------------------
search_soup = get_soup(FIRST_OUTPUT_PAGE)

rows = search_soup.select("table tbody tr")
records = []
print(f"Found {len(rows)} rows on the page.")

for tr in tqdm(rows, desc="Rows"):
    # alle <td>-Zellen der Zeile auf einmal holen
    cells = tr.find_all("td")
    if len(cells) < 3:          # Fußzeilen / Leerzeilen ĂŒberspringen
        continue

    # Spalte 1 – Datum
    date_txt = cells[0].get_text(strip=True)

    # Spalte 2 – Überschrift + Link
    anchor = cells[1].find("a", href=True)
    if anchor is None:          # Sicherheits-Check
        continue
    title   = anchor.get_text(strip=True)
    pr_url  = "https://www.berlin.de" + anchor["href"]

    # Spalte 3 – herausgebende Behörde („Ressort“)
    ressort = cells[2].get_text(strip=True)

    # deterministische ID, z. B. 1570469
    uid = anchor["href"].split("/")[-1].split(".")[-2]

        # -- Schritt 2: Detailseite herunterladen -------------------------
    html_file = HTML_DIR / f"{uid}.html"
    txt_file  = TXT_DIR  / f"{uid}.txt"

    if not html_file.exists():        # ĂŒberspringen, wenn bereits gescrapet
        pr_soup = get_soup(pr_url)

        # rohe HTML-Datei speichern
        html_file.write_text(str(pr_soup), encoding="utf-8")

        # Haupttext extrahieren; Fallback, falls sich die ID Àndert
        body = (pr_soup.select_one("#article") or        # neues Layout (2024)
                pr_soup.select_one("#content") or        # klassisches Layout
                pr_soup)                                 # letzter Ausweg
        clean_text = body.get_text(" ", strip=True)
        txt_file.write_text(clean_text, encoding="utf-8")
    else:
        # FĂŒr das DataFrame trotzdem LĂ€nge des Textes bestimmen
        clean_text = txt_file.read_text(encoding="utf-8")

    records.append(
        dict(
            date=date_txt,
            ressort=ressort,
            title=title,
            pr_url=pr_url,
            filename_html=html_file.name,
            filename_txt=txt_file.name,
            n_tokens=len(clean_text.split())
        )
    )
    time.sleep(0.4)      # Höflichkeitspause

# -- Schritt 3: Metadaten inspizieren ------------------------------------
df = pd.DataFrame(records)
df.head()          # normale Jupyter-Ausgabe genĂŒgt
Found 10 rows on the page.
Rows:   0%|          | 0/10 [00:00<?, ?it/s]
Rows:  10%|█         | 1/10 [00:01<00:16,  1.80s/it]
Rows:  20%|██        | 2/10 [00:02<00:10,  1.28s/it]
Rows:  30%|███       | 3/10 [00:03<00:07,  1.11s/it]
Rows:  40%|████      | 4/10 [00:04<00:06,  1.02s/it]
Rows:  50%|█████     | 5/10 [00:05<00:04,  1.03it/s]
Rows:  60%|██████    | 6/10 [00:06<00:03,  1.06it/s]
Rows:  70%|███████   | 7/10 [00:07<00:02,  1.07it/s]
Rows:  80%|████████  | 8/10 [00:08<00:01,  1.09it/s]
Rows:  90%|█████████ | 9/10 [00:08<00:00,  1.09it/s]
Rows: 100%|██████████| 10/10 [00:09<00:00,  1.10it/s]
Rows: 100%|██████████| 10/10 [00:09<00:00,  1.01it/s]

date ressort title pr_url filename_html filename_txt n_tokens
0 01.07.2025 Bezirksamt Marzahn-Hellersdorf Mark-Twain-Bibliothek: Journalistin Danuta Sch... https://www.berlin.de/ba-marzahn-hellersdorf/a... 1575880.html 1575880.txt 755
1 01.07.2025 Bezirksamt Neukölln 50 Jahre Engagement fĂŒr Kinder und Jugendliche... https://www.berlin.de/ba-neukoelln/aktuelles/p... 1575859.html 1575859.txt 1124
2 01.07.2025 Bezirksamt Marzahn-Hellersdorf Marzahn-Hellersdorf rĂŒstet sich gegen die Somm... https://www.berlin.de/ba-marzahn-hellersdorf/a... 1575841.html 1575841.txt 952
3 01.07.2025 Bezirksamt Pankow Wegen Trockenheit: Ab sofort temporÀres Grillv... https://www.berlin.de/ba-pankow/aktuelles/pres... 1575839.html 1575839.txt 658
4 01.07.2025 Bezirksamt Lichtenberg Aufruf zum Demokratiepreis 2025 https://www.berlin.de/ba-lichtenberg/aktuelles... 1575829.html 1575829.txt 762

đŸ€” Was passiert in diesem Codeblock oben?#

Klicken Sie unten, um die Schritt-fĂŒr-Schritt-ErklĂ€rung zu lesen:

Was passiert in diesem Codeblock oben? – Schritt fĂŒr Schritt

  1. Erste Seite einlesen

    search_soup = get_soup(SAMPLE_OUTPUT_PAGE) rows = search_soup.select("table tbody tr")

    • Die Funktion get_soup() lĂ€dt genau eine Ergebnisseite der Such-/Listenansicht und gibt sie als Beautiful-Soup-Objekt zurĂŒck.

    • Mit dem CSS-Selektor table tbody tr werden alle Tabellenzeilen der Ergebnisliste eingesammelt. Jede Zeile reprĂ€sentiert eine einzelne Pressemitteilung.

  2. Vorbereitung fĂŒr die Schleife

    records = [] print(f"Found {len(rows)} rows on the page.")

    • records soll spĂ€ter eine Liste von Dictionaries fĂŒr das DataFrame sammeln.

    • Eine kurze Ausgabe zeigt, wie viele Zeilen tatsĂ€chlich gefunden wurden – nĂŒtzlich fĂŒr Kontroll-/Debug-Zwecke.

  3. Iterieren mit Fortschrittsbalken

    for tr in tqdm(rows, desc="Rows"):

    • tqdm liefert einen hĂŒbschen Fortschrittsbalken – perfekt fĂŒr Lehr- und Live-Demos.

  4. Zellen extrahieren & PlausibilitĂ€t prĂŒfen

    cells = tr.find_all("td") if len(cells) < 3:          # footer / empty rows → ignore     continue

    • Alle <td> einer Zeile werden auf einmal geholt.

    • Hat eine Zeile weniger als drei Zellen, handelt es sich um Paginierungs- oder Leerzeilen; die werden ĂŒbersprungen.

  5. Spalte 1 – Datum

    date_txt = cells[0].get_text(strip=True)

    • strip=True entfernt ZeilenumbrĂŒche und Leerzeichen – wir erhalten saubere Strings wie „16.06.2025“.

  6. Spalte 2 – Überschrift & Link

    anchor = cells[1].find("a", href=True) if anchor is None:     continue title  = anchor.get_text(strip=True) pr_url = "https://www.berlin.de" + anchor["href"]

    • Innerhalb der zweiten Zelle steckt der anklickbare Link.

    • Sicherheits-Check: Falls doch kein <a> vorhanden ist, Zeile ĂŒberspringen.

    • Die relative URL wird zur vollstĂ€ndigen URL ergĂ€nzt.

  7. Spalte 3 – Ressort (herausgebende Behörde)

    ressort = cells[2].get_text(strip=True)

  8. Eindeutige ID ableiten

    uid = anchor["href"].split("/")[-1].split(".")[-2]

    • Vom Pfadsegment pressemitteilung.1570469.php wird mittels split() das numerische StĂŒck 1570469 herausgelöst.

    • Diese ID landet spĂ€ter im Dateinamen, damit jeder Release genau eine HTML- und eine TXT-Datei bekommt.

  9. Dateipfade festlegen

    html_file = HTML_DIR / f"{uid}.html" txt_file  = TXT_DIR  / f"{uid}.txt"

  10. HTML herunterladen & Text extrahieren (nur falls neu)

    ` if not html_file.exists(): pr_soup = get_soup(pr_url) html_file.write_text(str(pr_soup), encoding=”utf-8”)

    body = (pr_soup.select_one("#article")     # neues Layout
            or pr_soup.select_one("#content")  # altes Layout
            or pr_soup)                        # Fallback
    clean_text = body.get_text(" ", strip=True)
    txt_file.write_text(clean_text, encoding="utf-8")
    

    else: clean_text = txt_file.read_text(encoding=”utf-8”) `

    • Idempotenz: Wenn die Datei schon existiert, wird nichts erneut heruntergeladen – das spart Zeit und Traffic.

    • Der eigentliche Text sitzt mal in #article, mal in #content. Wir probieren beide Selektoren und greifen im Zweifel auf die ganze Seite zurĂŒck.

    • HTML und gereinigter Plain-Text werden getrennt gespeichert.

  11. Metadaten sammeln

    records.append(     dict(         date=date_txt,         ressort=ressort,         title=title,         pr_url=pr_url,         filename_html=html_file.name,         filename_txt=txt_file.name,         n_tokens=len(clean_text.split())     ) )

    • Alle wesentlichen Infos – inklusive Dateinamen und Token-Anzahl – landen in einem Dictionary, das wir spĂ€ter direkt in ein DataFrame gießen.

  12. Höfliche Pause

    time.sleep(0.4)

    • 400 ms warten verringert die Gefahr, den Server zu ĂŒberlasten.

  13. Auswertung in Pandas

    df = pd.DataFrame(records) df.head()

    • Am Ende verwandeln wir die gesammelten Dictionaries in ein DataFrame, um die ersten Zeilen gleich im Notebook inspizieren zu können.

So wird auf anschauliche Weise demonstriert, wie man gezielt Teile einer HTML-Tabelle parst, die Detailseiten herunterlĂ€dt, Text extrahiert und alles sauber fĂŒr weitere Analysen ablegt.

3. Massenscraping von Pressemitteilungen#

3.1. Implementierung der Logik zum Finden der letzten Seite in der Suchausgabe#

# ╔═╡ 80_pagination_helpers
# Basis-URL ohne "/page/<nr>"-Segment,
# aber *inklusive* aller Query-Parameter (= Filter der langen Such-URL)

SEARCH_ROOT = FIRST_OUTPUT_PAGE

def last_page_number() -> int:
    """
    Ermittelt ĂŒber das <nav>-Element ('pager-skip-to-last') die höchste
    Ergebnisseiten-Nummer.
    """
    soup = get_soup(SEARCH_ROOT)                            # 1. Suchseite laden
    last_link = soup.select_one("li.pager-skip-to-last a")
    if not last_link:
        raise RuntimeError("Konnte die letzte Seite nicht finden – Selector?")
    
    # href hat die Form ".../page/5239?<query>"
    m = re.search(r"/page/(\d+)", last_link["href"])
    if not m:
        raise RuntimeError("Seitenzahl nicht im href gefunden.")
    return int(m.group(1))

def page_url(page_num: int) -> str:
    """
    Baut die URL fĂŒr eine beliebige Seite nach folgendem Muster auf:
    <root>/page/<nr>?<identische Query-Parameter>
    """
    if page_num < 1:
        raise ValueError("Seitennummern beginnen bei 1.")
    return SEARCH_ROOT.replace("/search/", f"/search/page/{page_num}/")

3.2. Vorbereitung der Funktion fĂŒr das Massenscraping#

# ╔═╡ 81_bulk_crawler_with_autosave
META_CSV   = DATA_DIR / "metadata.csv"
BUFFER_SIZE = 100          # wie viele Records, bevor wir in die CSV fluschen?

def crawl_all_pages(
    pages: int | None = None,
    sleep_s: float = 0.4,
    buffer_size: int = BUFFER_SIZE,
) -> pd.DataFrame:
    """Crawlt alle Trefferseiten, speichert Metadaten inkrementell nach CSV."""
    if pages is None:
        pages = last_page_number()
        print(f"→ Letzte Trefferseite lautet {pages}")

    # --------------------------------------------------------------
    EXPECTED_HEADER = ["id", "url", "date", "title",
                       "source", "filename_html", "filename", "n_tokens"]
    
    existing_uids: set[str] = set()
    need_header = True                                           # default
    
    if META_CSV.exists() and META_CSV.stat().st_size > 0:
        # PrĂŒfen, ob schon ein Header vorhanden ist
        with open(META_CSV, newline="", encoding="utf-8") as fh:
            first_line = fh.readline().strip()
            need_header = first_line.split(",") != EXPECTED_HEADER
    
        # UIDs nur einlesen, wenn Header vorhanden
        if not need_header:
            with open(META_CSV, newline="", encoding="utf-8") as fh:
                reader = csv.DictReader(fh)
                existing_uids = {row["id"] for row in reader}
                logging.info("⚙  %d vorhandene DatensĂ€tze erkannt.", len(existing_uids))
        else:
            logging.warning("☝  metadata.csv hat noch keinen Header – wird ergĂ€nzt.")
    
    # Öffnen im richtigen Modus
    mode = "a" if META_CSV.exists() else "w"
    csvfile = open(META_CSV, mode, newline="", encoding="utf-8")
    writer = csv.DictWriter(csvfile, fieldnames=EXPECTED_HEADER)
    
    if need_header:
        writer.writeheader()
        csvfile.flush()

    buffer: list[dict] = [] 
    # --------------------------------------------------------------


    for p in tqdm(range(1, pages + 1), desc="Result pages"):
        list_soup = get_soup(page_url(p))
        if list_soup is None:
            continue                                # ganze Seite ĂŒberspringen

        for tr in list_soup.select("table tbody tr"):
            cells = tr.find_all("td")
            if len(cells) < 3:
                continue

            date_txt = cells[0].get_text(strip=True)
            anchor   = cells[1].find("a", href=True)
            if anchor is None:
                continue
            ressort  = cells[2].get_text(strip=True)
            pr_url   = "https://www.berlin.de" + anchor["href"]
            uid      = anchor["href"].split(".")[-2]

            # Doppelte auslassen (wichtig fĂŒr Resume!)
            if uid in existing_uids:
                continue # Datensatz bereits vorhanden → ĂŒberspringen

            html_fp = HTML_DIR / f"{uid}.html"
            txt_fp  = TXT_DIR  / f"{uid}.txt"

            # -------- Detailseite laden (oder bei 404 ĂŒberspringen) ----------
            pr_soup = get_soup(pr_url)
            if pr_soup is None:                   # 404 → keinen Record anlegen
                continue

            if not html_fp.exists():
                html_fp.write_text(str(pr_soup), encoding="utf-8")
                body = (pr_soup.select_one("#article") or
                        pr_soup.select_one("#content") or
                        pr_soup)
                clean = body.get_text(" ", strip=True)
                txt_fp.write_text(clean, encoding="utf-8")
            else:
                clean = txt_fp.read_text(encoding="utf-8")

            rec = dict(
                id=uid,
                url=pr_url,
                date=date_txt,
                title=anchor.get_text(strip=True),
                source=ressort,
                filename_html=html_fp.name,
                filename=txt_fp.name,        # nur „filename“
                n_tokens=len(clean.split())
            )
            buffer.append(rec)
            existing_uids.add(uid)                # Direkt markieren
            time.sleep(sleep_s)

            # ---------- Zwischenspeichern ----------------------------------
            if len(buffer) >= buffer_size:
                writer.writerows(buffer)
                csvfile.flush()
                buffer.clear()

        # Optional: Auch nach *jeder* Seite flushen
        if buffer:
            writer.writerows(buffer)
            csvfile.flush()
            buffer.clear()

    csvfile.close()
    logging.info("✅ Crawl abgeschlossen, CSV geschlossen.")

    # Finales DataFrame (fĂŒr direkte Notebook-Analyse)
    return pd.read_csv(META_CSV, encoding="utf-8")

đŸ€” Was passiert in diesem Codeblock oben?#

Klicken Sie unten, um die Schritt-fĂŒr-Schritt-ErklĂ€rung zu lesen:

Was passiert in diesem Bulk-Crawler oben?

  1. Einstiegs-Parameter

    • pages – wie viele Trefferseiten sollen verarbeitet werden? None ruft zuerst last_page_number() auf.

    • sleep_s – Höflichkeits-Delay zwischen den Detail-Requests.

    • buffer_size – Zahl der DatensĂ€tze, die gepuffert werden, bevor sie in die CSV geschrieben werden.

  2. CSV-Vorbereitung

    • Erwarteter Kopf: ["id","url","date","title","source","filename_html","filename","n_tokens"].

    • Wenn metadata.csv existiert, wird geprĂŒft, ob bereits ein Header vorhanden ist.

    • Sind schon Daten da, werden alle vorhandenen UIDs in die Menge existing_uids geladen, damit nichts doppelt gescrapet wird.

  3. CSV-Writer & Puffer

    • Datei-Modus: 'a', falls die Datei schon da ist, sonst 'w'.

    • Header wird geschrieben, falls er noch fehlt.

    • buffer sammelt neue DatensĂ€tze, bis buffer_size erreicht ist.

  4. Schleife ĂŒber Ergebnis-Seiten

    • for p in tqdm(range(1, pages + 1), desc="Result pages"): zeigt einen Fortschrittsbalken.

    • get_soup(page_url(p)) holt die jeweilige HTML-Seite; liefert sie None, wird die ganze Seite ĂŒbersprungen.

  5. Tabellenzeilen auswerten

    • Jede Zeile liefert date_txt, anchor (Link & Titel) und ressort.

    • UID wird aus dem Link extrahiert: uid = anchor["href"].split(".")[-2].

    • Wenn uid in existing_uids, wird sofort weitergemacht (continue).

  6. Detailseite holen & speichern

    • HTML-Datei-Pfad: html_fp = HTML_DIR / f"{uid}.html".

    • TXT-Datei-Pfad: txt_fp = TXT_DIR  / f"{uid}.txt".

    • Neue Detailseite wird nur geladen, wenn html_fp noch nicht existiert.

    • Text wird aus #article, #content oder notfalls dem ganzen Dokument extrahiert.

  7. Metadaten-Record bauen

    • Beispiel-Dict: {"id":uid, "url":pr_url, "date":date_txt, "title":title, "source":ressort, "filename_html":html_fp.name, "filename":txt_fp.name, "n_tokens":len(clean.split())}

    • Record landet im buffer, UID wird gleichzeitig zu existing_uids hinzugefĂŒgt.

    • time.sleep(sleep_s) wahrt Server-Höflichkeit.

  8. Autosave-Mechanismus

    • if len(buffer) >= buffer_size: → writer.writerows(buffer) schreibt den Puffer in die CSV, danach buffer.clear().

    • ZusĂ€tzlich wird nach jeder fertigen Ergebnisseite geflusht, damit höchstens eine Seite verloren gehen kann.

  9. AufrĂ€umen & RĂŒckgabe

    • Nach der großen Schleife: csvfile.close() und ein Log-Eintrag ✅ Crawl abgeschlossen.

    • Zum Schluss wird die komplette metadata.csv per pd.read_csv() als DataFrame zurĂŒckgegeben, sodass man direkt im Notebook weiterarbeiten kann.

Kurz gesagt: Der Crawler verarbeitet beliebig viele Trefferseiten, speichert Metadaten inkrementell, ĂŒberspringt 404/410-Links und setzt einen unterbrochenen Lauf dank existing_uids nahtlos fort.

3.3. Massenscraping#

Test run:#

# ╔═╡ 82_test_run_5_pages
df_test = crawl_all_pages(pages=5)
df_test.head()

Main run:#

# ╔═╡ 83_run_bulk
# Achtung: Das kann > 1 Stunde dauern und tausende Dateien erzeugen!
df_all = crawl_all_pages(pages=None)   # None → auto-detect
df_all.head()

Hilfscode zum Entfernen unnötiger Teile des HTML-Textes

Hide code cell content
## 84 Hilfscode zum Entfernen unnötiger Teile des HTML-Textes

BACKUP_DIR = DATA_DIR / "txt_backup"    # Sicherheitskopien
BACKUP_DIR.mkdir(exist_ok=True)

def extract_release(soup: BeautifulSoup) -> str | None:
    """Gibt den bereinigten Text einer Pressemitteilung zurĂŒck (oder None)."""
    h1   = soup.select_one("#layout-grid__area--herounit h1, h1.title")
    main = soup.select_one("#layout-grid__area--maincontent")
    if not main:
        return None

    title = h1.get_text(" ", strip=True) if h1 else ""
    body  = main.get_text(" ", strip=True)

    # Doppelte Überschrift am Anfang entfernen
    if body.lower().startswith(title.lower()):
        body = body[len(title):].lstrip()

    return (title + "\n\n" + body).strip()

changed = skipped = failed = 0

for html_fp in tqdm(sorted(HTML_DIR.glob("*.html")), desc="Rebuild"):
    soup = BeautifulSoup(html_fp.read_text(encoding="utf-8"), "lxml")
    text = extract_release(soup)

    if text is None:
        failed += 1
        logging.warning("⚠  %s: kein maincontent-Div gefunden", html_fp.name)
        continue

    txt_fp = TXT_DIR / f"{html_fp.stem}.txt"
    if txt_fp.exists() and txt_fp.read_text(encoding="utf-8").strip() == text:
        skipped += 1
        continue

    # Sicherheitskopie der alten Datei
    if txt_fp.exists():
        txt_fp.rename(BACKUP_DIR / txt_fp.name)

    txt_fp.write_text(text, encoding="utf-8")
    changed += 1

print(f"✔  {changed:,} Dateien bereinigt – {skipped:,} bereits ok – "
      f"{failed:,} ohne passenden Layout-Block")

4. Ergebnisse#

Der Dataframe ‘df_all’ und die Datei metadata.csv enthalten nun die Metadaten fĂŒr das Pressemitteilungskorpus, wĂ€hrend der Ordner txt die Texte der Pressemitteilungen enthĂ€lt.

Korpusumfang (23. 06. 2025)#

  • Pressemitteilungen: ≈ 51 800

  • Zeitspanne: 2001 – 2025

  • Ø LĂ€nge: 430 Tokens (Median 394)

Sie können nun mit computergestĂŒtzten Methoden untersucht werden.

5. Zusammenfassung des technischen Workflow#

  1. Filter setzen im Online‑Formular → alle oben aufgefĂŒhrten Institutionen anhaken (siehe URL in der Notebook‑Konstante SEARCH_ROOT).

  2. Letzte Ergebnisseite ermitteln via CSS‑Selektor li.pager-skip-to-last a (Stand Juni 2025: Seite 5239).

  3. Pagination ablaufen

    • Trefferzeilen auslesen (table tbody tr).

    • Detailseiten abrufen; Haupttext steckt verlĂ€sslich in #layout-grid__area--maincontent (Fallback: #article oder #content).

    • HTML + bereinigter Plain‑Text unter data/html/<id>.html bzw. data/txt/<id>.txt speichern.

    • Metadaten (Datum, Titel, Ressort, Dateinamen, Token‑Zahl) inkrementell in data/metadata.csv anhĂ€ngen (Autosave alle 100 DatensĂ€tze).

  4. Resume‑FĂ€higkeit: Vor jedem Lauf werden vorhandene UIDs aus der CSV eingelesen → keine Dubletten, unterbrochene Crawls lassen sich fortsetzen.

Note

404‑Seiten werden nach drei Fehlversuchen ĂŒbersprungen und im Log markiert.