Data Gathering und Data Wrangling mit Daten der CorrespSearch-API#

Wenn wir mit Daten arbeiten, ist es eine immer wiederkehrende Aufgabe, diese Daten zunächst zu sammeln und sie für ein Format bzw. für ein Datenmodell aufzubereiten, wie es zur Weiterverarbeitung erforderlich ist. Im Folgenden führen wir diese Schritte exemplarisch durch. Dazu nutzen wir für das Data Gathering die API des Projekts correspSearch. CorrespSearch bietet zahlreiche Metadaten zu einer Fülle an Briefkorrespondenzen. Wir werden uns auf die Briefkorrespondenz einer Person beschränken und werden die Metadaten zu den Briefen von Alexander von Humboldt mit Hilfe der API abrufen. Allerdings werden wir für diesen Zweck hier im Juypter Book nicht alle ca. 7.000 bei correspSearch verzeichneten Briefe nutzen, sondern lediglich diejenigen, die im Volltext der edition humboldt digital verfügbar sind - diese Volltexte werden wir im nächsten Kapitel dann auswerten. Wie das Vorgehen ist, um an die Metadaten dieser Briefe heranzukommen, werden wir im Folgenden exemplarisch durchgehen.

Install und Import#

Für das hier gewählte Vorgehen können wir ein kleines Python-Paket sehr gut nutzen, dass uns bei der Arbeit mit JSON-Objekten hilft. Zunächst installieren wir das Paket flatten-json mit Hilfe von PIP. Um den Befehl in einer Zelle des Jupyter Notebooks auszuführen, muss dem Befehl pip einfach ein ‘!’ vorangestellt werden. Danach können wir dieses Paket mit den anderen erforderlichen Paketen importieren. Siehe dazu auch den Exkurs zur Installation von Third-Party-Packages

Wer nochmal einen Refresher zu JSON benötigt, erfährt dazu alles grundlegende im Jupyter Book ‘Python Basics’ im Kapitel Dateien verarbeiten.

!pip install flatten-json
import os
import json
import time
import requests

import pandas as pd

from flatten_json import flatten

API von correspSearch#

Die Dokumentation zur correspSearch API v2 bietet alle wichtigen Informationen, um die Metadaten der in corresSearch vorhandenen Briefeditionen automatisiert abzufragen. Die Abfrage erfolgt mittels URL-Parameter: Mit Hilfe der Parameter kann nach Personen/Institutionen, Orten, nach Zeiträumen, Berufen der Personen, nach den Editionen sowie nach Verfügbarkeit gefiltert werden. In der Dokumentation finden sich neben den Erläuterung zu diesen Parametern auch die nötigen Informationen über die unterschiedlichen Rückgabeformate. Für das im Folgenden vorgestellte Vorgehen haben wir uns für das Format TEI-JSON entschieden. Weitere Erläuterungen zum Vorgehen bei einer API-Anfragen können im Kapitel “Einführung in Weg APIs” dieses Jupyter Books nachgelesen werden.

Bevor wir uns die Zusammensetzung der API-Anfrage zuwenden, schauen wir uns die Daten, die wir zurückerhalten, etwas genauer an. Im teiHeader sind allgemeine Metadaten zum Projekt enthalten. Für uns ist das notesStmt interessant, denn hier können wir herausfinden, wie viele Treffer unsere Anfrage ergeben hat. In sourceDesc finden sich die Informationen zu den Editionen / Publikationen aus denen die Metadaten zu den Briefen entnommen sind. Wir interessieren uns aber vor allem für die Infos in correspDesc, den Beschreibungen der angefragten Briefe, die in unter profileDesc stehen. Grundsätzlich sind eine Erkundung der Datenmodellierung sowie gute Kenntnisse der Daten von Vorteil und stets sehr hilfreich.

