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.

Hinweise zur AusfĂŒhrung des Notebooks

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

  1. Book-Only Mode: Sie lesen das Notebook hier im “Jupyter Book”, ohne den Code selbst auszufĂŒhren.

  2. Cloud Mode: Klicken Sie oben rechts in der MenĂŒleiste auf das Raketen-Symbol 🚀 und wĂ€hlen Sie “Colab”, um das Notebook auszufĂŒhren.

  3. Local Mode: Klicken Sie oben rechts in der MenĂŒleiste auf das Download-Symbol ↓ und wĂ€hlen Sie “.ipynb”, um das Notebook lokal auszufĂŒhren.

6.1.1. Ü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 beautifulsoup4 pandas itables
Requirement already satisfied: requests in /opt/hostedtoolcache/Python/3.11.15/x64/lib/python3.11/site-packages (2.32.3)
Requirement already satisfied: tqdm in /opt/hostedtoolcache/Python/3.11.15/x64/lib/python3.11/site-packages (4.70.0)
Collecting lxml
  Downloading lxml-6.1.1-cp311-cp311-manylinux_2_26_x86_64.manylinux_2_28_x86_64.whl.metadata (3.5 kB)
Requirement already satisfied: beautifulsoup4 in /opt/hostedtoolcache/Python/3.11.15/x64/lib/python3.11/site-packages (4.12.3)
Requirement already satisfied: pandas in /opt/hostedtoolcache/Python/3.11.15/x64/lib/python3.11/site-packages (3.0.5)
Collecting itables
  Downloading itables-2.9.1-py3-none-any.whl.metadata (12 kB)
Requirement already satisfied: charset-normalizer<4,>=2 in /opt/hostedtoolcache/Python/3.11.15/x64/lib/python3.11/site-packages (from requests) (3.3.2)
Requirement already satisfied: idna<4,>=2.5 in /opt/hostedtoolcache/Python/3.11.15/x64/lib/python3.11/site-packages (from requests) (3.8)
Requirement already satisfied: urllib3<3,>=1.21.1 in /opt/hostedtoolcache/Python/3.11.15/x64/lib/python3.11/site-packages (from requests) (2.2.2)
Requirement already satisfied: certifi>=2017.4.17 in /opt/hostedtoolcache/Python/3.11.15/x64/lib/python3.11/site-packages (from requests) (2024.8.30)
Requirement already satisfied: soupsieve>1.2 in /opt/hostedtoolcache/Python/3.11.15/x64/lib/python3.11/site-packages (from beautifulsoup4) (2.6)
Requirement already satisfied: numpy>=1.26.0 in /opt/hostedtoolcache/Python/3.11.15/x64/lib/python3.11/site-packages (from pandas) (2.1.1)
Requirement already satisfied: python-dateutil>=2.8.2 in /opt/hostedtoolcache/Python/3.11.15/x64/lib/python3.11/site-packages (from pandas) (2.9.0.post0)
Requirement already satisfied: six>=1.5 in /opt/hostedtoolcache/Python/3.11.15/x64/lib/python3.11/site-packages (from python-dateutil>=2.8.2->pandas) (1.16.0)
Downloading lxml-6.1.1-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 237.9 MB/s  0:00:00
?25h
Downloading itables-2.9.1-py3-none-any.whl (1.6 MB)
?25l   ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━ 0.0/1.6 MB ? eta -:--:--
   ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━ 1.6/1.6 MB 259.7 MB/s  0:00:00
?25h
Installing collected packages: lxml, itables
?25l
   ━━━━━━━━━━━━━━━━━━━━â•ș━━━━━━━━━━━━━━━━━━━ 1/2 [itables]
   ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━ 2/2 [itables]
?25h
Successfully installed itables-2.9.1 lxml-6.1.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
from itables import show

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

Auswahl
  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:

Auswahl

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.3. 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)

# Interaktives Anzeigen der Tabelle 
show(df)
Found 10 rows on the page.
Rows:   0%|          | 0/10 [00:00<?, ?it/s]
Rows:  10%|█         | 1/10 [00:00<00:07,  1.28it/s]
Rows:  20%|██        | 2/10 [00:01<00:06,  1.30it/s]
Rows:  30%|███       | 3/10 [00:02<00:05,  1.30it/s]
Rows:  40%|████      | 4/10 [00:03<00:04,  1.31it/s]
Rows:  50%|█████     | 5/10 [00:03<00:03,  1.30it/s]
Rows:  60%|██████    | 6/10 [00:04<00:03,  1.27it/s]
Rows:  70%|███████   | 7/10 [00:05<00:02,  1.29it/s]
Rows:  80%|████████  | 8/10 [00:06<00:01,  1.30it/s]
Rows:  90%|█████████ | 9/10 [00:06<00:00,  1.30it/s]
Rows: 100%|██████████| 10/10 [00:07<00:00,  1.30it/s]
Rows: 100%|██████████| 10/10 [00:07<00:00,  1.30it/s]

