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)