Claude Claude Opus 5 commited on
Commit
f493ed5
·
unverified ·
1 Parent(s): 632b2d9

Detect Google Scholar's block page, and unwrap Bing's redirect links

Browse files

Both found by running the live smoke test against the deployed MCP server,
not by the suite - the division of labour CONTRIBUTING.md already
describes.

Google Scholar serves an anti-bot interstitial to the Space's IP instead of
results. That page has no `div.gs_ri`, so the scraper sat in
`wait_for_selector` for the full 30 seconds and then reported a bare
"Timeout 30000ms exceeded" - slow for every caller, and worded like a
broken selector rather than a blocked deployment.

It now waits for the results selector and Google's challenge elements
together, so a block is recognised as soon as the page loads and raises
GoogleScholarBlockedException with a message that says what actually
happened. A challenge page carrying none of those elements falls back to
matching Google's wording, but only after the selector wait has failed:
sniffing the text up front would be faster and wrong, since a legitimate
Scholar search for "unusual traffic" returns papers whose snippets contain
that phrase. A page with neither results nor a recognisable challenge still
raises the underlying timeout, so a genuine selector regression can't hide
behind the block excuse.

Bing answers normally but does not put the destination in a result's href.
Every link is wrapped in `https://www.bing.com/ck/a?...&u=a1<base64url>`,
so callers were handed tracking links rather than the pages they point at -
useless for citation, deduplication, or deciding whether a result is worth
fetching. decode_bing_redirect unwraps them, verified against a redirect
captured from the live deployment.

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 payload comes from a scraped page, so only
http(s) destinations are let out - a javascript: or data: URL keeps the
original link instead of being handed back as if it were a result.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01MSNSYnceqvdVz7Csis4K9e

CONTRIBUTING.md CHANGED
@@ -30,13 +30,22 @@ ruff check . # same lint CI runs
30
  pytest
31
  ```
32
 
33
- 238 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
37
  how and why. Nothing here depends on, or can trip, any external site's
38
  anti-bot measures.
39
 
 
 
 
 
 
 
 
 
 
40
  **Important limitation to keep in mind:** this suite protects against
41
  regressions in this code. It cannot catch a live site changing its markup,
42
  because the fixtures are frozen snapshots of the selector contract as of
 
30
  pytest
31
  ```
32
 
33
+ 257 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
37
  how and why. Nothing here depends on, or can trip, any external site's
38
  anti-bot measures.
39
 
40
+ A note on the two backends that are blocked from the deployed Space's IP
41
+ at the time of writing: Google Scholar serves an anti-bot interstitial
42
+ rather than results, and `query_google_scholar` now detects that and raises
43
+ `GoogleScholarBlockedException` instead of waiting out a 30s selector
44
+ timeout. Bing answers normally but wraps every result link in its `/ck/a`
45
+ redirector, which `decode_bing_redirect` unwraps. Both were found by
46
+ running the live smoke test below against the deployed MCP server, not by
47
+ the suite - which is exactly the division of labour described next.
48
+
49
  **Important limitation to keep in mind:** this suite protects against
50
  regressions in this code. It cannot catch a live site changing its markup,
51
  because the fixtures are frozen snapshots of the selector contract as of
serp.py CHANGED
@@ -1,10 +1,12 @@
 
1
  from contextlib import asynccontextmanager
2
  from typing import Optional
3
  from duckduckgo_search import DDGS
4
  import httpx
5
  from pydantic import BaseModel, Field, field_validator
6
  from playwright.async_api import Browser, BrowserContext, Page
7
- from urllib.parse import quote_plus
 
8
  import re
9
  from lxml import etree
10
  import asyncio
@@ -53,6 +55,22 @@ class BraveSearchBlockedException(Exception):
53
  pass
54
 
55
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
56
  class BrowserUnavailableError(Exception):
57
  """Raised when a Playwright-backed query is attempted but the browser failed to start."""
58
 
@@ -119,11 +137,49 @@ async def _block_stylesheet_and_image_resources(route, request):
119
  await route.continue_()
120
 
121
 
122
- async def _extract_google_scholar_results(page: Page, n_results: int) -> list[dict]:
123
- """Extract results from an already-loaded Google Scholar results page."""
124
- await page.wait_for_selector("div.gs_ri")
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
125
 
126
- items = await page.locator("div.gs_ri").all()
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
127
  results = []
128
  for item in items[:n_results]:
129
  title = await item.locator("h3").inner_text(timeout=1000)
@@ -275,6 +331,39 @@ async def query_brave_search(browser: Browser, q: str, n_results: int = 10):
275
  return await _extract_brave_results(page, n_results)
