google-browser-scraper 0.0.2__tar.gz → 0.0.4__tar.gz

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.
Files changed (46) hide show
  1. google_browser_scraper-0.0.4/MANIFEST.in +1 -0
  2. {google_browser_scraper-0.0.2/src/google_browser_scraper.egg-info → google_browser_scraper-0.0.4}/PKG-INFO +57 -15
  3. {google_browser_scraper-0.0.2 → google_browser_scraper-0.0.4}/README.md +56 -14
  4. {google_browser_scraper-0.0.2 → google_browser_scraper-0.0.4}/pyproject.toml +1 -1
  5. {google_browser_scraper-0.0.2 → google_browser_scraper-0.0.4}/src/google_browser_scraper/__init__.py +1 -1
  6. {google_browser_scraper-0.0.2 → google_browser_scraper-0.0.4}/src/google_browser_scraper/browser.py +140 -26
  7. {google_browser_scraper-0.0.2 → google_browser_scraper-0.0.4}/src/google_browser_scraper/cli.py +90 -12
  8. {google_browser_scraper-0.0.2 → google_browser_scraper-0.0.4}/src/google_browser_scraper/links.py +31 -9
  9. {google_browser_scraper-0.0.2 → google_browser_scraper-0.0.4}/src/google_browser_scraper/output.py +4 -0
  10. google_browser_scraper-0.0.4/src/google_browser_scraper/pool.py +187 -0
  11. {google_browser_scraper-0.0.2 → google_browser_scraper-0.0.4}/src/google_browser_scraper/relay.py +42 -13
  12. google_browser_scraper-0.0.4/src/google_browser_scraper/scraper.py +1113 -0
  13. {google_browser_scraper-0.0.2 → google_browser_scraper-0.0.4}/src/google_browser_scraper/warmup.py +1 -2
  14. {google_browser_scraper-0.0.2 → google_browser_scraper-0.0.4/src/google_browser_scraper.egg-info}/PKG-INFO +57 -15
  15. {google_browser_scraper-0.0.2 → google_browser_scraper-0.0.4}/src/google_browser_scraper.egg-info/SOURCES.txt +6 -0
  16. google_browser_scraper-0.0.4/tests/fakegoogle.py +91 -0
  17. google_browser_scraper-0.0.4/tests/pages.py +93 -0
  18. google_browser_scraper-0.0.4/tests/stdio_mcp_child.py +33 -0
  19. google_browser_scraper-0.0.4/tests/test_browser_local.py +149 -0
  20. {google_browser_scraper-0.0.2 → google_browser_scraper-0.0.4}/tests/test_cli_and_output.py +26 -0
  21. {google_browser_scraper-0.0.2 → google_browser_scraper-0.0.4}/tests/test_engines_local.py +76 -0
  22. {google_browser_scraper-0.0.2 → google_browser_scraper-0.0.4}/tests/test_links.py +24 -0
  23. google_browser_scraper-0.0.4/tests/test_pool.py +245 -0
  24. {google_browser_scraper-0.0.2 → google_browser_scraper-0.0.4}/tests/test_relay.py +76 -6
  25. google_browser_scraper-0.0.4/tests/test_scraper.py +859 -0
  26. google_browser_scraper-0.0.2/src/google_browser_scraper/scraper.py +0 -449
  27. google_browser_scraper-0.0.2/tests/test_browser_local.py +0 -48
  28. google_browser_scraper-0.0.2/tests/test_scraper.py +0 -333
  29. {google_browser_scraper-0.0.2 → google_browser_scraper-0.0.4}/LICENSE +0 -0
  30. {google_browser_scraper-0.0.2 → google_browser_scraper-0.0.4}/setup.cfg +0 -0
  31. {google_browser_scraper-0.0.2 → google_browser_scraper-0.0.4}/src/google_browser_scraper/__main__.py +0 -0
  32. {google_browser_scraper-0.0.2 → google_browser_scraper-0.0.4}/src/google_browser_scraper/classify.py +0 -0
  33. {google_browser_scraper-0.0.2 → google_browser_scraper-0.0.4}/src/google_browser_scraper/doctor.py +0 -0
  34. {google_browser_scraper-0.0.2 → google_browser_scraper-0.0.4}/src/google_browser_scraper/engines.py +0 -0
  35. {google_browser_scraper-0.0.2 → google_browser_scraper-0.0.4}/src/google_browser_scraper/mcp.py +0 -0
  36. {google_browser_scraper-0.0.2 → google_browser_scraper-0.0.4}/src/google_browser_scraper/parse.py +0 -0
  37. {google_browser_scraper-0.0.2 → google_browser_scraper-0.0.4}/src/google_browser_scraper/proxy.py +0 -0
  38. {google_browser_scraper-0.0.2 → google_browser_scraper-0.0.4}/src/google_browser_scraper/server.py +0 -0
  39. {google_browser_scraper-0.0.2 → google_browser_scraper-0.0.4}/src/google_browser_scraper.egg-info/dependency_links.txt +0 -0
  40. {google_browser_scraper-0.0.2 → google_browser_scraper-0.0.4}/src/google_browser_scraper.egg-info/entry_points.txt +0 -0
  41. {google_browser_scraper-0.0.2 → google_browser_scraper-0.0.4}/src/google_browser_scraper.egg-info/requires.txt +0 -0
  42. {google_browser_scraper-0.0.2 → google_browser_scraper-0.0.4}/src/google_browser_scraper.egg-info/top_level.txt +0 -0
  43. {google_browser_scraper-0.0.2 → google_browser_scraper-0.0.4}/tests/test_classify.py +0 -0
  44. {google_browser_scraper-0.0.2 → google_browser_scraper-0.0.4}/tests/test_parse.py +0 -0
  45. {google_browser_scraper-0.0.2 → google_browser_scraper-0.0.4}/tests/test_proxy.py +0 -0
  46. {google_browser_scraper-0.0.2 → google_browser_scraper-0.0.4}/tests/test_serve_and_mcp.py +0 -0
