8.3. Beispiel 2: Library of Congress API#
Die Library of Congress (LOC) bietet eine Reihe sehr gut dokumentierter APIs zur Abfrage von Metadaten, Dateien und Volltexten aus dem Bestand der Bibliothek. Eine davon ist die API der Sammlung US-amerikanischer historischer Zeitungen Chronicling America. Diese API werden wir in dieser Stunde kennenlernen.
Übersicht über die LOC APIs: https://guides.loc.gov/digital-scholarship/accessing-digital-materials#s-lib-ctab-26648178-2
Dokumentation zur Chronicling America API: https://chroniclingamerica.loc.gov/about/api/
Zunächst machen wir uns mit der Chronicling America API vertraut.
Verständnisfragen
Welche Daten können darüber abgefragt werden?
Was ist euer Leseeindruck? Ist die Dokumentation vollständig, ausführlich, leicht verständlich, …?
An wen richtet sich die API und die Dokumentation?
Für unser Beispiel werden wir die Volltexte zu allen Ergebnissen einer Suche nach Schlüsselwörtern in den Volltexten der Zeitungen abfragen und herunterladen (Abschnitt “Searching the directory and newspaper pages using OpenSearch”, Unterabschnitt “Page search parameters”). Die Volltexte sind mithilfe von OCR-Verfahren erstellt, also mithilfe von automatischer Bilderkennung. Unsere Suchabfrage liefert also nur diejenigen Zeitungen, in denen die Suchwörter korrekt erkannt wurden.
8.3.1. Vorbereitung#
# Pakete importieren
import requests
import os
import math
# Dieses Modul müsst ihr nicht importieren
from utils import skip
8.3.2. Exploration der Chronicling America API#
Wie in der letzten Stunde müssen wir zur Abfrage von Daten wieder eine URI nach den Vorgaben der API Dokumentation zusammensetzen.
Suchabfragen können mit einem ? an die URL https://chroniclingamerica.loc.gov/search/pages/results/ angefügt werden.
Es gibt laut Dokumentationsseite drei verschiedene Abfrageparameter (Unterabschnitt “Page search parameters”):
andtext: the search query
format: ‘html’ (default), or ‘json’, or ‘atom’ (optional)
page: for paging results (optional)
Diese Parameter werden wir uns der Reihe nach ansehen.
Parameter andtext
# Der andtext Parameter: Volltexte nach Schlagwörtern oder Phrasen durchsuchen
# Suche nach Schlagwörtern book AND review
url = "https://chroniclingamerica.loc.gov/search/pages/results/?andtext=book+review"
Wenn diese URL im Browser eingegeben wird, öffnet sich dieselbe Suchmaske, die auch manuell zur Suche verwendet wird, aber der von uns zusammengesetzten URL werden automatisch zusätzliche Parameter hinzugefügt, z.B. rows=20 und searchType=basic. Andersherum zeigt eine aufmerksame Betrachtung der Ergebnisse der Suche nach einem Suchbegriff über die Suchmaske der Website https://chroniclingamerica.loc.gov, dass die Suche über die Suchmaske genau dieselben Ergebnisse liefert wie die API-Abfrage.
Fig. 8.4 Einfache Suche über die Suchmaske der Seite Chronicling America.#
Dieser Beobachtung können wir entnehmen, dass per Default ein HTML-Dokument mit den Ergebnissen geliefert wird, und dass die API unter der Motorhaube verwendet wird, um die Suchergebnisse zu generieren, die auch bei einer “manuellen” Suche über die Suchmaske geliefert werden. Wir können die Suchmaske also nutzen, um den andtext-Parameter besser zu verstehen und möglicherweise weitere, in den Dokumentationsseiten der API nicht explizit erwähnte Suchparameter zu identifizieren.
Wenn wir in der Suchmaske nach dem Suchbegriff “book review” suchen, dann steht in der URL “proxtext=book+review”. Wenn wir die Erweiterte Suche (Tab Advanced Search) öffnen, sehen wir, was das bedeutet: Unser Suchbegriff wurde automatisch unter der Überschrift “Enter Search”, bei “…with the words… within 5 words of each other” eingefügt. “proxtext=book+review” findet also alle gemeinsamen Erwähnungen von “book” und “review” in einem Kontextfenster von fünf Wörtern. Wenn wir alle Seiten finden wollen, in denen irgendwo “book” und “review” vorkommen, aber nicht unbedingt in einer bestimmten Nähe zueinander, können wir die Begriffe unter “…with all of the words” eingeben. Die URL ändert sich dann genau zu unserem bereits bekannten Parameter “andtext=book+review”. Und wenn wir direkt aufeinanderfolgende Wortkombinationen von “book review” finden wollen, können wir die Suchbegriffe bei “…with the phrase” eingeben. Dann steht in der URL statt andtext oder proxtext der Zusatz “&phrasetext=book+review”:
Fig. 8.5 Erweiterte Suche über die Suchmaske der Seite Chronicling America.#
Tatsächlich akzeptiert auch die Chronicling America API eine Abfrage-URI mit dem Zusatz &phrasetext:
# Suche nach Phrase "book review"
url = "https://chroniclingamerica.loc.gov/search/pages/results/?phrasetext=book+review"
search_results = requests.get(url)
# search_results
Bei der Suche nach Wortkombinationen können wir also von der in der API-Dokumentation angegebenen URI mit dem Parameter “andtext” abweichen und stattdessen den Parameter “phrasetext” nutzen.
Parameter format
Wir haben bereits festgestellt, dass die Abfrage einer UrI ohne Angabe des format-Parameters standardmäßig HTML-Dokumente als Nutzdaten liefert. Zur maschinellen Weiterverarbeitung in Python ist für uns aber JSON praktischer. Um JSON gelierfert zu bekommen, setzen wir den Parameter format auf “json”:
# Der format-Parameter: Ergebnisse im JSON-Format abfragen
# https://chroniclingamerica.loc.gov/search/pages/results/?phrasetext=book+review&format=json
# erste Seite der Suchergebnisse: 20 Ergebnisse je Seite
url = "https://chroniclingamerica.loc.gov/search/pages/results/?phrasetext=book+review&format=json"
search_results = requests.get(url)
# search_results
Note
JSON im Chrome Browser ansehen
Zur Ansicht der JSON-Datei im Chrome Browser können wir wieder auf die Entwicklertools zurückgreifen. Die Standardansicht ist nämlich sehr schwer lesbar, weil der JSON-String nicht formatiert ist. Um eine formatierte Ansicht zu erhalten, befolgt die folgenden Schritte: Entwicklertools öffnen -> “Sources”-Tab auswählen-> Link anklicken
Parameter page
Wenn der Parameter page bei der Abfrage nicht angegeben wird, dann werden per Default immer die ersten 20 Suchergebnisse (also die erste Seite der Suchergebnisse) zurückgeliefert:
default_page = "https://chroniclingamerica.loc.gov/search/pages/results/?phrasetext=book+review&format=json"
default_page_results = requests.get(default_page)
# default_page_results
Diese URL ist also äquivalent zur Angabe des Parameters mit page=1:
# Der page Parameter: Nur die ausgewählte Ergebnisseite abfragen
first_page = "https://chroniclingamerica.loc.gov/search/pages/results/?phrasetext=book+review&format=json&page=1"
first_page_results = requests.get(first_page)
# first_page_results
first_page_results.content == default_page_results.content
True
Verständnisfragen
Welche weiteren Parameter akzeptiert die Chronicling America API? Verwendet die Suchmaske unter dem Tab Advanced Search und beobachtet, wie verschiedene Parameter in die URL eingefügt werden.
Wie kann nur nach deutschsprachigen Texten gesucht werden?
Testabfrage
Mit diesem Wissen können wir eine Testabfrage durchführen.
Für unsere Abfragen wählen wir JSON als Rückgabeformat aus, weil wir den JSON-String bequem parsen können, indem wir den String mithilfe der Methode .json() in ein Python-Dictionary umwandeln:
url = "https://chroniclingamerica.loc.gov/search/pages/results/?phrasetext=book+review&format=json"
# JSON-String in Python Dictionary umwandeln
search_results = requests.get(url).json()
print(len(search_results["items"]))
---------------------------------------------------------------------------
JSONDecodeError Traceback (most recent call last)
File /Library/Frameworks/Python.framework/Versions/3.10/lib/python3.10/site-packages/requests/models.py:971, in Response.json(self, **kwargs)
970 try:
--> 971 return complexjson.loads(self.text, **kwargs)
972 except JSONDecodeError as e:
973 # Catch JSON-related errors and raise as requests.JSONDecodeError
974 # This aliases json.JSONDecodeError and simplejson.JSONDecodeError
File /Library/Frameworks/Python.framework/Versions/3.10/lib/python3.10/json/__init__.py:346, in loads(s, cls, object_hook, parse_float, parse_int, parse_constant, object_pairs_hook, **kw)
343 if (cls is None and object_hook is None and
344 parse_int is None and parse_float is None and
345 parse_constant is None and object_pairs_hook is None and not kw):
--> 346 return _default_decoder.decode(s)
347 if cls is None:
File /Library/Frameworks/Python.framework/Versions/3.10/lib/python3.10/json/decoder.py:337, in JSONDecoder.decode(self, s, _w)
333 """Return the Python representation of ``s`` (a ``str`` instance
334 containing a JSON document).
335
336 """
--> 337 obj, end = self.raw_decode(s, idx=_w(s, 0).end())
338 end = _w(s, end).end()
File /Library/Frameworks/Python.framework/Versions/3.10/lib/python3.10/json/decoder.py:355, in JSONDecoder.raw_decode(self, s, idx)
354 except StopIteration as err:
--> 355 raise JSONDecodeError("Expecting value", s, err.value) from None
356 return obj, end
JSONDecodeError: Expecting value: line 1 column 1 (char 0)
During handling of the above exception, another exception occurred:
JSONDecodeError Traceback (most recent call last)
Cell In[8], line 3
1 url = "https://chroniclingamerica.loc.gov/search/pages/results/?phrasetext=book+review&format=json"
2 # JSON-String in Python Dictionary umwandeln
----> 3 search_results = requests.get(url).json()
4 print(len(search_results["items"]))
File /Library/Frameworks/Python.framework/Versions/3.10/lib/python3.10/site-packages/requests/models.py:975, in Response.json(self, **kwargs)
971 return complexjson.loads(self.text, **kwargs)
972 except JSONDecodeError as e:
973 # Catch JSON-related errors and raise as requests.JSONDecodeError
974 # This aliases json.JSONDecodeError and simplejson.JSONDecodeError
--> 975 raise RequestsJSONDecodeError(e.msg, e.doc, e.pos)
JSONDecodeError: Expecting value: line 1 column 1 (char 0)
Das Dictionary enthält einen Schlüssel “items” mit einer Liste der Suchergebnisse als Wert. Die Suchergebnisse sind selbst als Dictionaries organisiert. Jedes Suchergebnis-Dictionary enthält einen Schlüssel “ocr_eng” mit den Volltexten:
# erstes Suchergebnis auf der ersten Seite der Suchergebnisse
search_results["items"][0]["ocr_eng"]
Um die Volltexte für alle Suchergebnisse auf der ersten Seite abzurufen und zu speichern, können wir eine for-Schleife entwerfen:
%%skip
# Volltexte für die gesamte erste Seite der Suchergebnisse speichern
items = search_results["items"]
for item in items:
ocr_text = item["ocr_eng"]
title = item["title"]
date = item["date"]
with open(f"{title}_{date}.txt", "w", encoding="utf-8") as file:
file.write(ocr_text)
Das wollen wir jetzt für alle Suchergebnisse auf allen Seiten reproduzieren.
8.3.3. Abfrage aller Volltexte mit “book review”#
Da es etwas unübersichtlich ist, wenn die heruntergeladenen Dateien in demselben Ordner liegen wie das Pythonskript, legen wir zunächst in unserem aktuellen Arbeitsverzeichnis (=Ordner, in dem die Jupyter Notebooks liegen) ein neues Verzeichnis an, in dem wir die Volltexte abspeichern werden:
%%skip
# Neues Verzeichnis anlegen: in diesem Ordner werden die Textdateien gespeichert
output_dir = os.path.join(os.getcwd(), "loc_ocr")
os.makedirs(output_dir, exist_ok=True) # exists_ok: nur erstellen, falls es noch nicht existiert
Wie gehen wir vor, um jetzt unsere for-Schleife oben nacheinander auf alle Ergebnisseiten anzuwenden? Erinnert euch an die drei Strategien, die wir beim Scrapen der Quotes to Scrape-Website im Abschnitt “Fortsetzung BeautifulSoup” diskutiert haben. Eine Idee wäre die Verwendung einer while Schleife mit HTTP Antwort != 200 als break-Bedingung. Diese Strategie ist aber nur anwendbar, wenn beim Abruf einer ungültigen Seite eine HTTP-Antwort ungleich 200 zurückgegeben wird. Das müssen wir zunächst überprüfen: Was passiert, wenn eine nicht existierende Seite aufgerufen wird? Als Beispiel rufen wir die Seite https://chroniclingamerica.loc.gov/search/pages/results/?rows=20&format=json&sequence=0&phrasetext=book+review&andtext=&page=20000 auf.
Tatsächlich gibt es eine Umleitung auf Seite 1 mit einem gültigen HTTP-Statuscode! Wir können also in diesem Fall die Strategie mit der while-Schleife nicht verwenden.
Eine andere Idee ist die Verwendung einer for-Schleife, die so lange läuft wie es Ergebnisseiten gibt. Dazu müssen wir aber die Gesamtzahl der Ergebnisseiten kennen. Das können wir entweder programmatisch lösen oder manuell aus dem User Interface ablesen. Um die Gesamtzahl der Ergebnisseiten direkt aus Python heraus zu ermitteln, könnten wir zum Beispiel die Anzahl der Ergebnisseiten durch die Anzahl der Ergebnisse auf jeder Ergebnisseite teilen und diese Zahl aufrunden:
# Gesamtanzahl der Ergebnisseiten ermitteln: Anzahl der Ergebnisse durch Anzahl der Ergebnisse pro Seite teilen
base_url = "https://chroniclingamerica.loc.gov/search/pages/results/?phrasetext=book+review&format=json"
search_results = requests.get(base_url).json()
pages_float = search_results["totalItems"] / search_results["itemsPerPage"]
pages = math.ceil(pages_float) # aufrunden
pages
Verständnisfragen
Betrachtet die Ergebnisse für unsere Anfrage über das User Interface der Website: https://chroniclingamerica.loc.gov/search/pages/results/?phrasetext=book+review. Stimmt die berechnete Anzahl der Ergebnisseiten?
Gibt es noch eine andere Möglichkeit, die letzte Ergebnisseite zu erkennen?
Was bedeuten die Schlüssel
endIndexundstartIndex? Welchen Wert haben Sie auf der ersten Ergebnisseite? Welchen Wert auf der letzten?
Zu der Gesamtzahl der Seiten addieren wir 1, da wir später die range(1, n)-Funktion verwenden wollen, welche eine Integersequenz von Zahl 1 bis Zahl n-1 generiert.
Unsere for-Schleife sieht dann so aus:
%%skip
# Volltexte zu allen Ergebnissen von allen Ergebnisseiten speichern
for page in range(1, pages + 1):
request_url = f"{base_url}&page={page}"
response = requests.get(request_url).json()
# überprüfen, ob die Anfrage erfolgreich war
print(response.status_code)
# for-Schleife für eine einzelne Ergebnisseite einsetzen
items = response["items"]
for item in items:
ocr_text = item["ocr_eng"]
title = item["title"]
date = item["date"]
filename = f"{title}_{date}.txt"
filepath = os.path.join(output_dir, filename)
with open(filepath, "w", encoding="utf-8") as file:
file.write(ocr_text)
Aber Achtung! Beim Ausführen des Codes oben gibt es nach einigen Schleifendurchläufen eine Fehlermeldung: JSONDecodeError: Expecting value: line 1 column 1 (char 0). Die Fehlermeldung entsteht dann, wenn die HTTP-Anfrage keine erfolgreiche Antwort liefert. Dieser Fall kann registriert werden, indem nach jeder Anfrage der Statuscode ausgegeben wird. Warum hat die Anfrage plötzlich keine erfolgreiche Antwort geliefert? Das liegt daran, dass wir uns nicht an die Einschränkungen der LOC gehalten haben und die HTTP-Anfrage dadurch ab einem bestimmten Punkt abgelehnt wird. Wenn wir dann versuchen, den Antwortbody mithilfe der .json()-Methode in ein Python Dictionary umzuwandeln, teilt der Python interpreter uns mit, dass das nicht möglich ist, weil wir die Methode nicht auf einen gültigen JSON-String angewendet haben.
Bei der Abfrage von sehr vielen Seiten müssen wir uns also nach den Einschränkungen der LOC richten und bestimmte Abfrageraten (Rate Limits) einhalten.
8.3.4. Rate Limits berücksichtigen und die Abfragerate steuern#
Wie wir bereits in der kurzen Einführung zu APIs besprochen haben, setzen Webseitenbetreiber:innen für gewöhnlich Grenzen für die Datenabfrage über ihre APIs fest. Manche kommunizieren diese Einschränkungen nur schriftlich in Form der Dokumentation, andere setzen sie technisch fest, sodass wiederholte Anfragen desselben Clients automatisch bei Überschreiten der erlaubten Abfragerate blockiert werden. Um dies zu verhindern und um den Server nicht mit vielen Anfragen, die schnell nacheinander gestellt werden, zu überlasten, muss der Code so geschrieben werden, dass die erlaubte Abfragerate der API eingehalten werden. Dazu können verschiedene Strategien angewandt werden, die in diesem Abschnitt vorgestellt werden.
Eine Recherche auf den Seiten der Library of Congress liefert die Seite https://www.loc.gov/apis/json-and-yaml/working-within-limits. Hier legt die LOC Einschränkungen für die der Chronicling America API übergeordnete Seite loc.gov fest. Die Chronicling America API wird zwar nicht explizit erwähnt, aber wir können vermuten, dass die Einschränkungen auch für die Chronicling America API gelten. Da etwas unklar ist, welches Limit für diese API gilt, richten wir uns nach der restriktivsten Vorgabe, nach der nur 20 Abfragen alle 10 Sekunden erlaubt sind. So sind wir in jedem Fall auf der sicheren Seite.
Wie können wir also die HTTP-Abfragen auf 20 Abfragen je 10 Sekunden einschränken? Dazu müssen wir den Code so umschreiben, dass innerhalb einer bestimmten Zeit nur eine bestimmte Anzahl an Anfragen gestellt werden.
Um die Abfragerate einzuschränken, gibt es mehrere Möglichkeiten:
Funktion
time.sleep()aus dem Paket time. Die Funktion time.sleep(x) kann in den Schleifenkörper einer for-Schleife eingefügt werden, um den nächsten Schleifendurchlauf um x Sekunden zu verzögern. Diese Methode ist einstiegsfreundlich, aber ungenau, weil die Laufzeit der Schleife selbst nicht in die Wartezeit mit einbezogen wird, sodass der nächste Schleifendurchlauf länger als notwendig verzögert wird.
import time
# Rate limiting mit time.sleep(): Allgemeines Schema
for i in range(1, 6):
print(i)
time.sleep(2)
%%skip
# Rate limiting mit time.sleep(): LOC API
base_url = "https://chroniclingamerica.loc.gov/search/pages/results/?andtext=&phrasetext=book+review&format=json"
search_results = requests.get(base_url).json()
pages_float = search_results["totalItems"] / search_results["itemsPerPage"]
pages = math.ceil(pages_float) # aufrunden
for page in range(1, pages + 1):
request_url = f"{base_url}&page={page}"
response = requests.get(request_url).json()
# for-Schleife für eine einzelne Ergebnisseite einsetzen
items = response["items"]
for item in items:
ocr_text = item["ocr_eng"]
title = item["title"]
date = item["date"]
filename = f"{title}_{date}.txt"
filepath = os.path.join(output_dir, filename)
with open(filepath, "w", encoding="utf-8") as file:
file.write(ocr_text)
time.sleep(10)
Python Dekoratoren aus dem Paket ratelimit. Wesentlich effizienter und eleganter ist die Verwendung von sogenannten Python Dekoratoren bzw. Decorators. Das Paket ratelimit bietet zwei solche Dekoratoren, die dazu verwendet werden können, um zu registrieren, wie häufig eine Funktion nacheinander aufgerufen wird, und die ab einer bestimmten Anzahl wiederholter Aufrufe eine Wartepause erzwingen. Um Decorators verwenden zu können, müssen wir unsere Abfrage jedoch in eine Funktion verpacken. Das Paket ratelimit wie auch die gängigen und immer noch viel verwendeten Alternativen (z.B. ratelimiter), werden allerdings seit einigen Jahren nicht mehr maintained. Das heißt, dass der Code bereits sehr alt ist und Probleme, auf die User:innen die Entwickler:innen des Pakets aufmerksam machen, nicht mehr behoben werden. So hat zum Beispiel GitHub User:in Justin VanWinkle darauf hingewiesen, dass ratelimit in bestimmten Umständen die Abfragerate nicht zuverlässig kontrolliert.
Note
Python Dekoratoren (Decorators)
A decorator in Python is a function that accepts another function as an argument. The decorator will usually modify or enhance the function it accepted and return the modified function. This means that when you call a decorated function, you will get a function that may be a little different that may have additional features compared with the base definition.
Quelle: Michael Droscill (2017).
Dekoratoren beruhen auf einem komplexen Konzept und wir können hier nicht tiefer einsteigen, aber wenn die ein oder andere Person doch etwas tiefer einsteigen will, kann ich diese beiden Ressourcen empfehlen:
Primer on Python Decorators, https://realpython.com/primer-on-python-decorators/
Python Decorators in 15 Minutes, https://www.youtube.com/watch?v=r7Dtus7N4pI
Bei der Verwendung der Dekoratoren aus dem Paket ratelimit verwenden wir diese Anleitung von Akshay Ranganath:
Rate Limiting with Python, https://akshayranganath.github.io/Rate-Limiting-With-Python/
# wir müssen zunächst die Anaconda Einstellungen ändern, damit wir das Paket ratelimit installieren können:
# import sys
# !conda config --append channels conda-forge
# Paket ratelimit installieren
# !conda install --yes --prefix {sys.prefix} ratelimit
from ratelimit import limits, sleep_and_retry
# Rate limiting mit Python decorators: Allgemeines Schema
PERIOD_SEC = 10
CALLS_PER_PERIOD_SEC = 3 # 3 Abfragen in 10 Sekunden
@sleep_and_retry
@limits(calls=CALLS_PER_PERIOD_SEC, period=PERIOD_SEC)
def test_function(x):
print(x)
for i in range(1, 6):
test_function(i)
%%skip
# Rate limiting mit Python decorators: LOC API
PERIOD_SEC = 10
CALLS_PER_PERIOD_SEC = 20 # 20 Abfragen in 10 Sekunden
@sleep_and_retry
@limits(calls=CALLS_PER_PERIOD_SEC, period=PERIOD_SEC)
def get_fulltext(url, output_dir):
response = requests.get(url).json()
# for-Schleife für eine einzelne Ergebnisseite einsetzen
items = response["items"]
for item in items:
ocr_text = item["ocr_eng"]
title = item["title"]
date = item["date"]
filename = f"{title}_{date}.txt"
filepath = os.path.join(output_dir, filename)
with open(filepath, "w", encoding="utf-8") as file:
file.write(ocr_text)
base_url = "https://chroniclingamerica.loc.gov/search/pages/results/?andtext=&phrasetext=book+review&format=json"
search_results = requests.get(base_url).json()
pages_float = search_results["totalItems"] / search_results["itemsPerPage"]
pages = math.ceil(pages_float) # aufrunden
for page in range(1, pages + 1):
request_url = f"{base_url}&page={page}"
get_fulltext(request_url, output_dir)
Beachtet, dass diese Funktionsdefinition sich in einem wichtigen Aspekt von der Definition der Funktion scrape_quotes() im Abschnitt “Fortsetzung BeautifulSoup” unterscheidet: Beim Aufruf der Funktion get_fulltext() wird nur genau eine Anfrage gestellt und die Funktion wird aus einer for-Schleife heraus aufgerufen. Die Funktion scrape_quotes() dagegen stellt beim Aufruf mehrere Anfragen und die for-Schleife, die über die zu scrapenden URLs iteriert, befindet sich in der Funktionsdefinition selbst. Bei der Verwendung der Dekoratoren zum Rate Limiting muss darauf geachtet werden, dass die Funktion so definiert ist wie die get_fulltext()-Funktion. Die Funktionsaufrufe können nämlich nur dann mithilfe der Funktionsaufrufe verzögert werden, wenn die Funktion auch mehrmals aufgerufen wird!
Feingranulares Rate Limiting mit dem Paket limits. Eine Alternative, die etwas mehr Code und Hintergrundwissen erfordert aber dafür auch viele Anpassungsmöglichkeiten bietet, ist das Paket limits. Das Paket ist eigentlich eher zur Implementierung von Rate Limiting in Schnittstellen und Softwaresystemen gedacht. Die Dokumentationsseiten beschreiben deswegen viele Anwendungsfälle und Konfigurationen, die recht komplex und für uns nicht relevant sind. Es kann beispielsweise zwischen verschiedenen Rate Limiting-Strategien ausgewählt werden und es kann festgelegt werden, ob die Anzahl der vergangenen Anfragen im Arbeitsspeicher oder einer externen Datenbank gespeichert werden soll. Für unsere Zwecke reicht immer der Arbeitsspeicher und die anderen Optionen können wir ignorieren. Ein möglicher Einsatz von limits zum Überwachen und Kontrollieren der Abfragerate im Rahmen von API-Abfragen könnte so aussehen:
from limits import strategies, storage, parse
# Rate limiting mit dem Paket limits: Allgemeines Schema
# Rate limit festlegen, z.B. drei Anfragen pro Sekunde. Hier geht auch z.B. "40/minute", "3/2seconds" oder "1 per second" according to https://limits.readthedocs.io/en/latest/api.html#limits.parse
rate_limit = parse("3/second")
# Speicherort festlegen: Anzahl der vergangenen Anfragen werden im Arbeitsspeicher gehalten (theoretisch ginge auch eine externe Datenbank)
memory_storage = storage.MemoryStorage()
# Strategie festlegen und Moving window rate limiter Objekt erstellen
moving_window = strategies.MovingWindowRateLimiter(memory_storage)
for i in range(1, 6):
while not moving_window.test(rate_limit, "test_requests"):
print(f"Rate limit exceeded for iteration {i}, waiting to retry...")
time.sleep(1) # alternativ 0.01 oder 0.1
moving_window.hit(rate_limit, "test_requests")
print(i)
%%skip
# Rate limiting mit Paket limits: LOC API
base_url = "https://chroniclingamerica.loc.gov/search/pages/results/?andtext=&phrasetext=book+review&format=json"
search_results = requests.get(base_url).json()
pages_float = search_results["totalItems"] / search_results["itemsPerPage"]
pages = math.ceil(pages_float) # aufrunden
rate_limit = parse("20/10seconds")
memory_storage = storage.MemoryStorage()
moving_window = strategies.MovingWindowRateLimiter(memory_storage)
for page in range(1, pages + 1):
while not moving_window.test(rate_limit, "loc_requests"):
print(f"Rate limit exceeded for {page}, waiting to retry...")
time.sleep(0.01)
moving_window.hit(rate_limit, "loc_requests")
request_url = f"{base_url}&page={page}"
response = requests.get(request_url).json()
# for-Schleife für eine einzelne Ergebnisseite einsetzen
items = response["items"]
for item in items:
ocr_text = item["ocr_eng"]
title = item["title"]
date = item["date"]
filename = f"{title}_{date}.txt"
filepath = os.path.join(output_dir, filename)
with open(filepath, "w", encoding="utf-8") as file:
file.write(ocr_text)
Die Methode .test() überprüft, ob das Rate Limit bereits erreicht wurde oder nicht, also ob im angegebenen Zeitfenster (hier 10 Sekunden) bereits die erlaubte Anzahl an Anfragen gestellt wurden (hier 20). Wenn das Limit erreicht wurde, wird False zurückgegeben, ansonsten True. Die Bedingung not moving_window.test(rate_limit, "loc_requests") wird also genau dann True, wenn das Limit erreicht wurde und 20 Anfragen in 10 Sekunden gestellt wurden. In diesem Fall wird gewartet, bis genug Zeit vergangen ist und wieder Anfragen erlaubt sind. Mit der Methode .hit() wird in jedem Durchlauf der for-Schleife eine Anfrage registriert, damit sich der Zähler für die Anzahl der bisher gestellten Anfragen im Objekt memory_storage erhöht. Mehr dazu könnt ihr in den Dokumentationsseiten des Pakets limits nachlesen.
Note
Konstanten (Constants)
Im Code oben verwenden wir Großbuchstaben, um die Variablen CALLS_PER_PERIOD_SEC und PERIOD_SEC zu benennen. Diese Schreibweise hat sich in Python für Konstanten etabliert, also für Variablen, deren Wert sich im Programmverlauf nicht ändert.
Note
Wahl des Dateinamens
In den Beispielen auf dieser Seite haben wir den Titel der Zeitschrift und das Publikationsdatum der jeweiligen Ausgabe als Dateinamen gewählt. Diese Metadaten sind aber eigentlich nicht ausreichend, um den Dateiinhalt eindeutig zu identifizieren, denn die Suchergebnisse beziehen sich nur auf eine Seite aus der angegebenen Ausgabe. Sobald unsere Suche mehr als eine Seite aus derselben Ausgabe liefert, gibt es mehrere Dateien mit denselben Namen, sodass Dateien möglicherweise überschrieben werden! Wie kann das Problem gelöst werden?
Lösung 1: Es werden noch mehr Metadaten, zum Beispiel die Seitenzahl, in den Dateinamen aufgenommen. Aber Achtung: Dateipfade dürfen auf den meisten Betriebssystemen höchstens 255 Zeichen lang sein. Wir müssen uns bei der Wahl der Metadaten also sicher sein, dass unter dem entsprechenden Schlüssel niemals eine sehr lange Zeichenkette steht. Wenn wir uns dem nicht sicher sein können, sollten zu lange Dateinamen mit if…else erkannt und behandelt (z.B. gekürzt) werden. Für die Validierung von Dateinamen gibt es auch ein spezialisiertes Paket, pathvalidate.
Lösung 2: Wir verwenden den Schlüssel “ID” als Dateinamen. Die ID ist immer eine Zeichenkette der Form “/lccn/sn86069395/1901-04-16/ed-1/seq-3/”. Allerdings müssen wir uns dann auch eine effiziente Strategie, wie zu dem Suchergebnis mit der angegebenen ID weitere Metadaten abgerufen werden können, überlegen und die entsprechende Abfragelogik in Python implementieren. Ein weiteres Problem stellt die ID selbst dar, denn die Schrägstriche sind von Schrägstrichen, die Teil des Dateipfads sind, nicht zu unterscheiden. Beim Schreiben der Datei versucht der Computer also, ein Verzeichnis mit dem Namen lccn zu finden, das vermutlich nicht existiert. Um die ID verwenden zu können, können die Schrägstriche aber einfach durch Unterstriche ersetzt werden. Das geht zum Beispiel mit dem Modul re, das wir ganz am Ende des Semesters kurz besprechen werden.
Ihr seht: einen optimalen Dateinamen gibt es oft nicht. Als Faustregel solltet ihr euch merken, dass Dateinamen immer den Inhalt eindeutig indentifizieren sollten, keine Sonderzeichen wie Schrägstriche und Leerzeichen enthalten sollten, und nicht zu lang sein dürfen.
8.3.5. Quellen#
Michael Driscoll. Chapter 25 – Decorators. 2017. URL: https://python101.pythonlibrary.org/chapter25_decorators.html.
Akshay Ranganath. Rate Limiting with Python. 2021. URL: https://akshayranganath.github.io/Rate-Limiting-With-Python/.
Guido van Rossum, Barry Warsaw, and Nick Coghlan. PEP 8: Constants. 2013. URL: https://peps.python.org/pep-0008/#constants.
Geir Arne Hjelle. Primer on Python Decorators. 2023. URL: https://realpython.com/primer-on-python-decorators/.
Kite. Python Decorators in 15 Minutes. 2020. URL: https://www.youtube.com/watch?v=r7Dtus7N4pI.
Leodanis Pozo Ramos. Python Constants: Improve Your Code's Maintainability. 2022. URL: https://realpython.com/python-constants/.
Library of Congress. Chronicling America. About the Site and API. 2023. URL: https://chroniclingamerica.loc.gov/about/api/.
Library of Congress. Working Within Limits. 2023. URL: https://www.loc.gov/apis/json-and-yaml/working-within-limits.
Limits 3.14.1 Documentation. Quickstart. 2023. URL: https://limits.readthedocs.io/en/stable/quickstart.html.
Limits 3.14.1 Documentation. Rate Limiting Strategies. 2023. URL: https://limits.readthedocs.io/en/stable/strategies.html.