gserp 0.2.0__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
gserp/__init__.py ADDED
@@ -0,0 +1,27 @@
1
+ """Google search results from a real browser through your own proxy.
2
+
3
+ from gserp import Scraper, Settings, ProxyTemplate
4
+
5
+ proxy = ProxyTemplate("http://user-session-{session}:pass@gate.example.com:7000")
6
+ for record in Scraper(proxy, Settings(pages=2)).run(["best running shoes"]):
7
+ print(record["organic_results"])
8
+ """
9
+
10
+ __version__ = "0.2.0"
11
+
12
+ from .classify import Verdict, classify # noqa: E402
13
+ from .parse import parse_serp # noqa: E402
14
+ from .proxy import NodeMavenSource, ProxyTemplate # noqa: E402
15
+ from .scraper import ExitsRefused, Scraper, Settings # noqa: E402
16
+
17
+ __all__ = [
18
+ "ExitsRefused",
19
+ "NodeMavenSource",
20
+ "ProxyTemplate",
21
+ "Scraper",
22
+ "Settings",
23
+ "Verdict",
24
+ "__version__",
25
+ "classify",
26
+ "parse_serp",
27
+ ]
gserp/__main__.py ADDED
@@ -0,0 +1,5 @@
1
+ import sys
2
+
3
+ from .cli import main
4
+
5
+ sys.exit(main())
gserp/browser.py ADDED
@@ -0,0 +1,485 @@
1
+ """One browser identity: one profile, one proxy session, one cookie jar.
2
+
3
+ - Patchright, headful: headless builds announce `HeadlessChrome` in the UA.
4
+ - A persistent context with `no_viewport=True`, so the page reports the real
5
+ screen rather than an emulated one.
6
+ - `locale` is never set: setting it through the context makes the main thread
7
+ and Web Workers disagree. `timezone_id` goes through CDP emulation and agrees
8
+ in workers, and is off unless asked for.
9
+ - The first query on an exit is typed into the box. Later ones on the same
10
+ exit can go straight to `/search?q=` (`direct=True`): once Google has served
11
+ the exit, it serves those as readily, faster and without the autocomplete
12
+ traffic each keystroke fires.
13
+ """
14
+
15
+ from __future__ import annotations
16
+
17
+ import math
18
+ import random
19
+ import shutil
20
+ import tempfile
21
+ import time
22
+ from dataclasses import dataclass, field, replace
23
+ from urllib.parse import urlencode, urlsplit, urlunsplit
24
+
25
+ HOME_URL = "https://www.google.com/?hl=en"
26
+
27
+
28
+ COOKIE_KEYS = ("name", "value", "domain", "path", "expires", "httpOnly", "secure", "sameSite")
29
+ #: The values the browser takes for a cookie's sameSite.
30
+ SAME_SITE = ("Strict", "Lax", "None")
31
+
32
+
33
+ def google_cookie(cookie: dict) -> bool:
34
+ """Whether a cookie belongs to google.com, the only Google host the scraper
35
+ talks to; cookies of country domains would never be sent."""
36
+ domain = cookie.get("domain")
37
+ if not isinstance(domain, str):
38
+ return False
39
+ host = domain.lower().strip(".")
40
+ return host == "google.com" or host.endswith(".google.com")
41
+
42
+
43
+ def load_cookies(path: str) -> tuple[dict, ...]:
44
+ """Google cookies from a JSON file: a list of Playwright cookie records, or
45
+ an object with such a list under "cookies". Other domains and expired
46
+ cookies are dropped; a file without a live NID is refused, since that is
47
+ the cookie Google's trust rides on."""
48
+ import json
49
+
50
+ with open(path, encoding="utf-8") as fh:
51
+ data = json.load(fh)
52
+ records = data.get("cookies") if isinstance(data, dict) else data
53
+ if not isinstance(records, list):
54
+ raise ValueError(f"{path}: expected a list of cookies or an object with 'cookies'")
55
+ out = []
56
+ for c in records:
57
+ if (
58
+ not isinstance(c, dict)
59
+ or not all(isinstance(c.get(k), str) and c[k] for k in ("name", "domain"))
60
+ or not isinstance(c.get("value"), str)
61
+ ):
62
+ raise ValueError(f"{path}: every cookie needs name, value and domain")
63
+ if not google_cookie(c):
64
+ continue
65
+ # A malformed field is refused here rather than by the browser, after a
66
+ # proxy IP and a browser have been started for it.
67
+ expires = c.get("expires", -1)
68
+ if (
69
+ isinstance(expires, bool)
70
+ or not isinstance(expires, (int, float))
71
+ or not math.isfinite(expires)
72
+ ):
73
+ raise ValueError(f"{path}: cookie {c['name']!r} has a malformed expires")
74
+ if 0 < expires < time.time():
75
+ continue # already expired; the browser would drop it anyway
76
+ if (
77
+ any(not isinstance(c.get(k, False), bool) for k in ("httpOnly", "secure"))
78
+ or c.get("sameSite", "Lax") not in SAME_SITE
79
+ # The browser refuses a SameSite=None cookie that is not Secure.
80
+ or (c.get("sameSite") == "None" and c.get("secure") is not True)
81
+ ):
82
+ raise ValueError(
83
+ f"{path}: cookie {c['name']!r} has a malformed httpOnly, secure or sameSite"
84
+ )
85
+ cookie = {k: c[k] for k in COOKIE_KEYS if k in c}
86
+ cookie.setdefault("path", "/")
87
+ out.append(cookie)
88
+ if not out:
89
+ raise ValueError(f"{path}: no Google cookies in it")
90
+ if not any(c["name"] == "NID" and c["value"] for c in out):
91
+ raise ValueError(f"{path}: no live NID cookie in it")
92
+ return tuple(out)
93
+
94
+
95
+ def search_page() -> str:
96
+ """Google's results page without a query, on the host of `HOME_URL`: what
97
+ cookies are read for and what the hybrid looks for in a NetLog."""
98
+ home = urlsplit(HOME_URL)
99
+ return urlunsplit((home.scheme, home.netloc, "/search", "", ""))
100
+
101
+
102
+ def results_url(query: str, hl: str = "en", gl: str | None = None, start: int = 0) -> str:
103
+ """Google's results page for `query`: `hl` is the interface language, `gl`
104
+ the country the results are for (Google picks one from the exit if unset),
105
+ `start` the first result's offset (10 for the second page). On the host of
106
+ `HOME_URL`, so a test can point both at a local server."""
107
+ params = {"q": query, "hl": hl}
108
+ if gl:
109
+ params["gl"] = gl
110
+ if start:
111
+ params["start"] = str(start)
112
+ return search_page() + "?" + urlencode(params)
113
+
114
+
115
+ #: How a query gets into the search box: key by key, all at once, or one word
116
+ #: at a time.
117
+ INPUT_MODES = ("type", "insert", "words")
118
+ SEARCH_BOX = "textarea[name='q'], input[name='q']"
119
+ READY = "#rso, #search"
120
+ # Reject first: it sets the smaller cookie. The middle selector is the
121
+ # redirect form of the wall served to EU exits.
122
+ CONSENT_BUTTONS = (
123
+ "button#W0wltc",
124
+ 'form[action="https://consent.google.com/save"]'
125
+ ':has(input[name="set_eom"][value="true"]) button',
126
+ "button#L2AGLb",
127
+ )
128
+ NAV_TIMEOUT_MS = 60_000
129
+ WARM_TIMEOUT_MS = 30_000
130
+ TYPE_ATTEMPTS = 2
131
+
132
+
133
+ @dataclass(frozen=True)
134
+ class Fetched:
135
+ """What one navigation left on screen."""
136
+
137
+ url: str
138
+ status: int | None
139
+ html: str
140
+ consent_dismissed: bool = False
141
+ #: Seconds per step, e.g. {"type": 3.1, "load": 4.2}: where the time went.
142
+ timings: dict[str, float] = field(default_factory=dict)
143
+
144
+
145
+ class BrowserSession:
146
+ """A Patchright persistent context bound to one proxy session.
147
+
148
+ Use as a context manager. The profile directory is temporary and removed on
149
+ close, so an identity is never reused by accident after it is retired.
150
+
151
+ `browser_args` are extra Chromium switches. **Never pass a
152
+ `--disable-features` here**: Chromium honours only the last such switch, so
153
+ one from the caller silently discards Playwright's own list, which includes
154
+ `OptimizationHints`.
155
+ """
156
+
157
+ name = "patchright"
158
+ #: Whether the hybrid (see `hybrid`) can drive this session.
159
+ hybrid_capable = True
160
+
161
+ def __init__(
162
+ self,
163
+ proxy: dict[str, str] | None = None,
164
+ *,
165
+ headless: bool = False,
166
+ channel: str | None = None,
167
+ timezone_id: str | None = None,
168
+ browser_args: list[str] | tuple[str, ...] = (),
169
+ rng: random.Random | None = None,
170
+ profile_dir: str | None = None,
171
+ input_mode: str = "type",
172
+ hl: str = "en",
173
+ gl: str | None = None,
174
+ cookies: list[dict] | tuple[dict, ...] = (),
175
+ ) -> None:
176
+ if input_mode not in INPUT_MODES:
177
+ raise ValueError(f"input mode must be one of {', '.join(INPUT_MODES)}")
178
+ self._input = input_mode
179
+ self._hl = hl
180
+ self._gl = gl
181
+ if any(a.startswith(("--disable-features", "--enable-features")) for a in browser_args):
182
+ raise ValueError(
183
+ "--disable-features/--enable-features would replace the browser's "
184
+ "own list rather than add to it; not accepted"
185
+ )
186
+ self._proxy = proxy
187
+ self._headless = headless
188
+ self._channel = channel
189
+ self._timezone_id = timezone_id
190
+ self._args = list(browser_args)
191
+ self._rng = rng or random.Random()
192
+ self._pw = None
193
+ self._context = None
194
+ # A given directory (a copy of another identity's profile) is used as
195
+ # it is; either way the profile is removed on close.
196
+ self._profile = profile_dir
197
+ #: Cookies added before the first page, once: the profile keeps them
198
+ #: across a restart.
199
+ self._cookies = [dict(c) for c in cookies]
200
+ self.page = None
201
+ #: A file to record Chromium's NetLog into from the next launch; the
202
+ #: hybrid reads its request headers from one.
203
+ self.netlog: str | None = None
204
+
205
+ @property
206
+ def hl(self) -> str:
207
+ return self._hl
208
+
209
+ @property
210
+ def gl(self) -> str | None:
211
+ return self._gl
212
+
213
+ def launch_args(self) -> list[str] | None:
214
+ args = list(self._args)
215
+ if self.netlog:
216
+ # The default capture mode keeps every request header in order and
217
+ # strips cookie values, which the hybrid takes from the browser anyway.
218
+ args += [f"--log-net-log={self.netlog}"]
219
+ return args or None
220
+
221
+ @property
222
+ def profile_dir(self) -> str | None:
223
+ return self._profile
224
+
225
+ def __enter__(self) -> BrowserSession:
226
+ if self._profile is None:
227
+ self._profile = tempfile.mkdtemp(prefix="gbs-profile-")
228
+ try:
229
+ self._open()
230
+ except Exception:
231
+ self.close()
232
+ raise
233
+ return self
234
+
235
+ def _open(self) -> None:
236
+ self._context = self._launch(self._profile)
237
+ if self._cookies:
238
+ self._context.add_cookies(self._cookies)
239
+ self._cookies = []
240
+ pages = self._context.pages
241
+ self.page = pages[0] if pages else self._context.new_page()
242
+
243
+ def suspend(self) -> str:
244
+ """Close the browser and keep its profile on disk, so it can be
245
+ copied; `resume()` opens it again on the same proxy."""
246
+ self._shut()
247
+ return self._profile
248
+
249
+ def resume(self) -> None:
250
+ self._open()
251
+
252
+ def _launch(self, profile: str):
253
+ from patchright.sync_api import sync_playwright
254
+
255
+ self._pw = sync_playwright().start()
256
+ return self._pw.chromium.launch_persistent_context(
257
+ user_data_dir=profile,
258
+ headless=self._headless,
259
+ channel=self._channel,
260
+ proxy=self._proxy,
261
+ no_viewport=True,
262
+ timezone_id=self._timezone_id,
263
+ args=self.launch_args(),
264
+ )
265
+
266
+ def __exit__(self, *exc) -> None:
267
+ self.close()
268
+
269
+ def close(self) -> None:
270
+ self._shut()
271
+ if self._profile:
272
+ shutil.rmtree(self._profile, ignore_errors=True)
273
+ self._profile = None
274
+
275
+ def _shut(self) -> None:
276
+ for step in (
277
+ lambda: self._context and self._context.close(),
278
+ lambda: self._pw and self._pw.stop(),
279
+ ):
280
+ try:
281
+ step()
282
+ except Exception:
283
+ pass
284
+ self._context = self._pw = self.page = None
285
+
286
+ # -- warming -------------------------------------------------------------
287
+
288
+ def visit(self, url: str, dwell_seconds: float) -> bool:
289
+ """Open a warm-up page and stay on it. Returns whether it arrived."""
290
+ try:
291
+ self.page.goto(url, wait_until="domcontentloaded", timeout=WARM_TIMEOUT_MS)
292
+ except Exception:
293
+ return False
294
+ time.sleep(dwell_seconds)
295
+ return True
296
+
297
+ # -- searching -----------------------------------------------------------
298
+
299
+ def search(
300
+ self, query: str, direct: bool = False, hl: str | None = None, gl: str | None = None
301
+ ) -> Fetched:
302
+ """Type `query` into Google's box and return the page it led to; with
303
+ `direct`, open the results page for it instead. `hl` and `gl` override
304
+ the session's for this query, and open the results page directly."""
305
+ # The search box path opens the English front page and has no place
306
+ # for a country, so any other language or country goes through the URL.
307
+ if direct or hl or gl or self._gl or self._hl != "en":
308
+ return self._search_direct(query, hl=hl, gl=gl)
309
+ page = self.page
310
+ timings: dict[str, float] = {}
311
+ mark = time.monotonic()
312
+ if not self._box_visible():
313
+ page.goto(HOME_URL, wait_until="domcontentloaded", timeout=NAV_TIMEOUT_MS)
314
+ timings["home"] = round(time.monotonic() - mark, 2)
315
+ mark = time.monotonic()
316
+ dismissed, typed = False, None
317
+ # The page can take focus or swap the box while the query is being
318
+ # typed, which leaves only its first characters behind. The box is
319
+ # found again and the query retyped once before giving up.
320
+ for _ in range(TYPE_ATTEMPTS):
321
+ dismissed = self._dismiss_consent() or dismissed
322
+ box = self._type_query(query)
323
+ try:
324
+ typed = box.input_value()
325
+ except Exception:
326
+ typed = None # the box was replaced and this handle is gone
327
+ if typed == query:
328
+ break
329
+ else:
330
+ raise RuntimeError(
331
+ f"search box holds {typed!r} after typing {query!r} "
332
+ f"{TYPE_ATTEMPTS} times; not submitting"
333
+ )
334
+ timings["type"] = round(time.monotonic() - mark, 2)
335
+ mark = time.monotonic()
336
+ # `no_wait_after=True` keeps `press` from waiting for the navigation a
337
+ # second time with Playwright's own 30 s default under ours.
338
+ with page.expect_navigation(wait_until="domcontentloaded", timeout=NAV_TIMEOUT_MS) as nav:
339
+ box.press("Enter", no_wait_after=True, timeout=NAV_TIMEOUT_MS)
340
+ timings["load"] = round(time.monotonic() - mark, 2)
341
+ fetched = self._snapshot(nav.value, dismissed)
342
+ return replace(fetched, timings={**timings, **fetched.timings})
343
+
344
+ def open_results(
345
+ self, query: str, start: int = 0, hl: str | None = None, gl: str | None = None
346
+ ) -> Fetched:
347
+ """Open the results page for `query` from result `start` on."""
348
+ return self._search_direct(query, start, hl=hl, gl=gl)
349
+
350
+ def google_cookies(self) -> str:
351
+ """The cookies the browser would send to Google search, as one header."""
352
+ cookies = self._context.cookies(search_page())
353
+ return "; ".join(f"{c['name']}={c['value']}" for c in cookies)
354
+
355
+ def google_cookie_records(self) -> tuple[dict, ...]:
356
+ """The browser's Google cookies as records `load_cookies` and the
357
+ `cookies` argument take, for starting other browsers from."""
358
+ now = time.time()
359
+ return tuple(
360
+ {k: c[k] for k in COOKIE_KEYS if k in c}
361
+ for c in self._context.cookies()
362
+ if google_cookie(c) and not 0 < c.get("expires", -1) < now
363
+ )
364
+
365
+ def set_google_cookies(self, header: str, deleted: tuple[str, ...] = ()) -> None:
366
+ """Take the values in a cookie header, keeping each cookie's attributes,
367
+ and drop the cookies named in `deleted` that the header does not set."""
368
+ known = {c["name"]: c for c in self._context.cookies(search_page())}
369
+ pairs = [p.strip().split("=", 1) for p in header.split(";") if "=" in p]
370
+ for name in set(deleted) - {name for name, _ in pairs}:
371
+ # Domain cookies and host-only ones set by www.google.com alike.
372
+ for domain in (".google.com", "www.google.com"):
373
+ self._context.clear_cookies(name=name, domain=domain)
374
+ update = []
375
+ for name, value in pairs:
376
+ base = known.get(name)
377
+ if base is not None and base["value"] == value:
378
+ continue
379
+ cookie = dict(base) if base else {"domain": ".google.com", "path": "/", "secure": True}
380
+ cookie.update(name=name, value=value)
381
+ cookie.pop("partitionKey", None)
382
+ update.append(cookie)
383
+ if update:
384
+ self._context.add_cookies(update)
385
+
386
+ def _search_direct(
387
+ self, query: str, start: int = 0, hl: str | None = None, gl: str | None = None
388
+ ) -> Fetched:
389
+ url = results_url(query, hl or self._hl, gl or self._gl, start)
390
+ mark = time.monotonic()
391
+ response = self.page.goto(url, wait_until="domcontentloaded", timeout=NAV_TIMEOUT_MS)
392
+ # A fresh exit can be shown the consent wall instead of results; clear
393
+ # it and open the results again, as the typed path does on the front page.
394
+ dismissed = self._dismiss_consent()
395
+ if dismissed:
396
+ response = self.page.goto(url, wait_until="domcontentloaded", timeout=NAV_TIMEOUT_MS)
397
+ load = round(time.monotonic() - mark, 2)
398
+ fetched = self._snapshot(response, dismissed)
399
+ return replace(fetched, timings={"load": load, **fetched.timings})
400
+
401
+ def _box_visible(self) -> bool:
402
+ try:
403
+ return any(h.is_visible() for h in self.page.query_selector_all(SEARCH_BOX))
404
+ except Exception:
405
+ # The page navigated while it was being read, as a warm-up page
406
+ # left idle can; the front page is loaded instead.
407
+ return False
408
+
409
+ def _type_query(self, query: str):
410
+ page = self.page
411
+ # Act on the handle that was found, never on the selector again: the
412
+ # selector also matches a hidden field, and `page.click` would pick it.
413
+ box = page.wait_for_selector(SEARCH_BOX, state="visible", timeout=NAV_TIMEOUT_MS)
414
+ box.click()
415
+ # A results page keeps the previous query in the box, and a failed
416
+ # attempt leaves part of this one. Typing after either appends, and the
417
+ # appended query returns a real page for the wrong question. Select it
418
+ # so the typing replaces it.
419
+ if box.input_value():
420
+ box.press("ControlOrMeta+a")
421
+ if self._input == "insert":
422
+ page.keyboard.insert_text(query)
423
+ elif self._input == "words":
424
+ words = query.split(" ")
425
+ for i, word in enumerate(words):
426
+ last = i == len(words) - 1
427
+ page.keyboard.insert_text(word if last else word + " ")
428
+ if not last:
429
+ time.sleep(self._rng.uniform(0.15, 0.5))
430
+ else:
431
+ box.type(query, delay=self._rng.randint(45, 140))
432
+ return box
433
+
434
+ def blank(self) -> None:
435
+ """Leave the current page, stopping whatever it is still loading."""
436
+ self.page.goto("about:blank")
437
+
438
+ def next_page(self) -> Fetched | None:
439
+ """Click "Next" on the current results page, if there is one."""
440
+ link = self.page.query_selector("a#pnnext")
441
+ if link is None or not link.is_visible():
442
+ return None
443
+ mark = time.monotonic()
444
+ with self.page.expect_navigation(
445
+ wait_until="domcontentloaded", timeout=NAV_TIMEOUT_MS
446
+ ) as nav:
447
+ link.click()
448
+ load = round(time.monotonic() - mark, 2)
449
+ fetched = self._snapshot(nav.value, False)
450
+ return replace(fetched, timings={"load": load, **fetched.timings})
451
+
452
+ def _snapshot(self, response, dismissed: bool) -> Fetched:
453
+ # Google builds results in the browser; reading at domcontentloaded
454
+ # catches the "enable JavaScript" scaffold. A refusal never grows the
455
+ # container, so the short timeout is expected to expire on those.
456
+ mark = time.monotonic()
457
+ try:
458
+ self.page.wait_for_selector(READY, state="attached", timeout=8_000)
459
+ except Exception:
460
+ pass
461
+ ready = round(time.monotonic() - mark, 2)
462
+ mark = time.monotonic()
463
+ html = self.page.content()
464
+ return Fetched(
465
+ url=self.page.url,
466
+ status=response.status if response is not None else None,
467
+ html=html,
468
+ consent_dismissed=dismissed,
469
+ timings={"ready": ready, "read": round(time.monotonic() - mark, 2)},
470
+ )
471
+
472
+ def _dismiss_consent(self) -> bool:
473
+ """Clear the consent overlay if one is up. It is intermittent, so
474
+ finding none is normal and not an error."""
475
+ for selector in CONSENT_BUTTONS:
476
+ for handle in self.page.query_selector_all(selector):
477
+ if handle.is_visible():
478
+ handle.click(timeout=10_000)
479
+ try:
480
+ self.page.wait_for_load_state("domcontentloaded", timeout=10_000)
481
+ except Exception:
482
+ pass
483
+ self.page.wait_for_timeout(500)
484
+ return True
485
+ return False
gserp/classify.py ADDED
@@ -0,0 +1,105 @@
1
+ """Decide what Google served before anything tries to read results out of it.
2
+
3
+ A refusal must never reach the parser, because a parser handed a refusal returns
4
+ zero results, and "Google found nothing" and "Google refused this exit" need
5
+ opposite responses: the first is an answer, the second means drop the exit and
6
+ ask again from a fresh one.
7
+
8
+ Only structural markers are used - ids, paths and endpoints - so a translated
9
+ page classifies the same as an English one.
10
+ """
11
+
12
+ from __future__ import annotations
13
+
14
+ from dataclasses import dataclass
15
+ from urllib.parse import urlsplit
16
+
17
+ OK = "ok"
18
+ NO_RESULTS = "no_results"
19
+ SORRY = "sorry"
20
+ WALL = "wall"
21
+ CONSENT = "consent"
22
+ NO_JS = "no_js"
23
+ BLOCK = "block"
24
+ EMPTY = "empty"
25
+
26
+ #: Google looked at this exit and refused it. Drop the exit.
27
+ REFUSED = frozenset({SORRY, WALL, BLOCK})
28
+ #: Google answered the query. Keep the exit.
29
+ SERVED = frozenset({OK, NO_RESULTS})
30
+
31
+
32
+ @dataclass(frozen=True)
33
+ class Verdict:
34
+ kind: str
35
+ reason: str
36
+
37
+ @property
38
+ def served(self) -> bool:
39
+ return self.kind in SERVED
40
+
41
+ @property
42
+ def refused(self) -> bool:
43
+ return self.kind in REFUSED
44
+
45
+
46
+ def classify(url: str | None, html: str | None, status: int | None = None) -> Verdict:
47
+ """Classify one Google response from its final URL, body and status.
48
+
49
+ Order matters. A served page can carry a dormant consent panel, so the
50
+ results test runs before the inline consent test; reversing them scores
51
+ working pages as interstitials.
52
+ """
53
+ if not html:
54
+ return Verdict(EMPTY, "no body")
55
+
56
+ parts = urlsplit(url or "")
57
+ host = parts.netloc.lower()
58
+ path = parts.path.lower()
59
+ low = html.lower()
60
+
61
+ # The refusal redirects to /sorry/ with a reCAPTCHA. It has arrived as 429
62
+ # and as 200 with the same body, so the status is a hint and not the rule.
63
+ if host.startswith("sorry.") or path.startswith("/sorry"):
64
+ return Verdict(SORRY, "redirected to /sorry/ with a captcha")
65
+ # A redirect stub that was not followed: a tiny body pointing at /sorry/
66
+ # is the refusal, not a page.
67
+ if len(html) < 2000 and "/sorry/" in low:
68
+ return Verdict(SORRY, "short body pointing at /sorry/")
69
+ if status == 429:
70
+ return Verdict(SORRY, "HTTP 429")
71
+ if "unusual traffic from your computer" in low:
72
+ return Verdict(SORRY, "unusual-traffic page")
73
+ if host.startswith("consent."):
74
+ return Verdict(CONSENT, "consent interstitial")
75
+
76
+ has_rso = 'id="rso"' in low
77
+ has_search = 'id="search"' in low
78
+ if has_rso:
79
+ return Verdict(OK, "results container present")
80
+
81
+ # A real empty SERP keeps `#search`. Retiring an exit on it would throw away
82
+ # a working identity for a query that simply has no results.
83
+ if has_search:
84
+ return Verdict(NO_RESULTS, "search container present, no results in it")
85
+
86
+ if "consent.google.com" in low:
87
+ return Verdict(CONSENT, "consent wall served inline, no results behind it")
88
+
89
+ # The "enable JavaScript" scaffold. It also carries `emsg=SG_REL`, so it is
90
+ # tested before the wall below. From a browser that ran scripts it means
91
+ # Google declined to render, so the scraper drops the exit.
92
+ if "enablejs" in low:
93
+ return Verdict(NO_JS, "served the no-JavaScript scaffold")
94
+
95
+ # A SERP-shaped page with an error marker and no results container: a
96
+ # refusal that does not redirect.
97
+ if "emsg=sg_rel" in low:
98
+ return Verdict(WALL, "SERP-shaped wall without results (emsg=SG_REL)")
99
+
100
+ if "<noscript" in low and "<h3" not in low:
101
+ return Verdict(NO_JS, "a scaffold with no results and a noscript block")
102
+
103
+ if "error 403" in low or status == 403:
104
+ return Verdict(BLOCK, "HTTP 403 page")
105
+ return Verdict(BLOCK, "no results and no known interstitial")