6.1. 🚀 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.

6.1.1. 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.

6.1.2. Ü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.14/x64/lib/python3.11/site-packages (2.32.3)
Requirement already satisfied: tqdm in /opt/hostedtoolcache/Python/3.11.14/x64/lib/python3.11/site-packages (4.67.1)
Collecting lxml
  Downloading lxml-6.0.2-cp311-cp311-manylinux_2_26_x86_64.manylinux_2_28_x86_64.whl.metadata (3.6 kB)
Requirement already satisfied: charset-normalizer<4,>=2 in /opt/hostedtoolcache/Python/3.11.14/x64/lib/python3.11/site-packages (from requests) (3.3.2)
Requirement already satisfied: idna<4,>=2.5 in /opt/hostedtoolcache/Python/3.11.14/x64/lib/python3.11/site-packages (from requests) (3.8)
Requirement already satisfied: urllib3<3,>=1.21.1 in /opt/hostedtoolcache/Python/3.11.14/x64/lib/python3.11/site-packages (from requests) (2.2.2)
Requirement already satisfied: certifi>=2017.4.17 in /opt/hostedtoolcache/Python/3.11.14/x64/lib/python3.11/site-packages (from requests) (2024.8.30)
Downloading lxml-6.0.2-cp311-cp311-manylinux_2_26_x86_64.manylinux_2_28_x86_64.whl (5.2 MB)
?25l   ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━ 0.0/5.2 MB ? eta -:--:--
   ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━ 5.2/5.2 MB 39.6 MB/s  0:00:00
?25h
Installing collected packages: lxml
Successfully installed lxml-6.0.2
Hide code cell content
import requests, pathlib, time, re, logging, textwrap, csv
from bs4 import BeautifulSoup
import pandas as pd
from tqdm import tqdm

6.1.3. 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.

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

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")

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

In dieser Code-Zelle unten definieren wir zwei kleine Hilfsfunktionen, die wir spÀter beim Web-Scraping immer wieder verwenden werden.
Wichtig: In dieser Zelle wird noch nichts „gescrapet“ – wir bereiten nur Werkzeuge vor.


1ïžâƒŁ get_soup() – Webseiten zuverlĂ€ssig abrufen

2ïžâƒŁ slugify() – Dateinamen aus Text erzeugen

