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 +27 -0
- gserp/__main__.py +5 -0
- gserp/browser.py +485 -0
- gserp/classify.py +105 -0
- gserp/cli.py +497 -0
- gserp/doctor.py +155 -0
- gserp/engines.py +53 -0
- gserp/env.py +16 -0
- gserp/hybrid.py +580 -0
- gserp/links.py +358 -0
- gserp/mcp.py +195 -0
- gserp/output.py +151 -0
- gserp/parse.py +560 -0
- gserp/pool.py +202 -0
- gserp/proxy.py +194 -0
- gserp/quality.py +93 -0
- gserp/relay.py +306 -0
- gserp/scraper.py +1826 -0
- gserp/server.py +516 -0
- gserp/warmup.py +44 -0
- gserp-0.2.0.dist-info/METADATA +497 -0
- gserp-0.2.0.dist-info/RECORD +26 -0
- gserp-0.2.0.dist-info/WHEEL +5 -0
- gserp-0.2.0.dist-info/entry_points.txt +2 -0
- gserp-0.2.0.dist-info/licenses/LICENSE +21 -0
- gserp-0.2.0.dist-info/top_level.txt +1 -0
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
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")
|