Google Patents no tiene API oficial. La vía de pago es SerpAPI, y las gratuitas oficiales cubren cada una una sola oficina, como PatentsView de la USPTO para patentes de EE. UU. Pero la web de Google Patents carga sus resultados desde un endpoint JSON, y ese endpoint responde a peticiones HTTP normales sin clave. La búsqueda de patentes del espacio Paper de la aplicación de escritorio osFoundry lo usa desde Rust. Abajo está la misma petición en Python con solo la biblioteca estándar, los filtros que acepta, cómo es la respuesta y qué pasa después de cinco consultas en medio minuto.
La petición es una query string dentro de otra query string
El endpoint es https://patents.google.com/xhr/query. Toma un único parámetro real, url, cuyo valor es a su vez una query string codificada: la misma que ves después del ? cuando buscas en la web. Por eso los términos de búsqueda se codifican dos veces.
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"] Los paréntesis alrededor de la consulta son los mismos que envía la web. Sin un user agent de navegador el endpoint tiene más probabilidades de rechazar la petición, así que la aplicación envía uno, y esta función también. La última comprobación antes de interpretar la respuesta se explica más abajo.
Filtros
Los filtros van en la query string interna, junto a q. Estos son los que la aplicación deja pasar, con el número de resultados para water bottle glows reminder to drink:
| parámetro interno | ejemplo | resultados |
|---|---|---|
| (ninguno) | 2757 | |
status | GRANT | 685 |
status | APPLICATION | 1739 |
country | JP | 157 |
before / after | priority:20190101 | no medido |
sort | new o old | no medido |
num / page | 20 / 1 | no medido |
results = search("water bottle glows reminder to drink", status="GRANT") print(results["total_num_results"]) # 685 Hay dos cosas en esas cifras que es fácil interpretar mal. La primera: 685 concedidas más 1739 solicitudes son 2424, no 2757, así que 333 documentos no son ni una cosa ni la otra y los dos filtros no dividen el conjunto. La segunda: el número cuenta documentos, no invenciones. La misma invención suele aparecer una vez como solicitud publicada y otra como concesión. "Smart container with interactive, colored lights" está en la lista de solicitudes como US20220218137A1 y en la de concedidas como US12082729B2, con el mismo título y la misma fecha de prioridad, 2019-04-30. Si quieres contar invenciones, agrupa por fecha de prioridad y título, o por familia, antes de contar.
Los filtros de fecha comparan con la fecha de prioridad, que es la que importa para el estado de la técnica. La aplicación solo deja pasar una fecha de 8 dígitos, y solo los valores exactos de país, estado y orden de arriba, porque estas cadenas se insertan en una 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)> { Leer la respuesta
Los resultados están anidados dos niveles: results.cluster[].result[].patent. Cada patent tiene title, snippet, priority_date, filing_date, grant_date, publication_date, inventor, assignee, publication_number, language, una ruta thumbnail y una lista de figures.
Los campos de texto vienen escapados como HTML, no son texto plano. Los títulos llevan un espacio al principio (' Liquid intake management system'), los apóstrofos llegan como ' y los fragmentos terminan en …. El Rust de la aplicación además quita las etiquetas, porque Google puede envolver las palabras coincidentes en <b>. Una expresión regular y html.unescape lo resuelven todo:
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 ... Las patentes japonesa y rusa llegan con título en inglés porque Google las traduce automáticamente. El solicitante no se traduce: el de JP6067764B2 sigue estando en japonés. El cuarto resultado es un generador de vapor para cigarrillos electrónicos, lo que muestra lo laxa que es la coincidencia. Las palabras sueltas no son una búsqueda de frase exacta, así que hay que leer los primeros resultados y afinar las palabras, no fiarse del total sin más.
Las imágenes son rutas relativas a https://patentimages.storage.googleapis.com/. La segunda figura de US12082729B2 está en e6/88/67/673b4fcbbe5404/US12082729-20240910-D00001.png, y ese host la sirve a una petición normal:
La respuesta también incluye total_num_pages y un bloque summary que cuenta solicitantes, inventores y clases CPC por año. Ambos se detienen en 1000 documentos: el "Total" del resumen fue 1000 en una búsqueda con 2757 resultados, y total_num_pages fue 100 con 10 resultados por página. Sin num, una página trae 10 resultados. La aplicación pide 20 y limita el valor entre 10 y 100.
Qué pasa cuando se envían demasiadas
Un script de prueba puso en cola seis consultas con una pausa de 4 segundos entre ellas. Las cuatro primeras respondieron en 2.8 a 4.5 segundos cada una. La quinta, unos 30 segundos después de la primera, devolvió un HTTP 500 con una página HTML. Un reintento un minuto después recibió un HTTP 503 y la página "Sorry..." de Google, igual que todos los reintentos siguientes, el último casi 14 minutos después del primer rechazo. La sexta consulta nunca se ejecutó.
Así que el bloqueo no es un error JSON que se pueda capturar solo con el código de estado. Puede ser un 503 o un 429, y aquí el primero fue incluso un 500, pero en todos los casos el cuerpo era una página HTML. Por eso la función de arriba comprueba si el cuerpo empieza por < antes de interpretarlo. La aplicación hace lo mismo y, cuando el usuario tiene una clave de SerpAPI, envía la misma consulta por SerpAPI en lugar de fallar:
// 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); } Para un script, las reglas prácticas son: guardar en caché cada respuesta por su query string interna, enviar una consulta cada vez y parar en la primera respuesta HTML en lugar de reintentar en bucle. El endpoint no está documentado, así que su forma puede cambiar. Para algo regular o grande, es más seguro usar una fuente oficial (PatentsView para EE. UU., Open Patent Services de la EPO para Europa, la API de información de patentes de la Oficina de Patentes de Japón para Japón) o SerpAPI.
La aplicación de escritorio que hace esta petición es gratuita en la página de descarga de osFoundry.


