Spaces:
Sleeping
Test the search URLs, and delete the harness that hid them
Browse filesThe Playwright tests drove a real browser through a wrapper that replaced
`page.goto` with a stand-in accepting the URL and discarding it. So every
scraper's navigation target was untested, and invisibly so: mutation
testing showed that replacing the Bing URL with a constant, dropping
`&num=` from the Scholar URL, and making the Google Patents URL ignore the
query all left the full suite green. A scraper searching for the wrong
thing entirely passed.
Finish the fetch/parse split that scrap.py and _extract_bing_results had
already started:
- Each scraper's URL construction becomes a named pure builder
(bing_search_url and friends), asserted directly in tests/test_serp_urls.py
- query present, query encoded, result count honoured, expected host.
22 tests, no browser, 0.11s. All four URL mutants now fail the suite.
- The extraction tests take an already-loaded page via a `page_factory`
fixture using `set_content`, exactly as the bing test already did. This
removes the need for the goto-patching Browser and Context wrappers, so
they are deleted (~45 lines of test infrastructure).
- Result-limit coverage added for all four extractors, and the shared
resource-blocking route handler is now tested - blocking the main
document instead of just stylesheets and images would break every
Playwright scrape.
Also cover the /serp/search aggregation, which had no endpoint test:
merging across queries, partial failure reported alongside results,
all-fail, and per-query fallthrough. Discarding every per-query error now
fails the suite.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01MSNSYnceqvdVz7Csis4K9e
- serp.py +38 -8
- tests/conftest.py +26 -0
- tests/helpers.py +1 -64
- tests/test_app.py +77 -0
- tests/test_serp_playwright.py +108 -42
- tests/test_serp_urls.py +78 -0
|
@@ -124,13 +124,21 @@ async def _extract_google_scholar_results(page: Page, n_results: int) -> list[di
|
|
| 124 |
return results
|
| 125 |
|
| 126 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 127 |
async def query_google_scholar(browser: Browser, q: str, n_results: int = 10):
|
| 128 |
"""Queries google scholar for the specified query and number of results. Returns relevant papers"""
|
| 129 |
async with playwright_open_page(browser) as page:
|
| 130 |
await page.route("**/*", _block_stylesheet_and_image_resources)
|
| 131 |
|
| 132 |
-
|
| 133 |
-
await page.goto(url)
|
| 134 |
|
| 135 |
return await _extract_google_scholar_results(page, n_results)
|
| 136 |
|
|
@@ -180,13 +188,17 @@ async def _extract_google_patents_results(page: Page, n_results: int) -> list[di
|
|
| 180 |
return results[:n_results]
|
| 181 |
|
| 182 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 183 |
async def query_google_patents(browser: Browser, q: str, n_results: int = 10):
|
| 184 |
"""Queries google patents for the specified query and number of results. Returns relevant patents"""
|
| 185 |
async with playwright_open_page(browser) as page:
|
| 186 |
await page.route("**/*", _block_stylesheet_and_image_resources)
|
| 187 |
|
| 188 |
-
|
| 189 |
-
await page.goto(url)
|
| 190 |
|
| 191 |
return await _extract_google_patents_results(page, n_results)
|
| 192 |
|
|
@@ -228,13 +240,22 @@ async def _extract_brave_results(page: Page, n_results: int) -> list[dict]:
|
|
| 228 |
return results
|
| 229 |
|
| 230 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 231 |
async def query_brave_search(browser: Browser, q: str, n_results: int = 10):
|
| 232 |
"""Queries Brave Search for the specified query."""
|
| 233 |
async with playwright_open_page(browser) as page:
|
| 234 |
await page.route("**/*", _block_stylesheet_and_image_resources)
|
| 235 |
|
| 236 |
-
|
| 237 |
-
await page.goto(url)
|
| 238 |
|
| 239 |
return await _extract_brave_results(page, n_results)
|
| 240 |
|
|
@@ -277,13 +298,22 @@ async def _extract_bing_results(page: Page, n_results: int) -> list[dict]:
|
|
| 277 |
return results
|
| 278 |
|
| 279 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 280 |
async def query_bing_search(browser: Browser, q: str, n_results: int = 10):
|
| 281 |
"""Queries bing search for the specified query"""
|
| 282 |
async with playwright_open_page(browser) as page:
|
| 283 |
await page.route("**/*", _block_stylesheet_and_image_resources)
|
| 284 |
|
| 285 |
-
|
| 286 |
-
await page.goto(url)
|
| 287 |
|
| 288 |
return await _extract_bing_results(page, n_results)
|
| 289 |
|
|
|
|
| 124 |
return results
|
| 125 |
|
| 126 |
|
| 127 |
+
def google_scholar_url(q: str, n_results: int) -> str:
|
| 128 |
+
"""The Google Scholar results URL for a query.
|
| 129 |
+
|
| 130 |
+
Split out from the scraper so it can be asserted on directly: the
|
| 131 |
+
Playwright tests replace `page.goto` and never see the URL.
|
| 132 |
+
"""
|
| 133 |
+
return f"https://scholar.google.com/scholar?q={quote_plus(q)}&num={n_results}"
|
| 134 |
+
|
| 135 |
+
|
| 136 |
async def query_google_scholar(browser: Browser, q: str, n_results: int = 10):
|
| 137 |
"""Queries google scholar for the specified query and number of results. Returns relevant papers"""
|
| 138 |
async with playwright_open_page(browser) as page:
|
| 139 |
await page.route("**/*", _block_stylesheet_and_image_resources)
|
| 140 |
|
| 141 |
+
await page.goto(google_scholar_url(q, n_results))
|
|
|
|
| 142 |
|
| 143 |
return await _extract_google_scholar_results(page, n_results)
|
| 144 |
|
|
|
|
| 188 |
return results[:n_results]
|
| 189 |
|
| 190 |
|
| 191 |
+
def google_patents_search_url(q: str, n_results: int) -> str:
|
| 192 |
+
"""The Google Patents search URL for a query."""
|
| 193 |
+
return f"https://patents.google.com/?q={quote_plus(q)}&num={n_results}"
|
| 194 |
+
|
| 195 |
+
|
| 196 |
async def query_google_patents(browser: Browser, q: str, n_results: int = 10):
|
| 197 |
"""Queries google patents for the specified query and number of results. Returns relevant patents"""
|
| 198 |
async with playwright_open_page(browser) as page:
|
| 199 |
await page.route("**/*", _block_stylesheet_and_image_resources)
|
| 200 |
|
| 201 |
+
await page.goto(google_patents_search_url(q, n_results))
|
|
|
|
| 202 |
|
| 203 |
return await _extract_google_patents_results(page, n_results)
|
| 204 |
|
|
|
|
| 240 |
return results
|
| 241 |
|
| 242 |
|
| 243 |
+
def brave_search_url(q: str, n_results: int = 10) -> str:
|
| 244 |
+
"""The Brave Search results URL for a query.
|
| 245 |
+
|
| 246 |
+
Brave paginates rather than taking a result count, so `n_results` is
|
| 247 |
+
applied while extracting rather than in the URL; it is accepted here to
|
| 248 |
+
keep the builders interchangeable.
|
| 249 |
+
"""
|
| 250 |
+
return f"https://search.brave.com/search?q={quote_plus(q)}"
|
| 251 |
+
|
| 252 |
+
|
| 253 |
async def query_brave_search(browser: Browser, q: str, n_results: int = 10):
|
| 254 |
"""Queries Brave Search for the specified query."""
|
| 255 |
async with playwright_open_page(browser) as page:
|
| 256 |
await page.route("**/*", _block_stylesheet_and_image_resources)
|
| 257 |
|
| 258 |
+
await page.goto(brave_search_url(q, n_results))
|
|
|
|
| 259 |
|
| 260 |
return await _extract_brave_results(page, n_results)
|
| 261 |
|
|
|
|
| 298 |
return results
|
| 299 |
|
| 300 |
|
| 301 |
+
def bing_search_url(q: str, n_results: int = 10) -> str:
|
| 302 |
+
"""The Bing results URL for a query.
|
| 303 |
+
|
| 304 |
+
Bing paginates rather than taking a result count, so `n_results` is
|
| 305 |
+
applied while extracting rather than in the URL; it is accepted here to
|
| 306 |
+
keep the builders interchangeable.
|
| 307 |
+
"""
|
| 308 |
+
return f"https://www.bing.com/search?q={quote_plus(q)}"
|
| 309 |
+
|
| 310 |
+
|
| 311 |
async def query_bing_search(browser: Browser, q: str, n_results: int = 10):
|
| 312 |
"""Queries bing search for the specified query"""
|
| 313 |
async with playwright_open_page(browser) as page:
|
| 314 |
await page.route("**/*", _block_stylesheet_and_image_resources)
|
| 315 |
|
| 316 |
+
await page.goto(bing_search_url(q, n_results))
|
|
|
|
| 317 |
|
| 318 |
return await _extract_bing_results(page, n_results)
|
| 319 |
|
|
@@ -59,3 +59,29 @@ async def browser():
|
|
| 59 |
yield b
|
| 60 |
await b.close()
|
| 61 |
await pw.stop()
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 59 |
yield b
|
| 60 |
await b.close()
|
| 61 |
await pw.stop()
|
| 62 |
+
|
| 63 |
+
|
| 64 |
+
@pytest_asyncio.fixture
|
| 65 |
+
async def page_factory(browser):
|
| 66 |
+
"""Returns a callable producing a real Page with fixture HTML loaded.
|
| 67 |
+
|
| 68 |
+
This is the whole seam the Playwright-driven tests need now that every
|
| 69 |
+
scraper is split into a URL builder, a navigation step and an
|
| 70 |
+
`_extract_*` function: the extractors take an already-loaded page, so
|
| 71 |
+
`set_content` reaches them directly. It replaces the goto-patching
|
| 72 |
+
Browser wrapper that used to be required, which had to accept the
|
| 73 |
+
navigation URL and throw it away - and so hid every URL bug.
|
| 74 |
+
"""
|
| 75 |
+
contexts = []
|
| 76 |
+
|
| 77 |
+
async def make(html: str):
|
| 78 |
+
context = await browser.new_context()
|
| 79 |
+
contexts.append(context)
|
| 80 |
+
page = await context.new_page()
|
| 81 |
+
await page.set_content(html)
|
| 82 |
+
return page
|
| 83 |
+
|
| 84 |
+
yield make
|
| 85 |
+
|
| 86 |
+
for context in contexts:
|
| 87 |
+
await context.close()
|
|
@@ -1,7 +1,4 @@
|
|
| 1 |
-
"""Shared test helpers: fixture loading
|
| 2 |
-
Browser wrapper used to characterize the scraper functions in `serp.py`
|
| 3 |
-
without hitting live search engines.
|
| 4 |
-
"""
|
| 5 |
|
| 6 |
from pathlib import Path
|
| 7 |
|
|
@@ -11,63 +8,3 @@ FIXTURES_DIR = Path(__file__).resolve().parent / "fixtures"
|
|
| 11 |
def load_fixture(*parts: str) -> str:
|
| 12 |
"""Read a text fixture from tests/fixtures/<parts...>."""
|
| 13 |
return FIXTURES_DIR.joinpath(*parts).read_text(encoding="utf-8")
|
| 14 |
-
|
| 15 |
-
|
| 16 |
-
class _GotoPatchingContext:
|
| 17 |
-
"""Wraps a real Playwright BrowserContext so every Page it creates loads
|
| 18 |
-
fixture HTML via `set_content()` instead of navigating over the network.
|
| 19 |
-
"""
|
| 20 |
-
|
| 21 |
-
def __init__(self, real_context, html: str):
|
| 22 |
-
self._real_context = real_context
|
| 23 |
-
self._html = html
|
| 24 |
-
|
| 25 |
-
async def new_page(self):
|
| 26 |
-
page = await self._real_context.new_page()
|
| 27 |
-
html = self._html
|
| 28 |
-
|
| 29 |
-
async def fake_goto(url, **kwargs):
|
| 30 |
-
# The scraper functions don't use the response object `goto`
|
| 31 |
-
# normally returns, so a plain None is a safe stand-in.
|
| 32 |
-
await page.set_content(html)
|
| 33 |
-
return None
|
| 34 |
-
|
| 35 |
-
page.goto = fake_goto
|
| 36 |
-
return page
|
| 37 |
-
|
| 38 |
-
async def close(self):
|
| 39 |
-
await self._real_context.close()
|
| 40 |
-
|
| 41 |
-
|
| 42 |
-
class _GotoPatchingBrowser:
|
| 43 |
-
"""Wraps a real Playwright Browser the same way, so it can be passed
|
| 44 |
-
directly to `serp.py` functions in place of the real `pw_browser`.
|
| 45 |
-
"""
|
| 46 |
-
|
| 47 |
-
def __init__(self, real_browser, html: str):
|
| 48 |
-
self._real_browser = real_browser
|
| 49 |
-
self._html = html
|
| 50 |
-
|
| 51 |
-
async def new_context(self, **kwargs):
|
| 52 |
-
real_context = await self._real_browser.new_context(**kwargs)
|
| 53 |
-
return _GotoPatchingContext(real_context, self._html)
|
| 54 |
-
|
| 55 |
-
|
| 56 |
-
def make_fixture_browser(real_browser, html: str) -> _GotoPatchingBrowser:
|
| 57 |
-
"""Build a Browser-like object for characterizing `serp.py`'s scraper
|
| 58 |
-
functions against fixture HTML.
|
| 59 |
-
|
| 60 |
-
Why this exists: each scraper (e.g. `query_bing_search`) registers its
|
| 61 |
-
own catch-all `page.route("**/*", ...)` resource blocker *inside* the
|
| 62 |
-
function, and that handler calls `route.continue_()` (not
|
| 63 |
-
`route.fallback()`) for the main document request. Playwright invokes
|
| 64 |
-
the most-recently-registered matching route first, so any route we
|
| 65 |
-
register on the page *before* calling the function never runs -
|
| 66 |
-
verified empirically, it's not a documented guarantee either way.
|
| 67 |
-
Patching `page.goto` itself, on a page created through this wrapper, is
|
| 68 |
-
the only reliable seam into these functions without touching their
|
| 69 |
-
source (see the code review this test suite followed from: the
|
| 70 |
-
fetch/navigate and parse logic aren't separated, which is exactly what
|
| 71 |
-
makes this workaround necessary).
|
| 72 |
-
"""
|
| 73 |
-
return _GotoPatchingBrowser(real_browser, html)
|
|
|
|
| 1 |
+
"""Shared test helpers: fixture loading."""
|
|
|
|
|
|
|
|
|
|
| 2 |
|
| 3 |
from pathlib import Path
|
| 4 |
|
|
|
|
| 8 |
def load_fixture(*parts: str) -> str:
|
| 9 |
"""Read a text fixture from tests/fixtures/<parts...>."""
|
| 10 |
return FIXTURES_DIR.joinpath(*parts).read_text(encoding="utf-8")
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
@@ -342,3 +342,80 @@ async def test_search_one_skips_a_backend_whose_circuit_is_open(monkeypatch):
|
|
| 342 |
q, results, err = await app_module._search_one("second query", 5)
|
| 343 |
assert ddg_calls == ["first query"] # unchanged - DDG was skipped
|
| 344 |
assert results == [{"title": "from brave"}]
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 342 |
q, results, err = await app_module._search_one("second query", 5)
|
| 343 |
assert ddg_calls == ["first query"] # unchanged - DDG was skipped
|
| 344 |
assert results == [{"title": "from brave"}]
|
| 345 |
+
|
| 346 |
+
|
| 347 |
+
# ------------------------------- /serp/search endpoint -------------------------------
|
| 348 |
+
# `_search_one` is covered above; this is the aggregation wrapped around it,
|
| 349 |
+
# which had no coverage - dropping every per-query error left the suite green.
|
| 350 |
+
|
| 351 |
+
|
| 352 |
+
async def test_search_endpoint_merges_results_from_every_query(client, monkeypatch):
|
| 353 |
+
async def fake_ddg(q, n):
|
| 354 |
+
return [{"title": f"result for {q}"}]
|
| 355 |
+
|
| 356 |
+
monkeypatch.setattr(app_module, "query_ddg_search", fake_ddg)
|
| 357 |
+
|
| 358 |
+
resp = await client.post("/serp/search", json={"queries": ["a", "b"], "n_results": 5})
|
| 359 |
+
|
| 360 |
+
data = resp.json()
|
| 361 |
+
assert {r["title"] for r in data["results"]} == {"result for a", "result for b"}
|
| 362 |
+
assert data["error"] is None
|
| 363 |
+
|
| 364 |
+
|
| 365 |
+
async def test_search_endpoint_reports_partial_failure_alongside_results(client, monkeypatch):
|
| 366 |
+
"""A query that no backend could answer must be reported even when
|
| 367 |
+
another query succeeded - otherwise the caller silently receives fewer
|
| 368 |
+
results than they asked for with no indication why.
|
| 369 |
+
"""
|
| 370 |
+
async def selective_ddg(q, n):
|
| 371 |
+
if q == "bad":
|
| 372 |
+
raise RuntimeError("blocked")
|
| 373 |
+
return [{"title": f"result for {q}"}]
|
| 374 |
+
|
| 375 |
+
async def failing(*args):
|
| 376 |
+
raise RuntimeError("blocked")
|
| 377 |
+
|
| 378 |
+
monkeypatch.setattr(app_module, "query_ddg_search", selective_ddg)
|
| 379 |
+
monkeypatch.setattr(app_module, "query_brave_search", failing)
|
| 380 |
+
monkeypatch.setattr(app_module, "query_bing_search", failing)
|
| 381 |
+
|
| 382 |
+
resp = await client.post("/serp/search", json={"queries": ["good", "bad"], "n_results": 5})
|
| 383 |
+
|
| 384 |
+
data = resp.json()
|
| 385 |
+
assert [r["title"] for r in data["results"]] == ["result for good"]
|
| 386 |
+
assert "bad" in data["error"]
|
| 387 |
+
|
| 388 |
+
|
| 389 |
+
async def test_search_endpoint_reports_an_error_when_everything_fails(client, monkeypatch):
|
| 390 |
+
async def failing(*args):
|
| 391 |
+
raise RuntimeError("blocked")
|
| 392 |
+
|
| 393 |
+
monkeypatch.setattr(app_module, "query_ddg_search", failing)
|
| 394 |
+
monkeypatch.setattr(app_module, "query_brave_search", failing)
|
| 395 |
+
monkeypatch.setattr(app_module, "query_bing_search", failing)
|
| 396 |
+
|
| 397 |
+
resp = await client.post("/serp/search", json={"queries": ["a", "b"], "n_results": 5})
|
| 398 |
+
|
| 399 |
+
data = resp.json()
|
| 400 |
+
assert data["results"] == []
|
| 401 |
+
# Both failed queries are named, not just the last one.
|
| 402 |
+
assert "'a'" in data["error"] and "'b'" in data["error"]
|
| 403 |
+
|
| 404 |
+
|
| 405 |
+
async def test_search_endpoint_falls_through_backends_per_query(client, monkeypatch):
|
| 406 |
+
"""The fallback chain is per query, so one blocked backend shouldn't
|
| 407 |
+
cost results for queries the next backend can answer."""
|
| 408 |
+
async def failing_ddg(q, n):
|
| 409 |
+
raise RuntimeError("ddg blocked")
|
| 410 |
+
|
| 411 |
+
async def fake_brave(browser, q, n):
|
| 412 |
+
return [{"title": f"brave has {q}"}]
|
| 413 |
+
|
| 414 |
+
monkeypatch.setattr(app_module, "query_ddg_search", failing_ddg)
|
| 415 |
+
monkeypatch.setattr(app_module, "query_brave_search", fake_brave)
|
| 416 |
+
|
| 417 |
+
resp = await client.post("/serp/search", json={"queries": ["a"], "n_results": 5})
|
| 418 |
+
|
| 419 |
+
data = resp.json()
|
| 420 |
+
assert data["results"] == [{"title": "brave has a"}]
|
| 421 |
+
assert data["error"] is None
|
|
@@ -1,36 +1,36 @@
|
|
| 1 |
-
"""
|
| 2 |
-
|
| 3 |
-
|
| 4 |
-
|
| 5 |
-
|
| 6 |
-
|
| 7 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 8 |
"""
|
| 9 |
|
| 10 |
import pytest
|
| 11 |
|
| 12 |
-
from helpers import load_fixture
|
| 13 |
from serp import (
|
| 14 |
BraveSearchBlockedException,
|
| 15 |
BrowserUnavailableError,
|
|
|
|
| 16 |
_extract_bing_results,
|
|
|
|
|
|
|
|
|
|
| 17 |
playwright_open_page,
|
| 18 |
-
query_bing_search,
|
| 19 |
-
query_brave_search,
|
| 20 |
-
query_google_patents,
|
| 21 |
-
query_google_scholar,
|
| 22 |
)
|
| 23 |
|
| 24 |
|
| 25 |
-
async def
|
| 26 |
-
|
| 27 |
-
(extract): `_extract_bing_results` takes a Page that's already loaded,
|
| 28 |
-
so this needs nothing beyond `set_content` - no goto-patching wrapper,
|
| 29 |
-
no browser-as-a-parameter indirection.
|
| 30 |
-
"""
|
| 31 |
-
context = await browser.new_context()
|
| 32 |
-
page = await context.new_page()
|
| 33 |
-
await page.set_content(load_fixture("bing_results.html"))
|
| 34 |
|
| 35 |
results = await _extract_bing_results(page, 10)
|
| 36 |
|
|
@@ -39,24 +39,19 @@ async def test_extract_bing_results_works_on_an_already_loaded_page(browser):
|
|
| 39 |
{"title": "Result B", "href": "https://example.org/b", "body": "Snippet B"},
|
| 40 |
]
|
| 41 |
|
| 42 |
-
await context.close()
|
| 43 |
|
|
|
|
|
|
|
| 44 |
|
| 45 |
-
|
| 46 |
-
fixture_browser = make_fixture_browser(browser, load_fixture("bing_results.html"))
|
| 47 |
|
| 48 |
-
|
| 49 |
-
|
| 50 |
-
assert results == [
|
| 51 |
-
{"title": "Result A", "href": "https://example.org/a", "body": "Snippet A"},
|
| 52 |
-
{"title": "Result B", "href": "https://example.org/b", "body": "Snippet B"},
|
| 53 |
-
]
|
| 54 |
|
| 55 |
|
| 56 |
-
async def
|
| 57 |
-
|
| 58 |
|
| 59 |
-
results = await
|
| 60 |
|
| 61 |
assert results == [
|
| 62 |
{"title": "Brave Result A", "body": "Brave snippet A", "href": "https://example.org/brave-a"},
|
|
@@ -64,17 +59,30 @@ async def test_brave_search_parses_results(browser):
|
|
| 64 |
]
|
| 65 |
|
| 66 |
|
| 67 |
-
async def
|
| 68 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 69 |
|
| 70 |
with pytest.raises(BraveSearchBlockedException):
|
| 71 |
-
await
|
| 72 |
|
| 73 |
|
| 74 |
-
async def
|
| 75 |
-
|
| 76 |
|
| 77 |
-
results = await
|
| 78 |
|
| 79 |
assert results == [
|
| 80 |
{"title": "Scholar Paper A", "body": "Scholar snippet A", "href": "https://example.org/scholar-a"},
|
|
@@ -82,10 +90,18 @@ async def test_google_scholar_parses_results(browser):
|
|
| 82 |
]
|
| 83 |
|
| 84 |
|
| 85 |
-
async def
|
| 86 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
| 87 |
|
| 88 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
| 89 |
|
| 90 |
assert results == [
|
| 91 |
{
|
|
@@ -103,7 +119,57 @@ async def test_google_patents_parses_results(browser):
|
|
| 103 |
]
|
| 104 |
|
| 105 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 106 |
async def test_playwright_open_page_raises_when_browser_unavailable():
|
| 107 |
with pytest.raises(BrowserUnavailableError):
|
| 108 |
async with playwright_open_page(None):
|
| 109 |
pass
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""Tests for the DOM-extraction half of the Playwright-driven scrapers.
|
| 2 |
+
|
| 3 |
+
Each scraper in serp.py is three separable pieces: a URL builder (pure,
|
| 4 |
+
tested in test_serp_urls.py), a navigation step, and an `_extract_*`
|
| 5 |
+
function that reads an already-loaded page. These test the third piece
|
| 6 |
+
against a real headless Chromium - the same engine production uses, so
|
| 7 |
+
selector behaviour is genuinely exercised - with the fixture HTML loaded
|
| 8 |
+
via `set_content`.
|
| 9 |
+
|
| 10 |
+
This used to require a wrapper that replaced `page.goto` on a
|
| 11 |
+
purpose-built fake Browser, because fetch and parse weren't separated.
|
| 12 |
+
That wrapper accepted the navigation URL and discarded it, which is
|
| 13 |
+
precisely why URL bugs were invisible to the suite; splitting the scrapers
|
| 14 |
+
made it unnecessary and it has been deleted.
|
| 15 |
"""
|
| 16 |
|
| 17 |
import pytest
|
| 18 |
|
| 19 |
+
from helpers import load_fixture
|
| 20 |
from serp import (
|
| 21 |
BraveSearchBlockedException,
|
| 22 |
BrowserUnavailableError,
|
| 23 |
+
_block_stylesheet_and_image_resources,
|
| 24 |
_extract_bing_results,
|
| 25 |
+
_extract_brave_results,
|
| 26 |
+
_extract_google_patents_results,
|
| 27 |
+
_extract_google_scholar_results,
|
| 28 |
playwright_open_page,
|
|
|
|
|
|
|
|
|
|
|
|
|
| 29 |
)
|
| 30 |
|
| 31 |
|
| 32 |
+
async def test_bing_results_are_extracted(page_factory):
|
| 33 |
+
page = await page_factory(load_fixture("bing_results.html"))
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 34 |
|
| 35 |
results = await _extract_bing_results(page, 10)
|
| 36 |
|
|
|
|
| 39 |
{"title": "Result B", "href": "https://example.org/b", "body": "Snippet B"},
|
| 40 |
]
|
| 41 |
|
|
|
|
| 42 |
|
| 43 |
+
async def test_bing_extraction_honours_the_result_limit(page_factory):
|
| 44 |
+
page = await page_factory(load_fixture("bing_results.html"))
|
| 45 |
|
| 46 |
+
results = await _extract_bing_results(page, 1)
|
|
|
|
| 47 |
|
| 48 |
+
assert [r["title"] for r in results] == ["Result A"]
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 49 |
|
| 50 |
|
| 51 |
+
async def test_brave_results_are_extracted(page_factory):
|
| 52 |
+
page = await page_factory(load_fixture("brave_results.html"))
|
| 53 |
|
| 54 |
+
results = await _extract_brave_results(page, 10)
|
| 55 |
|
| 56 |
assert results == [
|
| 57 |
{"title": "Brave Result A", "body": "Brave snippet A", "href": "https://example.org/brave-a"},
|
|
|
|
| 59 |
]
|
| 60 |
|
| 61 |
|
| 62 |
+
async def test_brave_extraction_honours_the_result_limit(page_factory):
|
| 63 |
+
page = await page_factory(load_fixture("brave_results.html"))
|
| 64 |
+
|
| 65 |
+
results = await _extract_brave_results(page, 1)
|
| 66 |
+
|
| 67 |
+
assert [r["title"] for r in results] == ["Brave Result A"]
|
| 68 |
+
|
| 69 |
+
|
| 70 |
+
async def test_brave_block_page_raises(page_factory):
|
| 71 |
+
"""Brave's anti-bot interstitial has no result cards, so it would
|
| 72 |
+
otherwise look like a legitimate zero-result search - and the
|
| 73 |
+
`/serp/search` fallback chain needs an exception to move to the next
|
| 74 |
+
backend.
|
| 75 |
+
"""
|
| 76 |
+
page = await page_factory(load_fixture("brave_blocked.html"))
|
| 77 |
|
| 78 |
with pytest.raises(BraveSearchBlockedException):
|
| 79 |
+
await _extract_brave_results(page, 10)
|
| 80 |
|
| 81 |
|
| 82 |
+
async def test_google_scholar_results_are_extracted(page_factory):
|
| 83 |
+
page = await page_factory(load_fixture("scholar_results.html"))
|
| 84 |
|
| 85 |
+
results = await _extract_google_scholar_results(page, 10)
|
| 86 |
|
| 87 |
assert results == [
|
| 88 |
{"title": "Scholar Paper A", "body": "Scholar snippet A", "href": "https://example.org/scholar-a"},
|
|
|
|
| 90 |
]
|
| 91 |
|
| 92 |
|
| 93 |
+
async def test_google_scholar_extraction_honours_the_result_limit(page_factory):
|
| 94 |
+
page = await page_factory(load_fixture("scholar_results.html"))
|
| 95 |
+
|
| 96 |
+
results = await _extract_google_scholar_results(page, 1)
|
| 97 |
+
|
| 98 |
+
assert [r["title"] for r in results] == ["Scholar Paper A"]
|
| 99 |
|
| 100 |
+
|
| 101 |
+
async def test_google_patents_results_are_extracted(page_factory):
|
| 102 |
+
page = await page_factory(load_fixture("patents", "search_results.html"))
|
| 103 |
+
|
| 104 |
+
results = await _extract_google_patents_results(page, 10)
|
| 105 |
|
| 106 |
assert results == [
|
| 107 |
{
|
|
|
|
| 119 |
]
|
| 120 |
|
| 121 |
|
| 122 |
+
async def test_google_patents_extraction_honours_the_result_limit(page_factory):
|
| 123 |
+
page = await page_factory(load_fixture("patents", "search_results.html"))
|
| 124 |
+
|
| 125 |
+
results = await _extract_google_patents_results(page, 1)
|
| 126 |
+
|
| 127 |
+
assert [r["id"] for r in results] == ["US11930446B2"]
|
| 128 |
+
|
| 129 |
+
|
| 130 |
+
# --------------------------------- shared plumbing ---------------------------------
|
| 131 |
+
|
| 132 |
+
|
| 133 |
async def test_playwright_open_page_raises_when_browser_unavailable():
|
| 134 |
with pytest.raises(BrowserUnavailableError):
|
| 135 |
async with playwright_open_page(None):
|
| 136 |
pass
|
| 137 |
+
|
| 138 |
+
|
| 139 |
+
class _FakeRoute:
|
| 140 |
+
def __init__(self):
|
| 141 |
+
self.aborted = False
|
| 142 |
+
self.continued = False
|
| 143 |
+
|
| 144 |
+
async def abort(self):
|
| 145 |
+
self.aborted = True
|
| 146 |
+
|
| 147 |
+
async def continue_(self):
|
| 148 |
+
self.continued = True
|
| 149 |
+
|
| 150 |
+
|
| 151 |
+
class _FakeRequest:
|
| 152 |
+
def __init__(self, resource_type):
|
| 153 |
+
self.resource_type = resource_type
|
| 154 |
+
|
| 155 |
+
|
| 156 |
+
@pytest.mark.parametrize("resource_type", ["stylesheet", "image"])
|
| 157 |
+
async def test_decorative_resources_are_blocked(resource_type):
|
| 158 |
+
"""Only page text matters to these scrapers, and skipping stylesheets
|
| 159 |
+
and images meaningfully speeds up navigation."""
|
| 160 |
+
route = _FakeRoute()
|
| 161 |
+
|
| 162 |
+
await _block_stylesheet_and_image_resources(route, _FakeRequest(resource_type))
|
| 163 |
+
|
| 164 |
+
assert route.aborted and not route.continued
|
| 165 |
+
|
| 166 |
+
|
| 167 |
+
@pytest.mark.parametrize("resource_type", ["document", "script", "xhr", "fetch"])
|
| 168 |
+
async def test_everything_else_is_allowed_through(resource_type):
|
| 169 |
+
"""Blocking the main document (or the XHR that renders results) would
|
| 170 |
+
break the scrape entirely."""
|
| 171 |
+
route = _FakeRoute()
|
| 172 |
+
|
| 173 |
+
await _block_stylesheet_and_image_resources(route, _FakeRequest(resource_type))
|
| 174 |
+
|
| 175 |
+
assert route.continued and not route.aborted
|
|
@@ -0,0 +1,78 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""The search URLs each scraper navigates to.
|
| 2 |
+
|
| 3 |
+
These had no coverage at all, and the gap was invisible: the Playwright
|
| 4 |
+
tests drive a real browser but patch `page.goto` with a stand-in that
|
| 5 |
+
accepts the URL and discards it, so everything downstream of navigation
|
| 6 |
+
was verified and everything upstream was not. Mutation testing confirmed
|
| 7 |
+
it - replacing the Bing URL with a constant, dropping `&num=` from the
|
| 8 |
+
Scholar URL, and making the Google Patents URL ignore the query all left
|
| 9 |
+
the full suite green. A scraper that searched for the wrong thing entirely
|
| 10 |
+
passed.
|
| 11 |
+
|
| 12 |
+
These are pure string assertions: no browser, no fixtures, microseconds.
|
| 13 |
+
"""
|
| 14 |
+
|
| 15 |
+
import pytest
|
| 16 |
+
|
| 17 |
+
from serp import (
|
| 18 |
+
bing_search_url,
|
| 19 |
+
brave_search_url,
|
| 20 |
+
google_patents_search_url,
|
| 21 |
+
google_scholar_url,
|
| 22 |
+
)
|
| 23 |
+
|
| 24 |
+
|
| 25 |
+
class TestQueryIsIncluded:
|
| 26 |
+
"""The single most important property: the URL must ask for what the
|
| 27 |
+
caller asked for."""
|
| 28 |
+
|
| 29 |
+
@pytest.mark.parametrize("builder", [
|
| 30 |
+
bing_search_url, brave_search_url, google_patents_search_url, google_scholar_url])
|
| 31 |
+
def test_the_query_reaches_the_url(self, builder):
|
| 32 |
+
assert "widgets" in builder("widgets", 10)
|
| 33 |
+
|
| 34 |
+
@pytest.mark.parametrize("builder", [
|
| 35 |
+
bing_search_url, brave_search_url, google_patents_search_url, google_scholar_url])
|
| 36 |
+
def test_a_different_query_produces_a_different_url(self, builder):
|
| 37 |
+
assert builder("widgets", 10) != builder("gadgets", 10)
|
| 38 |
+
|
| 39 |
+
|
| 40 |
+
class TestQueryIsEncoded:
|
| 41 |
+
"""A raw query in a URL is both a correctness bug (spaces, ampersands
|
| 42 |
+
and hashes truncate or corrupt the search) and the kind of thing that
|
| 43 |
+
turns user input into URL structure."""
|
| 44 |
+
|
| 45 |
+
@pytest.mark.parametrize("builder", [
|
| 46 |
+
bing_search_url, brave_search_url, google_patents_search_url, google_scholar_url])
|
| 47 |
+
def test_spaces_are_encoded(self, builder):
|
| 48 |
+
url = builder("agentic ai", 10)
|
| 49 |
+
assert "agentic+ai" in url or "agentic%20ai" in url
|
| 50 |
+
assert "agentic ai" not in url
|
| 51 |
+
|
| 52 |
+
@pytest.mark.parametrize("builder", [
|
| 53 |
+
bing_search_url, brave_search_url, google_patents_search_url, google_scholar_url])
|
| 54 |
+
def test_url_significant_characters_are_escaped(self, builder):
|
| 55 |
+
url = builder("a&b=c#d", 10)
|
| 56 |
+
query_part = url.split("?", 1)[1]
|
| 57 |
+
# Exactly one separator per parameter the builder itself added.
|
| 58 |
+
assert "a&b=c#d" not in query_part
|
| 59 |
+
assert "%26" in query_part
|
| 60 |
+
|
| 61 |
+
|
| 62 |
+
class TestResultCount:
|
| 63 |
+
def test_scholar_asks_for_the_requested_number_of_results(self):
|
| 64 |
+
assert "num=25" in google_scholar_url("widgets", 25)
|
| 65 |
+
|
| 66 |
+
def test_google_patents_asks_for_the_requested_number_of_results(self):
|
| 67 |
+
assert "num=25" in google_patents_search_url("widgets", 25)
|
| 68 |
+
|
| 69 |
+
|
| 70 |
+
class TestHosts:
|
| 71 |
+
@pytest.mark.parametrize("builder, host", [
|
| 72 |
+
(bing_search_url, "https://www.bing.com/search"),
|
| 73 |
+
(brave_search_url, "https://search.brave.com/search"),
|
| 74 |
+
(google_patents_search_url, "https://patents.google.com/"),
|
| 75 |
+
(google_scholar_url, "https://scholar.google.com/scholar"),
|
| 76 |
+
])
|
| 77 |
+
def test_points_at_the_expected_endpoint(self, builder, host):
|
| 78 |
+
assert builder("widgets", 10).startswith(host)
|