# -- helper -----------------------------------------------------------------
def get_soup(
    url: str,
    *,
    max_retries: int = 3,
    sleep_s: float = 10,
) -> BeautifulSoup | None:
    """
    LĂ€dt eine Webseite (URL) und gibt den HTML-Inhalt als BeautifulSoup-Objekt zurĂŒck.

    Warum braucht man das?
    - Beim Web-Scraping treten oft temporĂ€re Probleme auf (Netzwerk-Wackler, Server ĂŒberlastet,
      Rate-Limits). Diese Funktion versucht deshalb mehrere Male, eine Seite abzurufen, bevor sie aufgibt.

    Parameter
    ----------
    url : str
        Die Zieladresse der Webseite.
    max_retries : int (Standard: 3)
        Wie viele *Versuche insgesamt* sollen gemacht werden, bevor wir aufgeben?
    sleep_s : float (Standard: 10)
        Wie viele Sekunden warten wir zwischen den Versuchen?
        (Das ist höflich gegenĂŒber dem Server und reduziert die Gefahr von Sperren.)

    RĂŒckgabewert
    ------------
    BeautifulSoup | None
        - BeautifulSoup: wenn der Abruf erfolgreich war (HTTP Status 200)
        - None: wenn die Seite dauerhaft nicht verfĂŒgbar ist (z.B. 404/410) oder nach allen Versuchen
          immer noch kein Erfolg möglich war.

    Fehlerlogik (vereinfacht)
    -------------------------
    - 200 (OK): HTML parsen und zurĂŒckgeben.
    - 404 (Not Found) / 410 (Gone): Seite existiert nicht (mehr) → sofort None.
    - Alles andere (z.B. 500, 503, 429): vermutlich temporĂ€r → warten und erneut versuchen.
    """

    # Wir wiederholen den Abruf bis zu max_retries Mal.
    for attempt in range(1, max_retries + 1):
        try:
            # HTTP-Request:
            # - timeout=20: nicht ewig hÀngen, sondern nach 20s abbrechen
            # - User-Agent: viele Websites blocken "anonyme" Requests; ein klarer User-Agent ist fairer
            r = requests.get(
                url,
                timeout=20,
                headers={"User-Agent": "Mozilla/5.0 (QuadrigaScraper/1.0)"},
            )
        except requests.RequestException as err:
            # Netzwerkfehler (z.B. DNS, Timeout, Verbindungsabbruch):
            # → wir loggen das und versuchen es nach einer Pause erneut.
            logging.warning(
                "Netzwerkfehler %s – Versuch %d/%d", err, attempt, max_retries
            )
            time.sleep(sleep_s)
            continue

        # Erfolgsfall: HTTP 200 bedeutet "OK", Seite wurde geliefert.
        if r.status_code == 200:
            # BeautifulSoup baut aus HTML einen parsebaren Baum (z.B. fĂŒr soup.select, soup.find, ...)
            return BeautifulSoup(r.text, "lxml")

        # Dauerhafte Fehler: Seite ist nicht da oder wurde entfernt.
        # -> Hier bringt erneutes Probieren meistens nichts.
        if r.status_code in (404, 410):
            logging.info("❌ %s liefert %s – ĂŒberspringe.", url, r.status_code)
            return None

        # Alle anderen HTTP-Statuscodes können temporÀr sein:
        # - 429: zu viele Anfragen (Rate Limit)
        # - 5xx: Serverfehler
        # - 403: evtl. Block/Forbidden (kann auch dauerhaft sein, aber oft lohnt ein Retry)
        logging.warning(
            "Status %s auf %s – Versuch %d/%d, Wartezeit %ss",
            r.status_code, url, attempt, max_retries, sleep_s
        )
        time.sleep(sleep_s)

    # Wenn wir hier landen, sind alle Versuche gescheitert.
    logging.error(
        "🚹 %s nach %d Versuchen nicht erreichbar – ĂŒberspringe.", url, max_retries
    )
    return None


def slugify(text_: str, maxlen: int = 60) -> str:
    """
    Erzeugt aus einem beliebigen Text einen „dateinamen-tauglichen“ Slug.

    Wozu?
    - Wenn wir z.B. eine Headline als Dateiname speichern wollen, dĂŒrfen darin keine
      Sonderzeichen, Leerzeichen usw. sein. Diese Funktion macht daraus einen sicheren,
      kurzen Namen.

    Schritte
    --------
    1) Kleinschreibung
    2) Alles, was nicht „Wortzeichen“ ist (Buchstaben/Ziffern/_) → wird durch '-' ersetzt
    3) FĂŒhrende/abschließende '-' entfernen
    4) Auf maxlen Zeichen kĂŒrzen
    5) Falls am Ende nichts ĂŒbrig bleibt: 'untitled'

    Hinweis: Das ist eine „rough“ Slugify-Funktion (ohne perfekte Unicode-Transliteration).
    """
    text_ = re.sub(r"\W+", "-", text_.lower()).strip("-")
    return text_[:maxlen] or "untitled"

Extrahieren von Pressemitteilungen aus einer Seite mit Suchausgabe#

📄 Eine Ergebnisseite auslesen und Detailseiten speichern#

In dieser Code-Zelle wird eine einzelne Ergebnisseite mit Pressemitteilungen ausgelesen.
FĂŒr jede gefundene Tabellenzeile wird die zugehörige Detailseite aufgerufen, der Haupttext extrahiert und lokal gespeichert.

# -- Schritt 1: genau **eine** Ergebnisseite parsen -----------------------
# Wir laden hier bewusst nur *eine* Ergebnisseite der Listenansicht.
# Jede Tabellenzeile auf dieser Seite entspricht einer Pressemitteilung.

search_soup = get_soup(FIRST_OUTPUT_PAGE)

# In der Ergebnisliste steckt jede Pressemitteilung in einer Tabellenzeile (<tr>)
rows = search_soup.select("table tbody tr")