@@ -0,0 +1 @@
1
+ recursive-include tests *.py
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: google-browser-scraper
3
- Version: 0.0.2
3
+ Version: 0.0.4
4
4
  Summary: Google Search results from a real browser through your own sticky proxy, with captchas and blocks reported explicitly.
5
5
  Author: NodeMaven
6
6
  License: MIT
@@ -29,6 +29,10 @@ Requires-Dist: pytest>=8; extra == "dev"
29
29
  Requires-Dist: ruff>=0.6; extra == "dev"
30
30
  Dynamic: license-file
31
31
 
32
+ <p align="center">
33
+ <img src="https://raw.githubusercontent.com/nodemaven/google-browser-scraper/main/assets/banner.png" alt="Google Browser Scraper">
34
+ </p>
35
+
32
36
  # google-browser-scraper
33
37
 
34
38
  Google Search results from a real browser, through your own sticky proxy.
@@ -36,9 +40,15 @@ Google Search results from a real browser, through your own sticky proxy.
36
40
  Plain HTTP is no longer a reliable way to scrape Google: it often expects a
37
41
  browser that runs JavaScript, and a fresh browser on a fresh proxy exit can be
38
42
  refused quickly. This tool takes a browser path instead. It warms a sticky proxy
39
- session with ordinary browsing, types the query into Google's search box, checks
40
- whether Google served results or refused, and writes one JSON record per results
41
- page. Captchas, blocks and failures are reported as such, never as "no results".
43
+ session with ordinary browsing, opens Google's results page for each query in
44
+ that browser, checks whether Google served results or refused, and writes one
45
+ JSON record per results page. Captchas, blocks and failures are reported as such, never as "no results".
46
+
47
+ **Run it on Windows** - a desktop or a Windows VPS. That is where it is tested
48
+ and where Google serves it. On a Linux server without a GPU, Google refused
49
+ almost every search in testing; Chrome has no WebGL there, which is the main
50
+ suspect, so Linux and Docker are experimental for now. `google-browser-scraper
51
+ doctor` shows what your machine looks like to a website.
42
52
 
43
53
  ## Install
44
54
 
@@ -74,8 +84,8 @@ Success is a `results.jsonl` with one JSON object per results page:
74
84
 
75
85
  The first query takes a few minutes, because a new exit is warmed up first, and
76
86
  the warm-up costs noticeably more traffic than a search. Later queries on the
