SERPent / app.py
Claude
Move orchestration out of the route handlers into a service layer
e44fdef unverified
Raw
History Blame Contribute Delete
12.7 kB
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."
@asynccontextmanager
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)]
@app.middleware("http")
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.
@app.exception_handler(PatentNotFound)
async def _patent_not_found_handler(request: Request, exc: PatentNotFound):
return JSONResponse({"detail": str(exc)}, status_code=404)
@app.exception_handler(UpstreamUnavailable)
async def _upstream_unavailable_handler(request: Request, exc: UpstreamUnavailable):
return JSONResponse({"detail": str(exc)}, status_code=504 if exc.timeout else 502)
@app.exception_handler(OPSUnconfigured)
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 =====================
@serp_router.post("/search_scholar")
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)
@serp_router.post("/search_arxiv")
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)
@serp_router.post("/search_patents")
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)
@serp_router.post("/search_brave")
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)
@serp_router.post("/search_bing")
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)
@serp_router.post("/search_duck")
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)
@serp_router.post("/search")
async def search(params: SerpQuery, service: SearchServiceDep) -> SerpResults:
"""Attempts to search the specified queries using ALL backends"""
return await service.search(params)
# =========================== Scrapping endpoints ===========================
@scrap_router.get("/scrap_patent/{patent_id}")
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")
@scrap_router.post("/scrap_patents_bulk", response_model=PatentScrapBulkResponse)
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 ===========================
@ops_router.post("/search")
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)
@ops_router.get("/scrap_patent/{patent_id}")
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)
@ops_router.post("/scrap_patents_bulk", response_model=OPSBulkResponse)
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)