Google Patents hat keine offizielle API. Der kostenpflichtige Weg ist SerpAPI, und die kostenlosen offiziellen decken jeweils nur ein Amt ab, etwa PatentsView des USPTO für US-Patente. Die Website von Google Patents lädt ihre Ergebnisse aber von einem JSON-Endpunkt, und dieser Endpunkt beantwortet normale HTTP-Anfragen ohne Schlüssel. Die Patentsuche im Paper-Workspace der Desktop-App osFoundry nutzt ihn aus Rust. Unten steht dieselbe Anfrage in Python nur mit der Standardbibliothek, dazu die Filter, die er akzeptiert, wie die Antwort aussieht und was nach fünf Anfragen in einer halben Minute passiert.
Die Anfrage ist ein Query-String in einem Query-String
Der Endpunkt ist https://patents.google.com/xhr/query. Er nimmt einen einzigen echten Parameter, url, dessen Wert selbst ein kodierter Query-String ist: derselbe, den man nach dem ? sieht, wenn man auf der Website sucht. Die Suchbegriffe werden also zweimal kodiert.
import html, json, re, urllib.error, urllib.parse, urllib.request UA = "Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/134.0.0.0 Safari/537.36" def search(query, **filters): inner = urllib.parse.urlencode({"q": f"({query})", **filters}) url = "https://patents.google.com/xhr/query?" + urllib.parse.urlencode({"url": inner, "exp": ""}) req = urllib.request.Request(url, headers={"User-Agent": UA, "Accept": "application/json"}) try: with urllib.request.urlopen(req, timeout=30) as r: status, text = r.status, r.read().decode() except urllib.error.HTTPError as e: status, text = e.code, e.read().decode(errors="replace") if status in (429, 503) or text.lstrip().startswith("<"): raise RuntimeError(f"rate limited ({status})") return json.loads(text)["results"] Die Klammern um die Anfrage schickt auch die Website. Ohne Browser-User-Agent lehnt der Endpunkt eher ab, deshalb sendet die App einen, und diese Funktion ebenfalls. Die letzte Prüfung vor dem Parsen wird weiter unten erklärt.
Filter
Die Filter kommen in den inneren Query-String, neben q. Das sind die, die die App durchreicht, mit den Trefferzahlen für water bottle glows reminder to drink:
| innerer Parameter | Beispiel | Ergebnisse |
|---|---|---|
| (keiner) | 2757 | |
status | GRANT | 685 |
status | APPLICATION | 1739 |
country | JP | 157 |
before / after | priority:20190101 | nicht gemessen |
sort | new oder old | nicht gemessen |
num / page | 20 / 1 | nicht gemessen |
results = search("water bottle glows reminder to drink", status="GRANT") print(results["total_num_results"]) # 685 Zwei Dinge an diesen Zahlen versteht man leicht falsch. Erstens: 685 erteilte plus 1739 Anmeldungen sind 2424, nicht 2757, also sind 333 Dokumente weder das eine noch das andere, und die beiden Filter teilen die Menge nicht auf. Zweitens: Die Zahl zählt Dokumente, nicht Erfindungen. Dieselbe Erfindung taucht meist einmal als veröffentlichte Anmeldung und einmal als Patenterteilung auf. "Smart container with interactive, colored lights" steht in der Anmeldungsliste als US20220218137A1 und in der Liste der erteilten als US12082729B2, mit demselben Titel und demselben Prioritätsdatum, 2019-04-30. Will man Erfindungen zählen, gruppiert man vor dem Zählen nach Prioritätsdatum und Titel oder nach Patentfamilie.
Die Datumsfilter vergleichen mit dem Prioritätsdatum, und das ist für den Stand der Technik das entscheidende. Die App lässt nur ein 8-stelliges Datum durch und nur die genauen Werte für Land, Status und Sortierung von oben, weil diese Strings in eine URL eingesetzt werden:
/// Filter values are spliced into upstream query strings, so only the exact /// shapes each upstream documents are passed through. fn filter_params(filters: &PatentFilters) -> Vec<(&'static str, String)> { Die Antwort lesen
Die Treffer liegen zwei Ebenen tief: results.cluster[].result[].patent. Jedes patent hat title, snippet, priority_date, filing_date, grant_date, publication_date, inventor, assignee, publication_number, language, einen thumbnail-Pfad und eine Liste figures.
Die Textfelder sind HTML-escaped, kein Klartext. Titel kommen mit führendem Leerzeichen (' Liquid intake management system'), Apostrophe als ', und Snippets enden auf …. Das Rust der App entfernt außerdem Tags, weil Google gefundene Wörter in <b> einfassen kann. Ein regulärer Ausdruck und html.unescape erledigen alles:
def plain(v): return " ".join(html.unescape(re.sub(r"<[^>]*>", "", v or "")).split()) def hits(results): for cluster in results["cluster"]: for hit in cluster["result"]: yield hit["patent"] for p in hits(search("water bottle glows reminder to drink", status="GRANT")): print(p["publication_number"], p["priority_date"], plain(p["title"])) US12564280B2 2015-03-09 Wireless drink container for monitoring hydration US12082729B2 2019-04-30 Smart container with interactive, colored lights JP6067764B2 2013-04-03 Liquid intake management system RU2707893C2 2014-02-28 Personal steam generator for electronic cigarette US11581099B1 2017-05-24 Automated home system for senior care ... Die japanischen und russischen Patente kommen mit englischen Titeln zurück, weil Google sie maschinell übersetzt. Der Anmelder wird nicht übersetzt: Der von JP6067764B2 steht weiterhin auf Japanisch. Das vierte Ergebnis ist ein Dampferzeuger für E-Zigaretten, was zeigt, wie locker das Matching ist. Einfache Wörter sind keine exakte Phrasensuche. Man sollte also die obersten Treffer lesen und die Suchwörter anpassen, statt der Trefferzahl einfach zu glauben.
Bilder sind Pfade relativ zu https://patentimages.storage.googleapis.com/. Die zweite Abbildung von US12082729B2 liegt unter e6/88/67/673b4fcbbe5404/US12082729-20240910-D00001.png, und dieser Host liefert sie auf eine einfache Anfrage aus:
Die Antwort enthält außerdem total_num_pages und einen summary-Block, der Anmelder, Erfinder und CPC-Klassen pro Jahr zählt. Beides endet bei 1000 Dokumenten: Das "Total" der Zusammenfassung war 1000 bei einer Suche mit 2757 Ergebnissen, und total_num_pages war 100 bei 10 Ergebnissen pro Seite. Ohne num hat eine Seite 10 Treffer. Die App fragt 20 an und begrenzt den Wert auf 10 bis 100.
Was passiert, wenn man zu viele sendet
Ein Testskript stellte sechs Anfragen mit 4 Sekunden Pause dazwischen in die Warteschlange. Die ersten vier wurden in jeweils 2.8 bis 4.5 Sekunden beantwortet. Die fünfte, etwa 30 Sekunden nach der ersten, kam als HTTP 500 mit einer HTML-Seite zurück. Ein Wiederholungsversuch eine Minute später bekam HTTP 503 und Googles "Sorry..."-Seite, ebenso jeder weitere Versuch, der letzte fast 14 Minuten nach der ersten Ablehnung. Die sechste Anfrage lief nie.
Die Sperre ist also kein JSON-Fehler, den man allein über den Statuscode abfangen kann. Es kann ein 503 oder ein 429 sein, und hier war der erste sogar ein 500, aber in jedem Fall war der Body eine HTML-Seite. Deshalb prüft die Funktion oben, ob der Body mit < beginnt, bevor sie ihn parst. Die App macht dasselbe, und wenn der Nutzer einen SerpAPI-Schlüssel hat, schickt sie dieselbe Anfrage stattdessen über SerpAPI, statt zu scheitern:
// The block page is HTML served as 503 (sometimes 429). if status.as_u16() == 429 || status.as_u16() == 503 || text.trim_start().starts_with('<') { return Err(GoogleError::Limited); } Für ein Skript gelten praktisch diese Regeln: jede Antwort anhand ihres inneren Query-Strings cachen, immer nur eine Anfrage gleichzeitig senden und bei der ersten HTML-Antwort aufhören, statt in einer Schleife zu wiederholen. Der Endpunkt ist nicht dokumentiert, seine Form kann sich also ändern. Für alles Regelmäßige oder Große ist eine offizielle Quelle (PatentsView für die USA, die Open Patent Services des EPA für Europa, die Patentinformations-API des Japanischen Patentamts für Japan) oder SerpAPI die sicherere Wahl.
Die Desktop-App, die diese Anfrage stellt, ist auf der osFoundry-Downloadseite kostenlos.