77
- same exit take seconds. A summary of pages served and traffic used is printed at
78
- the end of every run.
87
+ same exit take seconds. A summary of pages served and traffic used, with the
88
+ hosts that took most of it, is printed at the end of every run.
79
89
 
80
90
  With NodeMaven, the session id is handled for you and the credentials come from
81
91
  the environment:
@@ -115,25 +125,56 @@ simple consumers can often switch with minimal mapping. Knowledge panels,
115
125
  shopping, images and other special blocks are not parsed.
116
126
 
117
127
  Each record also has an `exit` block: which proxy session answered and how many
118
- bytes the page cost. A query that failed is a record too, with an `error`.
128
+ bytes the page cost. `search_metadata.timings` says where the page's time went,
129
+ in seconds per step (`type`, `load`, `ready`, `read`, `parse`, and `links`, which
130
+ overlaps the next query and is not part of `total_time_taken`). A query
131
+ that failed is a record too, with an `error`.
119
132
  `--format csv` writes the organic results as a table, and `--price-per-gb` adds
120
133
  the cost per 1000 pages to the summary.
121
134
 
122
135
  ## How it works
123
136
 
124
137
  1. **New identity**: a sticky proxy session and a fresh browser profile.
125
- 2. **Warm-up**: a few ordinary pages, then Google's front page.
126
- 3. **Search**: the query is typed into the box and submitted.
138
+ 2. **Warm-up**: a few ordinary pages.
139
+ 3. **Search**: each query opens Google's results page directly, which is
140
+ faster than the search box and skips the autocomplete traffic of typing.
141
+ `--direct follow-ups` sends an exit's first query through the box and
142
+ `--direct none` every query; `--input` sets how a query goes into the box
143
+ (`type` key by key, `insert` all at once, `words` word by word).
127
144
  4. **Check before parsing**: the page is classified first, so a captcha or a
128
145
  block is reported as one and never parsed as an empty result.
129
146
  5. **Keep or drop**: an exit Google answers is reused for the next queries; an
130
147
  exit it refuses is dropped and the query is retried on a new one. If exit
131
148
  after exit is refused, the run stops instead of spending more traffic.
132
149
 
150
+ A served exit is kept for up to 50 queries (`--max-per-exit`). Every new exit
151
+ pays for a warm-up, so a short cap mostly buys more warm-up traffic. A browser
152
+ error on a search is retried once on the same exit (through Google's front page
153
+ when queries go through the search box) before the exit is dropped; a network
154
+ error drops it at once.
155
+
156
+ `--workers N` searches on N exits at once, one browser each, sharing one list
157
+ of queries. Each browser needs memory and CPU of its own. The run stops when
158
+ exit after exit is refused or unreachable with no worker served in between.
159
+
160
+ The command line copies the cookies of the first exit Google serves and
161
+ starts up to 10 new exits from that copy (`--clone N`, `--clone 0` turns it
162
+ off), on new sticky sessions, instead of warming each one; all workers share
163
+ the copy. Google's acceptance follows the cookies more than the address, so a
164
+ copy usually starts out served, and costs a few MB instead of a full warm-up.
165
+ A copy refused on its first query ends the copying; the copied cookies are
166
+ used for three hours at most (`--clone-max-age`). A copy waits 40-60 seconds
167
+ after its browser starts before the first search.
168
+
169
+ `--spare 1` warms the next exit in the background once the current one is
170
+ served, so a retired exit is replaced without waiting for a warm-up. A spare
171
+ costs a warm-up's traffic whether it is used or not.
172
+
133
173
  Google wraps result links in encrypted `/goto` redirects. Each one is resolved
134
- with a small request through a separate short-lived proxy session, so link
135
- resolution does not touch the warmed browser session. `--no-resolve-links`
136
- skips it.
174
+ with a small request through a separate short-lived proxy session, a few at a
175
+ time, so link resolution does not touch the warmed browser session. It runs in
176
+ the background while the browser goes on to the next query; records still come
177
+ out in the order each browser searched them. `--no-resolve-links` skips it.
137
178
 
138
179
  Traffic goes through a small local relay that counts bytes and blocks a large
139
180
  model download Chrome otherwise makes on every fresh profile.