# In dieser Liste sammeln wir spĂ€ter die Metadaten fĂŒr unser DataFrame
records = []

print(f"Found {len(rows)} rows on the page.")


# -- Schritt 2: Alle Tabellenzeilen durchgehen ---------------------------
# tqdm zeigt einen Fortschrittsbalken an – hilfreich fĂŒr lĂ€ngere LĂ€ufe
for tr in tqdm(rows, desc="Rows"):

    # Jede Tabellenzeile besteht aus mehreren Zellen (<td>)
    cells = tr.find_all("td")

    # Sicherheitscheck:
    # Wenn eine Zeile weniger als drei Zellen hat, handelt es sich meist
    # um Footer-, Leer- oder Paginierungszeilen → ĂŒberspringen
    if len(cells) < 3:
        continue

    # --- Spalte 1: Veröffentlichungsdatum -------------------------------
    # get_text(strip=True) entfernt ZeilenumbrĂŒche und ĂŒberflĂŒssige Leerzeichen
    date_txt = cells[0].get_text(strip=True)

    # --- Spalte 2: Titel + Link zur Detailseite --------------------------
    # In der zweiten Zelle befindet sich der klickbare <a>-Link
    anchor = cells[1].find("a", href=True)

    # Falls kein Link vorhanden ist, können wir diese Zeile nicht weiterverarbeiten
    if anchor is None:
        continue

    # Sichtbarer Titel der Pressemitteilung
    title = anchor.get_text(strip=True)

    # Der Link ist relativ → wir ergĂ€nzen die Basis-URL
    pr_url = "https://www.berlin.de" + anchor["href"]

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

    # --- Eindeutige ID aus der URL ableiten ------------------------------
    # Beispiel: /pressemitteilung.1570469.php → ID = 1570469
    # Diese ID verwenden wir spÀter als Dateinamen
    uid = anchor["href"].split("/")[-1].split(".")[-2]

    # -- Schritt 3: Detailseite herunterladen -----------------------------
    # Wir speichern jede Pressemitteilung lokal:
    #   - einmal als rohe HTML-Datei
    #   - einmal als bereinigten Text
    html_file = HTML_DIR / f"{uid}.html"
    txt_file  = TXT_DIR  / f"{uid}.txt"

    # Idempotenz-Prinzip:
    # Wenn die HTML-Datei bereits existiert, laden wir nichts neu herunter.
    if not html_file.exists():

        # Detailseite abrufen
        pr_soup = get_soup(pr_url)

        # Rohes HTML speichern (fĂŒr spĂ€tere Nachvollziehbarkeit)
        html_file.write_text(str(pr_soup), encoding="utf-8")

        # --- Haupttext extrahieren --------------------------------------
        # Die Seitenstruktur hat sich ĂŒber die Jahre geĂ€ndert.
        # Deshalb probieren wir mehrere mögliche Container:
        body = (
            pr_soup.select_one("#article") or   # neues Layout (ca. ab 2024)
            pr_soup.select_one("#content") or   # Àlteres Layout
            pr_soup                              # Fallback: ganze Seite
        )

        # Textinhalt extrahieren:
        # - Leerzeichen statt ZeilenumbrĂŒche
        # - fĂŒhrende / folgende Leerzeichen entfernen
        clean_text = body.get_text(" ", strip=True)

        # Bereinigten Text separat speichern
        txt_file.write_text(clean_text, encoding="utf-8")

    else:
        # Falls die Datei schon existiert:
        # Text aus der gespeicherten TXT-Datei lesen
        clean_text = txt_file.read_text(encoding="utf-8")

    # -- Schritt 4: Metadaten fĂŒr spĂ€tere Analyse sammeln -----------------
    # Alles landet als Dictionary in der records-Liste
    records.append(
        dict(
            date=date_txt,
            ressort=ressort,
            title=title,
            pr_url=pr_url,
            filename_html=html_file.name,
            filename_txt=txt_file.name,
            # einfache Token-SchĂ€tzung ĂŒber Wortanzahl
            n_tokens=len(clean_text.split())
        )
    )

    # Kleine Höflichkeitspause:
    # reduziert Serverlast und Risiko von Sperren
    time.sleep(0.4)


