Spaces:
Sleeping
Mirror SERPent from Hugging Face Space with bug fixes
Browse filesMirrors 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.
- .gitattributes +35 -0
- .gitignore +4 -0
- Dockerfile +18 -0
- README.md +15 -0
- app.py +384 -0
- docs/README_MCP.md +145 -0
- docs/docs.md +38 -0
- mcp_server.py +203 -0
- ops.py +321 -0
- requirements.txt +10 -0
- scrap.py +139 -0
- serp.py +296 -0
- utils.py +9 -0
|
@@ -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
|
|
@@ -0,0 +1,4 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
__pycache__
|
| 2 |
+
.vscode
|
| 3 |
+
.venv
|
| 4 |
+
.env
|
|
@@ -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"]
|
|
@@ -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.
|
|
@@ -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)
|
|
@@ -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.
|
|
@@ -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 |
+
|
|
@@ -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
|
|
@@ -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)
|
|
@@ -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
|
|
@@ -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 |
+
)
|
|
@@ -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
|
|
@@ -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}")
|