Spaces:
Running
Running
| import logging | |
| import os | |
| import secrets | |
| from contextlib import asynccontextmanager | |
| from typing import Annotated, Optional | |
| import httpx | |
| import uvicorn | |
| from fastapi import Depends, FastAPI, Request | |
| from fastapi.responses import JSONResponse | |
| from fastapi.routing import APIRouter | |
| from playwright.async_api import Browser, async_playwright | |
| from pydantic import BaseModel, Field, StringConstraints | |
| from circuit_breaker import CircuitBreaker | |
| from mcp_server import mount_mcp_server | |
| from ops import OPSBulkResponse, token_manager as ops_token_manager | |
| from scrap import PatentScrapBulkResponse, PatentScrapResult | |
| from serp import PATENT_ID_CORE, SerpQuery, SerpResults | |
| from services import (OPSUnconfigured, PatentNotFound, PatentService, | |
| SearchService, UpstreamUnavailable) | |
| # Anchored version of serp.py's PATENT_ID_CORE: validates a whole | |
| # user-supplied patent id rather than finding one inside free text. Rejects | |
| # obviously-malformed input (e.g. containing "/" or "?") with a clean 422 | |
| # instead of it reaching Google Patents/OPS as a confusing request. | |
| PatentId = Annotated[str, StringConstraints(pattern=rf"^{PATENT_ID_CORE}$")] | |
| # Same reasoning as MAX_QUERIES_PER_REQUEST in serp.py: one accepted | |
| # request must not be able to queue unbounded outbound work. The OPS | |
| # variant is the expensive one - three requests per id. | |
| MAX_BULK_PATENT_IDS = 50 | |
| logging.basicConfig( | |
| level=logging.INFO, | |
| format='[%(asctime)s][%(levelname)s][%(filename)s:%(lineno)d]: %(message)s', | |
| datefmt='%Y-%m-%d %H:%M:%S' | |
| ) | |
| # playwright global context | |
| playwright = None | |
| pw_browser: Optional[Browser] = None | |
| # httpx client. Constructed at import so the dependency providers below can | |
| # close over it, but its lifetime is owned by `api_lifespan`, which closes | |
| # it on shutdown alongside the browser. | |
| httpx_client = httpx.AsyncClient(timeout=30, limits=httpx.Limits( | |
| max_connections=30, max_keepalive_connections=20)) | |
| # Shared across all queries and requests: once a backend has failed | |
| # `failure_threshold` times in a row, skip it for `cooldown_seconds` instead | |
| # of attempting (and paying the timeout cost of) another call that's very | |
| # likely to fail - and, more importantly, stop hammering a backend that may | |
| # already be rate-limiting or blocking this deployment's IP. | |
| _backend_circuit_breaker = CircuitBreaker(failure_threshold=3, cooldown_seconds=60.0) | |
| # ===================== Optional API key protection ===================== | |
| # Unset by default, which keeps the API exactly as open as before. Set | |
| # SERPENT_API_KEY on the deployment to require every request (REST and MCP | |
| # alike) to carry a matching `X-API-Key` or `Authorization: Bearer <key>` | |
| # header. This exists to prevent the deployment from being used as an | |
| # anonymous, unauthenticated scraping proxy by anyone who finds the URL. | |
| SERPENT_API_KEY = os.environ.get("SERPENT_API_KEY") | |
| _PUBLIC_PATHS = {"/", "/openapi.json"} | |
| def _extract_supplied_key(request: Request) -> str: | |
| api_key = request.headers.get("x-api-key") | |
| if api_key: | |
| return api_key | |
| auth = request.headers.get("authorization", "") | |
| if auth.lower().startswith("bearer "): | |
| return auth[7:] | |
| return "" | |
| def _load_docs() -> str: | |
| try: | |
| with open("docs/docs.md", encoding="utf-8") as f: | |
| return f.read() | |
| except OSError as e: | |
| logging.warning(f"Could not read docs/docs.md, using fallback description: {e}") | |
| return "SERPent provides a SERP / scrapping API for use by AI agents / projects." | |
| async def api_lifespan(app: FastAPI): | |
| global playwright, pw_browser | |
| try: | |
| playwright = await async_playwright().start() | |
| # --no-sandbox: the container runs as a non-root user (see the | |
| # Dockerfile), and Chromium's own internal sandbox needs privileges | |
| # a non-root container user doesn't have - without this it fails to | |
| # launch at all. The container itself is still the isolation | |
| # boundary against a compromised renderer. | |
| pw_browser = await playwright.chromium.launch(headless=True, args=["--no-sandbox"]) | |
| logging.info("Playwright browser started.") | |
| except Exception as e: | |
| logging.warning(f"Playwright unavailable, browser-based endpoints will fail: {e}") | |
| yield | |
| if pw_browser: | |
| await pw_browser.close() | |
| if playwright: | |
| await playwright.stop() | |
| await httpx_client.aclose() | |
| app = FastAPI(lifespan=api_lifespan, docs_url="/", | |
| title="SERPent", description=_load_docs()) | |
| # ============================ service dependencies ============================ | |
| # The orchestration itself lives in services.py; these just wire the app's | |
| # collaborators into it. Overriding one of these via | |
| # `app.dependency_overrides` is how a test supplies its own circuit breaker | |
| # or OPS credentials, rather than reassigning module globals. | |
| def get_search_service() -> SearchService: | |
| return SearchService( | |
| http_client=httpx_client, | |
| # A provider, not the browser itself: the lifespan starts it after | |
| # import, and leaves it None when Playwright fails to start. | |
| browser_provider=lambda: pw_browser, | |
| circuit_breaker=_backend_circuit_breaker, | |
| ops_tokens=ops_token_manager, | |
| ) | |
| def get_patent_service() -> PatentService: | |
| return PatentService(http_client=httpx_client, ops_tokens=ops_token_manager) | |
| SearchServiceDep = Annotated[SearchService, Depends(get_search_service)] | |
| PatentServiceDep = Annotated[PatentService, Depends(get_patent_service)] | |
| async def api_key_guard(request: Request, call_next): | |
| """No-op unless SERPENT_API_KEY is set; then gates every path but the docs.""" | |
| if SERPENT_API_KEY and request.url.path not in _PUBLIC_PATHS: | |
| supplied = _extract_supplied_key(request) | |
| # Compare as bytes: compare_digest raises TypeError on str arguments | |
| # containing non-ASCII characters, and Starlette decodes headers as | |
| # latin-1, so an accented byte in the header would otherwise turn | |
| # this check into an unhandled 500 for any anonymous caller. | |
| if not supplied or not secrets.compare_digest( | |
| supplied.encode("utf-8"), SERPENT_API_KEY.encode("utf-8")): | |
| return JSONResponse( | |
| {"detail": "Missing or invalid API key. Supply it via the X-API-Key or Authorization: Bearer header."}, | |
| status_code=401, | |
| ) | |
| return await call_next(request) | |
| # ========================= domain errors -> status codes ========================= | |
| # 404 is a claim that the patent does not exist, and MCP_INSTRUCTIONS tells | |
| # agents to believe it and move on without retrying - so the services raise | |
| # PatentNotFound only when a backend positively said so, and everything else | |
| # arrives here as UpstreamUnavailable. | |
| async def _patent_not_found_handler(request: Request, exc: PatentNotFound): | |
| return JSONResponse({"detail": str(exc)}, status_code=404) | |
| async def _upstream_unavailable_handler(request: Request, exc: UpstreamUnavailable): | |
| return JSONResponse({"detail": str(exc)}, status_code=504 if exc.timeout else 502) | |
| async def _ops_unconfigured_handler(request: Request, exc: OPSUnconfigured): | |
| return JSONResponse({"detail": str(exc)}, status_code=503) | |
| # Router for scrapping related endpoints | |
| scrap_router = APIRouter(prefix="/scrap", tags=["scrapping"]) | |
| # Router for SERP-scrapping related endpoints | |
| serp_router = APIRouter(prefix="/serp", tags=["serp scrapping"]) | |
| # Router for EPO OPS (official patent API) endpoints | |
| ops_router = APIRouter(prefix="/ops", tags=["EPO OPS"]) | |
| # ===================== Search endpoints ===================== | |
| async def search_google_scholar(params: SerpQuery, service: SearchServiceDep) -> SerpResults: | |
| """Queries google scholar for the specified query""" | |
| logging.info(f"Searching Google Scholar for queries: {params.queries}") | |
| return await service.google_scholar(params) | |
| async def search_arxiv(params: SerpQuery, service: SearchServiceDep) -> SerpResults: | |
| """Searches arxiv for the specified queries and returns the found documents.""" | |
| logging.info(f"Searching Arxiv for queries: {params.queries}") | |
| return await service.arxiv(params) | |
| async def search_patents(params: SerpQuery, service: SearchServiceDep) -> SerpResults: | |
| """Searches google patents for the specified queries and returns the found documents. | |
| Falls back to the EPO OPS API for any query Google Patents returns nothing | |
| for, when OPS credentials are configured. | |
| """ | |
| logging.info(f"Searching Google Patents for queries: {params.queries}") | |
| return await service.patents(params) | |
| async def search_brave(params: SerpQuery, service: SearchServiceDep) -> SerpResults: | |
| """Searches brave search for the specified queries and returns the found documents.""" | |
| logging.info(f"Searching Brave Search for queries: {params.queries}") | |
| return await service.brave(params) | |
| async def search_bing(params: SerpQuery, service: SearchServiceDep) -> SerpResults: | |
| """Searches Bing search for the specified queries and returns the found documents.""" | |
| logging.info(f"Searching Bing Search for queries: {params.queries}") | |
| return await service.bing(params) | |
| async def search_duck(params: SerpQuery, service: SearchServiceDep) -> SerpResults: | |
| """Searches duckduckgo for the specified queries and returns the found documents""" | |
| logging.info(f"Searching DuckDuckGo for queries: {params.queries}") | |
| return await service.duckduckgo(params) | |
| async def search(params: SerpQuery, service: SearchServiceDep) -> SerpResults: | |
| """Attempts to search the specified queries using ALL backends""" | |
| return await service.search(params) | |
| # =========================== Scrapping endpoints =========================== | |
| async def scrap_patent(patent_id: PatentId, service: PatentServiceDep) -> PatentScrapResult: | |
| """Scraps the specified patent from Google Patents. | |
| Falls back to the EPO OPS API (which covers patents missing from Google | |
| Patents) when the scrape fails and OPS credentials are configured. | |
| """ | |
| return await service.scrap(patent_id) | |
| class ScrapPatentsRequest(BaseModel): | |
| """Request model for scrapping multiple patents.""" | |
| patent_ids: list[PatentId] = Field(..., | |
| min_length=1, | |
| max_length=MAX_BULK_PATENT_IDS, | |
| description="List of patent IDs to scrap") | |
| async def scrap_patents(params: ScrapPatentsRequest, | |
| service: PatentServiceDep) -> PatentScrapBulkResponse: | |
| """Scraps multiple patents from Google Patents.""" | |
| return await service.scrap_bulk(params.patent_ids) | |
| # =========================== EPO OPS endpoints =========================== | |
| async def ops_keyword_search(params: SerpQuery, service: SearchServiceDep) -> SerpResults: | |
| """Keyword-searches patents via the official EPO OPS API.""" | |
| logging.info(f"Searching EPO OPS for queries: {params.queries}") | |
| return await service.ops_keyword_search(params) | |
| async def ops_get_patent(patent_id: PatentId, service: PatentServiceDep) -> PatentScrapResult: | |
| """Retrieves a patent (biblio + abstract + claims + description) via EPO OPS.""" | |
| return await service.ops_scrap(patent_id) | |
| async def ops_get_patents_bulk(params: ScrapPatentsRequest, | |
| service: PatentServiceDep) -> OPSBulkResponse: | |
| """Retrieves multiple patents via EPO OPS.""" | |
| return await service.ops_scrap_bulk(params.patent_ids) | |
| # =============================================================================== | |
| app.include_router(serp_router) | |
| app.include_router(scrap_router) | |
| app.include_router(ops_router) | |
| # =============================== MCP server =================================== | |
| # Re-exposes every endpoint above as an MCP tool over streamable HTTP at /mcp. | |
| # Must stay below the include_router() calls, or the routes are not picked up. | |
| mcp = mount_mcp_server(app) | |
| if __name__ == "__main__": | |
| uvicorn.run(app, host="0.0.0.0", port=7860) | |