# -- Schritt 5: Metadaten in ein DataFrame ĂŒberfĂŒhren ---------------------
# Aus der Liste von Dictionaries wird ein Pandas-DataFrame
df = pd.DataFrame(records)

# Kurzer Blick auf die ersten Zeilen genĂŒgt
df.head()
WARNING: Status 429 auf 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= – Versuch 1/3, Wartezeit 10s
Found 10 rows on the page.
Rows:   0%|          | 0/10 [00:00<?, ?it/s]
Rows:  10%|█         | 1/10 [00:00<00:06,  1.33it/s]
Rows:  20%|██        | 2/10 [00:01<00:06,  1.33it/s]
Rows:  30%|███       | 3/10 [00:02<00:05,  1.34it/s]
Rows:  40%|████      | 4/10 [00:03<00:04,  1.33it/s]
Rows:  50%|█████     | 5/10 [00:03<00:03,  1.34it/s]
Rows:  60%|██████    | 6/10 [00:04<00:02,  1.34it/s]
Rows:  70%|███████   | 7/10 [00:05<00:02,  1.32it/s]
Rows:  80%|████████  | 8/10 [00:06<00:01,  1.33it/s]
Rows:  90%|█████████ | 9/10 [00:06<00:00,  1.33it/s]
Rows: 100%|██████████| 10/10 [00:07<00:00,  1.33it/s]
Rows: 100%|██████████| 10/10 [00:07<00:00,  1.33it/s]

date ressort title pr_url filename_html filename_txt n_tokens
0 08.01.2026 Bezirksamt Treptow-Köpenick Winterdienst auf Gehwegen https://www.berlin.de/ba-treptow-koepenick/akt... 1631430.html 1631430.txt 1219
1 08.01.2026 Bezirksamt Mitte Haushaltsbefragung im Sanierungsgebiet Badstra... https://www.berlin.de/ba-mitte/aktuelles/press... 1631404.html 1631404.txt 1100
2 08.01.2026 Senatsverwaltung fĂŒr Kultur und Gesellschaftli... Arbeitsstipendien Bildende Kunst 2026 vergeben https://www.berlin.de/sen/kultgz/aktuelles/pre... 1631391.html 1631391.txt 399
3 08.01.2026 Bezirksamt Friedrichshain-Kreuzberg SperrmĂŒll bequem und umweltfreundlich entsorge... https://www.berlin.de/ba-friedrichshain-kreuzb... 1631367.html 1631367.txt 1460
4 07.01.2026 Senatsverwaltung fĂŒr Wirtschaft, Energie und B... Senatorin Giffey und SHK Innung demonstrieren ... https://www.berlin.de/sen/web/presse/pressemit... 1631321.html 1631321.txt 579

Ziel dieser Zelle ist es, strukturierte Textdaten fĂŒr die anschließende Analyse zu erzeugen.

6.1.5. Massenscraping von Pressemitteilungen#

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

# -- Pagination-Hilfsfunktionen -------------------------------------------
# Diese Funktionen helfen uns dabei,
# 1) die *letzte* verfĂŒgbare Ergebnisseite zu finden und
# 2) URLs fĂŒr beliebige Seitenzahlen korrekt zu erzeugen.
#
# Wichtig: Die Basis-URL enthÀlt bereits alle Suchfilter
# (z.B. Zeitraum, Ressort usw.) als Query-Parameter.
# Diese dĂŒrfen beim BlĂ€ttern NICHT verloren gehen.

SEARCH_ROOT = FIRST_OUTPUT_PAGE  # erste Ergebnisseite inkl. aller Filter