276
 
277
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
278
  async def _extract_bing_results(page: Page, n_results: int) -> list[dict]:
279
  """Extract results from an already-loaded Bing results page."""
280
  await page.wait_for_selector("li.b_algo")
@@ -306,7 +395,7 @@ async def _extract_bing_results(page: Page, n_results: int) -> list[dict]:
306
  if title and url:
307
  results.append({
308
  "title": title.strip(),
309
- "href": url.strip(),
310
  "body": snippet.strip()
311
  })
312
 
 
1
+ import base64
2
  from contextlib import asynccontextmanager
3
  from typing import Optional
4
  from duckduckgo_search import DDGS
5
  import httpx
6
  from pydantic import BaseModel, Field, field_validator
7
  from playwright.async_api import Browser, BrowserContext, Page
8
+ from playwright.async_api import TimeoutError as PlaywrightTimeoutError
9
+ from urllib.parse import parse_qs, quote_plus, urlparse
10
  import re
11
  from lxml import etree
12
  import asyncio
 
55
  pass
56
 
57
 
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 BrowserUnavailableError(Exception):
75
  """Raised when a Playwright-backed query is attempted but the browser failed to start."""
76
 
 
137
  await route.continue_()
138
 
139
 
140
+ # How long to wait for Scholar to show either results or a challenge.
141
+ SCHOLAR_SELECTOR_TIMEOUT_MS = 30_000
142
+
143
+ _SCHOLAR_RESULT_SELECTOR = "div.gs_ri"
144
+ # Elements Google's challenge page carries. Waiting for these *alongside*
145
+ # the results selector is what makes a block fail fast instead of costing a
146
+ # full timeout.
147
+ _SCHOLAR_BLOCK_SELECTOR = "form#captcha-form, #recaptcha, .g-recaptcha, #infoDiv"
148
+ # Fallback for a challenge page carrying none of the above - Google varies
149
+ # the markup, so match on the wording it has used consistently.
150
+ _SCHOLAR_BLOCK_TEXT_MARKERS = (
151
+ "unusual traffic",
152
+ "not a robot",
153
+ "/sorry/index",
154
+ )
155
+
156
+
157
+ def _looks_like_scholar_block(page_content: str) -> bool:
158
+ lowered = page_content.lower()
159
+ return any(marker in lowered for marker in _SCHOLAR_BLOCK_TEXT_MARKERS)
160
+
161
 
162
+ async def _extract_google_scholar_results(page: Page, n_results: int,
163
+ timeout_ms: int = SCHOLAR_SELECTOR_TIMEOUT_MS) -> list[dict]:
164
+ """Extract results from an already-loaded Google Scholar results page.
165
+
166
+ Raises GoogleScholarBlockedException when Google served its anti-bot
167
+ interstitial instead of results. A page with neither results nor a
168
+ recognisable challenge still raises the underlying timeout, so a real
169
+ selector regression can't hide behind the block excuse.
170
+ """
171
+ try:
172
+ await page.wait_for_selector(
173
+ f"{_SCHOLAR_RESULT_SELECTOR}, {_SCHOLAR_BLOCK_SELECTOR}", timeout=timeout_ms)
174
+ except PlaywrightTimeoutError:
175
+ if _looks_like_scholar_block(await page.content()):
176
+ raise GoogleScholarBlockedException() from None
177
+ raise
178
+
179
+ if await page.locator(_SCHOLAR_BLOCK_SELECTOR).count():
180
+ raise GoogleScholarBlockedException()
181
+
182
+ items = await page.locator(_SCHOLAR_RESULT_SELECTOR).all()
183
  results = []
184
  for item in items[:n_results]:
185
  title = await item.locator("h3").inner_text(timeout=1000)
 
331
  return await _extract_brave_results(page, n_results)
332
 
333
 
334
+ def decode_bing_redirect(url: Optional[str]) -> Optional[str]:
335
+ """Resolve a Bing `/ck/a` tracking link to the page it points at.
336
+
337
+ Bing doesn't put the destination in a result's href; it wraps every link
338
+ in `https://www.bing.com/ck/a?...&u=a1<base64url>&ntb=1`. Handing that
339
+ back means callers get a tracking link instead of the page - useless for
340
+ citation, deduplication, or deciding whether a result is worth fetching.
341
+
342
+ Anything that isn't a decodable Bing redirect is returned unchanged: a
343
+ link we can't decode is still a working link, and losing it would be
344
+ worse than leaving it wrapped. The decoded value comes from a scraped
345
+ page, so only http(s) destinations are let out.
346
+ """
347
+ if not url or "bing.com/ck/a" not in url:
348
+ return url
349
+
350
+ encoded = parse_qs(urlparse(url).query).get("u", [""])[0]
351
+ if not encoded.startswith("a1"):
352
+ return url
353
+
354
+ payload = encoded[2:]
355
+ try:
356
+ # Bing strips base64 padding; restore however much is missing.
357
+ decoded = base64.urlsafe_b64decode(
358
+ payload + "=" * (-len(payload) % 4)).decode("utf-8")
359
+ except (ValueError, UnicodeDecodeError):
360
+ return url
361
+
362
+ if not decoded.startswith(("http://", "https://")):
363
+ return url
364
+ return decoded
365
+
366
+
367
  async def _extract_bing_results(page: Page, n_results: int) -> list[dict]:
