đ Massenscraping von Pressemitteilungen#
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.
Ă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.13/x64/lib/python3.11/site-packages (2.32.3)
Collecting tqdm
Downloading tqdm-4.67.1-py3-none-any.whl.metadata (57 kB)
Collecting lxml
Downloading lxml-6.0.0-cp311-cp311-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl.metadata (6.6 kB)
Requirement already satisfied: charset-normalizer<4,>=2 in /opt/hostedtoolcache/Python/3.11.13/x64/lib/python3.11/site-packages (from requests) (3.3.2)
Requirement already satisfied: idna<4,>=2.5 in /opt/hostedtoolcache/Python/3.11.13/x64/lib/python3.11/site-packages (from requests) (3.8)
Requirement already satisfied: urllib3<3,>=1.21.1 in /opt/hostedtoolcache/Python/3.11.13/x64/lib/python3.11/site-packages (from requests) (2.2.2)
Requirement already satisfied: certifi>=2017.4.17 in /opt/hostedtoolcache/Python/3.11.13/x64/lib/python3.11/site-packages (from requests) (2024.8.30)
Downloading tqdm-4.67.1-py3-none-any.whl (78 kB)
Downloading lxml-6.0.0-cp311-cp311-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl (5.2 MB)
?25l ââââââââââââââââââââââââââââââââââââââââ 0.0/5.2 MB ? eta -:--:--
âââââââââââââââââââââââââââșâââââââââââââ 3.4/5.2 MB 18.5 MB/s eta 0:00:01
ââââââââââââââââââââââââââââââââââââââââ 5.2/5.2 MB 21.0 MB/s eta 0:00:00
?25h
Installing collected packages: tqdm, lxml
?25l
âââââââââââââââââââââșâââââââââââââââââââ 1/2 [lxml]
ââââââââââââââââââââââââââââââââââââââââ 2/2 [lxml]
?25h
Successfully installed lxml-6.0.0 tqdm-4.67.1
Show code cell content
import requests, pathlib, time, re, logging, textwrap, csv
from bs4 import BeautifulSoup
import pandas as pd
from tqdm import tqdm
1. Abruf und Analyse der Suchseite fĂŒr Pressemitteilungen#
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.
2. Suchergebnisse scrapen und Pressemitteilungen extrahieren (auf einer Seite):#
2.1. Organisation der Ordnerstruktur#
# -- organise data ----------------------------------------------------
FIRST_OUTPUT_PAGE = (
"https://www.berlin.de/presse/pressemitteilungen/index/search/?searchtext=&boolean=0&startdate=&enddate=&alle-senatsverwaltungen=on&institutions%5B%5D=Presse-+und+Informationsamt+des+Landes+Berlin&institutions%5B%5D=Senatsverwaltung+fĂŒr+Bildung%2C+Jugend+und+Familie&institutions%5B%5D=Senatsverwaltung+fĂŒr+Finanzen&institutions%5B%5D=Senatsverwaltung+fĂŒr+Inneres+und+Sport&institutions%5B%5D=Senatsverwaltung+fĂŒr+Arbeit%2C+Soziales%2C+Gleichstellung%2C+Integration%2C+Vielfalt+und+Antidiskriminierung&institutions%5B%5D=Senatsverwaltung+fĂŒr+Justiz+und+Verbraucherschutz&institutions%5B%5D=Senatsverwaltung+fĂŒr+Kultur+und+Gesellschaftlichen+Zusammenhalt&institutions%5B%5D=Senatsverwaltung+fĂŒr+Stadtentwicklung%2C+Bauen+und+Wohnen&institutions%5B%5D=Senatsverwaltung+fĂŒr+MobilitĂ€t%2C+Verkehr%2C+Klimaschutz+und+Umwelt&institutions%5B%5D=Senatsverwaltung+fĂŒr+Wirtschaft%2C+Energie+und+Betriebe&institutions%5B%5D=Senatsverwaltung+fĂŒr+Wissenschaft%2C+Gesundheit+und+Pflege&alle-bezirksamt=on&institutions%5B%5D=Bezirksamt+Charlottenburg-Wilmersdorf&institutions%5B%5D=Bezirksamt+Friedrichshain-Kreuzberg&institutions%5B%5D=Bezirksamt+Lichtenberg&institutions%5B%5D=Bezirksamt+Marzahn-Hellersdorf&institutions%5B%5D=Bezirksamt+Mitte&institutions%5B%5D=Bezirksamt+Neukölln&institutions%5B%5D=Bezirksamt+Pankow&institutions%5B%5D=Bezirksamt+Reinickendorf&institutions%5B%5D=Bezirksamt+Spandau&institutions%5B%5D=Bezirksamt+Steglitz-Zehlendorf&institutions%5B%5D=Bezirksamt+Tempelhof-Schöneberg&institutions%5B%5D=Bezirksamt+Treptow-Köpenick&alle-landesbeauftragte=on&institutions%5B%5D=Beauftragte+des+Senats+fĂŒr+Integration+und+Migration&institutions%5B%5D=Beauftragter+zur+Aufarbeitung+der+SED-Diktatur&institutions%5B%5D=BĂŒrger-+und+Polizeibeauftragter+des+Landes+Berlin&institutions%5B%5D=Pflegebeauftragte+des+Landes+Berlin&institutions%5B%5D=Landestierschutzbeauftragte&institutions%5B%5D=Landeswahlleitung&bt="
)
DATA_DIR = pathlib.Path("../data")
HTML_DIR = DATA_DIR / "html"
TXT_DIR = DATA_DIR / "txt"
HTML_DIR.mkdir(parents=True, exist_ok=True)
TXT_DIR.mkdir(parents=True, exist_ok=True)
logging.basicConfig(level=logging.INFO, format="%(levelname)s: %(message)s")
2.2. Vorbereitung der Funktion, die die HTTP-Anfrage ausfĂŒhrt und die Antwort verarbeitet#
# -- helper -----------------------------------------------------------------
def get_soup(
url: str,
*,
max_retries: int = 3,
sleep_s: float = 10,
) -> BeautifulSoup | None:
"""
LĂ€dt eine URL und gibt BeautifulSoup zurĂŒck.
- Bei 404/410 wird nach *max_retries* Versuchen endgĂŒltig None geliefert.
- Bei allen anderen Fehlern wird bis *max_retries* weiterprobiert.
"""
for attempt in range(1, max_retries + 1):
try:
r = requests.get(
url,
timeout=20,
headers={"User-Agent": "Mozilla/5.0 (QuadrigaScraper/1.0)"},
)
except requests.RequestException as err:
logging.warning("Netzwerkfehler %s â Versuch %d/%d", err, attempt, max_retries)
time.sleep(sleep_s)
continue
if r.status_code == 200:
return BeautifulSoup(r.text, "lxml")
# Dauerhafte Fehler: 404 (Not Found) oder 410 (Gone)
if r.status_code in (404, 410):
logging.info("â %s liefert %s â ĂŒberspringe.", url, r.status_code)
return None
logging.warning(
"Status %s auf %s â Versuch %d/%d, Wartezeit %ss",
r.status_code, url, attempt, max_retries, sleep_s
)
time.sleep(sleep_s)
logging.error("đš %s nach %d Versuchen nicht erreichbar â ĂŒberspringe.", url, max_retries)
return None
def slugify(text_: str, maxlen: int = 60) -> str:
"""Rough filename-safe slug for headlines."""
text_ = re.sub(r"\W+", "-", text_.lower()).strip("-")
return text_[:maxlen] or "untitled"
đ€ Was passiert in diesem Codeblock oben?#
Klicken Sie unten, um die ErklÀrung zu lesen:
Was passiert in diesem Codeblock oben?
get_soup()
Ziel: Eine URL abrufen und â falls erfolgreich â als
BeautifulSoup
-Objekt zurĂŒckliefern.Konfigurierbare Robustheit
max_retries
(Std.: 3): Wie oft probiert das Skript es insgesamt?sleep_s
(Std.: 10 s): Wartezeit zwischen den Versuchen (Schutz vor Ăberlastung & Rate Limits).
Ablauf pro Versuch
HTTP-Request mit
20 Sekunden Timeout und
eigenem User-Agent âQuadrigaScraper/1.0â.
Status 200 â Seite wird geparst und sofort zurĂŒckgegeben.
Status 404 oder 410 â gelten als endgĂŒltig (âSeite existiert nichtâ). Nach bis zu
max_retries
Versuchen gibt die FunktionNone
zurĂŒck und der Aufrufer kann den Datensatz ĂŒberspringen.Andere Fehler (5xx, 429 usw.) oder Netzwerk-Ausnahmen lösen eine Warnung aus. Nach
sleep_s
Sekunden wird erneut versucht, bis das Limitmax_retries
erreicht ist.
Ergebnis:
Erfolgreicher Abruf â
BeautifulSoup
Dauerhafter Fehler â
None
(der Scraper fÀhrt fort, ohne zu hÀngen)
slugify()
Konvertiert beliebigen Text in einen dateinamen-tauglichen âSlugâ:
Kleinschreibung
Nicht-alphanumerische Zeichen durch Bindestriche ersetzen
fĂŒhrende/abschlieĂende Bindestriche entfernen
auf 60 Zeichen kĂŒrzen
Liefert bei komplett leerem Ergebnis den Fallback âuntitledâ.
2.3. Extrahieren von Pressemitteilungen aus einer Seite mit Suchausgabe#
# -- Schritt 1: genau **eine** Ergebnisseite parsen -----------------------
search_soup = get_soup(FIRST_OUTPUT_PAGE)
rows = search_soup.select("table tbody tr")
records = []
print(f"Found {len(rows)} rows on the page.")
for tr in tqdm(rows, desc="Rows"):
# alle <td>-Zellen der Zeile auf einmal holen
cells = tr.find_all("td")
if len(cells) < 3: # FuĂzeilen / Leerzeilen ĂŒberspringen
continue
# Spalte 1 â Datum
date_txt = cells[0].get_text(strip=True)
# Spalte 2 â Ăberschrift + Link
anchor = cells[1].find("a", href=True)
if anchor is None: # Sicherheits-Check
continue
title = anchor.get_text(strip=True)
pr_url = "https://www.berlin.de" + anchor["href"]
# Spalte 3 â herausgebende Behörde (âRessortâ)
ressort = cells[2].get_text(strip=True)
# deterministische ID, z. B. 1570469
uid = anchor["href"].split("/")[-1].split(".")[-2]
# -- Schritt 2: Detailseite herunterladen -------------------------
html_file = HTML_DIR / f"{uid}.html"
txt_file = TXT_DIR / f"{uid}.txt"
if not html_file.exists(): # ĂŒberspringen, wenn bereits gescrapet
pr_soup = get_soup(pr_url)
# rohe HTML-Datei speichern
html_file.write_text(str(pr_soup), encoding="utf-8")
# Haupttext extrahieren; Fallback, falls sich die ID Àndert
body = (pr_soup.select_one("#article") or # neues Layout (2024)
pr_soup.select_one("#content") or # klassisches Layout
pr_soup) # letzter Ausweg
clean_text = body.get_text(" ", strip=True)
txt_file.write_text(clean_text, encoding="utf-8")
else:
# FĂŒr das DataFrame trotzdem LĂ€nge des Textes bestimmen
clean_text = txt_file.read_text(encoding="utf-8")
records.append(
dict(
date=date_txt,
ressort=ressort,
title=title,
pr_url=pr_url,
filename_html=html_file.name,
filename_txt=txt_file.name,
n_tokens=len(clean_text.split())
)
)
time.sleep(0.4) # Höflichkeitspause
# -- Schritt 3: Metadaten inspizieren ------------------------------------
df = pd.DataFrame(records)
df.head() # normale Jupyter-Ausgabe genĂŒgt
Found 10 rows on the page.
Rows: 0%| | 0/10 [00:00<?, ?it/s]
Rows: 10%|â | 1/10 [00:01<00:16, 1.80s/it]
Rows: 20%|ââ | 2/10 [00:02<00:10, 1.28s/it]
Rows: 30%|âââ | 3/10 [00:03<00:07, 1.11s/it]
Rows: 40%|ââââ | 4/10 [00:04<00:06, 1.02s/it]
Rows: 50%|âââââ | 5/10 [00:05<00:04, 1.03it/s]
Rows: 60%|ââââââ | 6/10 [00:06<00:03, 1.06it/s]
Rows: 70%|âââââââ | 7/10 [00:07<00:02, 1.07it/s]
Rows: 80%|ââââââââ | 8/10 [00:08<00:01, 1.09it/s]
Rows: 90%|âââââââââ | 9/10 [00:08<00:00, 1.09it/s]
Rows: 100%|ââââââââââ| 10/10 [00:09<00:00, 1.10it/s]
Rows: 100%|ââââââââââ| 10/10 [00:09<00:00, 1.01it/s]
date | ressort | title | pr_url | filename_html | filename_txt | n_tokens | |
---|---|---|---|---|---|---|---|
0 | 01.07.2025 | Bezirksamt Marzahn-Hellersdorf | Mark-Twain-Bibliothek: Journalistin Danuta Sch... | https://www.berlin.de/ba-marzahn-hellersdorf/a... | 1575880.html | 1575880.txt | 755 |
1 | 01.07.2025 | Bezirksamt Neukölln | 50 Jahre Engagement fĂŒr Kinder und Jugendliche... | https://www.berlin.de/ba-neukoelln/aktuelles/p... | 1575859.html | 1575859.txt | 1124 |
2 | 01.07.2025 | Bezirksamt Marzahn-Hellersdorf | Marzahn-Hellersdorf rĂŒstet sich gegen die Somm... | https://www.berlin.de/ba-marzahn-hellersdorf/a... | 1575841.html | 1575841.txt | 952 |
3 | 01.07.2025 | Bezirksamt Pankow | Wegen Trockenheit: Ab sofort temporÀres Grillv... | https://www.berlin.de/ba-pankow/aktuelles/pres... | 1575839.html | 1575839.txt | 658 |
4 | 01.07.2025 | Bezirksamt Lichtenberg | Aufruf zum Demokratiepreis 2025 | https://www.berlin.de/ba-lichtenberg/aktuelles... | 1575829.html | 1575829.txt | 762 |
đ€ Was passiert in diesem Codeblock oben?#
Klicken Sie unten, um die Schritt-fĂŒr-Schritt-ErklĂ€rung zu lesen:
Was passiert in diesem Codeblock oben? â Schritt fĂŒr Schritt
Erste Seite einlesen
search_soup = get_soup(SAMPLE_OUTPUT_PAGE) rows = search_soup.select("table tbody tr")
Die Funktion
get_soup()
lĂ€dt genau eine Ergebnisseite der Such-/Listenansicht und gibt sie als Beautiful-Soup-Objekt zurĂŒck.Mit dem CSS-Selektor
table tbody tr
werden alle Tabellenzeilen der Ergebnisliste eingesammelt. Jede Zeile reprÀsentiert eine einzelne Pressemitteilung.
Vorbereitung fĂŒr die Schleife
records = [] print(f"Found {len(rows)} rows on the page.")
records
soll spĂ€ter eine Liste von Dictionaries fĂŒr das DataFrame sammeln.Eine kurze Ausgabe zeigt, wie viele Zeilen tatsĂ€chlich gefunden wurden â nĂŒtzlich fĂŒr Kontroll-/Debug-Zwecke.
Iterieren mit Fortschrittsbalken
for tr in tqdm(rows, desc="Rows"):
tqdm
liefert einen hĂŒbschen Fortschrittsbalken â perfekt fĂŒr Lehr- und Live-Demos.
Zellen extrahieren & PlausibilitĂ€t prĂŒfen
cells = tr.find_all("td") if len(cells) < 3: # footer / empty rows â ignore continue
Alle
<td>
einer Zeile werden auf einmal geholt.Hat eine Zeile weniger als drei Zellen, handelt es sich um Paginierungs- oder Leerzeilen; die werden ĂŒbersprungen.
Spalte 1 â Datum
date_txt = cells[0].get_text(strip=True)
strip=True
entfernt ZeilenumbrĂŒche und Leerzeichen â wir erhalten saubere Strings wie â16.06.2025â.
Spalte 2 â Ăberschrift & Link
anchor = cells[1].find("a", href=True) if anchor is None: continue title = anchor.get_text(strip=True) pr_url = "https://www.berlin.de" + anchor["href"]
Innerhalb der zweiten Zelle steckt der anklickbare Link.
Sicherheits-Check: Falls doch kein
<a>
vorhanden ist, Zeile ĂŒberspringen.Die relative URL wird zur vollstĂ€ndigen URL ergĂ€nzt.
Spalte 3 â Ressort (herausgebende Behörde)
ressort = cells[2].get_text(strip=True)
Eindeutige ID ableiten
uid = anchor["href"].split("/")[-1].split(".")[-2]
Vom Pfadsegment
pressemitteilung.1570469.php
wird mittelssplit()
das numerische StĂŒck 1570469 herausgelöst.Diese ID landet spĂ€ter im Dateinamen, damit jeder Release genau eine HTML- und eine TXT-Datei bekommt.
Dateipfade festlegen
html_file = HTML_DIR / f"{uid}.html" txt_file = TXT_DIR / f"{uid}.txt"
HTML herunterladen & Text extrahieren (nur falls neu)
` if not html_file.exists(): pr_soup = get_soup(pr_url) html_file.write_text(str(pr_soup), encoding=âutf-8â)
body = (pr_soup.select_one("#article") # neues Layout or pr_soup.select_one("#content") # altes Layout or pr_soup) # Fallback clean_text = body.get_text(" ", strip=True) txt_file.write_text(clean_text, encoding="utf-8")
else: clean_text = txt_file.read_text(encoding=âutf-8â) `
Idempotenz: Wenn die Datei schon existiert, wird nichts erneut heruntergeladen â das spart Zeit und Traffic.
Der eigentliche Text sitzt mal in
#article
, mal in#content
. Wir probieren beide Selektoren und greifen im Zweifel auf die ganze Seite zurĂŒck.HTML und gereinigter Plain-Text werden getrennt gespeichert.
Metadaten sammeln
records.append( dict( date=date_txt, ressort=ressort, title=title, pr_url=pr_url, filename_html=html_file.name, filename_txt=txt_file.name, n_tokens=len(clean_text.split()) ) )
Alle wesentlichen Infos â inklusive Dateinamen und Token-Anzahl â landen in einem Dictionary, das wir spĂ€ter direkt in ein DataFrame gieĂen.
Höfliche Pause
time.sleep(0.4)
400 ms warten verringert die Gefahr, den Server zu ĂŒberlasten.
Auswertung in Pandas
df = pd.DataFrame(records)
df.head()
Am Ende verwandeln wir die gesammelten Dictionaries in ein
DataFrame
, um die ersten Zeilen gleich im Notebook inspizieren zu können.
So wird auf anschauliche Weise demonstriert, wie man gezielt Teile einer HTML-Tabelle parst, die Detailseiten herunterlĂ€dt, Text extrahiert und alles sauber fĂŒr weitere Analysen ablegt.
3. Massenscraping von Pressemitteilungen#
3.1. Implementierung der Logik zum Finden der letzten Seite in der Suchausgabe#
# ââ⥠80_pagination_helpers
# Basis-URL ohne "/page/<nr>"-Segment,
# aber *inklusive* aller Query-Parameter (= Filter der langen Such-URL)
SEARCH_ROOT = FIRST_OUTPUT_PAGE
def last_page_number() -> int:
"""
Ermittelt ĂŒber das <nav>-Element ('pager-skip-to-last') die höchste
Ergebnisseiten-Nummer.
"""
soup = get_soup(SEARCH_ROOT) # 1. Suchseite laden
last_link = soup.select_one("li.pager-skip-to-last a")
if not last_link:
raise RuntimeError("Konnte die letzte Seite nicht finden â Selector?")
# href hat die Form ".../page/5239?<query>"
m = re.search(r"/page/(\d+)", last_link["href"])
if not m:
raise RuntimeError("Seitenzahl nicht im href gefunden.")
return int(m.group(1))
def page_url(page_num: int) -> str:
"""
Baut die URL fĂŒr eine beliebige Seite nach folgendem Muster auf:
<root>/page/<nr>?<identische Query-Parameter>
"""
if page_num < 1:
raise ValueError("Seitennummern beginnen bei 1.")
return SEARCH_ROOT.replace("/search/", f"/search/page/{page_num}/")
3.2. Vorbereitung der Funktion fĂŒr das Massenscraping#
# ââ⥠81_bulk_crawler_with_autosave
META_CSV = DATA_DIR / "metadata.csv"
BUFFER_SIZE = 100 # wie viele Records, bevor wir in die CSV fluschen?
def crawl_all_pages(
pages: int | None = None,
sleep_s: float = 0.4,
buffer_size: int = BUFFER_SIZE,
) -> pd.DataFrame:
"""Crawlt alle Trefferseiten, speichert Metadaten inkrementell nach CSV."""
if pages is None:
pages = last_page_number()
print(f"â Letzte Trefferseite lautet {pages}")
# --------------------------------------------------------------
EXPECTED_HEADER = ["id", "url", "date", "title",
"source", "filename_html", "filename", "n_tokens"]
existing_uids: set[str] = set()
need_header = True # default
if META_CSV.exists() and META_CSV.stat().st_size > 0:
# PrĂŒfen, ob schon ein Header vorhanden ist
with open(META_CSV, newline="", encoding="utf-8") as fh:
first_line = fh.readline().strip()
need_header = first_line.split(",") != EXPECTED_HEADER
# UIDs nur einlesen, wenn Header vorhanden
if not need_header:
with open(META_CSV, newline="", encoding="utf-8") as fh:
reader = csv.DictReader(fh)
existing_uids = {row["id"] for row in reader}
logging.info("âïž %d vorhandene DatensĂ€tze erkannt.", len(existing_uids))
else:
logging.warning("âïž metadata.csv hat noch keinen Header â wird ergĂ€nzt.")
# Ăffnen im richtigen Modus
mode = "a" if META_CSV.exists() else "w"
csvfile = open(META_CSV, mode, newline="", encoding="utf-8")
writer = csv.DictWriter(csvfile, fieldnames=EXPECTED_HEADER)
if need_header:
writer.writeheader()
csvfile.flush()
buffer: list[dict] = []
# --------------------------------------------------------------
for p in tqdm(range(1, pages + 1), desc="Result pages"):
list_soup = get_soup(page_url(p))
if list_soup is None:
continue # ganze Seite ĂŒberspringen
for tr in list_soup.select("table tbody tr"):
cells = tr.find_all("td")
if len(cells) < 3:
continue
date_txt = cells[0].get_text(strip=True)
anchor = cells[1].find("a", href=True)
if anchor is None:
continue
ressort = cells[2].get_text(strip=True)
pr_url = "https://www.berlin.de" + anchor["href"]
uid = anchor["href"].split(".")[-2]
# Doppelte auslassen (wichtig fĂŒr Resume!)
if uid in existing_uids:
continue # Datensatz bereits vorhanden â ĂŒberspringen
html_fp = HTML_DIR / f"{uid}.html"
txt_fp = TXT_DIR / f"{uid}.txt"
# -------- Detailseite laden (oder bei 404 ĂŒberspringen) ----------
pr_soup = get_soup(pr_url)
if pr_soup is None: # 404 â keinen Record anlegen
continue
if not html_fp.exists():
html_fp.write_text(str(pr_soup), encoding="utf-8")
body = (pr_soup.select_one("#article") or
pr_soup.select_one("#content") or
pr_soup)
clean = body.get_text(" ", strip=True)
txt_fp.write_text(clean, encoding="utf-8")
else:
clean = txt_fp.read_text(encoding="utf-8")
rec = dict(
id=uid,
url=pr_url,
date=date_txt,
title=anchor.get_text(strip=True),
source=ressort,
filename_html=html_fp.name,
filename=txt_fp.name, # nur âfilenameâ
n_tokens=len(clean.split())
)
buffer.append(rec)
existing_uids.add(uid) # Direkt markieren
time.sleep(sleep_s)
# ---------- Zwischenspeichern ----------------------------------
if len(buffer) >= buffer_size:
writer.writerows(buffer)
csvfile.flush()
buffer.clear()
# Optional: Auch nach *jeder* Seite flushen
if buffer:
writer.writerows(buffer)
csvfile.flush()
buffer.clear()
csvfile.close()
logging.info("â
Crawl abgeschlossen, CSV geschlossen.")
# Finales DataFrame (fĂŒr direkte Notebook-Analyse)
return pd.read_csv(META_CSV, encoding="utf-8")
đ€ Was passiert in diesem Codeblock oben?#
Klicken Sie unten, um die Schritt-fĂŒr-Schritt-ErklĂ€rung zu lesen:
Was passiert in diesem Bulk-Crawler oben?
Einstiegs-Parameter
pages
â wie viele Trefferseiten sollen verarbeitet werden?None
ruft zuerstlast_page_number()
auf.sleep_s
â Höflichkeits-Delay zwischen den Detail-Requests.buffer_size
â Zahl der DatensĂ€tze, die gepuffert werden, bevor sie in die CSV geschrieben werden.
CSV-Vorbereitung
Erwarteter Kopf:
["id","url","date","title","source","filename_html","filename","n_tokens"]
.Wenn
metadata.csv
existiert, wird geprĂŒft, ob bereits ein Header vorhanden ist.Sind schon Daten da, werden alle vorhandenen UIDs in die Menge
existing_uids
geladen, damit nichts doppelt gescrapet wird.
CSV-Writer & Puffer
Datei-Modus:
'a'
, falls die Datei schon da ist, sonst'w'
.Header wird geschrieben, falls er noch fehlt.
buffer
sammelt neue DatensÀtze, bisbuffer_size
erreicht ist.
Schleife ĂŒber Ergebnis-Seiten
for p in tqdm(range(1, pages + 1), desc="Result pages"):
zeigt einen Fortschrittsbalken.get_soup(page_url(p))
holt die jeweilige HTML-Seite; liefert sieNone
, wird die ganze Seite ĂŒbersprungen.
Tabellenzeilen auswerten
Jede Zeile liefert
date_txt
,anchor
(Link & Titel) undressort
.UID wird aus dem Link extrahiert:
uid = anchor["href"].split(".")[-2]
.Wenn
uid in existing_uids
, wird sofort weitergemacht (continue
).
Detailseite holen & speichern
HTML-Datei-Pfad:
html_fp = HTML_DIR / f"{uid}.html"
.TXT-Datei-Pfad:
txt_fp = TXT_DIR / f"{uid}.txt"
.Neue Detailseite wird nur geladen, wenn
html_fp
noch nicht existiert.Text wird aus
#article
,#content
oder notfalls dem ganzen Dokument extrahiert.
Metadaten-Record bauen
Beispiel-Dict:
{"id":uid, "url":pr_url, "date":date_txt, "title":title, "source":ressort, "filename_html":html_fp.name, "filename":txt_fp.name, "n_tokens":len(clean.split())}
Record landet im
buffer
, UID wird gleichzeitig zuexisting_uids
hinzugefĂŒgt.time.sleep(sleep_s)
wahrt Server-Höflichkeit.
Autosave-Mechanismus
if len(buffer) >= buffer_size:
âwriter.writerows(buffer)
schreibt den Puffer in die CSV, danachbuffer.clear()
.ZusÀtzlich wird nach jeder fertigen Ergebnisseite geflusht, damit höchstens eine Seite verloren gehen kann.
AufrĂ€umen & RĂŒckgabe
Nach der groĂen Schleife:
csvfile.close()
und ein Log-Eintragâ Crawl abgeschlossen
.Zum Schluss wird die komplette
metadata.csv
perpd.read_csv()
als DataFrame zurĂŒckgegeben, sodass man direkt im Notebook weiterarbeiten kann.
Kurz gesagt: Der Crawler verarbeitet beliebig viele Trefferseiten, speichert Metadaten inkrementell, ĂŒberspringt 404/410-Links und setzt einen unterbrochenen Lauf dank existing_uids
nahtlos fort.
3.3. Massenscraping#
Test run:#
# ââ⥠82_test_run_5_pages
df_test = crawl_all_pages(pages=5)
df_test.head()
Main run:#
# ââ⥠83_run_bulk
# Achtung: Das kann > 1 Stunde dauern und tausende Dateien erzeugen!
df_all = crawl_all_pages(pages=None) # None â auto-detect
df_all.head()
Hilfscode zum Entfernen unnötiger Teile des HTML-Textes
Show code cell content
## 84 Hilfscode zum Entfernen unnötiger Teile des HTML-Textes
BACKUP_DIR = DATA_DIR / "txt_backup" # Sicherheitskopien
BACKUP_DIR.mkdir(exist_ok=True)
def extract_release(soup: BeautifulSoup) -> str | None:
"""Gibt den bereinigten Text einer Pressemitteilung zurĂŒck (oder None)."""
h1 = soup.select_one("#layout-grid__area--herounit h1, h1.title")
main = soup.select_one("#layout-grid__area--maincontent")
if not main:
return None
title = h1.get_text(" ", strip=True) if h1 else ""
body = main.get_text(" ", strip=True)
# Doppelte Ăberschrift am Anfang entfernen
if body.lower().startswith(title.lower()):
body = body[len(title):].lstrip()
return (title + "\n\n" + body).strip()
changed = skipped = failed = 0
for html_fp in tqdm(sorted(HTML_DIR.glob("*.html")), desc="Rebuild"):
soup = BeautifulSoup(html_fp.read_text(encoding="utf-8"), "lxml")
text = extract_release(soup)
if text is None:
failed += 1
logging.warning("â ïž %s: kein maincontent-Div gefunden", html_fp.name)
continue
txt_fp = TXT_DIR / f"{html_fp.stem}.txt"
if txt_fp.exists() and txt_fp.read_text(encoding="utf-8").strip() == text:
skipped += 1
continue
# Sicherheitskopie der alten Datei
if txt_fp.exists():
txt_fp.rename(BACKUP_DIR / txt_fp.name)
txt_fp.write_text(text, encoding="utf-8")
changed += 1
print(f"âïž {changed:,} Dateien bereinigt â {skipped:,} bereits ok â "
f"{failed:,} ohne passenden Layout-Block")
4. Ergebnisse#
Der Dataframe âdf_allâ und die Datei metadata.csv enthalten nun die Metadaten fĂŒr das Pressemitteilungskorpus, wĂ€hrend der Ordner txt die Texte der Pressemitteilungen enthĂ€lt.
Korpusumfang (23.âŻ06.âŻ2025)#
Pressemitteilungen: ââŻ51âŻ800
Zeitspanne: 2001 â 2025
Ă LĂ€nge: 430 Tokens (Median 394)
Sie können nun mit computergestĂŒtzten Methoden untersucht werden.
5. Zusammenfassung des technischen Workflow#
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:#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).
ResumeâFĂ€higkeit: Vor jedem Lauf werden vorhandene UIDs aus der CSV eingelesen â keine Dubletten, unterbrochene Crawls lassen sich fortsetzen.
Note
404âSeiten werden nach drei Fehlversuchen ĂŒbersprungen und im Log markiert.