Spaces:
Sleeping
Sleeping
File size: 12,013 Bytes
924632b c320b8e 924632b c320b8e 924632b | 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 74 75 76 77 78 79 80 81 82 83 84 85 86 87 88 89 90 91 92 93 94 95 96 97 98 99 100 101 102 103 104 105 106 107 108 109 110 111 112 113 114 115 116 117 118 119 120 121 122 123 124 125 126 127 128 129 130 131 132 133 134 135 136 137 138 139 140 141 142 143 144 145 146 147 148 149 150 151 152 153 154 155 156 157 158 159 160 161 162 163 164 165 166 167 168 169 170 171 172 173 174 175 176 177 178 179 180 181 182 183 184 185 186 187 188 189 190 191 192 193 194 195 196 197 198 199 200 201 202 203 204 205 206 207 208 209 210 211 212 213 214 215 216 217 218 219 220 221 222 223 224 225 226 227 228 229 230 231 232 233 234 235 236 237 238 239 240 241 242 243 244 245 246 247 248 249 250 251 252 253 254 255 256 257 258 259 260 261 262 263 264 265 266 267 268 269 270 271 272 273 274 275 276 277 278 279 280 281 282 283 284 285 286 287 288 289 290 291 292 293 294 295 296 297 298 299 300 301 302 303 304 305 306 307 308 309 310 311 312 313 314 315 316 317 318 319 320 321 322 | """EPO Open Patent Services (OPS) v3.2 backend.
Legitimate REST alternative to scraping Espacenet (which sits behind an
interactive Cloudflare CAPTCHA). Provides keyword search and by-number
retrieval of the same bibliographic / full-text data.
Auth is OAuth2 client-credentials: the consumer key/secret are exchanged for a
short-lived (~20 min) bearer token at the token endpoint. Credentials are read
from the OPS_CONSUMER_KEY / OPS_CONSUMER_SECRET environment variables.
"""
import asyncio
import base64
import logging
import os
import re
import time
from typing import Optional
from dotenv import load_dotenv
from httpx import AsyncClient, HTTPStatusError
from pydantic import BaseModel
from scrap import ClassificationCode, PatentScrapResult
load_dotenv()
OPS_BASE = "https://ops.epo.org/3.2/rest-services"
OPS_TOKEN_URL = "https://ops.epo.org/3.2/auth/accesstoken"
class OPSNotConfigured(Exception):
"""Raised when OPS credentials are not set in the environment."""
class OPSError(Exception):
"""Raised when OPS returns an error that isn't a plain 'not found'."""
class _OPSTokenManager:
"""Caches and refreshes the OPS OAuth2 bearer token."""
def __init__(self) -> None:
self._key = os.environ.get("OPS_CONSUMER_KEY")
self._secret = os.environ.get("OPS_CONSUMER_SECRET")
self._token: Optional[str] = None
self._expiry: float = 0.0
self._lock = asyncio.Lock()
@property
def configured(self) -> bool:
return bool(self._key and self._secret)
async def get_token(self, client: AsyncClient, force: bool = False) -> str:
if not self.configured:
raise OPSNotConfigured(
"OPS_CONSUMER_KEY / OPS_CONSUMER_SECRET are not set.")
async with self._lock:
# 30s safety margin before expiry.
if not force and self._token and time.monotonic() < self._expiry - 30:
return self._token
basic = base64.b64encode(
f"{self._key}:{self._secret}".encode()).decode()
resp = await client.post(
OPS_TOKEN_URL,
headers={"Authorization": f"Basic {basic}"},
data={"grant_type": "client_credentials"},
)
resp.raise_for_status()
data = resp.json()
self._token = data["access_token"]
self._expiry = time.monotonic() + int(data.get("expires_in", 1199))
logging.info("Obtained new OPS access token.")
return self._token
token_manager = _OPSTokenManager()
_KIND_SUFFIX_RE = re.compile(r"[A-Z]\d{0,2}$")
def _normalize_epodoc(number: str) -> str:
"""Strip whitespace and a trailing kind code for the epodoc format.
OPS epodoc lookups reject the kind suffix (e.g. 'US11930446B2' -> 404), so
'US11930446B2' becomes 'US11930446' and 'EP4760514A1' becomes 'EP4760514'.
"""
number = number.strip().replace(" ", "")
return _KIND_SUFFIX_RE.sub("", number)
async def _ops_get(client: AsyncClient, path: str, params: Optional[dict] = None) -> dict:
"""GET an OPS endpoint as JSON, refreshing the token once on auth failure."""
token = await token_manager.get_token(client)
url = f"{OPS_BASE}{path}"
headers = {"Authorization": f"Bearer {token}",
"Accept": "application/json"}
resp = await client.get(url, params=params, headers=headers)
# A stale/invalid token surfaces as 400/401/403 mentioning the token; refresh once.
if resp.status_code in (400, 401, 403) and "token" in resp.text.lower():
token = await token_manager.get_token(client, force=True)
headers["Authorization"] = f"Bearer {token}"
resp = await client.get(url, params=params, headers=headers)
resp.raise_for_status()
return resp.json()
# --------------------------- JSON walking helpers ---------------------------
# OPS returns XML-to-JSON: text nodes are {"$": "..."}, attributes are "@name",
# and repeated elements become lists (but a single element stays a bare dict).
def _as_list(node) -> list:
if node is None:
return []
return node if isinstance(node, list) else [node]
def _text(node) -> Optional[str]:
if isinstance(node, dict):
return node.get("$")
if isinstance(node, str):
return node
return None
def _pick_lang(nodes: list, lang: str = "en"):
"""From a list of {@lang, ...} nodes, prefer the requested language."""
nodes = _as_list(nodes)
for n in nodes:
if isinstance(n, dict) and n.get("@lang", "").lower() == lang:
return n
return nodes[0] if nodes else None
def _epodoc_number(exchange_doc: dict) -> Optional[str]:
"""Best-effort human patent number (e.g. 'EP4760514')."""
bd = exchange_doc.get("bibliographic-data", {})
ref = bd.get("publication-reference", {})
for doc_id in _as_list(ref.get("document-id")):
if doc_id.get("@document-id-type") == "epodoc":
return _text(doc_id.get("doc-number"))
country = exchange_doc.get("@country", "")
number = exchange_doc.get("@doc-number", "")
return f"{country}{number}" if (country or number) else None
def _extract_title(bibliographic_data: dict) -> Optional[str]:
title = _pick_lang(bibliographic_data.get("invention-title"))
return _text(title) if title else None
def _extract_abstract(exchange_doc: dict) -> Optional[str]:
abstract = _pick_lang(exchange_doc.get("abstract"))
if not abstract:
return None
paras = [_text(p) for p in _as_list(abstract.get("p"))]
joined = " ".join(p for p in paras if p)
return joined or None
def _extract_classifications(bibliographic_data: dict) -> Optional[list[ClassificationCode]]:
"""Assemble CPC symbols from patent-classifications (no descriptions in OPS)."""
codes: list[ClassificationCode] = []
seen: set[str] = set()
container = bibliographic_data.get("patent-classifications", {})
for pc in _as_list(container.get("patent-classification")):
section = _text(pc.get("section")) or ""
klass = _text(pc.get("class")) or ""
subclass = _text(pc.get("subclass")) or ""
main_group = _text(pc.get("main-group")) or ""
subgroup = _text(pc.get("subgroup")) or ""
if not (section and klass and subclass and main_group):
continue
code = f"{section}{klass}{subclass}{main_group}/{subgroup}".rstrip("/")
if code not in seen:
seen.add(code)
codes.append(ClassificationCode(code=code, description=""))
return codes or None
def _fulltext_paragraphs(data: dict, section: str) -> Optional[str]:
"""Extract text from a fulltext service response (claims / description)."""
wpd = data.get("ops:world-patent-data", {})
ftxt_docs = wpd.get("ftxt:fulltext-documents", {})
doc = ftxt_docs.get("ftxt:fulltext-document")
doc = doc[0] if isinstance(doc, list) else doc
if not doc:
return None
node = _pick_lang(doc.get(section)) if isinstance(
doc.get(section), list) else doc.get(section)
if not node:
return None
if section == "claims":
lines: list[str] = []
for claim in _as_list(node.get("claim")):
for ct in _as_list(claim.get("claim-text")):
t = _text(ct)
if t:
lines.append(t)
return "\n".join(lines) or None
# description
paras = [_text(p) for p in _as_list(node.get("p"))]
return "\n".join(p for p in paras if p) or None
# ------------------------------- Public API --------------------------------
def _to_cql(query: str) -> str:
"""Turn a free-text query into a CQL expression OPS accepts.
OPS only auto-wraps a single bare word; multi-word queries like
'agentic ai' are invalid CQL and 404. Queries already containing a
relational operator ('=') are assumed to be hand-written CQL and passed
through unchanged.
"""
q = query.strip()
if not q or "=" in q:
return q
escaped = q.replace('"', " ").strip()
return f'txt all "{escaped}"'
async def ops_search(client: AsyncClient, query: str, n_results: int) -> list[dict]:
"""Keyword search against OPS published-data, normalized to SERP dicts."""
n_results = max(1, min(n_results, 100)) # OPS caps a page at 100.
try:
data = await _ops_get(
client,
"/published-data/search/biblio",
params={"q": _to_cql(query), "Range": f"1-{n_results}"},
)
except HTTPStatusError as e:
# OPS returns 404 (SERVER.EntityNotFound) when a search has no hits.
if e.response.status_code == 404:
return []
raise
search = data.get("ops:world-patent-data", {}).get("ops:biblio-search", {})
result = search.get("ops:search-result", {})
results: list[dict] = []
for entry in _as_list(result.get("exchange-documents")):
ed = entry.get("exchange-document") if isinstance(entry, dict) else None
if not ed:
continue
number = _epodoc_number(ed)
bd = ed.get("bibliographic-data", {})
results.append({
"title": _extract_title(bd) or number or "Untitled",
"body": _extract_abstract(ed) or "",
"href": f"https://worldwide.espacenet.com/patent/search?q={number}" if number else "",
"id": number,
})
return results
async def ops_biblio(client: AsyncClient, number: str, doc_type: str = "publication",
fmt: str = "epodoc") -> dict:
if fmt == "epodoc":
number = _normalize_epodoc(number)
data = await _ops_get(
client, f"/published-data/{doc_type}/{fmt}/{number}/biblio")
return data["ops:world-patent-data"]["exchange-documents"]["exchange-document"]
async def _ops_fulltext(client: AsyncClient, number: str, section: str,
doc_type: str = "publication", fmt: str = "epodoc") -> Optional[str]:
"""Fetch claims/description full text; returns None when unavailable (404)."""
if fmt == "epodoc":
number = _normalize_epodoc(number)
try:
data = await _ops_get(client, f"/published-data/{doc_type}/{fmt}/{number}/{section}")
except HTTPStatusError as e:
if e.response.status_code == 404:
return None
raise
return _fulltext_paragraphs(data, section)
async def ops_scrap_patent(client: AsyncClient, number: str, doc_type: str = "publication",
fmt: str = "epodoc") -> PatentScrapResult:
"""Retrieve a patent via OPS and shape it like the Google Patents scraper."""
exchange_doc = await ops_biblio(client, number, doc_type, fmt)
bd = exchange_doc.get("bibliographic-data", {})
claims, description = await asyncio.gather(
_ops_fulltext(client, number, "claims", doc_type, fmt),
_ops_fulltext(client, number, "description", doc_type, fmt),
return_exceptions=True,
)
claims = None if isinstance(claims, Exception) else claims
description = None if isinstance(description, Exception) else description
return PatentScrapResult(
title=_extract_title(bd) or (_epodoc_number(exchange_doc) or number),
abstract=_extract_abstract(exchange_doc),
description=description,
claims=claims,
field_of_invention=None,
background=None,
classifications=_extract_classifications(bd),
)
class OPSBulkResponse(BaseModel):
patents: list[PatentScrapResult]
failed_ids: list[str]
async def ops_scrap_patent_bulk(client: AsyncClient, numbers: list[str]) -> OPSBulkResponse:
results = await asyncio.gather(
*[ops_scrap_patent(client, n) for n in numbers], return_exceptions=True)
patents = [r for r in results if not isinstance(r, Exception)]
failed = [numbers[i] for i, r in enumerate(
results) if isinstance(r, Exception)]
return OPSBulkResponse(patents=patents, failed_ids=failed)
|