SERPent / serp.py
Claude
Classify a blocked Scholar page before waiting, not after
8a62982 unverified
Raw
History Blame Contribute Delete
22.6 kB
import base64
from contextlib import asynccontextmanager
from typing import Optional
from duckduckgo_search import DDGS
import httpx
from pydantic import BaseModel, Field, field_validator
from playwright.async_api import Browser, BrowserContext, Page
from playwright.async_api import TimeoutError as PlaywrightTimeoutError
from urllib.parse import parse_qs, quote_plus, urlparse
import re
from lxml import etree
import asyncio
from asyncio import Semaphore
# Concurrency limit for Playwright browser contexts.
# This is to prevent too many concurrent browser contexts from being created,
PLAYWRIGHT_CONCURRENCY_LIMIT = 10
# One request must not be able to fan out without limit. Every search
# endpoint issues one outbound call per query (and `/serp/search` may try
# three backends for each), so the length of this list is a direct
# multiplier on outbound load - `n_results` was clamped from the start
# while the list holding the queries was not bounded at all.
MAX_QUERIES_PER_REQUEST = 50
class SerpQuery(BaseModel):
queries: list[str] = Field(...,
min_length=1,
max_length=MAX_QUERIES_PER_REQUEST,
description="The list of queries to search for")
n_results: int = Field(
10, description="Number of results to return for each query. Valid values are 10, 25, 50 and 100")
@field_validator("n_results")
@classmethod
def _clamp_n_results(cls, v: int) -> int:
"""Keep callers from passing a negative or absurdly large value straight into
a page URL or loop bound (every backend but OPS did this unclamped)."""
return max(1, min(v, 100))
class SerpResults(BaseModel):
"""Model for SERP scrapping results"""
error: Optional[str]
results: Optional[list[dict]]
class BraveSearchBlockedException(Exception):
"""Dummy exception to detect when the headless browser is flagged as suspicious."""
def __init__(self, *args):
super().__init__("Brave Search blocked the request, likely due to flagging browser as suspicious")
pass
class GoogleScholarBlockedException(Exception):
"""Raised when Google Scholar serves its anti-bot interstitial.
Scholar refuses datacenter IPs with a challenge page rather than
results. That page has no `div.gs_ri`, so without this the scraper
waits out the full selector timeout and then reports a bare "Timeout
exceeded" - slow, and indistinguishable from a genuine selector
regression.
Carries the evidence that classified it. Without that, "blocked" is an
assertion the reader has no way to check, and there is no way to tell
which signal fired - which is exactly what made the first two attempts
at this hard to debug from the deployment.
"""
def __init__(self, evidence: str = ""):
message = ("Google Scholar blocked the request (anti-bot interstitial served "
"instead of results); the deployment's IP is likely rate-limited.")
if evidence:
message = f"{message} Evidence: {evidence}"
super().__init__(message)
class GoogleScholarUnavailableError(Exception):
"""Scholar returned neither results nor anything we can classify.
Carries what the page actually was - final URL, title, a text excerpt -
because the alternative is a bare selector timeout that says nothing.
The first attempt at block detection here guessed at Google's challenge
markup and matched none of it; the error was useless for working out
why. Whatever we fail to classify, we report.
"""
class BrowserUnavailableError(Exception):
"""Raised when a Playwright-backed query is attempted but the browser failed to start."""
def __init__(self, *args):
super().__init__("Playwright browser is not available (it failed to start at startup).")
class EmptyResultsError(Exception):
"""A backend returned zero results.
Deliberately distinct from a backend erroring, because zero results is
ambiguous: it can mean the query genuinely has no hits, or that the
backend served an anti-bot page that parsed to nothing. Callers (the
`/serp/search` fallback chain in particular) rely on an exception to
move on to the next backend, but should not treat this one as evidence
that the backend itself is unhealthy.
"""
class DuckDuckGoBlockedException(EmptyResultsError):
"""Raised when DuckDuckGo returns zero results.
The duckduckgo_search library doesn't always raise when its request is
blocked/rate-limited — it can also just parse zero results from the
anti-bot response page and return an empty list. A hard block usually
does raise (and that still counts against the backend); this covers the
soft case, which is indistinguishable from a genuinely empty search.
"""
def __init__(self, *args):
super().__init__("DuckDuckGo returned no results, likely blocked or rate-limited")
_PLAYWRIGHT_CONCURRENCY_SEMAPHORE = Semaphore(PLAYWRIGHT_CONCURRENCY_LIMIT)
@asynccontextmanager
async def playwright_open_page(browser: Optional[Browser]):
"""Context manager for playwright pages"""
if browser is None:
raise BrowserUnavailableError()
async with _PLAYWRIGHT_CONCURRENCY_SEMAPHORE:
context: BrowserContext = await browser.new_context()
page: Page = await context.new_page()
try:
yield page
finally:
# Close the context even if closing the page fails (a crashed
# renderer), otherwise the context leaks for the process's life.
try:
await page.close()
finally:
await context.close()
async def _block_stylesheet_and_image_resources(route, request):
"""Shared `page.route("**/*", ...)` handler for every scraper below: skip
fetching stylesheets/images, since only the page's text content matters
here and this meaningfully speeds up navigation.
"""
if request.resource_type in ["stylesheet", "image"]:
await route.abort()
else:
await route.continue_()
# How long to wait for Scholar to show either results or a challenge.
SCHOLAR_SELECTOR_TIMEOUT_MS = 30_000
_SCHOLAR_RESULT_SELECTOR = "div.gs_ri"
# Elements Google's challenge pages have carried. Waiting for these
# *alongside* the results selector is what lets a block fail fast instead
# of costing a full timeout. Matched on presence rather than visibility:
# reCAPTCHA renders in an iframe and its container is commonly zero-height
# until it loads, so a challenge element can be attached but never visible.
_SCHOLAR_BLOCK_SELECTOR = (
"form#captcha-form, #recaptcha, .g-recaptcha, #infoDiv, "
"form[action*='sorry'], #gs_captcha_f, img[src*='sorry'], "
"div#af-error-container, input[name='captcha']"
)
# Wording Google's interstitials have used. Only consulted once the
# selectors have failed - a legitimate Scholar search for "unusual traffic"
# returns papers whose snippets contain that phrase.
_SCHOLAR_BLOCK_TEXT_MARKERS = (
"unusual traffic",
"not a robot",
"/sorry/index",
"automated queries",
"your computer network",
)
# A /sorry redirect is markup-independent evidence, unlike any selector.
_SCHOLAR_BLOCK_URL_MARKERS = ("/sorry", "/challenge")
def _looks_like_scholar_block(page_content: str) -> bool:
lowered = page_content.lower()
return any(marker in lowered for marker in _SCHOLAR_BLOCK_TEXT_MARKERS)
def _url_looks_blocked(url: Optional[str]) -> bool:
return bool(url) and any(m in url.lower() for m in _SCHOLAR_BLOCK_URL_MARKERS)
async def _describe_page(page: Page, final_url: Optional[str]) -> str:
"""A short, human-readable summary of whatever we actually got.
Deliberately best-effort: this runs on an error path, so a failure to
read the page must not replace the original problem with a new one.
"""
parts = [f"url={final_url or getattr(page, 'url', 'unknown')!r}"]
try:
parts.append(f"title={await page.title()!r}")
except Exception:
parts.append("title=<unreadable>")
try:
text = " ".join((await page.inner_text("body")).split())
parts.append(f"text={text[:300]!r}")
except Exception:
parts.append("text=<unreadable>")
return ", ".join(parts)
async def _extract_google_scholar_results(page: Page, n_results: int,
timeout_ms: int = SCHOLAR_SELECTOR_TIMEOUT_MS,
final_url: Optional[str] = None) -> list[dict]:
"""Extract results from an already-loaded Google Scholar results page.
Raises GoogleScholarBlockedException when Google served an anti-bot
interstitial instead of results, and GoogleScholarUnavailableError -
carrying the page's URL, title and a text excerpt - when it served
something we can't classify. A bare selector timeout is never the
outcome, because it tells whoever reads the logs nothing about why.
"""
url = final_url if final_url is not None else getattr(page, "url", None)
async def _block_evidence() -> Optional[str]:
"""Which signal, if any, says this is a challenge page."""
if _url_looks_blocked(url):
return f"redirected to {url!r}"
try:
if await page.locator(_SCHOLAR_BLOCK_SELECTOR).count():
return "challenge element present in the page"
except Exception:
pass
if _looks_like_scholar_block(await page.content()):
return "page wording matches Google's interstitial"
return None
# Classify before waiting on anything. A page carrying no results at
# all is decidable the moment navigation completes - Scholar is
# server-rendered, so a genuine results page already has div.gs_ri in
# the DOM - and every signal (redirect URL, challenge element, Google's
# wording) is available now. Doing this here rather than in the timeout
# handler is what stops a block costing a full selector wait: measured
# against the deployment, classification was already correct but
# arrived 30s late for exactly this reason.
#
# The "no results" guard is also what makes the wording check safe this
# early: a real search for "unusual traffic" returns papers, so it
# never reaches here.
if await page.locator(_SCHOLAR_RESULT_SELECTOR).count() == 0:
early_evidence = await _block_evidence()
if early_evidence:
raise GoogleScholarBlockedException(early_evidence)
try:
await page.wait_for_selector(
f"{_SCHOLAR_RESULT_SELECTOR}, {_SCHOLAR_BLOCK_SELECTOR}",
state="attached", timeout=timeout_ms)
except PlaywrightTimeoutError:
evidence = await _block_evidence()
if evidence:
raise GoogleScholarBlockedException(evidence) from None
raise GoogleScholarUnavailableError(
"Google Scholar returned neither results nor a recognisable "
f"challenge page ({await _describe_page(page, url)})") from None
# Results win. Only a page with none of them gets classified, which
# keeps the wording check from firing on a legitimate search whose
# snippets happen to quote Google's interstitial.
if await page.locator(_SCHOLAR_RESULT_SELECTOR).count() == 0:
raise GoogleScholarBlockedException(
await _block_evidence() or "no results on the page")
items = await page.locator(_SCHOLAR_RESULT_SELECTOR).all()
results = []
for item in items[:n_results]:
title = await item.locator("h3").inner_text(timeout=1000)
body = await item.locator("div.gs_rs").inner_text(timeout=1000)
href = await item.locator("h3 > a").get_attribute("href")
results.append({
"title": title,
"body": body,
"href": href
})
return results
def google_scholar_url(q: str, n_results: int) -> str:
"""The Google Scholar results URL for a query.
Split out from the scraper so it can be asserted on directly: the
Playwright tests replace `page.goto` and never see the URL.
"""
return f"https://scholar.google.com/scholar?q={quote_plus(q)}&num={n_results}"
async def query_google_scholar(browser: Browser, q: str, n_results: int = 10):
"""Queries google scholar for the specified query and number of results. Returns relevant papers"""
async with playwright_open_page(browser) as page:
await page.route("**/*", _block_stylesheet_and_image_resources)
await page.goto(google_scholar_url(q, n_results))
return await _extract_google_scholar_results(page, n_results)
# A patent id's shape, e.g. "US11930446B2" or "EP4760514A1": a two-letter
# country code, at least 6 digits, and an optional kind-code suffix (a
# letter plus 0-2 digits). PATENT_ID_REGEX finds one inside free text (used
# to pull ids out of scraped search results); app.py reuses the same core
# pattern, anchored, to validate a user-supplied patent id - one definition
# so the two can't drift apart.
PATENT_ID_CORE = r"[A-Z]{2}\d{6,}(?:[A-Z]\d?)?"
PATENT_ID_REGEX = rf"\b{PATENT_ID_CORE}\b"
async def _extract_google_patents_results(page: Page, n_results: int) -> list[dict]:
"""Extract results from an already-loaded Google Patents search page."""
# Wait for at least one search result item to appear
# This ensures the page has loaded enough to start scraping
await page.wait_for_function(
"() => document.querySelectorAll('search-result-item').length >= 1",
timeout=30_000
)
items = await page.locator("search-result-item").all()
results = []
for item in items:
text = " ".join(await item.locator("span").all_inner_texts())
match = re.search(PATENT_ID_REGEX, text)
if not match:
continue
patent_id = match.group()
try:
title = await item.locator("h3, h4").first.inner_text(timeout=1000)
body = await item.locator("div.abstract, div.result-snippet, .snippet, .result-text").first.inner_text(timeout=1000)
except Exception:
continue # If we can't get title or body, skip this item
results.append({
"id": patent_id,
"href": f"https://patents.google.com/patent/{patent_id}/en",
"title": title,
"body": body
})
return results[:n_results]
def google_patents_search_url(q: str, n_results: int) -> str:
"""The Google Patents search URL for a query."""
return f"https://patents.google.com/?q={quote_plus(q)}&num={n_results}"
async def query_google_patents(browser: Browser, q: str, n_results: int = 10):
"""Queries google patents for the specified query and number of results. Returns relevant patents"""
async with playwright_open_page(browser) as page:
await page.route("**/*", _block_stylesheet_and_image_resources)
await page.goto(google_patents_search_url(q, n_results))
return await _extract_google_patents_results(page, n_results)
async def _extract_brave_results(page: Page, n_results: int) -> list[dict]:
"""Extract results from an already-loaded Brave Search results page.
Raises BraveSearchBlockedException if the page looks like Brave's
anti-bot interstitial rather than a results page.
"""
results_cards = await page.locator('.snippet').all()
if len(results_cards) == 0:
page_content = await page.content()
if "suspicious" in page_content:
raise BraveSearchBlockedException()
results = []
for result in results_cards:
title = await result.locator('.title').all_inner_texts()
description = await result.locator('.snippet-description').all_inner_texts()
url = await result.locator('a').nth(0).get_attribute('href')
# Filter out results with no URL or brave-specific URLs
if url is None or url.startswith('/'):
continue
results.append({
"title": title[0] if title else "",
"body": description[0] if description else "",
"href": url
})
if len(results) >= n_results:
break
return results
def brave_search_url(q: str, n_results: int = 10) -> str:
"""The Brave Search results URL for a query.
Brave paginates rather than taking a result count, so `n_results` is
applied while extracting rather than in the URL; it is accepted here to
keep the builders interchangeable.
"""
return f"https://search.brave.com/search?q={quote_plus(q)}"
async def query_brave_search(browser: Browser, q: str, n_results: int = 10):
"""Queries Brave Search for the specified query."""
async with playwright_open_page(browser) as page:
await page.route("**/*", _block_stylesheet_and_image_resources)
await page.goto(brave_search_url(q, n_results))
return await _extract_brave_results(page, n_results)
def decode_bing_redirect(url: Optional[str]) -> Optional[str]:
"""Resolve a Bing `/ck/a` tracking link to the page it points at.
Bing doesn't put the destination in a result's href; it wraps every link
in `https://www.bing.com/ck/a?...&u=a1<base64url>&ntb=1`. Handing that
back means callers get a tracking link instead of the page - useless for
citation, deduplication, or deciding whether a result is worth fetching.
Anything that isn't a decodable Bing redirect is returned unchanged: a
link we can't decode is still a working link, and losing it would be
worse than leaving it wrapped. The decoded value comes from a scraped
page, so only http(s) destinations are let out.
"""
if not url or "bing.com/ck/a" not in url:
return url
encoded = parse_qs(urlparse(url).query).get("u", [""])[0]
if not encoded.startswith("a1"):
return url
payload = encoded[2:]
try:
# Bing strips base64 padding; restore however much is missing.
decoded = base64.urlsafe_b64decode(
payload + "=" * (-len(payload) % 4)).decode("utf-8")
except (ValueError, UnicodeDecodeError):
return url
if not decoded.startswith(("http://", "https://")):
return url
return decoded
async def _extract_bing_results(page: Page, n_results: int) -> list[dict]:
"""Extract results from an already-loaded Bing results page."""
await page.wait_for_selector("li.b_algo")
results = []
items = await page.query_selector_all("li.b_algo")
for item in items[:n_results]:
title_el = await item.query_selector("h2 > a")
url = await title_el.get_attribute("href") if title_el else None
title = await title_el.inner_text() if title_el else ""
snippet = ""
# Try several fallback selectors
for selector in [
"div.b_caption p", # typical snippet
"div.b_caption", # sometimes snippet is here
"div.b_snippet", # used in some result types
"div.b_text", # used in some panels
"p" # fallback to any paragraph
]:
snippet_el = await item.query_selector(selector)
if snippet_el:
snippet = await snippet_el.inner_text()
if snippet.strip():
break
if title and url:
results.append({
"title": title.strip(),
"href": decode_bing_redirect(url.strip()),
"body": snippet.strip()
})
return results
def bing_search_url(q: str, n_results: int = 10) -> str:
"""The Bing results URL for a query.
Bing paginates rather than taking a result count, so `n_results` is
applied while extracting rather than in the URL; it is accepted here to
keep the builders interchangeable.
"""
return f"https://www.bing.com/search?q={quote_plus(q)}"
async def query_bing_search(browser: Browser, q: str, n_results: int = 10):
"""Queries bing search for the specified query"""
async with playwright_open_page(browser) as page:
await page.route("**/*", _block_stylesheet_and_image_resources)
await page.goto(bing_search_url(q, n_results))
return await _extract_bing_results(page, n_results)
def _ddg_text_blocking(q: str, n_results: int) -> list[dict]:
"""The synchronous half of a DuckDuckGo query.
`DDGS.text()` performs blocking HTTP I/O. It is deliberately isolated
here so `query_ddg_search` can hand it to a worker thread rather than
calling it on the event loop.
"""
return list(DDGS().text(q, max_results=n_results))
async def query_ddg_search(q: str, n_results: int = 10):
"""Queries duckduckgo search for the specified query.
The underlying library call is synchronous, so it runs in a worker
thread: calling it directly would freeze the whole event loop for the
duration of the request, and DuckDuckGo is the first backend tried for
every query in `/serp/search`.
"""
raw_results = await asyncio.to_thread(_ddg_text_blocking, q, n_results)
results = [
{"title": r["title"], "body": r["body"], "href": r["href"]}
for r in raw_results
]
if not results:
raise DuckDuckGoBlockedException()
return results
async def query_arxiv(client: httpx.AsyncClient, query: str, max_results: int = 3):
"""Searches arXiv for the specified query and returns a list of results with titles and PDF URLs."""
ATOM_NAMESPACE = {'atom': 'http://www.w3.org/2005/Atom'}
ARXIV_API_URL = 'https://export.arxiv.org/api/query?'
search_params = {
'search_query': query,
'start': 0,
'max_results': max_results
}
query_url = ARXIV_API_URL
response = await client.get(query_url, params=search_params)
response.raise_for_status()
root = etree.fromstring(response.content)
entries = root.findall('atom:entry', ATOM_NAMESPACE)
results = []
for entry in entries:
title = entry.find(
'atom:title', ATOM_NAMESPACE).text.strip().replace('\n', ' ')
id = entry.find('atom:id', ATOM_NAMESPACE).text.strip()
pdf_url = entry.find(
'atom:id', ATOM_NAMESPACE).text.replace('/abs/', '/pdf/')
summary = entry.find(
'atom:summary', ATOM_NAMESPACE).text.strip()
results.append({'title': title, 'href': pdf_url,
'body': summary, 'id': id})
return results