368
  """Extract results from an already-loaded Bing results page."""
369
  await page.wait_for_selector("li.b_algo")
 
395
  if title and url:
396
  results.append({
397
  "title": title.strip(),
398
+ "href": decode_bing_redirect(url.strip()),
399
  "body": snippet.strip()
400
  })
401
 
tests/fixtures/bing_results_redirect.html ADDED
@@ -0,0 +1,13 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ <!DOCTYPE html>
2
+ <html>
3
+ <body>
4
+ <li class="b_algo">
5
+ <h2><a href="https://www.bing.com/ck/a?!&amp;&amp;p=355fd6a450a5&amp;ptn=3&amp;ver=2&amp;hsh=4&amp;u=a1aHR0cHM6Ly9kb2NzLnB5dGhvbi5vcmcvMy9saWJyYXJ5L2FzeW5jaW8uaHRtbA&amp;ntb=1">asyncio documentation</a></h2>
6
+ <div class="b_caption"><p>Snippet A</p></div>
7
+ </li>
8
+ <li class="b_algo">
9
+ <h2><a href="https://example.org/direct">Direct Result</a></h2>
10
+ <div class="b_caption"><p>Snippet B</p></div>
11
+ </li>
12
+ </body>
13
+ </html>
tests/fixtures/scholar_blocked.html ADDED
@@ -0,0 +1,14 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ <!DOCTYPE html>
2
+ <html>
3
+ <head><title>https://scholar.google.com/sorry/index</title></head>
4
+ <body>
5
+ <div id="infoDiv">
6
+ <p>Our systems have detected unusual traffic from your computer network.
7
+ This page checks to see if it's really you sending the requests, and
8
+ not a robot.</p>
9
+ </div>
10
+ <form id="captcha-form" action="/sorry/index" method="post">
11
+ <div class="g-recaptcha" data-sitekey="placeholder"></div>
12
+ </form>
13
+ </body>
14
+ </html>
tests/fixtures/scholar_blocked_no_markers.html ADDED
@@ -0,0 +1,6 @@
 
 
 
 
 
 
 
