Google Patents non ha un'API ufficiale. La strada a pagamento è SerpAPI, e quelle ufficiali gratuite coprono ciascuna un solo ufficio, come PatentsView dell'USPTO per i brevetti statunitensi. Ma il sito di Google Patents carica i risultati da un endpoint JSON, e quell'endpoint risponde a semplici richieste HTTP senza chiave. La ricerca di brevetti nello spazio Paper dell'app desktop osFoundry lo usa da Rust. Qui sotto trovi la stessa richiesta in Python con la sola libreria standard, i filtri che accetta, com'è fatta la risposta e cosa succede dopo cinque query in mezzo minuto.
La richiesta è una query string dentro una query string
L'endpoint è https://patents.google.com/xhr/query. Accetta un solo parametro vero, url, il cui valore è a sua volta una query string codificata: la stessa che vedi dopo il ? quando cerchi sul sito. I termini di ricerca vengono quindi codificati due volte.
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"] Le parentesi attorno alla query sono quelle che manda anche il sito. Senza uno user agent da browser l'endpoint tende più facilmente a rifiutare, quindi l'app ne invia uno, e così fa questa funzione. L'ultimo controllo prima del parsing è spiegato più avanti.
Filtri
I filtri vanno nella query string interna, accanto a q. Sono quelli che l'app lascia passare, con il numero di risultati per water bottle glows reminder to drink:
| parametro interno | esempio | risultati |
|---|---|---|
| (nessuno) | 2757 | |
status | GRANT | 685 |
status | APPLICATION | 1739 |
country | JP | 157 |
before / after | priority:20190101 | non misurato |
sort | new oppure old | non misurato |
num / page | 20 / 1 | non misurato |
results = search("water bottle glows reminder to drink", status="GRANT") print(results["total_num_results"]) # 685 In questi numeri ci sono due cose facili da fraintendere. La prima: 685 concessi più 1739 domande fanno 2424, non 2757, quindi 333 documenti non sono né l'uno né l'altro e i due filtri non dividono l'insieme. La seconda: il conteggio riguarda documenti, non invenzioni. La stessa invenzione compare di solito una volta come domanda pubblicata e un'altra come brevetto concesso. "Smart container with interactive, colored lights" è nell'elenco delle domande come US20220218137A1 e in quello dei concessi come US12082729B2, con lo stesso titolo e la stessa data di priorità, 2019-04-30. Se vuoi contare le invenzioni, raggruppa per data di priorità e titolo, o per famiglia, prima di contare.
I filtri di data confrontano la data di priorità, che è quella che conta per l'arte anteriore. L'app lascia passare solo una data di 8 cifre, e solo i valori esatti di paese, stato e ordinamento visti sopra, perché queste stringhe vengono inserite in un URL:
/// 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)> { Leggere la risposta
I risultati sono annidati su due livelli: results.cluster[].result[].patent. Ogni patent ha title, snippet, priority_date, filing_date, grant_date, publication_date, inventor, assignee, publication_number, language, un percorso thumbnail e un elenco di figures.
I campi di testo sono con escape HTML, non testo semplice. I titoli arrivano con uno spazio iniziale (' Liquid intake management system'), gli apostrofi come ' e gli snippet finiscono con …. Il Rust dell'app toglie anche i tag, perché Google può racchiudere le parole trovate in <b>. Basta un'espressione regolare e html.unescape:
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 ... I brevetti giapponesi e russi tornano con titoli in inglese perché Google li traduce automaticamente. Il richiedente non viene tradotto: quello di JP6067764B2 è ancora in giapponese. Il quarto risultato è un generatore di vapore per sigarette elettroniche, il che mostra quanto sia largo il confronto. Parole semplici non sono una ricerca per frase esatta: conviene leggere i primi risultati e raffinare le parole, non fidarsi del totale così com'è.
Le immagini sono percorsi relativi a https://patentimages.storage.googleapis.com/. La seconda figura di US12082729B2 si trova in e6/88/67/673b4fcbbe5404/US12082729-20240910-D00001.png, e quell'host la serve a una richiesta semplice:
La risposta contiene anche total_num_pages e un blocco summary che conta richiedenti, inventori e classi CPC per anno. Entrambi si fermano a 1000 documenti: il "Total" del riepilogo era 1000 per una ricerca con 2757 risultati, e total_num_pages era 100 con 10 risultati a pagina. Senza num, una pagina ha 10 risultati. L'app ne chiede 20 e limita il valore tra 10 e 100.
Cosa succede se ne invii troppe
Uno script di prova ha messo in coda sei query con una pausa di 4 secondi tra l'una e l'altra. Le prime quattro hanno risposto in 2.8-4.5 secondi ciascuna. La quinta, circa 30 secondi dopo la prima, è tornata come HTTP 500 con una pagina HTML. Un nuovo tentativo un minuto dopo ha ricevuto HTTP 503 e la pagina "Sorry..." di Google, e lo stesso è successo a tutti i tentativi successivi, l'ultimo quasi 14 minuti dopo il primo rifiuto. La sesta query non è mai partita.
Il blocco, quindi, non è un errore JSON che si intercetta con il solo codice di stato. Può essere un 503 o un 429, e qui il primo era addirittura un 500, ma in ogni caso il corpo era una pagina HTML. Per questo la funzione qui sopra controlla se il corpo inizia con < prima di fare il parsing. L'app fa lo stesso e, se l'utente ha una chiave SerpAPI, manda la stessa query tramite SerpAPI invece di fallire:
// 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); } Per uno script le regole pratiche sono: mettere in cache ogni risposta in base alla sua query string interna, inviare una query alla volta e fermarsi alla prima risposta HTML invece di riprovare in un ciclo. L'endpoint non è documentato, quindi la sua forma può cambiare. Per qualsiasi uso regolare o massiccio, una fonte ufficiale (PatentsView per gli Stati Uniti, gli Open Patent Services dell'EPO per l'Europa, l'API di informazione sui brevetti dell'Ufficio brevetti giapponese per il Giappone) o SerpAPI è la scelta più sicura.
L'app desktop che fa questa richiesta è gratuita dalla pagina di download di osFoundry.