@@ -203,9 +244,10 @@ Google refuses, and tells you what each page cost.
203
244
 
204
245
  ## Status
205
246
 
206
- Early. Tested on Windows. **Linux and Docker are experimental**: on a machine
207
- without a GPU, Chrome has no WebGL, which a normal desktop always has; the
208
- Docker image turns on a software renderer for it. The `cloak` engine
247
+ Early. Windows is the tested and recommended platform. **Linux and Docker are
248
+ experimental**: on a machine without a GPU, Chrome has no WebGL, which a normal
249
+ desktop always has; the Docker image turns on a software renderer for it, and
250
+ whether Google serves that is not measured yet. The `cloak` engine
209
251
  (`--engine cloak`) is experimental too; `patchright` is the default.
210
252
 
211
253
  Google changes its pages often. If results come back empty or wrong, run with
@@ -1,3 +1,7 @@
1
+ <p align="center">
2
+ <img src="https://raw.githubusercontent.com/nodemaven/google-browser-scraper/main/assets/banner.png" alt="Google Browser Scraper">
3
+ </p>
4
+
1
5
  # google-browser-scraper
2
6
 
3
7
  Google Search results from a real browser, through your own sticky proxy.
@@ -5,9 +9,15 @@ Google Search results from a real browser, through your own sticky proxy.
5
9
  Plain HTTP is no longer a reliable way to scrape Google: it often expects a
6
10
  browser that runs JavaScript, and a fresh browser on a fresh proxy exit can be
7
11
  refused quickly. This tool takes a browser path instead. It warms a sticky proxy
8
- session with ordinary browsing, types the query into Google's search box, checks
9
- whether Google served results or refused, and writes one JSON record per results
10
- page. Captchas, blocks and failures are reported as such, never as "no results".
12
+ session with ordinary browsing, opens Google's results page for each query in
13
+ that browser, checks whether Google served results or refused, and writes one
14
+ JSON record per results page. Captchas, blocks and failures are reported as such, never as "no results".
15
+
16
+ **Run it on Windows** - a desktop or a Windows VPS. That is where it is tested
17
+ and where Google serves it. On a Linux server without a GPU, Google refused
18
+ almost every search in testing; Chrome has no WebGL there, which is the main
19
+ suspect, so Linux and Docker are experimental for now. `google-browser-scraper
20
+ doctor` shows what your machine looks like to a website.
11
21
 
12
22
  ## Install
13
23
 
@@ -43,8 +53,8 @@ Success is a `results.jsonl` with one JSON object per results page:
43
53
 
44
54
  The first query takes a few minutes, because a new exit is warmed up first, and
45
55
  the warm-up costs noticeably more traffic than a search. Later queries on the
46
- same exit take seconds. A summary of pages served and traffic used is printed at
47
- the end of every run.
56
+ same exit take seconds. A summary of pages served and traffic used, with the
57
+ hosts that took most of it, is printed at the end of every run.
48
58
 
49
59
  With NodeMaven, the session id is handled for you and the credentials come from
50
60
  the environment:
@@ -84,25 +94,56 @@ simple consumers can often switch with minimal mapping. Knowledge panels,
84
94
  shopping, images and other special blocks are not parsed.
85
95
 
86
96
  Each record also has an `exit` block: which proxy session answered and how many
87
- bytes the page cost. A query that failed is a record too, with an `error`.
97
+ bytes the page cost. `search_metadata.timings` says where the page's time went,
98
+ in seconds per step (`type`, `load`, `ready`, `read`, `parse`, and `links`, which
99
+ overlaps the next query and is not part of `total_time_taken`). A query
100
+ that failed is a record too, with an `error`.
88
101
  `--format csv` writes the organic results as a table, and `--price-per-gb` adds
89
102
  the cost per 1000 pages to the summary.
90
103
 
91
104
  ## How it works
92
105
 
93
106
  1. **New identity**: a sticky proxy session and a fresh browser profile.
94
- 2. **Warm-up**: a few ordinary pages, then Google's front page.
95
- 3. **Search**: the query is typed into the box and submitted.
107
+ 2. **Warm-up**: a few ordinary pages.
108
+ 3. **Search**: each query opens Google's results page directly, which is
109
+ faster than the search box and skips the autocomplete traffic of typing.
110
+ `--direct follow-ups` sends an exit's first query through the box and
111
+ `--direct none` every query; `--input` sets how a query goes into the box
112
+ (`type` key by key, `insert` all at once, `words` word by word).
96
113
  4. **Check before parsing**: the page is classified first, so a captcha or a