ⓘdate ressort title pr_url filename_html filename_txt n_tokens
29.07.2026Bezirksamt Friedrichshain-KreuzbergUnterzeichnung der Kooperationsvereinbarung Stadtteilarbeit im Bezirk Friedrichshain-Kreuzberghttps://www.berlin.de/ba-friedrichshain-kreuzberg/aktuelles/pressemitteilungen/2026/pressemitteilung.1698445.php1698445.html1698445.txt1552
29.07.2026Bezirksamt ReinickendorfMigrationsbeirat Reinickendorf veröffentlicht Stellungnahme zum CSD 2026https://www.berlin.de/ba-reinickendorf/aktuelles/pressemitteilungen/2026/pressemitteilung.1698444.php1698444.html1698444.txt1030
29.07.2026Bezirksamt Tempelhof-SchönebergStart der Wahlwerbung am 2. August 2026: Ordnungsamt kontrolliert Einhaltunghttps://www.berlin.de/ba-tempelhof-schoeneberg/aktuelles/pressemitteilungen/2026/pressemitteilung.1698381.php1698381.html1698381.txt967
29.07.2026Bezirksamt Tempelhof-SchönebergBSR-Kieztag am 5. August 2026 am DĂŒrerplatzhttps://www.berlin.de/ba-tempelhof-schoeneberg/aktuelles/pressemitteilungen/2026/pressemitteilung.1698368.php1698368.html1698368.txt1165
29.07.2026Bezirksamt Steglitz-ZehlendorfNeuauflage des Hitzeaktionsplans fĂŒr Steglitz-Zehlendorfhttps://www.berlin.de/ba-steglitz-zehlendorf/aktuelles/pressemitteilungen/2026/pressemitteilung.1698357.php1698357.html1698357.txt1131
29.07.2026Bezirksamt Marzahn-HellersdorfKatrin Rohnstock ĂŒber Treuhand-Erfahrungen: GesprĂ€ch in der Mark-Twain-Bibliothek in Marzahn-Hellersdorfhttps://www.berlin.de/ba-marzahn-hellersdorf/aktuelles/pressemitteilungen/2026/pressemitteilung.1698350.php1698350.html1698350.txt763
29.07.2026Bezirksamt ReinickendorfHitzewarnung fĂŒr Berlin: Hinweise zum Schutz vor Hitzehttps://www.berlin.de/ba-reinickendorf/aktuelles/pressemitteilungen/2026/pressemitteilung.1698344.php1698344.html1698344.txt710
29.07.2026Bezirksamt Tempelhof-SchönebergNeues aus der Volkshochschule Tempelhof-Schöneberghttps://www.berlin.de/ba-tempelhof-schoeneberg/aktuelles/pressemitteilungen/2026/pressemitteilung.1698282.php1698282.html1698282.txt1076
29.07.2026Bezirksamt LichtenbergLichtenberg investiert rund 4,4 Millionen Euro in leistungsfĂ€hige Straßen und barrierefreie Wegehttps://www.berlin.de/ba-lichtenberg/aktuelles/pressemitteilungen/2026/pressemitteilung.1698295.php1698295.html1698295.txt1363
29.07.2026Bezirksamt Treptow-KöpenickNeuer Flyer stÀrkt den Sprachaustausch: Sprachcafés in Treptow-Köpenick neu aufgelegthttps://www.berlin.de/ba-treptow-koepenick/aktuelles/pressemitteilungen/2026/pressemitteilung.1698285.php1698285.html1698285.txt985

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

