Spaces:
Running
Classify a blocked Scholar page before waiting, not after
Browse files#8 made the classification correct but left it arriving 30s late. Measured
against the deployment: the error now says "Google Scholar blocked the
request", but it still takes 30.7s, because every signal was consulted
only in the selector-timeout handler.
All of them are available the moment navigation completes, so they are
checked there instead. A page carrying no results at all is decidable
immediately: Scholar is server-rendered, so a genuine results page already
has div.gs_ri in the DOM.
That "no results" guard is also what makes the wording check safe this
early - and it caught a real false positive during this change. Without
it, a legitimate search for "unusual traffic" returns papers whose
snippets quote the interstitial and was misreported as a block. The test
written for that risk failed against the first version of this commit,
which is the whole reason it exists.
The block exception now carries the evidence that classified it -
redirect URL, challenge element, or wording. Diagnostics had only been
added to the unclassified path in #8, which is precisely the path that did
not fire in production, so "blocked" was an assertion with nothing behind
it and no way to tell which signal had matched.
Mutation testing also showed the explicit up-front URL check was dead
code: removing it changed no test, because the evidence check below
already consults the URL and a /sorry page never has results. Deleted
rather than kept as an untested branch.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01MSNSYnceqvdVz7Csis4K9e
- CONTRIBUTING.md +1 -1
- serp.py +49 -17
- tests/test_serp_playwright.py +81 -2
|
@@ -30,7 +30,7 @@ ruff check . # same lint CI runs
|
|
| 30 |
pytest
|
| 31 |
```
|
| 32 |
|
| 33 |
-
|
| 34 |
`respx`; the Playwright-driven scrapers (Bing, Brave, Google Scholar,
|
| 35 |
Google Patents search) run against a real headless Chromium but navigate to
|
| 36 |
local fixture HTML instead of the live sites — see `tests/helpers.py` for
|
|
|
|
| 30 |
pytest
|
| 31 |
```
|
| 32 |
|
| 33 |
+
264 tests, a few seconds, fully offline. All outbound HTTP is mocked with
|
| 34 |
`respx`; the Playwright-driven scrapers (Bing, Brave, Google Scholar,
|
| 35 |
Google Patents search) run against a real headless Chromium but navigate to
|
| 36 |
local fixture HTML instead of the live sites — see `tests/helpers.py` for
|
|
@@ -58,17 +58,24 @@ class BraveSearchBlockedException(Exception):
|
|
| 58 |
class GoogleScholarBlockedException(Exception):
|
| 59 |
"""Raised when Google Scholar serves its anti-bot interstitial.
|
| 60 |
|
| 61 |
-
Scholar refuses datacenter IPs with a
|
| 62 |
-
results. That page
|
| 63 |
-
|
| 64 |
-
|
| 65 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 66 |
"""
|
| 67 |
|
| 68 |
-
def __init__(self,
|
| 69 |
-
|
| 70 |
-
|
| 71 |
-
|
|
|
|
|
|
|
| 72 |
|
| 73 |
|
| 74 |
class GoogleScholarUnavailableError(Exception):
|
|
@@ -217,29 +224,54 @@ async def _extract_google_scholar_results(page: Page, n_results: int,
|
|
| 217 |
"""
|
| 218 |
url = final_url if final_url is not None else getattr(page, "url", None)
|
| 219 |
|
| 220 |
-
async def
|
|
|
|
| 221 |
if _url_looks_blocked(url):
|
| 222 |
-
return
|
| 223 |
try:
|
| 224 |
if await page.locator(_SCHOLAR_BLOCK_SELECTOR).count():
|
| 225 |
-
return
|
| 226 |
except Exception:
|
| 227 |
pass
|
| 228 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 229 |
|
| 230 |
try:
|
| 231 |
await page.wait_for_selector(
|
| 232 |
f"{_SCHOLAR_RESULT_SELECTOR}, {_SCHOLAR_BLOCK_SELECTOR}",
|
| 233 |
state="attached", timeout=timeout_ms)
|
| 234 |
except PlaywrightTimeoutError:
|
| 235 |
-
|
| 236 |
-
|
|
|
|
| 237 |
raise GoogleScholarUnavailableError(
|
| 238 |
"Google Scholar returned neither results nor a recognisable "
|
| 239 |
f"challenge page ({await _describe_page(page, url)})") from None
|
| 240 |
|
| 241 |
-
|
| 242 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
| 243 |
|
| 244 |
items = await page.locator(_SCHOLAR_RESULT_SELECTOR).all()
|
| 245 |
results = []
|
|
|
|
| 58 |
class GoogleScholarBlockedException(Exception):
|
| 59 |
"""Raised when Google Scholar serves its anti-bot interstitial.
|
| 60 |
|
| 61 |
+
Scholar refuses datacenter IPs with a challenge page rather than
|
| 62 |
+
results. That page has no `div.gs_ri`, so without this the scraper
|
| 63 |
+
waits out the full selector timeout and then reports a bare "Timeout
|
| 64 |
+
exceeded" - slow, and indistinguishable from a genuine selector
|
| 65 |
+
regression.
|
| 66 |
+
|
| 67 |
+
Carries the evidence that classified it. Without that, "blocked" is an
|
| 68 |
+
assertion the reader has no way to check, and there is no way to tell
|
| 69 |
+
which signal fired - which is exactly what made the first two attempts
|
| 70 |
+
at this hard to debug from the deployment.
|
| 71 |
"""
|
| 72 |
|
| 73 |
+
def __init__(self, evidence: str = ""):
|
| 74 |
+
message = ("Google Scholar blocked the request (anti-bot interstitial served "
|
| 75 |
+
"instead of results); the deployment's IP is likely rate-limited.")
|
| 76 |
+
if evidence:
|
| 77 |
+
message = f"{message} Evidence: {evidence}"
|
| 78 |
+
super().__init__(message)
|
| 79 |
|
| 80 |
|
| 81 |
class GoogleScholarUnavailableError(Exception):
|
|
|
|
| 224 |
"""
|
| 225 |
url = final_url if final_url is not None else getattr(page, "url", None)
|
| 226 |
|
| 227 |
+
async def _block_evidence() -> Optional[str]:
|
| 228 |
+
"""Which signal, if any, says this is a challenge page."""
|
| 229 |
if _url_looks_blocked(url):
|
| 230 |
+
return f"redirected to {url!r}"
|
| 231 |
try:
|
| 232 |
if await page.locator(_SCHOLAR_BLOCK_SELECTOR).count():
|
| 233 |
+
return "challenge element present in the page"
|
| 234 |
except Exception:
|
| 235 |
pass
|
| 236 |
+
if _looks_like_scholar_block(await page.content()):
|
| 237 |
+
return "page wording matches Google's interstitial"
|
| 238 |
+
return None
|
| 239 |
+
|
| 240 |
+
# Classify before waiting on anything. A page carrying no results at
|
| 241 |
+
# all is decidable the moment navigation completes - Scholar is
|
| 242 |
+
# server-rendered, so a genuine results page already has div.gs_ri in
|
| 243 |
+
# the DOM - and every signal (redirect URL, challenge element, Google's
|
| 244 |
+
# wording) is available now. Doing this here rather than in the timeout
|
| 245 |
+
# handler is what stops a block costing a full selector wait: measured
|
| 246 |
+
# against the deployment, classification was already correct but
|
| 247 |
+
# arrived 30s late for exactly this reason.
|
| 248 |
+
#
|
| 249 |
+
# The "no results" guard is also what makes the wording check safe this
|
| 250 |
+
# early: a real search for "unusual traffic" returns papers, so it
|
| 251 |
+
# never reaches here.
|
| 252 |
+
if await page.locator(_SCHOLAR_RESULT_SELECTOR).count() == 0:
|
| 253 |
+
early_evidence = await _block_evidence()
|
| 254 |
+
if early_evidence:
|
| 255 |
+
raise GoogleScholarBlockedException(early_evidence)
|
| 256 |
|
| 257 |
try:
|
| 258 |
await page.wait_for_selector(
|
| 259 |
f"{_SCHOLAR_RESULT_SELECTOR}, {_SCHOLAR_BLOCK_SELECTOR}",
|
| 260 |
state="attached", timeout=timeout_ms)
|
| 261 |
except PlaywrightTimeoutError:
|
| 262 |
+
evidence = await _block_evidence()
|
| 263 |
+
if evidence:
|
| 264 |
+
raise GoogleScholarBlockedException(evidence) from None
|
| 265 |
raise GoogleScholarUnavailableError(
|
| 266 |
"Google Scholar returned neither results nor a recognisable "
|
| 267 |
f"challenge page ({await _describe_page(page, url)})") from None
|
| 268 |
|
| 269 |
+
# Results win. Only a page with none of them gets classified, which
|
| 270 |
+
# keeps the wording check from firing on a legitimate search whose
|
| 271 |
+
# snippets happen to quote Google's interstitial.
|
| 272 |
+
if await page.locator(_SCHOLAR_RESULT_SELECTOR).count() == 0:
|
| 273 |
+
raise GoogleScholarBlockedException(
|
| 274 |
+
await _block_evidence() or "no results on the page")
|
| 275 |
|
| 276 |
items = await page.locator(_SCHOLAR_RESULT_SELECTOR).all()
|
| 277 |
results = []
|
|
@@ -283,11 +283,51 @@ async def test_a_sorry_redirect_url_counts_as_a_block(page_factory):
|
|
| 283 |
"""Google redirects blocked clients to /sorry/index. The final URL is
|
| 284 |
markup-independent evidence, unlike any selector we can guess at."""
|
| 285 |
page = await page_factory("<html><body><p>nothing recognisable here</p></body></html>")
|
| 286 |
-
page.url_override = "https://scholar.google.com/sorry/index?continue=..."
|
| 287 |
|
| 288 |
with pytest.raises(GoogleScholarBlockedException):
|
| 289 |
await _extract_google_scholar_results(
|
| 290 |
-
page, 10, timeout_ms=1000,
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 291 |
|
| 292 |
|
| 293 |
async def test_normal_scholar_results_are_unaffected(page_factory):
|
|
@@ -313,3 +353,42 @@ async def test_bing_result_hrefs_are_decoded(page_factory):
|
|
| 313 |
"https://docs.python.org/3/library/asyncio.html",
|
| 314 |
"https://example.org/direct",
|
| 315 |
]
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 283 |
"""Google redirects blocked clients to /sorry/index. The final URL is
|
| 284 |
markup-independent evidence, unlike any selector we can guess at."""
|
| 285 |
page = await page_factory("<html><body><p>nothing recognisable here</p></body></html>")
|
|
|
|
| 286 |
|
| 287 |
with pytest.raises(GoogleScholarBlockedException):
|
| 288 |
await _extract_google_scholar_results(
|
| 289 |
+
page, 10, timeout_ms=1000,
|
| 290 |
+
final_url="https://scholar.google.com/sorry/index?continue=...")
|
| 291 |
+
|
| 292 |
+
|
| 293 |
+
async def test_a_sorry_redirect_is_recognised_without_any_waiting(page_factory):
|
| 294 |
+
"""The URL is known before the page is even inspected, so a /sorry
|
| 295 |
+
redirect must not cost a selector wait.
|
| 296 |
+
|
| 297 |
+
Measured against the live deployment, this was the whole remaining
|
| 298 |
+
problem: classification was correct but arrived only after the full
|
| 299 |
+
30s timeout, because the URL was checked in the timeout handler rather
|
| 300 |
+
than up front. A long timeout is passed deliberately - the assertion
|
| 301 |
+
is that it is never reached.
|
| 302 |
+
"""
|
| 303 |
+
import time
|
| 304 |
+
|
| 305 |
+
page = await page_factory("<html><body><p>nothing recognisable here</p></body></html>")
|
| 306 |
+
|
| 307 |
+
started = time.perf_counter()
|
| 308 |
+
with pytest.raises(GoogleScholarBlockedException):
|
| 309 |
+
await _extract_google_scholar_results(
|
| 310 |
+
page, 10, timeout_ms=30_000,
|
| 311 |
+
final_url="https://scholar.google.com/sorry/index?continue=...")
|
| 312 |
+
elapsed = time.perf_counter() - started
|
| 313 |
+
|
| 314 |
+
assert elapsed < 2, f"waited {elapsed:.1f}s on a URL that was known up front"
|
| 315 |
+
|
| 316 |
+
|
| 317 |
+
async def test_a_block_reports_the_page_it_saw(page_factory):
|
| 318 |
+
"""Diagnostics were added only to the unclassified path, which is the
|
| 319 |
+
path that did not fire in production. The block path needs them too -
|
| 320 |
+
otherwise "blocked" is an assertion the reader cannot check, and there
|
| 321 |
+
is no way to tell which signal fired.
|
| 322 |
+
"""
|
| 323 |
+
page = await page_factory("<html><body><p>nothing recognisable here</p></body></html>")
|
| 324 |
+
|
| 325 |
+
with pytest.raises(GoogleScholarBlockedException) as exc_info:
|
| 326 |
+
await _extract_google_scholar_results(
|
| 327 |
+
page, 10, timeout_ms=1000,
|
| 328 |
+
final_url="https://scholar.google.com/sorry/index?continue=xyz")
|
| 329 |
+
|
| 330 |
+
assert "sorry" in str(exc_info.value), "the evidence must appear in the message"
|
| 331 |
|
| 332 |
|
| 333 |
async def test_normal_scholar_results_are_unaffected(page_factory):
|
|
|
|
| 353 |
"https://docs.python.org/3/library/asyncio.html",
|
| 354 |
"https://example.org/direct",
|
| 355 |
]
|
| 356 |
+
|
| 357 |
+
|
| 358 |
+
async def test_a_challenge_page_with_only_wording_is_also_recognised_immediately(page_factory):
|
| 359 |
+
"""Don't leave the fast path depending on which signal happens to fire.
|
| 360 |
+
|
| 361 |
+
A page with no results at all, whose wording matches Google's
|
| 362 |
+
interstitial, is decidable the moment it loads - Scholar is
|
| 363 |
+
server-rendered, so a genuine results page has div.gs_ri in the DOM as
|
| 364 |
+
soon as navigation completes. Requiring "no results present" is what
|
| 365 |
+
makes the wording check safe this early: a real search for "unusual
|
| 366 |
+
traffic" returns papers, so it never reaches this branch.
|
| 367 |
+
"""
|
| 368 |
+
import time
|
| 369 |
+
|
| 370 |
+
page = await page_factory(
|
| 371 |
+
"<html><body><p>Our systems have detected unusual traffic from your "
|
| 372 |
+
"computer network.</p></body></html>")
|
| 373 |
+
|
| 374 |
+
started = time.perf_counter()
|
| 375 |
+
with pytest.raises(GoogleScholarBlockedException):
|
| 376 |
+
await _extract_google_scholar_results(page, 10, timeout_ms=30_000)
|
| 377 |
+
elapsed = time.perf_counter() - started
|
| 378 |
+
|
| 379 |
+
assert elapsed < 2, f"waited {elapsed:.1f}s on a page that was decidable up front"
|
| 380 |
+
|
| 381 |
+
|
| 382 |
+
async def test_a_real_results_page_mentioning_unusual_traffic_is_not_a_block(page_factory):
|
| 383 |
+
"""The guard on the branch above, stated as a test: results win over
|
| 384 |
+
wording, so a legitimate search whose snippets contain the interstitial
|
| 385 |
+
phrasing is never misreported as a block."""
|
| 386 |
+
page = await page_factory(
|
| 387 |
+
'<html><body><div class="gs_ri"><h3><a href="https://example.org/p">'
|
| 388 |
+
"Detecting unusual traffic in networks</a></h3>"
|
| 389 |
+
'<div class="gs_rs">Our systems have detected unusual traffic patterns.</div>'
|
| 390 |
+
"</div></body></html>")
|
| 391 |
+
|
| 392 |
+
results = await _extract_google_scholar_results(page, 10)
|
| 393 |
+
|
| 394 |
+
assert [r["title"] for r in results] == ["Detecting unusual traffic in networks"]
|