97
114
  block is reported as one and never parsed as an empty result.
98
115
  5. **Keep or drop**: an exit Google answers is reused for the next queries; an
99
116
  exit it refuses is dropped and the query is retried on a new one. If exit
100
117
  after exit is refused, the run stops instead of spending more traffic.
101
118
 
119
+ A served exit is kept for up to 50 queries (`--max-per-exit`). Every new exit
120
+ pays for a warm-up, so a short cap mostly buys more warm-up traffic. A browser
121
+ error on a search is retried once on the same exit (through Google's front page
122
+ when queries go through the search box) before the exit is dropped; a network
123
+ error drops it at once.
124
+
125
+ `--workers N` searches on N exits at once, one browser each, sharing one list
126
+ of queries. Each browser needs memory and CPU of its own. The run stops when
127
+ exit after exit is refused or unreachable with no worker served in between.
128
+
129
+ The command line copies the cookies of the first exit Google serves and
130
+ starts up to 10 new exits from that copy (`--clone N`, `--clone 0` turns it
131
+ off), on new sticky sessions, instead of warming each one; all workers share
132
+ the copy. Google's acceptance follows the cookies more than the address, so a
133
+ copy usually starts out served, and costs a few MB instead of a full warm-up.
134
+ A copy refused on its first query ends the copying; the copied cookies are
135
+ used for three hours at most (`--clone-max-age`). A copy waits 40-60 seconds
136
+ after its browser starts before the first search.
137
+
138
+ `--spare 1` warms the next exit in the background once the current one is
139
+ served, so a retired exit is replaced without waiting for a warm-up. A spare
140
+ costs a warm-up's traffic whether it is used or not.
141
+
102
142
  Google wraps result links in encrypted `/goto` redirects. Each one is resolved
103
- with a small request through a separate short-lived proxy session, so link
104
- resolution does not touch the warmed browser session. `--no-resolve-links`
105
- skips it.
143
+ with a small request through a separate short-lived proxy session, a few at a
144
+ time, so link resolution does not touch the warmed browser session. It runs in
145
+ the background while the browser goes on to the next query; records still come
146
+ out in the order each browser searched them. `--no-resolve-links` skips it.
106
147
 
107
148
  Traffic goes through a small local relay that counts bytes and blocks a large
108
149
  model download Chrome otherwise makes on every fresh profile.
@@ -172,9 +213,10 @@ Google refuses, and tells you what each page cost.
172
213
 
173
214
  ## Status
174
215
 
175
- Early. Tested on Windows. **Linux and Docker are experimental**: on a machine
176
- without a GPU, Chrome has no WebGL, which a normal desktop always has; the
177
- Docker image turns on a software renderer for it. The `cloak` engine
216
+ Early. Windows is the tested and recommended platform. **Linux and Docker are
217
+ experimental**: on a machine without a GPU, Chrome has no WebGL, which a normal
218
+ desktop always has; the Docker image turns on a software renderer for it, and
219
+ whether Google serves that is not measured yet. The `cloak` engine
178
220
  (`--engine cloak`) is experimental too; `patchright` is the default.
179
221
 
180
222
  Google changes its pages often. If results come back empty or wrong, run with
@@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta"
4
4
 
5
5
  [project]
6
6
  name = "google-browser-scraper"
7
- version = "0.0.2"
7
+ version = "0.0.4"
8
8
  description = "Google Search results from a real browser through your own sticky proxy, with captchas and blocks reported explicitly."
9
9
  readme = "README.md"
10
10
  license = { text = "MIT" }
@@ -7,7 +7,7 @@ for record in Scraper(proxy, Settings(pages=2)).run(["best running shoes"]):
7
7
  print(record["organic_results"])