1
+ <!DOCTYPE html>
2
+ <html>
3
+ <body>
4
+ <p>Our systems have detected unusual traffic from your computer network.</p>
5
+ </body>
6
+ </html>
tests/test_bing_redirect.py ADDED
@@ -0,0 +1,98 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """Decoding Bing's result-link redirector.
2
+
3
+ Bing does not put the destination in a result's href. It wraps every link
4
+ in `https://www.bing.com/ck/a?...&u=a1<base64url>&ntb=1`, so the URLs this
5
+ API handed back were Bing tracking links rather than the pages they point
6
+ at. An agent following one goes through Bing's redirector, and the href is
7
+ useless for deduplication, citation, or deciding whether a result is worth
8
+ fetching.
9
+
10
+ Verified against a redirect captured from the live deployment: the `u`
11
+ parameter is the literal prefix "a1" followed by the destination, base64url
12
+ encoded with its padding stripped.
13
+ """
14
+
15
+ import pytest
16
+
17
+ from serp import decode_bing_redirect
18
+
19
+ # Captured from a real /serp/search response.
20
+ REAL_REDIRECT = (
21
+ "https://www.bing.com/ck/a?!&&p=355fd6a450a5a451290a8e71334947199bc81cf4"
22
+ "c6084e0f8daa8d5a0debc698JmltdHM9MTc4Nzk2MTYwMA&ptn=3&ver=2&hsh=4"
23
+ "&fclid=12f2c74a-55e9-6296-1923-d08f542d635f"
24
+ "&u=a1aHR0cHM6Ly9kb2NzLnB5dGhvbi5vcmcvMy9saWJyYXJ5L2FzeW5jaW8uaHRtbA&ntb=1"
25
+ )
26
+
27
+
28
+ def test_decodes_a_real_captured_redirect():
29
+ assert decode_bing_redirect(REAL_REDIRECT) == "https://docs.python.org/3/library/asyncio.html"
30
+
31
+
32
+ def test_decodes_a_url_whose_base64_needs_padding():
33
+ """Bing strips base64 padding, so the decoder has to restore it - and
34
+ the amount needed varies with the length of the destination."""
35
+ import base64
36
+
37
+ for destination in [
38
+ "https://example.org/a",
39
+ "https://example.org/ab",
40
+ "https://example.org/abc",
41
+ "https://example.org/abcd",
42
+ ]:
43
+ payload = base64.urlsafe_b64encode(destination.encode()).decode().rstrip("=")
44
+ assert decode_bing_redirect(f"https://www.bing.com/ck/a?u=a1{payload}") == destination
45
+
46
+
47
+ # ------------------------------ leaving links alone ------------------------------
48
+ # Every failure mode below must return the original URL. A link that can't be
49
+ # decoded is still a working link; replacing it with None or "" would lose it.
50
+
51
+
52
+ def test_a_direct_url_passes_through_unchanged():
53
+ assert decode_bing_redirect("https://example.org/page") == "https://example.org/page"
54
+
55
+
56
+ def test_a_redirect_without_a_u_parameter_passes_through():
57
+ url = "https://www.bing.com/ck/a?!&&p=abc&ntb=1"
58
+ assert decode_bing_redirect(url) == url
59
+
60
+
61
+ def test_a_u_parameter_without_the_a1_prefix_passes_through():
62
+ url = "https://www.bing.com/ck/a?u=zzsomethingelse"
63
+ assert decode_bing_redirect(url) == url
64
+
65
+
66
+ def test_undecodable_base64_passes_through():
67
+ url = "https://www.bing.com/ck/a?u=a1!!!not-base64!!!"
68
+ assert decode_bing_redirect(url) == url
69
+
70
+
71
+ def test_base64_that_is_not_utf8_passes_through():
72
+ import base64
73
+ payload = base64.urlsafe_b64encode(b"\xff\xfe\xfd").decode().rstrip("=")
74
+ url = f"https://www.bing.com/ck/a?u=a1{payload}"
75
+ assert decode_bing_redirect(url) == url
76
+
77
+
78
+ @pytest.mark.parametrize("hostile", [
79
+ "javascript:alert(1)",
80
+ "data:text/html;base64,PHNjcmlwdD4=",
81
+ "file:///etc/passwd",
82
+ "not a url at all",
83
+ ])
84
+ def test_a_decoded_non_http_scheme_is_rejected(hostile):
85
+ """The decoded payload is attacker-influenceable content from a scraped
86
+ page, so only http(s) destinations are allowed out. Anything else keeps
87
+ the original Bing link rather than being handed to a caller as if it
88
+ were a result URL.
89
+ """
90
+ import base64
91
+ payload = base64.urlsafe_b64encode(hostile.encode()).decode().rstrip("=")
92
+ url = f"https://www.bing.com/ck/a?u=a1{payload}"
93
+
94
+ assert decode_bing_redirect(url) == url
95
+
96
+
97
+ def test_none_is_handled():
98
+ assert decode_bing_redirect(None) is None
tests/test_search_service.py CHANGED
@@ -388,3 +388,23 @@ async def test_search_names_every_failed_query_not_just_the_last(monkeypatch):
388
 
389
  assert result.results == []
390
  assert "'a'" in result.error and "'b'" in result.error
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
388
 
389
  assert result.results == []
390
  assert "'a'" in result.error and "'b'" in result.error
391
+
392
+
393
+ async def test_a_scholar_block_is_reported_as_such_to_the_caller(monkeypatch):
394
+ """The point of the dedicated exception is the message the caller sees:
395
+ "Google Scholar blocked the request" tells an operator their IP is
396
+ rate-limited, where the bare "Timeout 30000ms exceeded" it replaced read
397
+ like a broken selector.
398
+ """
399
+ from serp import GoogleScholarBlockedException
400
+
401
+ async def blocked(browser, q, n):
402
+ raise GoogleScholarBlockedException()
403
+
404
+ monkeypatch.setattr(services_module, "query_google_scholar", blocked)
405
+
406
+ result = await make_service().google_scholar(SerpQuery(queries=["anything"]))
407
+
408
+ assert result.results == []
409
+ assert "blocked" in result.error.lower()
410
+ assert "timeout" not in result.error.lower()
tests/test_serp_playwright.py CHANGED
@@ -20,6 +20,7 @@ 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,
@@ -173,3 +174,87 @@ async def test_everything_else_is_allowed_through(resource_type):
173
  await _block_stylesheet_and_image_resources(route, _FakeRequest(resource_type))
