Claude Claude Opus 5 commited on
Commit
8a62982
·
unverified ·
1 Parent(s): addbf67

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

Files changed (3) hide show
  1. CONTRIBUTING.md +1 -1
  2. serp.py +49 -17
  3. tests/test_serp_playwright.py +81 -2
CONTRIBUTING.md CHANGED
@@ -30,7 +30,7 @@ ruff check . # same lint CI runs
30
  pytest
31
  ```
32
 
33
- 260 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
 
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
serp.py CHANGED
@@ -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 /sorry challenge page rather than
62
- results. That page simply has no `div.gs_ri`, so without this the
63
- scraper waits out the full selector timeout and then reports a bare
64
- "Timeout exceeded" - slow, and indistinguishable from a genuine
65
- selector regression.
 
 
 
 
 
66
  """
67
 
68
- def __init__(self, *args):
69
- super().__init__(
70
- "Google Scholar blocked the request (anti-bot interstitial served "
71
- "instead of results); the deployment's IP is likely rate-limited.")
 
 
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 _blocked() -> bool:
 
221
  if _url_looks_blocked(url):
222
- return True
223
  try:
224
  if await page.locator(_SCHOLAR_BLOCK_SELECTOR).count():
225
- return True
226
  except Exception:
227
  pass
228
- return _looks_like_scholar_block(await page.content())
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
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
- if await _blocked():
236
- raise GoogleScholarBlockedException() from None
 
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
- if await page.locator(_SCHOLAR_RESULT_SELECTOR).count() == 0 or await _blocked():
242
- raise GoogleScholarBlockedException()
 
 
 
 
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 = []
tests/test_serp_playwright.py CHANGED
@@ -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, final_url=page.url_override)
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
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"]