8
8
  """
9
9
 
10
- __version__ = "0.0.2"
10
+ __version__ = "0.0.4"
11
11
 
12
12
  from .classify import Verdict, classify # noqa: E402
13
13
  from .parse import parse_serp # noqa: E402
@@ -6,7 +6,10 @@
6
6
  - `locale` is never set: setting it through the context makes the main thread
7
7
  and Web Workers disagree. `timezone_id` goes through CDP emulation and agrees
8
8
  in workers, and is off unless asked for.
9
- - The query is typed into the box, not sent as `/search?q=`.
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.
10
13
  """
11
14
 
12
15
  from __future__ import annotations
@@ -15,9 +18,13 @@ import random
15
18
  import shutil
16
19
  import tempfile
17
20
  import time
18
- from dataclasses import dataclass
21
+ from dataclasses import dataclass, field, replace
22
+ from urllib.parse import urlencode, urlsplit, urlunsplit
19
23
 
20
24
  HOME_URL = "https://www.google.com/?hl=en"
25
+ #: How a query gets into the search box: key by key, all at once, or one word
26
+ #: at a time.
27
+ INPUT_MODES = ("type", "insert", "words")
21
28
  SEARCH_BOX = "textarea[name='q'], input[name='q']"
22
29
  READY = "#rso, #search"
23
30
  # Reject first: it sets the smaller cookie. The middle selector is the
@@ -30,6 +37,7 @@ CONSENT_BUTTONS = (
30
37
  )
31
38
  NAV_TIMEOUT_MS = 60_000
32
39
  WARM_TIMEOUT_MS = 30_000
40
+ TYPE_ATTEMPTS = 2
33
41
 
34
42
 
35
43
  @dataclass(frozen=True)
@@ -40,6 +48,8 @@ class Fetched:
40
48
  status: int | None
41
49
  html: str
42
50
  consent_dismissed: bool = False
51
+ #: Seconds per step, e.g. {"type": 3.1, "load": 4.2}: where the time went.
52
+ timings: dict[str, float] = field(default_factory=dict)
43
53
 
44
54
 
45
55
  class BrowserSession:
@@ -65,7 +75,12 @@ class BrowserSession:
65
75
  timezone_id: str | None = None,
66
76
  browser_args: list[str] | tuple[str, ...] = (),
67
77
  rng: random.Random | None = None,
78
+ profile_dir: str | None = None,
79
+ input_mode: str = "type",
68
80
  ) -> None:
81
+ if input_mode not in INPUT_MODES:
82
+ raise ValueError(f"input mode must be one of {', '.join(INPUT_MODES)}")
83
+ self._input = input_mode
69
84
  if any(a.startswith(("--disable-features", "--enable-features")) for a in browser_args):