174
 
175
  assert route.continued and not route.aborted
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
20
  from serp import (
21
  BraveSearchBlockedException,
22
  BrowserUnavailableError,
23
+ GoogleScholarBlockedException,
24
  _block_stylesheet_and_image_resources,
25
  _extract_bing_results,
26
  _extract_brave_results,
 
174
  await _block_stylesheet_and_image_resources(route, _FakeRequest(resource_type))
175
 
176
  assert route.continued and not route.aborted
177
+
178
+
179
+ # ----------------------------- Google Scholar blocking -----------------------------
180
+ # Scholar serves an anti-bot interstitial to datacenter IPs instead of
181
+ # results. Because that page simply has no `div.gs_ri`, the scraper used to
182
+ # sit in `wait_for_selector` for the full 30s timeout and then report a bare
183
+ # "Timeout 30000ms exceeded" - slow, and it reads like a broken selector
184
+ # rather than a blocked deployment. Observed live on the deployed Space.
185
+
186
+
187
+ async def test_google_scholar_block_page_raises(page_factory):
188
+ page = await page_factory(load_fixture("scholar_blocked.html"))
189
+
190
+ with pytest.raises(GoogleScholarBlockedException):
191
+ await _extract_google_scholar_results(page, 10)
192
+
193
+
194
+ async def test_google_scholar_block_is_detected_without_waiting_for_the_timeout(page_factory):
195
+ """The whole point: a challenge page is recognised immediately rather
196
+ than costing every caller a full selector timeout."""
197
+ import time
198
+
199
+ page = await page_factory(load_fixture("scholar_blocked.html"))
200
+
201
+ started = time.perf_counter()
202
+ with pytest.raises(GoogleScholarBlockedException):
203
+ await _extract_google_scholar_results(page, 10)
204
+ elapsed = time.perf_counter() - started
205
+
206
+ assert elapsed < 5, f"took {elapsed:.1f}s to notice a block page"
207
+
208
+
209
+ async def test_google_scholar_block_is_detected_from_page_text_alone(page_factory):
210
+ """Google varies its interstitial. When none of the known challenge
211
+ elements are present, fall back to the wording before giving up.
212
+
213
+ This path is deliberately reached only *after* the selector wait times
214
+ out, so the test passes a short timeout rather than sitting through the
215
+ real one. Sniffing the text up front would be faster but wrong: a
216
+ legitimate Scholar search for "unusual traffic" returns papers whose
217
+ snippets contain that phrase, and would be misreported as a block. The
218
+ fuzzy check is only safe once the precise one has already failed.
219
+ """
220
+ page = await page_factory(load_fixture("scholar_blocked_no_markers.html"))
221
+
222
+ with pytest.raises(GoogleScholarBlockedException):
223
+ await _extract_google_scholar_results(page, 10, timeout_ms=1000)
224
+
225
+
226
+ async def test_a_page_with_neither_results_nor_a_block_still_times_out(page_factory):
227
+ """An unrecognised empty page must not be silently reported as a block -
228
+ that would hide a genuine selector regression behind a plausible excuse.
229
+ """
230
+ from playwright.async_api import TimeoutError as PlaywrightTimeoutError
231
+
232
+ page = await page_factory("<html><body><p>something else entirely</p></body></html>")
233
+
234
+ with pytest.raises(PlaywrightTimeoutError):
235
+ await _extract_google_scholar_results(page, 10, timeout_ms=1000)
236
+
237
+
238
+ async def test_normal_scholar_results_are_unaffected(page_factory):
239
+ """The block check must not cost the happy path anything."""
240
+ page = await page_factory(load_fixture("scholar_results.html"))
241
+
242
+ results = await _extract_google_scholar_results(page, 10)
243
+
244
+ assert [r["title"] for r in results] == ["Scholar Paper A", "Scholar Paper B"]
245
+
246
+
247
+ # ------------------------------- Bing redirect links -------------------------------
248
+
249
+
250
+ async def test_bing_result_hrefs_are_decoded(page_factory):
251
+ """Bing wraps result links in its /ck/a redirector; the extractor should
252
+ hand back the destination, not the tracking link."""
253
+ page = await page_factory(load_fixture("bing_results_redirect.html"))
254
+
255
+ results = await _extract_bing_results(page, 10)
256
+
257
+ assert [r["href"] for r in results] == [
258
+ "https://docs.python.org/3/library/asyncio.html",
259
+ "https://example.org/direct",
260
+ ]