{
    "teiHeader": {
        "fileDesc": {
            "titleStmt": {
                "title": "correspSearch API 2.0 (BETA)",
                "editor": [
                    {
                        "#text": "correspsearch@bbaw.de",
                        "email": "no-email-provided@correspsearch.net"
                    }
                ]
            },
            "publicationStmt": {
                "publisher": [
                    {
                        "ref": {
                            "target": "https://www.bbaw.de/",
                            "#text": "Berlin-Brandenburg Academy of Sciences and Humanities"
                        }
                    }
                ],
                "availability": {
                    "licence": [
                        {
                            "target": "https://creativecommons.org/licenses/by/4.0/",
                            "#text": "CC-BY 4.0"
                        }
                    ]
                },
                "idno": "https://correspsearch.net/api/v2.0/tei-json.xql?s=http://d-nb.info/gnd/118554700&e=d238c5bd-a978-475e-8784-58ce7ec82010",
                "date": {
                    "when": "2024-04-18T09:49:17.468+02:00"
                }
            },
            "notesStmt": {
                "note": "1-10 of 523 hits",
                "relatedItem": {
                    "type": "next",
                    "target": "https://correspsearch.net/api/v2.0/tei-json.xql?s=http://d-nb.info/gnd/118554700&e=d238c5bd-a978-475e-8784-58ce7ec82010&x=2"
                }
            },
            "sourceDesc": {
                "bibl": [
                    {
                        "xml:id": "d238c5bd-a978-475e-8784-58ce7ec82010",
                        "type": "online",
                        "#text": "edition humboldt digital, hg. v. Ottmar Ette. Berlin-Brandenburgische Akademie der Wissenschaften, Berlin 2017–2023.",
                        "ref": {
                            "target": "https://edition-humboldt.de",
                            "#text": "https://edition-humboldt.de"
                        }
                    }
                ]
            }
        },
        "profileDesc": {
            "correspDesc": [
                {
                    "ref": "https://edition-humboldt.de/H0002656",
                    "source": "#d238c5bd-a978-475e-8784-58ce7ec82010",
                    "correspAction": [
                        {
                            "type": "sent",
                            "persName": [
                                {
                                    "ref": "http://d-nb.info/gnd/118554700",
                                    "#text": "Alexander von Humboldt"
                                }
                            ],
                            "placeName": [
                                {
                                    "ref": "http://sws.geonames.org/2911298",
                                    "#text": "Hamburg"
                                }
                            ],
                            "date": [
                                {
                                    "from": "1791-01-28",
                                    "to": "1791-02-20"
                                }
                            ]
                        },
                        {
                            "type": "received",
                            "persName": [
                                {
                                    "ref": "http://d-nb.info/gnd/118805193",
                                    "#text": "Samuel Thomas von Soemmerring"
                                }
                            ]
                        }
                    ]
                },

Konstruktion der API-Anfrage#

Nachdem wir uns gerade die Metadaten zum ersten Brief angesehen haben, betrachten wir nun die Konstruktion der API-Anfrage genauer.

  • Zunächst setzen wir die Variable api_base_url auf das Rückgabeformat TEI-JSON.

  • Dann nutzen wir für die Variable person die GND-ID für Alexander von Humboldt.

  • Da wir direkt bei der Anfrage einen ersten Filter einsetzen möchten, mit dem wir nur die Briefe aus der edition humboldt digital zurückerhalten, wählen wir für den Parameter c= die entsprechende Info aus der CMIF-Datei für diese Edition aus und weisen diese der Variable edition zu.

CMIF steht für Correspondence Metadata Interchange-Format. Nähere Infos hierzu finden sich in der Dokumentation von CorrespSearch. Auch die edition humboldt digital nutzt dieses Format und stellt die entsprechende Informationen der correspSearch-API zur Verfügung. Den richtigen Wert für den Parameter, um die Ergebnisse von nur dieser Edition zu filtern, können wir über die erweiterte Suche bei correspSearch herausfinden. Wenn wir die erweiterte Suche aufrufen, gibt es dort das Feld CMIF-Datei. Hier können wir den String ‘edition humboldt digital’ eingeben und die Suche starten. Im URL-Feld des Browser können wir nun den Wert für den Parameter c sehen und für unsere API-Query herauskopieren. Dieser lautet: https://edition-humboldt.de/api/v1.2/cmif.xql

# TEI-JSON
api_base_url = 'https://correspsearch.net/api/v2.0/tei-json.xql?'

# GND-ID für Alexander von Humboldt
person = 'http://d-nb.info/gnd/118554700'

# Parameter für Edition 
edition = 'https://edition-humboldt.de/api/v1.2/cmif.xql'

Schließlich setzen wir die einzelnen Bausteine zur API-Anfrage zusammen. Angaben zur Filterung werden jeweils mit dem & angefügt. Mit print können wir den Anfrage-String ausgeben.

api_query = f'{api_base_url}s={person}&c={edition}'
print(api_query)

Vorbereiten der API-Anfrage#

Als nächsten nutzen wir das Python-Modul requests, um die Anfrage einmalig zu stellen. Wir können dann nachsehen, wieviele Treffer die Anfrage ergeben hat, um für die Konstruktion der eigentlichen Anfrage eine wichtige Information zu erhalten. Denn: Pro Anfrage werden jeweils die Informationen für 100 Treffer zurückgegeben, wir müssen also eine Schleife nutzen – und hierfür benötigen wir die Anzahl, wie oft diese Schleife durchlaufen werden soll. Dazu greifen wir über ['teiHeader']['fileDesc']['notesStmt']['note'] auf den String zu, der die Angaben zu den hits enthält. Den String splitten wir auf, nutzen das vorletzte Elemente der durch den split-Befehl entstehenden Liste, wandeln dieses in ein integer um und teilen diese Zahl mit der floor division durch 100 auf die abgerundete Ganzzahl, um dann noch 1 hinzu zu addieren.

Diesen so berechnten Wert nutzen wir wiederum für den URL-Parameter x=, der für die Paginierung steht. Auf diese Weise erhalten wir die Treffer in Blöcken zu jeweils 100 records.

response = requests.get(api_query)
from pprint import pprint
response.json()['teiHeader']['fileDesc']['notesStmt']['note']
record_pages = int(response.json()['teiHeader']['fileDesc']['notesStmt']['note'].split()[-2]) // 10 + 1
print(record_pages)

Mit der nächsten Code-Zelle legen wir einen Ordner im data-Ordner an, um dort die JSON-Dateien zu speichern.

path = 'data/correspSearch-api-tei-json'

try:
    os.mkdir(path)
except:
    print('Ordner existiert bereits.')

Durchführung der API-Anfrage#

Nun können wir die API-Anfrage starten. Dazu durchlaufen wir die Schleife entsprechend der Anzahl der Treffer in hunderter Blöcken. Nach jedem Durchlauf wird der Paginierungs-Parameter x= um eins erhöht. Wir geben dann die Angabe über den Fortgang des Durchlaufs mit dem print-Befehl aus. Danach konstruieren wir einen Dateinamen; dazu nutzen wir wiederum die Laufvariable der Schleife. Die erhaltenen Daten speichern wir im JSON-Format ab. Zuletzt warten wir 5 Sekunden, um die API mit den Anfragen nicht zu stark zu belasten.

for page_nr in range(1, record_pages + 1):

    response = requests.get(f'{api_query}&x={page_nr}')

    print(response.json()['teiHeader']['fileDesc']['notesStmt']['note'])

    with open(f'{path}/{str(page_nr).zfill(4)}_AvH_edi_hum_dig.json', 'w', encoding='utf-8') as f:
        json.dump(response.json(), f, ensure_ascii=False, indent=4)

    time.sleep(5)

Schauen wir uns den Inhalt des data\correspSearch-api-tei-json-Ordners kurz an, ob alles passt.

files = os.listdir(path)
print(len(files))
print(sorted(files))

Von JSON zum Pandas Dataframe#

Das nächste Ziel ist es, die Daten aus den JSON-Dateien in einen pandas Dataframe zu bringen. Die in der Variable files enthaltenen und sortierten Dateien durchlaufen wir mit einer for-Schleife: Wir öffnen die Datei, lesen die JSON ein und nutzen nun die Methode flatten aus dem Paket flatten-json, um mit Hilfe einer List Comprehension die verschachtelte Stuktur der Dictionaries ‘einzuebnen’. Mit extend() fügen wir die jeweiligen Listen einer neu erstellten Liste hinzu. Diese Liste, die alle JSON-Objekte enthält, können wir nun in einen Dataframe überführen. Wir nutzen flatten allerdings lediglich für die Dictionaries, die in correspDesc enthalten sind. Daher setzen wir die Indexierung über ['teiHeader']['profileDesc']['correspDesc'] in den JSON-Objekten auf diesen für uns relevanten Abschnitt der Datei.

all_json_obj_flattened = []

for file in sorted(files):

    if file.endswith('.json'):

        with open(f'{path}/{file}', 'r', encoding='utf8') as f:

            json_obj = json.load(f)

            dic_flattened = [ flatten(dic) for dic in json_obj['teiHeader']['profileDesc']['correspDesc'] ]

            all_json_obj_flattened.extend(dic_flattened)
df = pd.DataFrame(all_json_obj_flattened)
print(df.shape)
df.head()

Aufbereiten des Dataframes#

Wir erhalten nun einen Dataframe mit 523 Zeilen und 28 Spalten. Mit df.isna().sum() können wir anzeigen, wie viele der Werte leer geblieben, also NANs (not a number) sind. In den nächsten Schritten wollen wir diesen Dataframe weiter aufbereiten, denn wir wollen für die Zwecke dieses JupyterBooks einen etwas kleineren Datensatz nutzen. Um den Umfang zu reduzieren, werden wir nur die Datensätze der Briefe nutzen, deren Datierung genau angegeben ist. Diese Info finden wir in der Spalte correspAction_0_date_0_when, in der auch noch 227 Werte fehlen.

print(df.shape)
df.isna().sum()

Im Folgenden werden wir mit dropna() verschiedene Spalten und Zeilen löschen, in denen keine Werte enthalten sind. Wir nutzen das Argument subset=, um Spalten gezielt auszuwählen und wir verwenden auch das Argument inplace=True, um die Änderungen direkt an dem Dataframe zu vollziehen.

df.dropna(subset=['correspAction_0_date_0_when'], inplace=True)
print(df.shape)

In der folgenden Zelle erstellen wir einen Dataframe, der die Informationen zu Ortsangaben zwischenspeichert. In der Zelle darauf können wir nun alle Spalten, die leere Werte enthalten, löschen. Wir erhalten nun einen Dataframe mit 296 Zeilen und nur noch 9 Spalten.

df_place = df.loc[:, ['correspAction_0_placeName_0_ref', 'correspAction_0_placeName_0_#text']]
df_place.shape
df.dropna(axis=1, inplace=True)
print(df.shape)

Ferner löschen wir noch Spalten, die die Info sent bzw. receive enthalten. Durch eine spätere Umbenennung der Spalten können wir diese Infos über Sender und Empfänger jedoch in einer anderen Spalten mit unterbringen. Nun haben wir nur noch 7 Spalten und alle 296 Zeilen enthalten einen Wert in den einzelnen Zellen.

df.drop(['correspAction_0_type', 'correspAction_1_type'], axis=1, inplace=True)
print(df.shape)
print(df.isna().sum())

Im nächsten Schritt fügen wir die zwischengespeicherten Infos zu den Ortsangaben dem Dataframe mit join() wieder hinzu: Das Ergebnis ist ein Dataframe mit 296 Zeillen und 9 Spalten.

df = df.join(df_place)
print(df.shape)
df.head()

In den letzten Schritten benennen wir die Spaltennamen um und reseten den Index der Zeilen, sodass wir eine saubere Durchnummerierung der Zeilen von 0 bis 295 erhalten. Zum Schluss speichern wir den Dataframe als csv-Datei ab.

# rename columns
df.columns = ['reference', 'edition_id', 'sender_id', 'sender', 'receiver_id', 'receiver', 'date', 'place_id', 'place' ]

# reset index
df.reset_index(drop=True, inplace=True)

df.head()

Zum Schluss speichern wir den Dataframe als CSV-Datei ab.

# save
df.to_csv('data/AvH-letters-with-date.csv', index=False)