Almaatla commited on
Commit
fabb9dc
·
unverified ·
2 Parent(s): 57c466b8a62982

Classify a blocked Scholar page before waiting, not after (#9)

Browse files

#8 made the classification correct but left it arriving 30s late. Measured
against the deployment: the error said "Google Scholar blocked the
request", but still took 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 - and deliberately all of them, not just the one
suspected of firing in production, since a fix contingent on that guess
would repeat the mistake from #7. 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 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 block exception now carries the evidence that classified it.
Diagnostics had only been added to the unclassified path in #8 - precisely
the path that did not fire in production.

Mutation testing also showed the explicit up-front URL check was dead
code, since the evidence check below already consults the URL and a /sorry
page never has results. Deleted rather than kept as an untested branch.

264 tests, lint clean, timing asserted rather than assumed.

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"]