def last_page_number() -> int:
    """
    Bestimmt die höchste verfĂŒgbare Ergebnisseiten-Nummer.

    Vorgehen:
    - Die erste Suchseite wird geladen.
    - Im Navigationsbereich (<nav>) suchen wir gezielt nach dem Link
      „zur letzten Seite“.
    - Aus dessen URL wird die Seitenzahl extrahiert.

    RĂŒckgabewert
    ------------
    int
        Nummer der letzten Ergebnisseite (z.B. 5239)
    """
    # Erste Ergebnisseite laden
    soup = get_soup(SEARCH_ROOT)

    # Der Link zur letzten Seite steckt im <li>-Element
    # mit der Klasse "pager-skip-to-last"
    last_link = soup.select_one("li.pager-skip-to-last a")
    if not last_link:
        raise RuntimeError(
            "Konnte die letzte Seite nicht finden – CSS-Selector prĂŒfen."
        )

    # href hat typischerweise die Form:
    # ".../search/page/5239?<gleiche Filterparameter>"
    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:
    """
    Erzeugt die URL fĂŒr eine bestimmte Ergebnisseite.

    Idee:
    - Wir nehmen die Basis-Such-URL
    - ersetzen "/search/" durch "/search/page/<nr>/"
    - alle Filter (Query-Parameter) bleiben unverÀndert erhalten

    Parameter
    ---------
    page_num : int
        GewĂŒnschte Seitennummer (beginnt bei 1)
    """
    if page_num < 1:
        raise ValueError("Seitennummern beginnen bei 1.")

    return SEARCH_ROOT.replace(
        "/search/", f"/search/page/{page_num}/"
    )

Vorbereitung der Funktion fĂŒr das Massenscraping#

Diese Zelle lÀdt alle Trefferseiten der Pressemitteilungen nacheinander.
Die Detailseiten werden verarbeitet, Texte gespeichert und Metadaten laufend in eine CSV-Datei geschrieben, sodass der Crawl jederzeit sicher unterbrochen und fortgesetzt werden kann.

# Diese Zelle enthĂ€lt einen „Bulk-Crawler“, der *alle* Ergebnisseiten
# nacheinander verarbeitet und Metadaten schrittweise in eine CSV-Datei schreibt.

META_CSV    = DATA_DIR / "metadata.csv"

# Wie viele DatensÀtze sammeln wir im Speicher,
# bevor wir sie sicher in die CSV-Datei schreiben?
BUFFER_SIZE = 100


