Claude commited on
Commit
cb1e234
·
unverified ·
0 Parent(s):

Mirror SERPent from Hugging Face Space with bug fixes

Browse files

Mirrors OrganizedProgrammers/SERPent (https://huggingface.co/spaces/OrganizedProgrammers/SERPent)
with the following fixes applied on top:

- app.py: make /serp/search try all queries concurrently instead of
sequentially, and surface per-query backend failures in the response's
error field instead of silently dropping them when at least one other
query in the batch succeeded.
- app.py: add opt-in SERPENT_API_KEY protection (X-API-Key or Authorization:
Bearer) gating REST and MCP alike via HTTP middleware, closing the
unauthenticated open-scraping-proxy surface. Unset by default, so
behavior is unchanged until explicitly enabled.
- app.py: load docs/docs.md safely (context-managed read, fallback
description) instead of an unguarded open().read() at import time.
- serp.py: raise a clear BrowserUnavailableError instead of an
AttributeError when Playwright failed to start and pw_browser is None.
- serp.py: clamp n_results to [1, 100] in SerpQuery so a negative or huge
value can't reach a scraped page URL/loop bound unclamped.
- scrap.py: fix scrap_patent_bulk_async's patent_ids type hint
(list[int] -> list[str]; patent IDs are strings like "US11930446B2").
- scrap.py: raise a clear error instead of crashing on None.get(...) when
the DC.title meta tag is missing from a scraped page.

Files changed (13) hide show
  1. .gitattributes +35 -0
  2. .gitignore +4 -0
  3. Dockerfile +18 -0
  4. README.md +15 -0
  5. app.py +384 -0
  6. docs/README_MCP.md +145 -0
  7. docs/docs.md +38 -0
  8. mcp_server.py +203 -0
  9. ops.py +321 -0
  10. requirements.txt +10 -0
  11. scrap.py +139 -0
  12. serp.py +296 -0
  13. utils.py +9 -0
.gitattributes ADDED
@@ -0,0 +1,35 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ *.7z filter=lfs diff=lfs merge=lfs -text
2
+ *.arrow filter=lfs diff=lfs merge=lfs -text
3
+ *.bin filter=lfs diff=lfs merge=lfs -text
4
+ *.bz2 filter=lfs diff=lfs merge=lfs -text
5
+ *.ckpt filter=lfs diff=lfs merge=lfs -text
6
+ *.ftz filter=lfs diff=lfs merge=lfs -text
7
+ *.gz filter=lfs diff=lfs merge=lfs -text
8
+ *.h5 filter=lfs diff=lfs merge=lfs -text
9
+ *.joblib filter=lfs diff=lfs merge=lfs -text
10
+ *.lfs.* filter=lfs diff=lfs merge=lfs -text
11
+ *.mlmodel filter=lfs diff=lfs merge=lfs -text
12
+ *.model filter=lfs diff=lfs merge=lfs -text
13
+ *.msgpack filter=lfs diff=lfs merge=lfs -text
14
+ *.npy filter=lfs diff=lfs merge=lfs -text
15
+ *.npz filter=lfs diff=lfs merge=lfs -text
16
+ *.onnx filter=lfs diff=lfs merge=lfs -text
17
+ *.ot filter=lfs diff=lfs merge=lfs -text
18
+ *.parquet filter=lfs diff=lfs merge=lfs -text
19
+ *.pb filter=lfs diff=lfs merge=lfs -text
20
+ *.pickle filter=lfs diff=lfs merge=lfs -text
21
+ *.pkl filter=lfs diff=lfs merge=lfs -text
22
+ *.pt filter=lfs diff=lfs merge=lfs -text
23
+ *.pth filter=lfs diff=lfs merge=lfs -text
24
+ *.rar filter=lfs diff=lfs merge=lfs -text
25
+ *.safetensors filter=lfs diff=lfs merge=lfs -text
26
+ saved_model/**/* filter=lfs diff=lfs merge=lfs -text
27
+ *.tar.* filter=lfs diff=lfs merge=lfs -text
28
+ *.tar filter=lfs diff=lfs merge=lfs -text
29
+ *.tflite filter=lfs diff=lfs merge=lfs -text
30
+ *.tgz filter=lfs diff=lfs merge=lfs -text
31
+ *.wasm filter=lfs diff=lfs merge=lfs -text
32
+ *.xz filter=lfs diff=lfs merge=lfs -text
33
+ *.zip filter=lfs diff=lfs merge=lfs -text
34
+ *.zst filter=lfs diff=lfs merge=lfs -text
35
+ *tfevents* filter=lfs diff=lfs merge=lfs -text
.gitignore ADDED
@@ -0,0 +1,4 @@
 
 
 
 
 
1
+ __pycache__
2
+ .vscode
3
+ .venv
4
+ .env
Dockerfile ADDED
@@ -0,0 +1,18 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ FROM python:3.11-slim
2
+
3
+ WORKDIR /app
4
+
5
+ COPY requirements.txt .
6
+ RUN pip install --no-cache-dir -r requirements.txt
7
+
8
+ ENV PLAYWRIGHT_BROWSERS_PATH=0
9
+
10
+ RUN pip install --no-cache-dir playwright && \
11
+ playwright install-deps chromium && \
12
+ playwright install chromium
13
+
14
+ COPY . .
15
+
16
+ EXPOSE 7860
17
+
18
+ CMD ["python", "./app.py"]
README.md ADDED
@@ -0,0 +1,15 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ ---
2
+ title: SERPent
3
+ emoji: 🐍
4
+ colorFrom: green
5
+ colorTo: yellow
6
+ sdk: docker
7
+ app_port: 7860
8
+ short_description: A SERP scrapping API for AI projects
9
+ pinned: true
10
+ ---
11
+
12
+
13
+ # `SERPent`
14
+
15
+ `SERPent` provides a SERP / scrapping API for use by AI agents / projects.
app.py ADDED
@@ -0,0 +1,384 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import asyncio
2
+ import logging
3
+ import os
4
+ import secrets
5
+ from contextlib import asynccontextmanager
6
+ from typing import Optional
7
+ from fastapi import APIRouter, FastAPI, HTTPException, Request
8
+ from fastapi.responses import JSONResponse
9
+ from fastapi.routing import APIRouter
10
+ import httpx
11
+ from httpx import HTTPStatusError
12
+ from pydantic import BaseModel, Field
13
+ from playwright.async_api import async_playwright, Browser, BrowserContext, Page
14
+ import uvicorn
15
+
16
+ from scrap import PatentScrapBulkResponse, PatentScrapResult, scrap_patent_async, scrap_patent_bulk_async
17
+ from serp import SerpQuery, SerpResults, query_arxiv, query_bing_search, query_brave_search, query_ddg_search, query_google_patents, query_google_scholar
18
+ from ops import OPSBulkResponse, OPSNotConfigured, ops_scrap_patent, ops_scrap_patent_bulk, ops_search, token_manager as ops_token_manager
19
+ from utils import log_gathered_exceptions
20
+ from mcp_server import mount_mcp_server
21
+
22
+ logging.basicConfig(
23
+ level=logging.INFO,
24
+ format='[%(asctime)s][%(levelname)s][%(filename)s:%(lineno)d]: %(message)s',
25
+ datefmt='%Y-%m-%d %H:%M:%S'
26
+ )
27
+
28
+ # playwright global context
29
+ playwright = None
30
+ pw_browser: Optional[Browser] = None
31
+
32
+ # httpx client
33
+ httpx_client = httpx.AsyncClient(timeout=30, limits=httpx.Limits(
34
+ max_connections=30, max_keepalive_connections=20))
35
+
36
+ # ===================== Optional API key protection =====================
37
+ # Unset by default, which keeps the API exactly as open as before. Set
38
+ # SERPENT_API_KEY on the deployment to require every request (REST and MCP
39
+ # alike) to carry a matching `X-API-Key` or `Authorization: Bearer <key>`
40
+ # header. This exists to prevent the deployment from being used as an
41
+ # anonymous, unauthenticated scraping proxy by anyone who finds the URL.
42
+ SERPENT_API_KEY = os.environ.get("SERPENT_API_KEY")
43
+ _PUBLIC_PATHS = {"/", "/openapi.json"}
44
+
45
+
46
+ def _extract_supplied_key(request: Request) -> str:
47
+ api_key = request.headers.get("x-api-key")
48
+ if api_key:
49
+ return api_key
50
+ auth = request.headers.get("authorization", "")
51
+ if auth.lower().startswith("bearer "):
52
+ return auth[7:]
53
+ return ""
54
+
55
+
56
+ def _load_docs() -> str:
57
+ try:
58
+ with open("docs/docs.md", encoding="utf-8") as f:
59
+ return f.read()
60
+ except OSError as e:
61
+ logging.warning(f"Could not read docs/docs.md, using fallback description: {e}")
62
+ return "SERPent provides a SERP / scrapping API for use by AI agents / projects."
63
+
64
+
65
+ @asynccontextmanager
66
+ async def api_lifespan(app: FastAPI):
67
+ global playwright, pw_browser
68
+ try:
69
+ playwright = await async_playwright().start()
70
+ pw_browser = await playwright.chromium.launch(headless=True)
71
+ logging.info("Playwright browser started.")
72
+ except Exception as e:
73
+ logging.warning(f"Playwright unavailable, browser-based endpoints will fail: {e}")
74
+ yield
75
+
76
+ if pw_browser:
77
+ await pw_browser.close()
78
+ if playwright:
79
+ await playwright.stop()
80
+
81
+ app = FastAPI(lifespan=api_lifespan, docs_url="/",
82
+ title="SERPent", description=_load_docs())
83
+
84
+
85
+ @app.middleware("http")
86
+ async def api_key_guard(request: Request, call_next):
87
+ """No-op unless SERPENT_API_KEY is set; then gates every path but the docs."""
88
+ if SERPENT_API_KEY and request.url.path not in _PUBLIC_PATHS:
89
+ supplied = _extract_supplied_key(request)
90
+ if not supplied or not secrets.compare_digest(supplied, SERPENT_API_KEY):
91
+ return JSONResponse(
92
+ {"detail": "Missing or invalid API key. Supply it via the X-API-Key or Authorization: Bearer header."},
93
+ status_code=401,
94
+ )
95
+ return await call_next(request)
96
+
97
+ # Router for scrapping related endpoints
98
+ scrap_router = APIRouter(prefix="/scrap", tags=["scrapping"])
99
+ # Router for SERP-scrapping related endpoints
100
+ serp_router = APIRouter(prefix="/serp", tags=["serp scrapping"])
101
+ # Router for EPO OPS (official patent API) endpoints
102
+ ops_router = APIRouter(prefix="/ops", tags=["EPO OPS"])
103
+
104
+ # ===================== Search endpoints =====================
105
+
106
+
107
+ @serp_router.post("/search_scholar")
108
+ async def search_google_scholar(params: SerpQuery):
109
+ """Queries google scholar for the specified query"""
110
+ logging.info(f"Searching Google Scholar for queries: {params.queries}")
111
+ results = await asyncio.gather(*[query_google_scholar(pw_browser, q, params.n_results) for q in params.queries], return_exceptions=True)
112
+ log_gathered_exceptions(results, "google scholar search", params)
113
+
114
+ # Filter out exceptions and flatten the results
115
+ filtered_results = [r for r in results if not isinstance(r, Exception)]
116
+ flattened_results = [
117
+ item for sublist in filtered_results for item in sublist]
118
+
119
+ # all queries failed, return the last exception
120
+ if len(filtered_results) == 0:
121
+ return SerpResults(results=[], error=str(results[-1]))
122
+
123
+ return SerpResults(results=flattened_results, error=None)
124
+
125
+
126
+ @serp_router.post("/search_arxiv")
127
+ async def search_arxiv(params: SerpQuery):
128
+ """Searches arxiv for the specified queries and returns the found documents."""
129
+ logging.info(f"Searching Arxiv for queries: {params.queries}")
130
+ results = await asyncio.gather(*[query_arxiv(httpx_client, q, params.n_results) for q in params.queries], return_exceptions=True)
131
+ log_gathered_exceptions(results, "arxiv search", params)
132
+
133
+ filtered_results = [r for r in results if not isinstance(r, Exception)]
134
+ flattened_results = [
135
+ item for sublist in filtered_results for item in sublist]
136
+
137
+ if len(filtered_results) == 0:
138
+ return SerpResults(results=[], error=str(results[-1]))
139
+
140
+ return SerpResults(results=flattened_results, error=None)
141
+
142
+
143
+ @serp_router.post("/search_patents")
144
+ async def search_patents(params: SerpQuery) -> SerpResults:
145
+ """Searches google patents for the specified queries and returns the found documents.
146
+
147
+ Falls back to the EPO OPS API for any query Google Patents returns nothing
148
+ for, when OPS credentials are configured.
149
+ """
150
+ logging.info(f"Searching Google Patents for queries: {params.queries}")
151
+ results = await asyncio.gather(*[query_google_patents(pw_browser, q, params.n_results) for q in params.queries], return_exceptions=True)
152
+ log_gathered_exceptions(results, "google patent search", params)
153
+
154
+ # Fall back to OPS for queries that errored or returned no results.
155
+ if ops_token_manager.configured:
156
+ for i, res in enumerate(results):
157
+ if isinstance(res, Exception) or not res:
158
+ try:
159
+ logging.info(
160
+ f"Google Patents empty for `{params.queries[i]}`, trying OPS.")
161
+ results[i] = await ops_search(httpx_client, params.queries[i], params.n_results)
162
+ except Exception as e:
163
+ logging.warning(f"OPS fallback failed: {e}")
164
+
165
+ # Filter out exceptions and flatten the results
166
+ filtered_results = [r for r in results if not isinstance(r, Exception)]
167
+ flattened_results = [
168
+ item for sublist in filtered_results for item in sublist]
169
+
170
+ # all queries failed, return the last exception
171
+ if len(filtered_results) == 0:
172
+ return SerpResults(results=[], error=str(results[-1]))
173
+
174
+ return SerpResults(results=flattened_results, error=None)
175
+
176
+
177
+ @serp_router.post("/search_brave")
178
+ async def search_brave(params: SerpQuery) -> SerpResults:
179
+ """Searches brave search for the specified queries and returns the found documents."""
180
+ logging.info(f"Searching Brave Search for queries: {params.queries}")
181
+ results = await asyncio.gather(*[query_brave_search(pw_browser, q, params.n_results) for q in params.queries], return_exceptions=True)
182
+ log_gathered_exceptions(results, "brave search", params)
183
+
184
+ # Filter out exceptions and flatten the results
185
+ filtered_results = [r for r in results if not isinstance(r, Exception)]
186
+ flattened_results = [
187
+ item for sublist in filtered_results for item in sublist]
188
+
189
+ # all queries failed, return the last exception
190
+ if len(filtered_results) == 0:
191
+ return SerpResults(results=[], error=str(results[-1]))
192
+
193
+ return SerpResults(results=flattened_results, error=None)
194
+
195
+
196
+ @serp_router.post("/search_bing")
197
+ async def search_bing(params: SerpQuery) -> SerpResults:
198
+ """Searches Bing search for the specified queries and returns the found documents."""
199
+ logging.info(f"Searching Bing Search for queries: {params.queries}")
200
+ results = await asyncio.gather(*[query_bing_search(pw_browser, q, params.n_results) for q in params.queries], return_exceptions=True)
201
+ log_gathered_exceptions(results, "bing search", params)
202
+
203
+ # Filter out exceptions and flatten the results
204
+ filtered_results = [r for r in results if not isinstance(r, Exception)]
205
+ flattened_results = [
206
+ item for sublist in filtered_results for item in sublist]
207
+
208
+ # all queries failed, return the last exception
209
+ if len(filtered_results) == 0:
210
+ return SerpResults(results=[], error=str(results[-1]))
211
+
212
+ return SerpResults(results=flattened_results, error=None)
213
+
214
+
215
+ @serp_router.post("/search_duck")
216
+ async def search_duck(params: SerpQuery) -> SerpResults:
217
+ """Searches duckduckgo for the specified queries and returns the found documents"""
218
+ logging.info(f"Searching DuckDuckGo for queries: {params.queries}")
219
+ results = await asyncio.gather(*[query_ddg_search(q, params.n_results) for q in params.queries], return_exceptions=True)
220
+ log_gathered_exceptions(results, "duckduckgo search", params)
221
+
222
+ # Filter out exceptions and flatten the results
223
+ filtered_results = [r for r in results if not isinstance(r, Exception)]
224
+ flattened_results = [
225
+ item for sublist in filtered_results for item in sublist]
226
+
227
+ # all queries failed, return the last exception
228
+ if len(filtered_results) == 0:
229
+ return SerpResults(results=[], error=str(results[-1]))
230
+
231
+ return SerpResults(results=flattened_results, error=None)
232
+
233
+
234
+ async def _search_one(q: str, n_results: int) -> tuple[str, list[dict], Optional[str]]:
235
+ """Try DDG, then Brave, then Bing for a single query; stop at the first success."""
236
+ backends = [
237
+ ("DuckDuckGo", lambda: query_ddg_search(q, n_results)),
238
+ ("Brave Search", lambda: query_brave_search(pw_browser, q, n_results)),
239
+ ("Bing", lambda: query_bing_search(pw_browser, q, n_results)),
240
+ ]
241
+ last_error: Optional[Exception] = None
242
+ for name, call in backends:
243
+ try:
244
+ logging.info(f"Querying {name} with query: `{q}`")
245
+ return q, await call(), None
246
+ except Exception as e:
247
+ logging.error(f"Failed to query {name} with query `{q}`: {e}")
248
+ last_error = e
249
+
250
+ return q, [], f"All backends failed for query '{q}': {last_error}"
251
+
252
+
253
+ @serp_router.post("/search")
254
+ async def search(params: SerpQuery):
255
+ """Attempts to search the specified queries using ALL backends"""
256
+ outcomes = await asyncio.gather(*[_search_one(q, params.n_results) for q in params.queries])
257
+
258
+ results: list[dict] = []
259
+ errors: list[str] = []
260
+ for _q, res, err in outcomes:
261
+ results.extend(res)
262
+ if err:
263
+ errors.append(err)
264
+
265
+ if len(results) == 0:
266
+ return SerpResults(results=[], error="; ".join(errors) if errors else "All backends are rate-limited.")
267
+
268
+ return SerpResults(results=results, error="; ".join(errors) if errors else None)
269
+
270
+ # =========================== Scrapping endpoints ===========================
271
+
272
+
273
+ @scrap_router.get("/scrap_patent/{patent_id}")
274
+ async def scrap_patent(patent_id: str) -> PatentScrapResult:
275
+ """Scraps the specified patent from Google Patents.
276
+
277
+ Falls back to the EPO OPS API (which covers patents missing from Google
278
+ Patents) when the scrape fails and OPS credentials are configured.
279
+ """
280
+ try:
281
+ return await scrap_patent_async(httpx_client, f"https://patents.google.com/patent/{patent_id}/en")
282
+ except HTTPStatusError as e:
283
+ logging.warning(
284
+ f"Google Patents returned {e.response.status_code} for {patent_id}.")
285
+ except Exception as e:
286
+ logging.warning(f"Failed to scrap patent {patent_id}: {e}")
287
+
288
+ if not ops_token_manager.configured:
289
+ raise HTTPException(
290
+ status_code=404,
291
+ detail=f"Patent '{patent_id}' not found on Google Patents (EPO OPS fallback not configured).")
292
+
293
+ try:
294
+ logging.info(f"Trying OPS for patent {patent_id}.")
295
+ return await ops_scrap_patent(httpx_client, patent_id)
296
+ except HTTPStatusError as e:
297
+ if e.response.status_code == 404:
298
+ raise HTTPException(
299
+ status_code=404,
300
+ detail=f"Patent '{patent_id}' not found on Google Patents or EPO OPS.")
301
+ raise HTTPException(
302
+ status_code=502,
303
+ detail=f"EPO OPS returned {e.response.status_code} for '{patent_id}'.")
304
+ except Exception as e:
305
+ logging.warning(f"OPS fallback failed for {patent_id}: {e}")
306
+ raise HTTPException(
307
+ status_code=502,
308
+ detail=f"EPO OPS request failed for '{patent_id}': {e}")
309
+
310
+
311
+ class ScrapPatentsRequest(BaseModel):
312
+ """Request model for scrapping multiple patents."""
313
+ patent_ids: list[str] = Field(...,
314
+ description="List of patent IDs to scrap")
315
+
316
+
317
+ @scrap_router.post("/scrap_patents_bulk", response_model=PatentScrapBulkResponse)
318
+ async def scrap_patents(params: ScrapPatentsRequest) -> PatentScrapBulkResponse:
319
+ """Scraps multiple patents from Google Patents."""
320
+ patents = await scrap_patent_bulk_async(httpx_client, params.patent_ids)
321
+ return patents
322
+
323
+ # =========================== EPO OPS endpoints ===========================
324
+
325
+
326
+ @ops_router.post("/search")
327
+ async def ops_keyword_search(params: SerpQuery) -> SerpResults:
328
+ """Keyword-searches patents via the official EPO OPS API."""
329
+ logging.info(f"Searching EPO OPS for queries: {params.queries}")
330
+ results = await asyncio.gather(*[ops_search(httpx_client, q, params.n_results) for q in params.queries], return_exceptions=True)
331
+ log_gathered_exceptions(results, "OPS search", params)
332
+
333
+ filtered_results = [r for r in results if not isinstance(r, Exception)]
334
+ flattened_results = [
335
+ item for sublist in filtered_results for item in sublist]
336
+
337
+ if len(filtered_results) == 0:
338
+ return SerpResults(results=[], error=str(results[-1]))
339
+
340
+ return SerpResults(results=flattened_results, error=None)
341
+
342
+
343
+ @ops_router.get("/scrap_patent/{patent_id}")
344
+ async def ops_get_patent(patent_id: str) -> PatentScrapResult:
345
+ """Retrieves a patent (biblio + abstract + claims + description) via EPO OPS."""
346
+ if not ops_token_manager.configured:
347
+ raise HTTPException(
348
+ status_code=503,
349
+ detail="EPO OPS is not configured (OPS_CONSUMER_KEY / OPS_CONSUMER_SECRET missing).")
350
+ try:
351
+ return await ops_scrap_patent(httpx_client, patent_id)
352
+ except OPSNotConfigured:
353
+ raise HTTPException(status_code=503, detail="EPO OPS is not configured.")
354
+ except HTTPStatusError as e:
355
+ if e.response.status_code == 404:
356
+ raise HTTPException(
357
+ status_code=404, detail=f"Patent '{patent_id}' not found in EPO OPS.")
358
+ raise HTTPException(
359
+ status_code=502,
360
+ detail=f"EPO OPS returned {e.response.status_code} for '{patent_id}'.")
361
+ except Exception as e:
362
+ logging.warning(f"Failed to retrieve patent {patent_id} from OPS: {e}")
363
+ raise HTTPException(
364
+ status_code=502, detail=f"EPO OPS request failed for '{patent_id}': {e}")
365
+
366
+
367
+ @ops_router.post("/scrap_patents_bulk", response_model=OPSBulkResponse)
368
+ async def ops_get_patents_bulk(params: ScrapPatentsRequest) -> OPSBulkResponse:
369
+ """Retrieves multiple patents via EPO OPS."""
370
+ return await ops_scrap_patent_bulk(httpx_client, params.patent_ids)
371
+
372
+ # ===============================================================================
373
+
374
+ app.include_router(serp_router)
375
+ app.include_router(scrap_router)
376
+ app.include_router(ops_router)
377
+
378
+ # =============================== MCP server ===================================
379
+ # Re-exposes every endpoint above as an MCP tool over streamable HTTP at /mcp.
380
+ # Must stay below the include_router() calls, or the routes are not picked up.
381
+ mcp = mount_mcp_server(app)
382
+
383
+ if __name__ == "__main__":
384
+ uvicorn.run(app, host="0.0.0.0", port=7860)
docs/README_MCP.md ADDED
@@ -0,0 +1,145 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ # MCP server support
2
+
3
+ SERPent now speaks two protocols from the same deployment:
4
+
5
+ | Audience | Endpoint |
6
+ |---|---|
7
+ | REST clients / Swagger UI | `https://<host>/` (docs), `POST /serp/...`, `/scrap/...`, `/ops/...` |
8
+ | MCP clients (Claude, Cursor, agents) | `https://<host>/mcp` — streamable HTTP transport |
9
+
10
+ ## What changed
11
+
12
+ | File | Change |
13
+ |---|---|
14
+ | `mcp_server.py` | **New.** Builds the MCP server from the FastAPI app and mounts it. |
15
+ | `app.py` | Two lines: `from mcp_server import mount_mcp_server` and `mcp = mount_mcp_server(app)` after the `include_router` calls. |
16
+ | `requirements.txt` | Added `fastmcp>=3.0,<4`. |
17
+ | `Dockerfile` | Unchanged — same image, same port 7860, same `CMD`. |
18
+
19
+ ## How it works
20
+
21
+ `FastMCP.from_fastapi()` reads the app's OpenAPI schema and turns every route
22
+ into an MCP tool. When a tool is called, the request is dispatched **back into
23
+ the same FastAPI app in-process** over an ASGI transport — no network hop, no
24
+ second server, no duplicated business logic. Add an endpoint, get a tool.
25
+
26
+ The 12 tools generated today:
27
+
28
+ ```
29
+ search search_arxiv search_google_scholar
30
+ search_patents search_brave search_bing
31
+ search_duck scrap_patent scrap_patents
32
+ ops_keyword_search ops_get_patent ops_get_patents_bulk
33
+ ```
34
+
35
+ Tool names come from the Python handler names rather than FastAPI's generated
36
+ operation ids, so agents see `search_arxiv` instead of
37
+ `search_arxiv_serp_search_arxiv_post`. Descriptions and JSON schemas come from
38
+ your docstrings and Pydantic models — improving a docstring improves the tool.
39
+ To pin a specific tool name, set `operation_id="..."` on the route decorator.
40
+
41
+ ## Connecting a client
42
+
43
+ **Claude Code / any streamable-HTTP client:**
44
+
45
+ ```bash
46
+ claude mcp add --transport http serpent https://<your-space>.hf.space/mcp
47
+ ```
48
+
49
+ **`.mcp.json` / `claude_desktop_config.json`:**
50
+
51
+ ```json
52
+ {
53
+ "mcpServers": {
54
+ "serpent": {
55
+ "type": "http",
56
+ "url": "https://<your-space>.hf.space/mcp"
57
+ }
58
+ }
59
+ }
60
+ ```
61
+
62
+ **Private HF Space** — pass your token:
63
+
64
+ ```json
65
+ {
66
+ "mcpServers": {
67
+ "serpent": {
68
+ "type": "http",
69
+ "url": "https://<your-space>.hf.space/mcp",
70
+ "headers": { "Authorization": "Bearer hf_..." }
71
+ }
72
+ }
73
+ }
74
+ ```
75
+
76
+ **If `SERPENT_API_KEY` is set on the deployment** (see Configuration below),
77
+ also pass it, either as `X-API-Key` or reusing the `Authorization: Bearer`
78
+ header:
79
+
80
+ ```json
81
+ {
82
+ "mcpServers": {
83
+ "serpent": {
84
+ "type": "http",
85
+ "url": "https://<your-space>.hf.space/mcp",
86
+ "headers": { "X-API-Key": "..." }
87
+ }
88
+ }
89
+ }
90
+ ```
91
+
92
+ **Quick check without a client:**
93
+
94
+ ```bash
95
+ curl -X POST http://localhost:7860/mcp \
96
+ -H 'Content-Type: application/json' \
97
+ -H 'Accept: application/json, text/event-stream' \
98
+ -d '{"jsonrpc":"2.0","id":1,"method":"initialize","params":{
99
+ "protocolVersion":"2025-06-18","capabilities":{},
100
+ "clientInfo":{"name":"curl","version":"1"}}}'
101
+ ```
102
+
103
+ Or with the FastMCP CLI: `fastmcp inspect http://localhost:7860/mcp`
104
+
105
+ ## Configuration
106
+
107
+ All optional, all environment variables:
108
+
109
+ | Variable | Default | Purpose |
110
+ |---|---|---|
111
+ | `MCP_ENABLED` | `1` | Set to `0` to run REST-only. |
112
+ | `MCP_PATH` | `/mcp` | Where the MCP endpoint lives. |
113
+ | `MCP_STATELESS` | `1` | Stateless HTTP — no session affinity needed behind the HF Spaces proxy. Set `0` for resumable SSE streams. |
114
+ | `MCP_ALLOWED_HOSTS` | `*` | Comma-separated Host allow-list (DNS-rebinding protection). |
115
+ | `MCP_ALLOWED_ORIGINS` | `*` | Comma-separated Origin allow-list (browser clients). |
116
+ | `SERPENT_API_KEY` | unset | When set, every REST and MCP request must carry a matching `X-API-Key` or `Authorization: Bearer <key>` header (except `/` and `/openapi.json`). Unset = fully open, same as before. Set this to stop the deployment being usable as an anonymous scraping proxy by anyone who finds the URL. |
117
+
118
+ ## Notes and caveats
119
+
120
+ - **Ordering matters.** `mount_mcp_server(app)` must come after every
121
+ `include_router()` call. Routes registered after it are not exposed as tools,
122
+ and are not reachable at all — the MCP app is mounted at the root so that a
123
+ bare `POST /mcp` answers directly instead of 307-redirecting to `/mcp/`
124
+ (a redirect some MCP clients mishandle).
125
+ - **Lifespans are combined**, so Playwright still starts up and shuts down
126
+ exactly as before, alongside the MCP session manager.
127
+ - **The `ops_*` tools** are exposed but return 503 without
128
+ `OPS_CONSUMER_KEY` / `OPS_CONSUMER_SECRET`. The server instructions tell
129
+ agents to prefer the regular tools, which already fall back to OPS.
130
+ - **Auth.** `SERPENT_API_KEY` (see Configuration) gates both REST and MCP
131
+ uniformly via an HTTP-level middleware, so it applies before FastMCP's
132
+ in-process dispatch and needs no per-tool wiring. It's separate from
133
+ whatever HF Space visibility (public/private) or token you also use.
134
+ - **Tool count.** 12 tools with six overlapping search backends is a lot of
135
+ surface for a model to choose from. If tool selection gets noisy, pass
136
+ `route_maps=[...]` to `FastMCP.from_fastapi()` in `mcp_server.py` to exclude
137
+ the per-backend tools and keep `search`, `search_arxiv`, `search_patents`,
138
+ `search_google_scholar` and the scrapers.
139
+
140
+ ## Verified
141
+
142
+ Tested against `fastmcp 3.4.7` / `fastapi 0.141.1`: server boots, all 12 tools
143
+ list over HTTP with correct schemas, tool calls round-trip through the real
144
+ endpoints, and the existing REST routes (`/`, `/openapi.json`, `/redoc`,
145
+ `/serp/*`) are unaffected.
docs/docs.md ADDED
@@ -0,0 +1,38 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ # `SERPent`
2
+
3
+ ## SERP results scrapping
4
+
5
+ SERPent exposes an unified API to query SERP (Search Engine Result Pages) for a few common search engines, namely:
6
+
7
+ - DuckDuckGo
8
+ - Brave
9
+ - Bing
10
+ - Google Patents
11
+ - arXiv
12
+ - Google
13
+
14
+ The application uses the `playwright` library to control a headless web browser, to simulate normal user activity, to fool the anti-bot measures often present on those sites. See the `/serp/` endpoints for search results scrapping.
15
+
16
+
17
+ ## Website sources scrapping
18
+
19
+ SERPent also exposes a few endpoints to scrap the contents of certain sources (patents, scholar). See the `/scrap/` endpoints for supported website sources scrapping.
20
+
21
+
22
+ ## EPO OPS (official patent API)
23
+
24
+ SERPent integrates the European Patent Office's Open Patent Services (OPS) v3.2 REST API — a legitimate source for patents that are missing from, or unscrapable via, Google Patents. See the `/ops/` endpoints for keyword search and by-number retrieval (bibliographic data, abstract, claims, description, CPC classifications).
25
+
26
+ OPS requires OAuth2 client credentials. Set them via the `OPS_CONSUMER_KEY` and `OPS_CONSUMER_SECRET` environment variables (register a free app at the EPO Developer Portal). When configured, the Google Patents endpoints (`/serp/search_patents` and `/scrap/scrap_patent/{id}`) automatically fall back to OPS when they return nothing.
27
+
28
+
29
+ ## Access control
30
+
31
+ By default every endpoint is open (no authentication), matching how this API
32
+ has always run. To restrict access, set the `SERPENT_API_KEY` environment
33
+ variable — every request (REST and MCP alike) then needs a matching
34
+ `X-API-Key` or `Authorization: Bearer <key>` header, except this docs page and
35
+ `/openapi.json`. This is recommended once the deployment is public, since it
36
+ otherwise doubles as an anonymous, unauthenticated scraping proxy for anyone
37
+ who finds the URL.
38
+
mcp_server.py ADDED
@@ -0,0 +1,203 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """MCP (Model Context Protocol) server for SERPent.
2
+
3
+ Every route registered on the FastAPI app is automatically re-exposed as an MCP
4
+ tool, so a single deployment serves both audiences:
5
+
6
+ REST -> POST https://<host>/serp/search_arxiv
7
+ MCP -> POST https://<host>/mcp (streamable HTTP transport)
8
+
9
+ Nothing here duplicates business logic. FastMCP reads the app's OpenAPI schema
10
+ and, when a tool is called, dispatches the request back into the same FastAPI
11
+ app in-process (via an ASGI transport, no network round-trip), so tools always
12
+ stay in sync with the endpoints.
13
+
14
+ Usage (see the bottom of `app.py`):
15
+
16
+ from mcp_server import mount_mcp_server
17
+ mcp = mount_mcp_server(app)
18
+
19
+ Configuration (all optional, read from the environment):
20
+
21
+ MCP_ENABLED "0"/"false" to disable the MCP server entirely.
22
+ MCP_PATH Mount path. Default "/mcp".
23
+ MCP_STATELESS "0" to keep per-session state. Default stateless, which
24
+ is what you want behind the HF Spaces proxy.
25
+ MCP_ALLOWED_HOSTS Comma-separated Host allow-list. Default "*".
26
+ MCP_ALLOWED_ORIGINS Comma-separated Origin allow-list. Default "*".
27
+ MCP_EXPOSE_ALL "1" to expose every endpoint as a tool instead of the
28
+ curated set (see EXCLUDED_ROUTES below).
29
+ """
30
+
31
+ from __future__ import annotations
32
+
33
+ import logging
34
+ import os
35
+ from typing import Optional
36
+
37
+ from fastapi import FastAPI
38
+ from fastapi.routing import APIRoute
39
+ from fastapi.utils import generate_unique_id
40
+
41
+ from fastmcp import FastMCP
42
+ from fastmcp.server.providers.openapi import MCPType, RouteMap
43
+ from fastmcp.utilities.lifespan import combine_lifespans
44
+
45
+ logger = logging.getLogger(__name__)
46
+
47
+ MCP_SERVER_NAME = "SERPent"
48
+
49
+ # Endpoints kept out of the MCP tool surface. They stay fully available over
50
+ # REST — this only controls what an LLM sees.
51
+ #
52
+ # Fewer, well-differentiated tools measurably improve tool selection, and both
53
+ # groups below are redundant *for an agent*:
54
+ #
55
+ # /ops/* The regular `scrap_patent` / `search_patents` tools already
56
+ # fall back to EPO OPS on their own. Calling the OPS tools
57
+ # directly skips that fallback, so a patent missing from OPS
58
+ # becomes a hard error instead of a Google Patents hit.
59
+ # search_brave These are `search` with the fallback chain removed. `search`
60
+ # search_bing already tries DuckDuckGo, then Brave, then Bing, so exposing
61
+ # search_duck them separately only invites the model to pick a worse path.
62
+ #
63
+ # Set MCP_EXPOSE_ALL=1 to expose everything again, or edit this list.
64
+ EXCLUDED_ROUTES = [
65
+ r"^/ops/.*",
66
+ r"^/serp/search_(brave|bing|duck)$",
67
+ ]
68
+
69
+ MCP_INSTRUCTIONS = """\
70
+ SERPent gives you web, academic and patent search plus full-text patent retrieval.
71
+
72
+ Typical workflow:
73
+ 1. Search with the backend that matches the question:
74
+ - `search` for general web results (tries DuckDuckGo, then Brave, then Bing)
75
+ - `search_arxiv` for preprints, `search_google_scholar` for academic papers
76
+ - `search_patents` for prior art (falls back to the EPO OPS API)
77
+ 2. Feed the patent ids you found to `scrap_patent` (one) or `scrap_patents`
78
+ (many, and much faster than looping) to get title, abstract, claims and
79
+ description.
80
+
81
+ Every search tool accepts a LIST of queries and runs them concurrently, so send
82
+ all your query variations in one call rather than making several calls. Set
83
+ `n_results` to control how many hits come back per query.
84
+
85
+ `search` and `search_patents` already fall back across several backends
86
+ internally, including the official EPO OPS patent API, so a single call is the
87
+ most thorough option available — there is no lower-level tool to reach for when
88
+ one comes back empty. A patent that returns "not found" is genuinely absent
89
+ from every backend; move on to the next id rather than retrying.
90
+ """
91
+
92
+
93
+ def _env_flag(name: str, default: bool) -> bool:
94
+ raw = os.getenv(name)
95
+ if raw is None:
96
+ return default
97
+ return raw.strip().lower() not in {"0", "false", "no", "off", ""}
98
+
99
+
100
+ def _env_list(name: str, default: list[str]) -> list[str]:
101
+ raw = os.getenv(name)
102
+ if not raw:
103
+ return default
104
+ return [item.strip() for item in raw.split(",") if item.strip()]
105
+
106
+
107
+ def _tool_names(app: FastAPI) -> dict[str, str]:
108
+ """Map each route's OpenAPI operationId to a clean MCP tool name.
109
+
110
+ FastAPI's generated operationIds look like
111
+ `search_arxiv_serp_search_arxiv_post`, which is what an LLM would otherwise
112
+ see. The Python handler name (`search_arxiv`) is a far better tool name, so
113
+ use that instead. Adding an explicit `operation_id=` to a route decorator
114
+ overrides this.
115
+ """
116
+ names: dict[str, str] = {}
117
+
118
+ def walk(routes) -> None:
119
+ for route in routes:
120
+ # Depending on the FastAPI version, included routers either flatten
121
+ # into the parent or stay nested, so recurse.
122
+ if isinstance(route, APIRoute):
123
+ generate = getattr(route, "generate_unique_id_function", None)
124
+ # FastAPI stores an unset callable as a DefaultPlaceholder.
125
+ generate = getattr(generate, "value", generate)
126
+ if not callable(generate):
127
+ generate = generate_unique_id
128
+ operation_id = route.operation_id or generate(route)
129
+ names[operation_id] = route.name
130
+ # Starlette Mounts nest under `.routes`; FastAPI >= 0.141 wraps
131
+ # included routers in `_IncludedRouter`, which nests under
132
+ # `.original_router`.
133
+ sub = getattr(route, "routes", None)
134
+ if sub:
135
+ walk(sub)
136
+ included = getattr(route, "original_router", None)
137
+ if included is not None and getattr(included, "routes", None):
138
+ walk(included.routes)
139
+
140
+ walk(app.routes)
141
+ return names
142
+
143
+
144
+ def build_mcp_server(app: FastAPI) -> FastMCP:
145
+ """Build an MCP server exposing the app's routes as tools."""
146
+ if _env_flag("MCP_EXPOSE_ALL", False):
147
+ route_maps = None
148
+ logger.info("MCP exposing all endpoints (MCP_EXPOSE_ALL=1).")
149
+ else:
150
+ route_maps = [
151
+ RouteMap(pattern=pattern, mcp_type=MCPType.EXCLUDE)
152
+ for pattern in EXCLUDED_ROUTES
153
+ ]
154
+
155
+ mcp = FastMCP.from_fastapi(
156
+ app=app,
157
+ name=MCP_SERVER_NAME,
158
+ mcp_names=_tool_names(app),
159
+ route_maps=route_maps,
160
+ # Requests are dispatched straight back into this same app in-process.
161
+ httpx_client_kwargs={"timeout": 300.0},
162
+ )
163
+ mcp.instructions = MCP_INSTRUCTIONS
164
+ return mcp
165
+
166
+
167
+ def mount_mcp_server(app: FastAPI, path: Optional[str] = None) -> Optional[FastMCP]:
168
+ """Mount the MCP server onto `app` (default: `/mcp`).
169
+
170
+ Must be called after every router has been included, otherwise the missing
171
+ routes will not show up as tools. Returns None when MCP is disabled.
172
+ """
173
+ if not _env_flag("MCP_ENABLED", True):
174
+ logger.info("MCP server disabled (MCP_ENABLED=0).")
175
+ return None
176
+
177
+ path = path or os.getenv("MCP_PATH", "/mcp")
178
+ path = "/" + path.strip("/")
179
+
180
+ mcp = build_mcp_server(app)
181
+
182
+ mcp_app = mcp.http_app(
183
+ path=path,
184
+ stateless_http=_env_flag("MCP_STATELESS", True),
185
+ allowed_hosts=_env_list("MCP_ALLOWED_HOSTS", ["*"]),
186
+ allowed_origins=_env_list("MCP_ALLOWED_ORIGINS", ["*"]),
187
+ )
188
+
189
+ # The MCP session manager needs its own lifespan to run. The app already has
190
+ # one (Playwright), so run both.
191
+ app.router.lifespan_context = combine_lifespans(
192
+ app.router.lifespan_context,
193
+ mcp_app.lifespan,
194
+ )
195
+
196
+ # The MCP endpoint is `path` *inside* mcp_app, and mcp_app is mounted at the
197
+ # root. Mounting it at `path` instead would make Starlette answer a bare
198
+ # `POST /mcp` with a 307 to `/mcp/`, which several MCP clients choke on.
199
+ # This mount is added last, so all the API routes above still match first.
200
+ app.mount("/", mcp_app)
201
+
202
+ logger.info("MCP server mounted at %s (streamable HTTP).", path)
203
+ return mcp
ops.py ADDED
@@ -0,0 +1,321 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """EPO Open Patent Services (OPS) v3.2 backend.
2
+
3
+ Legitimate REST alternative to scraping Espacenet (which sits behind an
4
+ interactive Cloudflare CAPTCHA). Provides keyword search and by-number
5
+ retrieval of the same bibliographic / full-text data.
6
+
7
+ Auth is OAuth2 client-credentials: the consumer key/secret are exchanged for a
8
+ short-lived (~20 min) bearer token at the token endpoint. Credentials are read
9
+ from the OPS_CONSUMER_KEY / OPS_CONSUMER_SECRET environment variables.
10
+ """
11
+
12
+ import asyncio
13
+ import base64
14
+ import logging
15
+ import os
16
+ import re
17
+ import time
18
+ from typing import Optional
19
+
20
+ from dotenv import load_dotenv
21
+ from httpx import AsyncClient, HTTPStatusError
22
+ from pydantic import BaseModel
23
+
24
+ from scrap import ClassificationCode, PatentScrapResult
25
+
26
+ load_dotenv()
27
+
28
+ OPS_BASE = "https://ops.epo.org/3.2/rest-services"
29
+ OPS_TOKEN_URL = "https://ops.epo.org/3.2/auth/accesstoken"
30
+
31
+
32
+ class OPSNotConfigured(Exception):
33
+ """Raised when OPS credentials are not set in the environment."""
34
+
35
+
36
+ class OPSError(Exception):
37
+ """Raised when OPS returns an error that isn't a plain 'not found'."""
38
+
39
+
40
+ class _OPSTokenManager:
41
+ """Caches and refreshes the OPS OAuth2 bearer token."""
42
+
43
+ def __init__(self) -> None:
44
+ self._key = os.environ.get("OPS_CONSUMER_KEY")
45
+ self._secret = os.environ.get("OPS_CONSUMER_SECRET")
46
+ self._token: Optional[str] = None
47
+ self._expiry: float = 0.0
48
+ self._lock = asyncio.Lock()
49
+
50
+ @property
51
+ def configured(self) -> bool:
52
+ return bool(self._key and self._secret)
53
+
54
+ async def get_token(self, client: AsyncClient, force: bool = False) -> str:
55
+ if not self.configured:
56
+ raise OPSNotConfigured(
57
+ "OPS_CONSUMER_KEY / OPS_CONSUMER_SECRET are not set.")
58
+
59
+ async with self._lock:
60
+ # 30s safety margin before expiry.
61
+ if not force and self._token and time.monotonic() < self._expiry - 30:
62
+ return self._token
63
+
64
+ basic = base64.b64encode(
65
+ f"{self._key}:{self._secret}".encode()).decode()
66
+ resp = await client.post(
67
+ OPS_TOKEN_URL,
68
+ headers={"Authorization": f"Basic {basic}"},
69
+ data={"grant_type": "client_credentials"},
70
+ )
71
+ resp.raise_for_status()
72
+ data = resp.json()
73
+ self._token = data["access_token"]
74
+ self._expiry = time.monotonic() + int(data.get("expires_in", 1199))
75
+ logging.info("Obtained new OPS access token.")
76
+ return self._token
77
+
78
+
79
+ token_manager = _OPSTokenManager()
80
+
81
+
82
+ _KIND_SUFFIX_RE = re.compile(r"[A-Z]\d{0,2}$")
83
+
84
+
85
+ def _normalize_epodoc(number: str) -> str:
86
+ """Strip whitespace and a trailing kind code for the epodoc format.
87
+
88
+ OPS epodoc lookups reject the kind suffix (e.g. 'US11930446B2' -> 404), so
89
+ 'US11930446B2' becomes 'US11930446' and 'EP4760514A1' becomes 'EP4760514'.
90
+ """
91
+ number = number.strip().replace(" ", "")
92
+ return _KIND_SUFFIX_RE.sub("", number)
93
+
94
+
95
+ async def _ops_get(client: AsyncClient, path: str, params: Optional[dict] = None) -> dict:
96
+ """GET an OPS endpoint as JSON, refreshing the token once on auth failure."""
97
+ token = await token_manager.get_token(client)
98
+ url = f"{OPS_BASE}{path}"
99
+ headers = {"Authorization": f"Bearer {token}",
100
+ "Accept": "application/json"}
101
+ resp = await client.get(url, params=params, headers=headers)
102
+
103
+ # A stale/invalid token surfaces as 400/401/403 mentioning the token; refresh once.
104
+ if resp.status_code in (400, 401, 403) and "token" in resp.text.lower():
105
+ token = await token_manager.get_token(client, force=True)
106
+ headers["Authorization"] = f"Bearer {token}"
107
+ resp = await client.get(url, params=params, headers=headers)
108
+
109
+ resp.raise_for_status()
110
+ return resp.json()
111
+
112
+
113
+ # --------------------------- JSON walking helpers ---------------------------
114
+ # OPS returns XML-to-JSON: text nodes are {"$": "..."}, attributes are "@name",
115
+ # and repeated elements become lists (but a single element stays a bare dict).
116
+
117
+ def _as_list(node) -> list:
118
+ if node is None:
119
+ return []
120
+ return node if isinstance(node, list) else [node]
121
+
122
+
123
+ def _text(node) -> Optional[str]:
124
+ if isinstance(node, dict):
125
+ return node.get("$")
126
+ if isinstance(node, str):
127
+ return node
128
+ return None
129
+
130
+
131
+ def _pick_lang(nodes: list, lang: str = "en"):
132
+ """From a list of {@lang, ...} nodes, prefer the requested language."""
133
+ nodes = _as_list(nodes)
134
+ for n in nodes:
135
+ if isinstance(n, dict) and n.get("@lang", "").lower() == lang:
136
+ return n
137
+ return nodes[0] if nodes else None
138
+
139
+
140
+ def _epodoc_number(exchange_doc: dict) -> Optional[str]:
141
+ """Best-effort human patent number (e.g. 'EP4760514')."""
142
+ bd = exchange_doc.get("bibliographic-data", {})
143
+ ref = bd.get("publication-reference", {})
144
+ for doc_id in _as_list(ref.get("document-id")):
145
+ if doc_id.get("@document-id-type") == "epodoc":
146
+ return _text(doc_id.get("doc-number"))
147
+ country = exchange_doc.get("@country", "")
148
+ number = exchange_doc.get("@doc-number", "")
149
+ return f"{country}{number}" if (country or number) else None
150
+
151
+
152
+ def _extract_title(bibliographic_data: dict) -> Optional[str]:
153
+ title = _pick_lang(bibliographic_data.get("invention-title"))
154
+ return _text(title) if title else None
155
+
156
+
157
+ def _extract_abstract(exchange_doc: dict) -> Optional[str]:
158
+ abstract = _pick_lang(exchange_doc.get("abstract"))
159
+ if not abstract:
160
+ return None
161
+ paras = [_text(p) for p in _as_list(abstract.get("p"))]
162
+ joined = " ".join(p for p in paras if p)
163
+ return joined or None
164
+
165
+
166
+ def _extract_classifications(bibliographic_data: dict) -> Optional[list[ClassificationCode]]:
167
+ """Assemble CPC symbols from patent-classifications (no descriptions in OPS)."""
168
+ codes: list[ClassificationCode] = []
169
+ seen: set[str] = set()
170
+ container = bibliographic_data.get("patent-classifications", {})
171
+ for pc in _as_list(container.get("patent-classification")):
172
+ section = _text(pc.get("section")) or ""
173
+ klass = _text(pc.get("class")) or ""
174
+ subclass = _text(pc.get("subclass")) or ""
175
+ main_group = _text(pc.get("main-group")) or ""
176
+ subgroup = _text(pc.get("subgroup")) or ""
177
+ if not (section and klass and subclass and main_group):
178
+ continue
179
+ code = f"{section}{klass}{subclass}{main_group}/{subgroup}".rstrip("/")
180
+ if code not in seen:
181
+ seen.add(code)
182
+ codes.append(ClassificationCode(code=code, description=""))
183
+ return codes or None
184
+
185
+
186
+ def _fulltext_paragraphs(data: dict, section: str) -> Optional[str]:
187
+ """Extract text from a fulltext service response (claims / description)."""
188
+ wpd = data.get("ops:world-patent-data", {})
189
+ ftxt_docs = wpd.get("ftxt:fulltext-documents", {})
190
+ doc = ftxt_docs.get("ftxt:fulltext-document")
191
+ doc = doc[0] if isinstance(doc, list) else doc
192
+ if not doc:
193
+ return None
194
+ node = _pick_lang(doc.get(section)) if isinstance(
195
+ doc.get(section), list) else doc.get(section)
196
+ if not node:
197
+ return None
198
+
199
+ if section == "claims":
200
+ lines: list[str] = []
201
+ for claim in _as_list(node.get("claim")):
202
+ for ct in _as_list(claim.get("claim-text")):
203
+ t = _text(ct)
204
+ if t:
205
+ lines.append(t)
206
+ return "\n".join(lines) or None
207
+
208
+ # description
209
+ paras = [_text(p) for p in _as_list(node.get("p"))]
210
+ return "\n".join(p for p in paras if p) or None
211
+
212
+
213
+ # ------------------------------- Public API --------------------------------
214
+
215
+ def _to_cql(query: str) -> str:
216
+ """Turn a free-text query into a CQL expression OPS accepts.
217
+
218
+ OPS only auto-wraps a single bare word; multi-word queries like
219
+ 'agentic ai' are invalid CQL and 404. Queries already containing a
220
+ relational operator ('=') are assumed to be hand-written CQL and passed
221
+ through unchanged.
222
+ """
223
+ q = query.strip()
224
+ if not q or "=" in q:
225
+ return q
226
+ escaped = q.replace('"', " ").strip()
227
+ return f'txt all "{escaped}"'
228
+
229
+
230
+ async def ops_search(client: AsyncClient, query: str, n_results: int) -> list[dict]:
231
+ """Keyword search against OPS published-data, normalized to SERP dicts."""
232
+ n_results = max(1, min(n_results, 100)) # OPS caps a page at 100.
233
+ try:
234
+ data = await _ops_get(
235
+ client,
236
+ "/published-data/search/biblio",
237
+ params={"q": _to_cql(query), "Range": f"1-{n_results}"},
238
+ )
239
+ except HTTPStatusError as e:
240
+ # OPS returns 404 (SERVER.EntityNotFound) when a search has no hits.
241
+ if e.response.status_code == 404:
242
+ return []
243
+ raise
244
+ search = data.get("ops:world-patent-data", {}).get("ops:biblio-search", {})
245
+ result = search.get("ops:search-result", {})
246
+ results: list[dict] = []
247
+ for entry in _as_list(result.get("exchange-documents")):
248
+ ed = entry.get("exchange-document") if isinstance(entry, dict) else None
249
+ if not ed:
250
+ continue
251
+ number = _epodoc_number(ed)
252
+ bd = ed.get("bibliographic-data", {})
253
+ results.append({
254
+ "title": _extract_title(bd) or number or "Untitled",
255
+ "body": _extract_abstract(ed) or "",
256
+ "href": f"https://worldwide.espacenet.com/patent/search?q={number}" if number else "",
257
+ "id": number,
258
+ })
259
+ return results
260
+
261
+
262
+ async def ops_biblio(client: AsyncClient, number: str, doc_type: str = "publication",
263
+ fmt: str = "epodoc") -> dict:
264
+ if fmt == "epodoc":
265
+ number = _normalize_epodoc(number)
266
+ data = await _ops_get(
267
+ client, f"/published-data/{doc_type}/{fmt}/{number}/biblio")
268
+ return data["ops:world-patent-data"]["exchange-documents"]["exchange-document"]
269
+
270
+
271
+ async def _ops_fulltext(client: AsyncClient, number: str, section: str,
272
+ doc_type: str = "publication", fmt: str = "epodoc") -> Optional[str]:
273
+ """Fetch claims/description full text; returns None when unavailable (404)."""
274
+ if fmt == "epodoc":
275
+ number = _normalize_epodoc(number)
276
+ try:
277
+ data = await _ops_get(client, f"/published-data/{doc_type}/{fmt}/{number}/{section}")
278
+ except HTTPStatusError as e:
279
+ if e.response.status_code == 404:
280
+ return None
281
+ raise
282
+ return _fulltext_paragraphs(data, section)
283
+
284
+
285
+ async def ops_scrap_patent(client: AsyncClient, number: str, doc_type: str = "publication",
286
+ fmt: str = "epodoc") -> PatentScrapResult:
287
+ """Retrieve a patent via OPS and shape it like the Google Patents scraper."""
288
+ exchange_doc = await ops_biblio(client, number, doc_type, fmt)
289
+ bd = exchange_doc.get("bibliographic-data", {})
290
+
291
+ claims, description = await asyncio.gather(
292
+ _ops_fulltext(client, number, "claims", doc_type, fmt),
293
+ _ops_fulltext(client, number, "description", doc_type, fmt),
294
+ return_exceptions=True,
295
+ )
296
+ claims = None if isinstance(claims, Exception) else claims
297
+ description = None if isinstance(description, Exception) else description
298
+
299
+ return PatentScrapResult(
300
+ title=_extract_title(bd) or (_epodoc_number(exchange_doc) or number),
301
+ abstract=_extract_abstract(exchange_doc),
302
+ description=description,
303
+ claims=claims,
304
+ field_of_invention=None,
305
+ background=None,
306
+ classifications=_extract_classifications(bd),
307
+ )
308
+
309
+
310
+ class OPSBulkResponse(BaseModel):
311
+ patents: list[PatentScrapResult]
312
+ failed_ids: list[str]
313
+
314
+
315
+ async def ops_scrap_patent_bulk(client: AsyncClient, numbers: list[str]) -> OPSBulkResponse:
316
+ results = await asyncio.gather(
317
+ *[ops_scrap_patent(client, n) for n in numbers], return_exceptions=True)
318
+ patents = [r for r in results if not isinstance(r, Exception)]
319
+ failed = [numbers[i] for i, r in enumerate(
320
+ results) if isinstance(r, Exception)]
321
+ return OPSBulkResponse(patents=patents, failed_ids=failed)
requirements.txt ADDED
@@ -0,0 +1,10 @@
 
 
 
 
 
 
 
 
 
 
 
1
+ fastapi
2
+ uvicorn
3
+ pydantic
4
+ playwright
5
+ duckduckgo_search
6
+ beautifulsoup4
7
+ httpx
8
+ lxml
9
+ python-dotenv
10
+ fastmcp>=3.0,<4
scrap.py ADDED
@@ -0,0 +1,139 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import asyncio
2
+ import logging
3
+ import re
4
+ from typing import Optional
5
+ from httpx import AsyncClient
6
+ from bs4 import BeautifulSoup
7
+ from pydantic import BaseModel
8
+
9
+
10
+ class ClassificationCode(BaseModel):
11
+ """A single CPC or IPC classification code with its description."""
12
+ code: str
13
+ description: str
14
+
15
+
16
+ class PatentScrapResult(BaseModel):
17
+ """Schema for the result of scraping a google patents page."""
18
+ # The title of the patent.
19
+ title: str
20
+ # The abstract of the patent, if available.
21
+ abstract: Optional[str] = None
22
+ # The full description of the patent containing the field of the invention, background, summary, etc.
23
+ description: Optional[str] = None
24
+ # The full claims of the patent.
25
+ claims: Optional[str] = None
26
+ # The field of the invention, if available.
27
+ field_of_invention: Optional[str] = None
28
+ # The background of the invention, if available.
29
+ background: Optional[str] = None
30
+ # CPC and IPC classification codes with descriptions.
31
+ classifications: Optional[list[ClassificationCode]] = None
32
+
33
+
34
+ async def scrap_patent_async(client: AsyncClient, patent_url: str) -> PatentScrapResult:
35
+ headers = {
36
+ "User-Agent": "Mozilla/5.0 (compatible; GPTBot/1.0; +https://openai.com/gptbot)"
37
+ }
38
+ response = await client.get(patent_url, headers=headers)
39
+ response.raise_for_status()
40
+
41
+ soup = BeautifulSoup(response.text, "html.parser")
42
+
43
+ # Abstract
44
+ abstract_div = soup.find("div", {"class": "abstract"})
45
+ abstract = abstract_div.get_text(
46
+ strip=True) if abstract_div else None
47
+
48
+ # Description
49
+ description_section = soup.find("section", itemprop="description")
50
+ description = description_section.get_text(
51
+ separator="\n", strip=True) if description_section else None
52
+
53
+ # Field of the Invention
54
+ invention_field_match = re.findall(
55
+ r"(FIELD OF THE INVENTION|TECHNICAL FIELD)(.*?)(?:(BACKGROUND|BACKGROUND OF THE INVENTION|SUMMARY|BRIEF SUMMARY|DETAILED DESCRIPTION|DESCRIPTION OF THE RELATED ART))", description, re.IGNORECASE | re.DOTALL) if description_section else None
56
+ invention_field = invention_field_match[0][1].strip(
57
+ ) if invention_field_match else None
58
+
59
+ # Background of the Invention
60
+ invention_background_match = re.findall(
61
+ r"(BACKGROUND OF THE INVENTION|BACKGROUND)(.*?)(?:(SUMMARY|BRIEF SUMMARY|DETAILED DESCRIPTION|DESCRIPTION OF THE PREFERRED EMBODIMENTS|DESCRIPTION))", description, re.IGNORECASE | re.DOTALL) if description_section else None
62
+ invention_background = invention_background_match[0][1].strip(
63
+ ) if invention_background_match else None
64
+
65
+ # Claims
66
+ claims_section = soup.find("section", itemprop="claims")
67
+ claims = claims_section.get_text(
68
+ separator="\n", strip=True) if claims_section else None
69
+
70
+ # Patent Title
71
+ meta_title_tag = soup.find("meta", {"name": "DC.title"})
72
+ if meta_title_tag is None or not meta_title_tag.get("content"):
73
+ # Unexpected page layout (interstitial, non-patent page, markup
74
+ # change) rather than a normal 4xx/5xx - raise so callers (the OPS
75
+ # fallback in app.py, or scrap_patent_bulk_async's gather) treat it
76
+ # as a failed scrape instead of crashing on `None.get(...)`.
77
+ raise ValueError(
78
+ f"Could not find a patent title (meta[name=DC.title]) at {patent_url}; "
79
+ "the page may not be a valid Google Patents patent page.")
80
+ meta_title = meta_title_tag.get("content").strip()
81
+
82
+ # Patent publication number
83
+ # pub_num = soup.select_one("h2#pubnum").get_text(strip=True)
84
+ # get the h2 with id ="pubnum" and extract the text
85
+
86
+ # Classification codes (CPC + IPC, flat list, deduplicated by code).
87
+ # Google Patents renders each code in a <span> inside a <li>. Leaf entries
88
+ # (no nested <ul>) carry exactly one code; parent entries are breadcrumbs.
89
+ leaf_code_re = re.compile(r'^[A-Z]\d{2}[A-Z]\d+/\d+$')
90
+ classifications = []
91
+ seen_codes: set[str] = set()
92
+ for li in soup.find_all("li"):
93
+ if li.find("ul"):
94
+ continue
95
+ span = li.find("span")
96
+ if not span:
97
+ continue
98
+ code = span.get_text(strip=True)
99
+ if leaf_code_re.match(code) and code not in seen_codes:
100
+ seen_codes.add(code)
101
+ full_text = li.get_text(separator=" ", strip=True)
102
+ desc = full_text.replace(code, "", 1).strip().lstrip("—").lstrip("-").strip()
103
+ classifications.append(ClassificationCode(code=code, description=desc))
104
+
105
+ return PatentScrapResult(
106
+ # publication_number=pub_num,
107
+ abstract=abstract,
108
+ description=description,
109
+ claims=claims,
110
+ title=meta_title,
111
+ field_of_invention=invention_field,
112
+ background=invention_background,
113
+ classifications=classifications or None
114
+ )
115
+
116
+
117
+ class PatentScrapBulkResponse(BaseModel):
118
+ """Response model for bulk patent scraping."""
119
+ patents: list[PatentScrapResult]
120
+ failed_ids: list[str]
121
+
122
+
123
+ async def scrap_patent_bulk_async(client: AsyncClient, patent_ids: list[str]) -> PatentScrapBulkResponse:
124
+ """Scrape multiple patents asynchronously."""
125
+ urls = [
126
+ f"https://patents.google.com/patent/{pid}/en" for pid in patent_ids]
127
+ results = await asyncio.gather(*[scrap_patent_async(client, url) for url in urls], return_exceptions=True)
128
+
129
+ filtered_results = [
130
+ res for res in results if not isinstance(res, Exception)]
131
+
132
+ failed_ids = [
133
+ patent_ids[i] for i, res in enumerate(results) if isinstance(res, Exception)
134
+ ]
135
+
136
+ return PatentScrapBulkResponse(
137
+ patents=filtered_results,
138
+ failed_ids=failed_ids
139
+ )
serp.py ADDED
@@ -0,0 +1,296 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ from contextlib import asynccontextmanager
2
+ from typing import Optional
3
+ from duckduckgo_search import DDGS
4
+ import httpx
5
+ from pydantic import BaseModel, Field, field_validator
6
+ from playwright.async_api import Browser, BrowserContext, Page, TimeoutError
7
+ from urllib.parse import quote_plus
8
+ import logging
9
+ import re
10
+ from lxml import etree
11
+ from asyncio import Semaphore
12
+
13
+ # Concurrency limit for Playwright browser contexts.
14
+ # This is to prevent too many concurrent browser contexts from being created,
15
+ PLAYWRIGHT_CONCURRENCY_LIMIT = 10
16
+
17
+
18
+ class SerpQuery(BaseModel):
19
+ queries: list[str] = Field(...,
20
+ description="The list of queries to search for")
21
+ n_results: int = Field(
22
+ 10, description="Number of results to return for each query. Valid values are 10, 25, 50 and 100")
23
+
24
+ @field_validator("n_results")
25
+ @classmethod
26
+ def _clamp_n_results(cls, v: int) -> int:
27
+ """Keep callers from passing a negative or absurdly large value straight into
28
+ a page URL or loop bound (every backend but OPS did this unclamped)."""
29
+ return max(1, min(v, 100))
30
+
31
+
32
+ class SerpResults(BaseModel):
33
+ """Model for SERP scrapping results"""
34
+ error: Optional[str]
35
+ results: Optional[list[dict]]
36
+
37
+
38
+ class BraveSearchBlockedException(Exception):
39
+ """Dummy exception to detect when the headless browser is flagged as suspicious."""
40
+
41
+ def __init__(self, *args):
42
+ super().__init__("Brave Search blocked the request, likely due to flagging browser as suspicious")
43
+ pass
44
+
45
+
46
+ class BrowserUnavailableError(Exception):
47
+ """Raised when a Playwright-backed query is attempted but the browser failed to start."""
48
+
49
+ def __init__(self, *args):
50
+ super().__init__("Playwright browser is not available (it failed to start at startup).")
51
+
52
+
53
+ _PLAYWRIGHT_CONCURRENCY_SEMAPHORE = Semaphore(PLAYWRIGHT_CONCURRENCY_LIMIT)
54
+
55
+
56
+ @asynccontextmanager
57
+ async def playwright_open_page(browser: Optional[Browser]):
58
+ """Context manager for playwright pages"""
59
+ if browser is None:
60
+ raise BrowserUnavailableError()
61
+ async with _PLAYWRIGHT_CONCURRENCY_SEMAPHORE:
62
+ context: BrowserContext = await browser.new_context()
63
+ page: Page = await context.new_page()
64
+ try:
65
+ yield page
66
+ finally:
67
+ await page.close()
68
+ await context.close()
69
+
70
+
71
+ async def query_google_scholar(browser: Browser, q: str, n_results: int = 10):
72
+ """Queries google scholar for the specified query and number of results. Returns relevant papers"""
73
+
74
+ async with playwright_open_page(browser) as page:
75
+
76
+ async def _block_resources(route, request):
77
+ if request.resource_type in ["stylesheet", "image"]:
78
+ await route.abort()
79
+ else:
80
+ await route.continue_()
81
+
82
+ await page.route("**/*", _block_resources)
83
+
84
+ url = f"https://scholar.google.com/scholar?q={quote_plus(q)}&num={n_results}"
85
+ await page.goto(url)
86
+
87
+ await page.wait_for_selector("div.gs_ri")
88
+
89
+ items = await page.locator("div.gs_ri").all()
90
+ results = []
91
+ for item in items[:n_results]:
92
+ title = await item.locator("h3").inner_text(timeout=1000)
93
+ body = await item.locator("div.gs_rs").inner_text(timeout=1000)
94
+ href = await item.locator("h3 > a").get_attribute("href")
95
+
96
+ results.append({
97
+ "title": title,
98
+ "body": body,
99
+ "href": href
100
+ })
101
+
102
+ return results
103
+
104
+
105
+ async def query_google_patents(browser: Browser, q: str, n_results: int = 10):
106
+ """Queries google patents for the specified query and number of results. Returns relevant patents"""
107
+
108
+ # regex to locate a patent id
109
+ PATENT_ID_REGEX = r"\b[A-Z]{2}\d{6,}(?:[A-Z]\d?)?\b"
110
+
111
+ async with playwright_open_page(browser) as page:
112
+
113
+ async def _block_resources(route, request):
114
+ if request.resource_type in ["stylesheet", "image"]:
115
+ await route.abort()
116
+ else:
117
+ await route.continue_()
118
+
119
+ await page.route("**/*", _block_resources)
120
+
121
+ url = f"https://patents.google.com/?q={quote_plus(q)}&num={n_results}"
122
+ await page.goto(url)
123
+
124
+ # Wait for at least one search result item to appear
125
+ # This ensures the page has loaded enough to start scraping
126
+ await page.wait_for_function(
127
+ f"""() => document.querySelectorAll('search-result-item').length >= 1""",
128
+ timeout=30_000
129
+ )
130
+
131
+ items = await page.locator("search-result-item").all()
132
+ results = []
133
+ for item in items:
134
+ text = " ".join(await item.locator("span").all_inner_texts())
135
+ match = re.search(PATENT_ID_REGEX, text)
136
+ if not match:
137
+ continue
138
+
139
+ patent_id = match.group()
140
+
141
+ try:
142
+ title = await item.locator("h3, h4").first.inner_text(timeout=1000)
143
+ body = await item.locator("div.abstract, div.result-snippet, .snippet, .result-text").first.inner_text(timeout=1000)
144
+ except:
145
+ continue # If we can't get title or body, skip this item
146
+
147
+ results.append({
148
+ "id": patent_id,
149
+ "href": f"https://patents.google.com/patent/{patent_id}/en",
150
+ "title": title,
151
+ "body": body
152
+ })
153
+
154
+ return results[:n_results]
155
+
156
+
157
+ async def query_brave_search(browser: Browser, q: str, n_results: int = 10):
158
+ """Queries Brave Search for the specified query."""
159
+
160
+ async with playwright_open_page(browser) as page:
161
+
162
+ async def _block_resources(route, request):
163
+ if request.resource_type in ["stylesheet", "image"]:
164
+ await route.abort()
165
+ else:
166
+ await route.continue_()
167
+
168
+ await page.route("**/*", _block_resources)
169
+
170
+ url = f"https://search.brave.com/search?q={quote_plus(q)}"
171
+ await page.goto(url)
172
+
173
+ results_cards = await page.locator('.snippet').all()
174
+
175
+ if len(results_cards) == 0:
176
+ page_content = await page.content()
177
+
178
+ if "suspicious" in page_content:
179
+ raise BraveSearchBlockedException()
180
+
181
+ results = []
182
+
183
+ for result in results_cards:
184
+ title = await result.locator('.title').all_inner_texts()
185
+ description = await result.locator('.snippet-description').all_inner_texts()
186
+ url = await result.locator('a').nth(0).get_attribute('href')
187
+
188
+ # Filter out results with no URL or brave-specific URLs
189
+ if url is None or url.startswith('/'):
190
+ continue
191
+
192
+ results.append({
193
+ "title": title[0] if title else "",
194
+ "body": description[0] if description else "",
195
+ "href": url
196
+ })
197
+
198
+ if len(results) >= n_results:
199
+ break
200
+
201
+ return results
202
+
203
+
204
+ async def query_bing_search(browser: Browser, q: str, n_results: int = 10):
205
+ """Queries bing search for the specified query"""
206
+ async with playwright_open_page(browser) as page:
207
+ async def _block_resources(route, request):
208
+ if request.resource_type in ["stylesheet", "image"]:
209
+ await route.abort()
210
+ else:
211
+ await route.continue_()
212
+
213
+ await page.route("**/*", _block_resources)
214
+
215
+ url = f"https://www.bing.com/search?q={quote_plus(q)}"
216
+ await page.goto(url)
217
+
218
+ await page.wait_for_selector("li.b_algo")
219
+
220
+ results = []
221
+
222
+ items = await page.query_selector_all("li.b_algo")
223
+ for item in items[:n_results]:
224
+ title_el = await item.query_selector("h2 > a")
225
+ url = await title_el.get_attribute("href") if title_el else None
226
+ title = await title_el.inner_text() if title_el else ""
227
+
228
+ snippet = ""
229
+
230
+ # Try several fallback selectors
231
+ for selector in [
232
+ "div.b_caption p", # typical snippet
233
+ "div.b_caption", # sometimes snippet is here
234
+ "div.b_snippet", # used in some result types
235
+ "div.b_text", # used in some panels
236
+ "p" # fallback to any paragraph
237
+ ]:
238
+ snippet_el = await item.query_selector(selector)
239
+ if snippet_el:
240
+ snippet = await snippet_el.inner_text()
241
+ if snippet.strip():
242
+ break
243
+
244
+ if title and url:
245
+ results.append({
246
+ "title": title.strip(),
247
+ "href": url.strip(),
248
+ "body": snippet.strip()
249
+ })
250
+
251
+ return results
252
+
253
+
254
+ async def query_ddg_search(q: str, n_results: int = 10):
255
+ """Queries duckduckgo search for the specified query"""
256
+ ddgs = DDGS()
257
+ results = []
258
+
259
+ for result in ddgs.text(q, max_results=n_results):
260
+ results.append(
261
+ {"title": result["title"], "body": result["body"], "href": result["href"]})
262
+
263
+ return results
264
+
265
+
266
+ async def query_arxiv(client: httpx.AsyncClient, query: str, max_results: int = 3):
267
+ """Searches arXiv for the specified query and returns a list of results with titles and PDF URLs."""
268
+ ATOM_NAMESPACE = {'atom': 'http://www.w3.org/2005/Atom'}
269
+ ARXIV_API_URL = 'https://export.arxiv.org/api/query?'
270
+
271
+ search_params = {
272
+ 'search_query': query,
273
+ 'start': 0,
274
+ 'max_results': max_results
275
+ }
276
+ query_url = ARXIV_API_URL
277
+
278
+ response = await client.get(query_url, params=search_params)
279
+ response.raise_for_status()
280
+
281
+ root = etree.fromstring(response.content)
282
+ entries = root.findall('atom:entry', ATOM_NAMESPACE)
283
+
284
+ results = []
285
+ for entry in entries:
286
+ title = entry.find(
287
+ 'atom:title', ATOM_NAMESPACE).text.strip().replace('\n', ' ')
288
+ id = entry.find('atom:id', ATOM_NAMESPACE).text.strip()
289
+ pdf_url = entry.find(
290
+ 'atom:id', ATOM_NAMESPACE).text.replace('/abs/', '/pdf/')
291
+ summary = entry.find(
292
+ 'atom:summary', ATOM_NAMESPACE).text.strip()
293
+ results.append({'title': title, 'href': pdf_url,
294
+ 'body': summary, 'id': id})
295
+
296
+ return results
utils.py ADDED
@@ -0,0 +1,9 @@
 
 
 
 
 
 
 
 
 
 
1
+ from asyncio.log import logger
2
+ from serp import SerpQuery
3
+
4
+
5
+ def log_gathered_exceptions(results: list, context: str, params: SerpQuery):
6
+ """Logs gathered exceptions with context and parameters."""
7
+ for exc, q in zip(results, params.queries):
8
+ if isinstance(exc, Exception):
9
+ logger.warning(f"Error during {context} for query '{q}': {exc}")