6.1.4. 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 = [
        "DC.identifier", "DC.source", "DC.date", "DC.title",
        "DC.publisher", "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)
                # The original code had row["id"] here, but it should be DC.identifier
                existing_uids = {row["DC.identifier"] 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 ---------------------------------
            # Corrected to match EXPECTED_HEADER keys
            rec = {
                "DC.identifier": uid,
                "DC.date": date_txt,
                "DC.title": anchor.get_text(strip=True),
                "DC.source": ressort,
                "DC.publisher": "", # Added, assuming no direct extraction for it
                "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 3 Seiten:#

# Testlauf mit 3 Seiten
df_test = crawl_all_pages(pages=3)
INFO: ⚙  51826 vorhandene DatensĂ€tze erkannt.
Result pages:   0%|          | 0/3 [00:00<?, ?it/s]
Result pages:  33%|███▎      | 1/3 [00:09<00:18,  9.25s/it]
WARNING: Status 429 auf https://www.berlin.de/ba-treptow-koepenick/aktuelles/pressemitteilungen/2026/pressemitteilung.1698194.php – Versuch 1/3, Wartezeit 10s
Result pages:  67%|██████▋   | 2/3 [00:29<00:15, 15.97s/it]
Result pages: 100%|██████████| 3/3 [00:42<00:00, 14.26s/it]
Result pages: 100%|██████████| 3/3 [00:42<00:00, 14.05s/it]
INFO: ✅ Crawl abgeschlossen, CSV geschlossen.
show(df_test)
ⓘDC.identifier DC.source DC.date DC.title DC.publisher filename_html filename n_tokens
1571189https://www.berlin.de/rbmskzl/aktuelles/pressemitteilungen/2025/pressemitteilung.1571189.php18.06.2025Mehr Sicherheit fĂŒr Daten und Digitalisierungsprojekte – der neue Data-Governance-WegweiserPresse- und Informationsamt des Landes Berlin1571189.html1571189.txt786
1571151https://www.berlin.de/ba-spandau/aktuelles/pressemitteilungen/2025/pressemitteilung.1571151.php18.06.2025Aufruf zur Teilnahme: Online-Befragung Spandauer Unternehmen zur Entwicklung eines bezirklichen WirtschaftsflÀchenkonzepts gestartetBezirksamt Spandau1571151.html1571151.txt713
1571106https://www.berlin.de/ba-marzahn-hellersdorf/aktuelles/pressemitteilungen/2025/pressemitteilung.1571106.php18.06.2025Marzahn-Hellersdorf sucht freie RĂ€ume fĂŒr bĂŒrgerschaftliches EngagementBezirksamt Marzahn-Hellersdorf1571106.html1571106.txt671
1571032https://www.berlin.de/ba-lichtenberg/aktuelles/pressemitteilungen/2025/pressemitteilung.1571032.php18.06.20254. Queere Kunst- und Kulturtage in LichtenbergBezirksamt Lichtenberg1571032.html1571032.txt828
1571030https://www.berlin.de/ba-neukoelln/aktuelles/pressemitteilungen/2025/pressemitteilung.1571030.php18.06.2025Online-Umfrage zur Sauberkeit und Ordnung in BerlinBezirksamt Neukölln1571030.html1571030.txt978
1571042https://www.berlin.de/ba-friedrichshain-kreuzberg/aktuelles/pressemitteilungen/2025/pressemitteilung.1571042.php18.06.2025Lesung von Mareike Barmeyer mit „Lauf, Mama, lauf! Geschichten mit Kindern“ in der BezirkszentralbibliothekBezirksamt Friedrichshain-Kreuzberg1571042.html1571042.txt1524
1571021https://www.berlin.de/ba-steglitz-zehlendorf/aktuelles/pressemitteilungen/2025/pressemitteilung.1571021.php18.06.202524. öffentliche Sitzung des Ausschusses fĂŒr GebĂ€ude, Wirtschaft, Inklusion, Verwaltungsmodernisierung, DigitalisierungBezirksamt Steglitz-Zehlendorf1571021.html1571021.txt703
1570984https://www.berlin.de/ba-spandau/aktuelles/pressemitteilungen/2025/pressemitteilung.1570984.php18.06.2025Presseeinladung: Hissen der Prideflagge am 09.07.2025 vor dem Rathaus SpandauBezirksamt Spandau1570984.html1570984.txt633
1571001https://www.berlin.de/ba-friedrichshain-kreuzberg/aktuelles/pressemitteilungen/2025/pressemitteilung.1571001.php18.06.2025JazzOrchester X-berg (JOX) gewinnt 1. Platz beim Deutschen Orchesterwettbewerb 2025Bezirksamt Friedrichshain-Kreuzberg1571001.html1571001.txt1493
1570968https://www.berlin.de/ba-treptow-koepenick/aktuelles/pressemitteilungen/2025/pressemitteilung.1570968.php18.06.2025Sommerzeit ist Badezeit: Der FEZ-Badesee startet in die SaisonBezirksamt Treptow-Köpenick1570968.html1570968.txt944
(51,846 more rows not shown)

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.5. 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: 2011 – 2025

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

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

6.1.6. Zusammenfassung des technischen Workflow#

  1. Filter setzen im Online‑Formular → ĂŒber die Checkboxen werden gezielt alle Institutionen der Berliner Exekutive angehakt (Senatsverwaltungen, BezirksĂ€mter und Landesbeauftragte); zusammen mit einer leeren Suchanfrage liefert die Suche so sĂ€mtliche Pressemitteilungen dieser Institutionen. Die vollstĂ€ndige Such‑URL inklusive aller Filter ist in der Notebook‑Konstante FIRST_OUTPUT_PAGE hinterlegt (die im Pagination‑Code als SEARCH_ROOT weiterverwendet wird).

  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.