def crawl_all_pages(
    pages: int | None = None,
    sleep_s: float = 0.4,
    buffer_size: int = BUFFER_SIZE,
) -> pd.DataFrame:
    """
    Crawlt alle Trefferseiten der Pressemitteilungen und speichert Metadaten
    inkrementell in einer CSV-Datei.

    Zentrale Idee
    -------------
    - Die Ergebnisseiten werden nacheinander abgearbeitet.
    - FĂŒr jede Pressemitteilung wird die Detailseite geladen (falls nötig).
    - HTML und bereinigter Text werden lokal gespeichert.
    - Metadaten werden *schrittweise* in eine CSV geschrieben (Autosave).

    Vorteile dieses Ansatzes
    ------------------------
    - Der Crawl kann jederzeit unterbrochen und spÀter fortgesetzt werden.
    - Bereits vorhandene DatensÀtze werden nicht doppelt verarbeitet.
    - Auch bei Fehlern geht nur wenig Arbeit verloren.

    Parameter
    ---------
    pages : int | None
        Wie viele Ergebnisseiten sollen verarbeitet werden?
        None bedeutet: automatisch die letzte verfĂŒgbare Seite ermitteln.
    sleep_s : float
        Wartezeit zwischen Detailseiten-Requests (Server-Höflichkeit).
    buffer_size : int
        Anzahl der DatensÀtze, die gepuffert werden, bevor sie in die CSV geschrieben werden.
    """

    # Wenn keine Seitenzahl angegeben ist, bestimmen wir sie automatisch
    if pages is None:
        pages = last_page_number()
        print(f"→ Letzte Trefferseite lautet {pages}")

    # --------------------------------------------------------------
    # Vorbereitung der CSV-Datei
    # --------------------------------------------------------------

    # Erwarteter Spaltenkopf der Metadaten-Datei
    EXPECTED_HEADER = [
        "id", "url", "date", "title",
        "source", "filename_html", "filename", "n_tokens"
    ]

    # In dieser Menge merken wir uns alle bereits bekannten IDs (UIDs)
    # → wichtig, um den Crawl spĂ€ter fortsetzen zu können
    existing_uids: set[str] = set()

    # Standardannahme: Header fehlt (wird ggf. korrigiert)
    need_header = True

    # Falls metadata.csv bereits existiert und nicht leer ist:
    if META_CSV.exists() and META_CSV.stat().st_size > 0:

        # PrĂŒfen, ob der Header schon korrekt vorhanden ist
        with open(META_CSV, newline="", encoding="utf-8") as fh:
            first_line = fh.readline().strip()
            need_header = first_line.split(",") != EXPECTED_HEADER

        # Nur wenn ein korrekter Header vorhanden ist,
        # lesen wir die bestehenden IDs ein
        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."
            )

    # CSV-Datei im passenden Modus öffnen:
    # - append ("a"), wenn sie existiert
    # - write ("w"), wenn sie neu ist
    mode = "a" if META_CSV.exists() else "w"
    csvfile = open(META_CSV, mode, newline="", encoding="utf-8")
    writer = csv.DictWriter(csvfile, fieldnames=EXPECTED_HEADER)

    # Falls nötig, schreiben wir den Header jetzt
    if need_header:
        writer.writeheader()
        csvfile.flush()

    # Puffer fĂŒr neue DatensĂ€tze (Autosave-Mechanismus)
    buffer: list[dict] = []

    # --------------------------------------------------------------
    # Hauptschleife: alle Ergebnisseiten durchlaufen
    # --------------------------------------------------------------
    for p in tqdm(range(1, pages + 1), desc="Result pages"):

        # HTML der aktuellen Ergebnisseite abrufen
        list_soup = get_soup(page_url(p))

        # Falls die ganze Seite nicht erreichbar ist → ĂŒberspringen
        if list_soup is None:
            continue

        # Jede Tabellenzeile entspricht einer Pressemitteilung
        for tr in list_soup.select("table tbody tr"):

            cells = tr.find_all("td")
            if len(cells) < 3:
                continue  # Footer-/Leerzeilen ignorieren

            # --- Basis-Metadaten aus der Tabelle -------------------------
            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 aus der URL extrahieren (z.B. pressemitteilung.1570469.php)
            uid = anchor["href"].split(".")[-2]

            # Bereits bekannte DatensĂ€tze ĂŒberspringen (Resume-Funktion)
            if uid in existing_uids:
                continue

            # --- Dateipfade festlegen -----------------------------------
            html_fp = HTML_DIR / f"{uid}.html"
            txt_fp  = TXT_DIR  / f"{uid}.txt"

            # --- Detailseite laden --------------------------------------
            pr_soup = get_soup(pr_url)

            # 404 / 410 → keine Detailseite → kein Datensatz
            if pr_soup is None:
                continue

            # HTML und Text nur neu erzeugen, wenn noch nicht vorhanden
            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")

            # --- Metadaten-Record bauen ---------------------------------
            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,
                n_tokens=len(clean.split())
            )

            buffer.append(rec)
            existing_uids.add(uid)   # sofort merken (wichtig bei Abbruch)
            time.sleep(sleep_s)

            # --- Autosave: Puffer in CSV schreiben ----------------------
            if len(buffer) >= buffer_size:
                writer.writerows(buffer)
                csvfile.flush()
                buffer.clear()

        # Optionales Sicherheits-Flush nach jeder Ergebnisseite
        if buffer:
            writer.writerows(buffer)
            csvfile.flush()
            buffer.clear()

    # --------------------------------------------------------------
    # AufrĂ€umen & RĂŒckgabe
    # --------------------------------------------------------------
    csvfile.close()
    logging.info("✅ Crawl abgeschlossen, CSV geschlossen.")

    # CSV erneut einlesen, um direkt im Notebook weiterzuarbeiten
    return pd.read_csv(META_CSV, encoding="utf-8")

Massenscraping#

Testlauf mit 5 Seiten:#

# Testlauf mit 5 Seiten
df_test = crawl_all_pages(pages=5)
df_test.head()

Alle Seiten crawlen:#

# Achtung: Das kann > 1 Stunde dauern und tausende Dateien erzeugen!
# Alle Seiten crawlen
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
## 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")

6.1.6. 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.

6.1.7. 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.

Hinweis

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