70
85
  raise ValueError(
71
86
  "--disable-features/--enable-features would replace the browser's "
@@ -79,19 +94,38 @@ class BrowserSession:
79
94
  self._rng = rng or random.Random()
80
95
  self._pw = None
81
96
  self._context = None
82
- self._profile = None
97
+ # A given directory (a copy of another identity's profile) is used as
98
+ # it is; either way the profile is removed on close.
99
+ self._profile = profile_dir
83
100
  self.page = None
84
101
 
102
+ @property
103
+ def profile_dir(self) -> str | None:
104
+ return self._profile
105
+
85
106
  def __enter__(self) -> BrowserSession:
86
- self._profile = tempfile.mkdtemp(prefix="gbs-profile-")
107
+ if self._profile is None:
108
+ self._profile = tempfile.mkdtemp(prefix="gbs-profile-")
87
109
  try:
88
- self._context = self._launch(self._profile)
110
+ self._open()
89
111
  except Exception:
90
112
  self.close()
91
113
  raise
114
+ return self
115
+
116
+ def _open(self) -> None:
117
+ self._context = self._launch(self._profile)
92
118
  pages = self._context.pages
93
119
  self.page = pages[0] if pages else self._context.new_page()
94
- return self
120
+
121
+ def suspend(self) -> str:
122
+ """Close the browser and keep its profile on disk, so it can be
123
+ copied; `resume()` opens it again on the same proxy."""
124
+ self._shut()
125
+ return self._profile
126
+
127
+ def resume(self) -> None:
128
+ self._open()
95
129
 
96
130
  def _launch(self, profile: str):
97
131
  from patchright.sync_api import sync_playwright
@@ -111,6 +145,12 @@ class BrowserSession:
111
145
  self.close()
112
146
 
113
147
  def close(self) -> None:
148
+ self._shut()
149
+ if self._profile:
150
+ shutil.rmtree(self._profile, ignore_errors=True)
151
+ self._profile = None
152
+
153
+ def _shut(self) -> None:
114
154
  for step in (
115
155
  lambda: self._context and self._context.close(),
116
156
  lambda: self._pw and self._pw.stop(),
@@ -119,9 +159,7 @@ class BrowserSession:
119
159
  step()
120
160
  except Exception:
121
161
  pass
122
- if self._profile:
123
- shutil.rmtree(self._profile, ignore_errors=True)
124
- self._context = self._pw = self._profile = self.page = None
162
+ self._context = self._pw = self.page = None
125
163
 
126
164
  # -- warming -------------------------------------------------------------
127
165
 
@@ -136,55 +174,131 @@ class BrowserSession:
136
174
 
137
175
  # -- searching -----------------------------------------------------------
138
176
 
139
- def search(self, query: str) -> Fetched:
140
- """Type `query` into Google's box and return the page it led to."""
177
+ def search(self, query: str, direct: bool = False) -> Fetched:
178
+ """Type `query` into Google's box and return the page it led to; with
179
+ `direct`, open the results page for it instead."""
180
+ if direct:
181
+ return self._search_direct(query)
141
182
  page = self.page
142
- if not any(h.is_visible() for h in page.query_selector_all(SEARCH_BOX)):
183
+ timings: dict[str, float] = {}
184
+ mark = time.monotonic()
185
+ if not self._box_visible():
143
186
  page.goto(HOME_URL, wait_until="domcontentloaded", timeout=NAV_TIMEOUT_MS)
187
+ timings["home"] = round(time.monotonic() - mark, 2)
188
+ mark = time.monotonic()
189
+ dismissed, typed = False, None
190
+ # The page can take focus or swap the box while the query is being
191
+ # typed, which leaves only its first characters behind. The box is
192
+ # found again and the query retyped once before giving up.
193
+ for _ in range(TYPE_ATTEMPTS):
194
+ dismissed = self._dismiss_consent() or dismissed
195
+ box = self._type_query(query)
196
+ try:
197
+ typed = box.input_value()
198
+ except Exception:
199
+ typed = None # the box was replaced and this handle is gone
200
+ if typed == query:
201
+ break
202
+ else:
203
+ raise RuntimeError(
204
+ f"search box holds {typed!r} after typing {query!r} "
205
+ f"{TYPE_ATTEMPTS} times; not submitting"
206
+ )
207
+ timings["type"] = round(time.monotonic() - mark, 2)
208
+ mark = time.monotonic()
209
+ # `no_wait_after=True` keeps `press` from waiting for the navigation a
210
+ # second time with Playwright's own 30 s default under ours.
211
+ with page.expect_navigation(wait_until="domcontentloaded", timeout=NAV_TIMEOUT_MS) as nav:
212
+ box.press("Enter", no_wait_after=True, timeout=NAV_TIMEOUT_MS)
213
+ timings["load"] = round(time.monotonic() - mark, 2)
214
+ fetched = self._snapshot(nav.value, dismissed)
215
+ return replace(fetched, timings={**timings, **fetched.timings})
216
+
217
+ def _search_direct(self, query: str) -> Fetched:
218
+ home = urlsplit(HOME_URL)
219
+ url = urlunsplit(
220
+ (home.scheme, home.netloc, "/search", urlencode({"q": query, "hl": "en"}), "")
221
+ )
222
+ mark = time.monotonic()
223
+ response = self.page.goto(url, wait_until="domcontentloaded", timeout=NAV_TIMEOUT_MS)
224
+ # A fresh exit can be shown the consent wall instead of results; clear
225
+ # it and open the results again, as the typed path does on the front page.
144
226
  dismissed = self._dismiss_consent()
227
+ if dismissed:
228
+ response = self.page.goto(url, wait_until="domcontentloaded", timeout=NAV_TIMEOUT_MS)
229
+ load = round(time.monotonic() - mark, 2)
230
+ fetched = self._snapshot(response, dismissed)
231
+ return replace(fetched, timings={"load": load, **fetched.timings})
232
+
233
+ def _box_visible(self) -> bool:
234
+ try:
235
+ return any(h.is_visible() for h in self.page.query_selector_all(SEARCH_BOX))
236
+ except Exception:
237
+ # The page navigated while it was being read, as a warm-up page
238
+ # left idle can; the front page is loaded instead.
239
+ return False
240
+
241
+ def _type_query(self, query: str):
242
+ page = self.page
145
243
  # Act on the handle that was found, never on the selector again: the
146
244
  # selector also matches a hidden field, and `page.click` would pick it.
147
245
  box = page.wait_for_selector(SEARCH_BOX, state="visible", timeout=NAV_TIMEOUT_MS)
148
246
  box.click()
149
- # A results page keeps the previous query in the box. Typing after it
150
- # appends, and the appended query returns a real page for the wrong
151
- # question. Select it so the typing replaces it.
247
+ # A results page keeps the previous query in the box, and a failed
248
+ # attempt leaves part of this one. Typing after either appends, and the
249
+ # appended query returns a real page for the wrong question. Select it
250
+ # so the typing replaces it.
152
251
  if box.input_value():
153
252
  box.press("ControlOrMeta+a")
154
- box.type(query, delay=self._rng.randint(45, 140))
155
- typed = box.input_value()
156
- if typed != query:
157
- raise RuntimeError(f"search box holds {typed!r} after typing {query!r}; not submitting")
158
- # `no_wait_after=True` keeps `press` from waiting for the navigation a
159
- # second time with Playwright's own 30 s default under ours.
160
- with page.expect_navigation(wait_until="domcontentloaded", timeout=NAV_TIMEOUT_MS) as nav:
161
- box.press("Enter", no_wait_after=True, timeout=NAV_TIMEOUT_MS)
162
- return self._snapshot(nav.value, dismissed)
253
+ if self._input == "insert":
254
+ page.keyboard.insert_text(query)
255
+ elif self._input == "words":
256
+ words = query.split(" ")
257
+ for i, word in enumerate(words):
258
+ last = i == len(words) - 1
259
+ page.keyboard.insert_text(word if last else word + " ")
260
+ if not last:
261
+ time.sleep(self._rng.uniform(0.15, 0.5))
262
+ else:
263
+ box.type(query, delay=self._rng.randint(45, 140))
264
+ return box
265
+
266
+ def blank(self) -> None:
267
+ """Leave the current page, stopping whatever it is still loading."""
268
+ self.page.goto("about:blank")
163
269
 
164
270
  def next_page(self) -> Fetched | None:
165
271
  """Click "Next" on the current results page, if there is one."""
166
272
  link = self.page.query_selector("a#pnnext")
167
273
  if link is None or not link.is_visible():
168
274
  return None
275
+ mark = time.monotonic()
169
276
  with self.page.expect_navigation(
170
277
  wait_until="domcontentloaded", timeout=NAV_TIMEOUT_MS
171
278
  ) as nav:
172
279
  link.click()
173
- return self._snapshot(nav.value, False)
280
+ load = round(time.monotonic() - mark, 2)
281
+ fetched = self._snapshot(nav.value, False)
282
+ return replace(fetched, timings={"load": load, **fetched.timings})
174
283
 
175
284
  def _snapshot(self, response, dismissed: bool) -> Fetched:
176
285
  # Google builds results in the browser; reading at domcontentloaded
177
286
  # catches the "enable JavaScript" scaffold. A refusal never grows the
178
287
  # container, so the short timeout is expected to expire on those.
288
+ mark = time.monotonic()
179
289
  try:
180
290
  self.page.wait_for_selector(READY, state="attached", timeout=8_000)
181
291
  except Exception:
182
292
  pass
293
+ ready = round(time.monotonic() - mark, 2)
294
+ mark = time.monotonic()
295
+ html = self.page.content()
183
296
  return Fetched(
184
297
  url=self.page.url,
185
298
  status=response.status if response is not None else None,
186
- html=self.page.content(),
299
+ html=html,
187
300
  consent_dismissed=dismissed,
301
+ timings={"ready": ready, "read": round(time.monotonic() - mark, 2)},
188
302
  )
189
303
 
190
304
  def _dismiss_consent(self) -> bool: