6.1. đ Massenscraping von Pressemitteilungen#
6.1.1. Hinweise zur AusfĂŒhrung des Notebooks#
Dieses Notebook kann auf unterschiedlichen Levels erarbeitet werden (siehe Abschnitt âTechnische Voraussetzungenâ):
Book-Only Mode
Cloud Mode: DafĂŒr auf đ klicken und z.B. in Colab ausfĂŒhren.
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:
Wir werden die Struktur des Teils der Website untersuchen, der alle Pressemitteilungen enthÀlt.
Wir werden die URL-Links zu allen Pressemitteilungen abrufen.
AbschlieĂend werden wir alle Pressemitteilungen scrapen.
Show 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
Show 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#
Wir wissen bereits, dass das SuchmenĂŒ auf der Website Berlin.de gezielt die Auswahl der fĂŒr uns interessanten Abteilungen ermöglicht.

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

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:

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
Show 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#
Filter setzen im OnlineâFormular â alle oben aufgefĂŒhrten Institutionen anhaken (siehe URL in der NotebookâKonstante
SEARCH_ROOT).Letzte Ergebnisseite ermitteln via CSSâSelektor
li.pager-skip-to-last a(Stand JuniâŻ2025: Seite 5239).Pagination ablaufen
Trefferzeilen auslesen (
table tbody tr).Detailseiten abrufen; Haupttext steckt verlÀsslich in
#layout-grid__area--maincontent(Fallback:#articleoder#content).HTMLÂ +Â bereinigter PlainâText unter
data/html/<id>.htmlbzw.data/txt/<id>.txtspeichern.Metadaten (Datum, Titel, Ressort, Dateinamen, TokenâZahl) inkrementell in
data/metadata.csvanhÀngen (Autosave alle 100 DatensÀtze).
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.