google-browser-scraper 0.0.3__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 (45) hide show
  1. {google_browser_scraper-0.0.3/src/google_browser_scraper.egg-info → google_browser_scraper-0.0.4}/PKG-INFO +42 -23
  2. {google_browser_scraper-0.0.3 → google_browser_scraper-0.0.4}/README.md +41 -22
  3. {google_browser_scraper-0.0.3 → google_browser_scraper-0.0.4}/pyproject.toml +1 -1
  4. {google_browser_scraper-0.0.3 → google_browser_scraper-0.0.4}/src/google_browser_scraper/__init__.py +1 -1
  5. {google_browser_scraper-0.0.3 → google_browser_scraper-0.0.4}/src/google_browser_scraper/browser.py +48 -4
  6. {google_browser_scraper-0.0.3 → google_browser_scraper-0.0.4}/src/google_browser_scraper/cli.py +27 -4
  7. {google_browser_scraper-0.0.3 → google_browser_scraper-0.0.4}/src/google_browser_scraper/links.py +3 -0
  8. {google_browser_scraper-0.0.3 → google_browser_scraper-0.0.4}/src/google_browser_scraper/pool.py +35 -10
  9. {google_browser_scraper-0.0.3 → google_browser_scraper-0.0.4}/src/google_browser_scraper/relay.py +23 -10
  10. {google_browser_scraper-0.0.3 → google_browser_scraper-0.0.4}/src/google_browser_scraper/scraper.py +309 -65
  11. {google_browser_scraper-0.0.3 → google_browser_scraper-0.0.4}/src/google_browser_scraper/warmup.py +1 -2
  12. {google_browser_scraper-0.0.3 → google_browser_scraper-0.0.4/src/google_browser_scraper.egg-info}/PKG-INFO +42 -23
  13. {google_browser_scraper-0.0.3 → google_browser_scraper-0.0.4}/tests/fakegoogle.py +11 -0
  14. google_browser_scraper-0.0.4/tests/test_browser_local.py +149 -0
  15. {google_browser_scraper-0.0.3 → google_browser_scraper-0.0.4}/tests/test_engines_local.py +1 -0
  16. google_browser_scraper-0.0.4/tests/test_pool.py +245 -0
  17. {google_browser_scraper-0.0.3 → google_browser_scraper-0.0.4}/tests/test_relay.py +23 -0
  18. {google_browser_scraper-0.0.3 → google_browser_scraper-0.0.4}/tests/test_scraper.py +195 -8
  19. google_browser_scraper-0.0.3/tests/test_browser_local.py +0 -76
  20. google_browser_scraper-0.0.3/tests/test_pool.py +0 -105
  21. {google_browser_scraper-0.0.3 → google_browser_scraper-0.0.4}/LICENSE +0 -0
  22. {google_browser_scraper-0.0.3 → google_browser_scraper-0.0.4}/MANIFEST.in +0 -0
  23. {google_browser_scraper-0.0.3 → google_browser_scraper-0.0.4}/setup.cfg +0 -0
  24. {google_browser_scraper-0.0.3 → google_browser_scraper-0.0.4}/src/google_browser_scraper/__main__.py +0 -0
  25. {google_browser_scraper-0.0.3 → google_browser_scraper-0.0.4}/src/google_browser_scraper/classify.py +0 -0
  26. {google_browser_scraper-0.0.3 → google_browser_scraper-0.0.4}/src/google_browser_scraper/doctor.py +0 -0
  27. {google_browser_scraper-0.0.3 → google_browser_scraper-0.0.4}/src/google_browser_scraper/engines.py +0 -0
  28. {google_browser_scraper-0.0.3 → google_browser_scraper-0.0.4}/src/google_browser_scraper/mcp.py +0 -0
  29. {google_browser_scraper-0.0.3 → google_browser_scraper-0.0.4}/src/google_browser_scraper/output.py +0 -0
  30. {google_browser_scraper-0.0.3 → google_browser_scraper-0.0.4}/src/google_browser_scraper/parse.py +0 -0
  31. {google_browser_scraper-0.0.3 → google_browser_scraper-0.0.4}/src/google_browser_scraper/proxy.py +0 -0
  32. {google_browser_scraper-0.0.3 → google_browser_scraper-0.0.4}/src/google_browser_scraper/server.py +0 -0
  33. {google_browser_scraper-0.0.3 → google_browser_scraper-0.0.4}/src/google_browser_scraper.egg-info/SOURCES.txt +0 -0
  34. {google_browser_scraper-0.0.3 → google_browser_scraper-0.0.4}/src/google_browser_scraper.egg-info/dependency_links.txt +0 -0
  35. {google_browser_scraper-0.0.3 → google_browser_scraper-0.0.4}/src/google_browser_scraper.egg-info/entry_points.txt +0 -0
  36. {google_browser_scraper-0.0.3 → google_browser_scraper-0.0.4}/src/google_browser_scraper.egg-info/requires.txt +0 -0
  37. {google_browser_scraper-0.0.3 → google_browser_scraper-0.0.4}/src/google_browser_scraper.egg-info/top_level.txt +0 -0
  38. {google_browser_scraper-0.0.3 → google_browser_scraper-0.0.4}/tests/pages.py +0 -0
  39. {google_browser_scraper-0.0.3 → google_browser_scraper-0.0.4}/tests/stdio_mcp_child.py +0 -0
  40. {google_browser_scraper-0.0.3 → google_browser_scraper-0.0.4}/tests/test_classify.py +0 -0
  41. {google_browser_scraper-0.0.3 → google_browser_scraper-0.0.4}/tests/test_cli_and_output.py +0 -0
  42. {google_browser_scraper-0.0.3 → google_browser_scraper-0.0.4}/tests/test_links.py +0 -0
  43. {google_browser_scraper-0.0.3 → google_browser_scraper-0.0.4}/tests/test_parse.py +0 -0
  44. {google_browser_scraper-0.0.3 → google_browser_scraper-0.0.4}/tests/test_proxy.py +0 -0
  45. {google_browser_scraper-0.0.3 → google_browser_scraper-0.0.4}/tests/test_serve_and_mcp.py +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: google-browser-scraper
3
- Version: 0.0.3
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:
@@ -116,7 +126,8 @@ 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
128
  bytes the page cost. `search_metadata.timings` says where the page's time went,
119
- in seconds per step (`type`, `load`, `ready`, `read`, `parse`, `links`). A query
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
120
131
  that failed is a record too, with an `error`.
121
132
  `--format csv` writes the organic results as a table, and `--price-per-gb` adds
122
133
  the cost per 1000 pages to the summary.
@@ -124,8 +135,12 @@ the cost per 1000 pages to the summary.
124
135
  ## How it works
125
136
 
126
137
  1. **New identity**: a sticky proxy session and a fresh browser profile.
127
- 2. **Warm-up**: a few ordinary pages, then Google's front page.
128
- 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).
129
144
  4. **Check before parsing**: the page is classified first, so a captcha or a
130
145
  block is reported as one and never parsed as an empty result.
131
146
  5. **Keep or drop**: an exit Google answers is reused for the next queries; an
@@ -134,20 +149,22 @@ the cost per 1000 pages to the summary.
134
149
 
135
150
  A served exit is kept for up to 50 queries (`--max-per-exit`). Every new exit
136
151
  pays for a warm-up, so a short cap mostly buys more warm-up traffic. A browser
137
- error on a search is retried once from Google's front page before the exit is
138
- dropped; a network error drops it at once.
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.
139
155
 
140
156
  `--workers N` searches on N exits at once, one browser each, sharing one list
141
- of queries. Each browser needs memory and CPU of its own; if exit after exit is
142
- refused on any worker, the whole run stops.
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.
143
159
 
144
160
  The command line copies the cookies of the first exit Google serves and
145
161
  starts up to 10 new exits from that copy (`--clone N`, `--clone 0` turns it
146
- off), on new sticky sessions, instead of warming each one. Google's acceptance follows the cookies more than the
147
- address, so a copy usually starts out served, and costs a few MB instead of a
148
- full warm-up. A copy refused on its first query ends the copying; the copied
149
- cookies are used for an hour at most (`--clone-max-age`). A copy waits 40-60
150
- seconds after its browser starts before the first search.
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.
151
168
 
152
169
  `--spare 1` warms the next exit in the background once the current one is
153
170
  served, so a retired exit is replaced without waiting for a warm-up. A spare
@@ -155,8 +172,9 @@ costs a warm-up's traffic whether it is used or not.
155
172
 
156
173
  Google wraps result links in encrypted `/goto` redirects. Each one is resolved
157
174
  with a small request through a separate short-lived proxy session, a few at a
158
- time, so link resolution does not touch the warmed browser session.
159
- `--no-resolve-links` skips it.
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.
160
178
 
161
179
  Traffic goes through a small local relay that counts bytes and blocks a large
162
180
  model download Chrome otherwise makes on every fresh profile.
@@ -226,9 +244,10 @@ Google refuses, and tells you what each page cost.
226
244
 
227
245
  ## Status
228
246
 
229
- Early. Tested on Windows. **Linux and Docker are experimental**: on a machine
230
- without a GPU, Chrome has no WebGL, which a normal desktop always has; the
231
- 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
232
251
  (`--engine cloak`) is experimental too; `patchright` is the default.
233
252
 
234
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:
@@ -85,7 +95,8 @@ 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
97
  bytes the page cost. `search_metadata.timings` says where the page's time went,
88
- in seconds per step (`type`, `load`, `ready`, `read`, `parse`, `links`). A query
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
89
100
  that failed is a record too, with an `error`.
90
101
  `--format csv` writes the organic results as a table, and `--price-per-gb` adds
91
102
  the cost per 1000 pages to the summary.
@@ -93,8 +104,12 @@ the cost per 1000 pages to the summary.
93
104
  ## How it works
94
105
 
95
106
  1. **New identity**: a sticky proxy session and a fresh browser profile.
96
- 2. **Warm-up**: a few ordinary pages, then Google's front page.
97
- 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).
98
113
  4. **Check before parsing**: the page is classified first, so a captcha or a
99
114
  block is reported as one and never parsed as an empty result.
100
115
  5. **Keep or drop**: an exit Google answers is reused for the next queries; an
@@ -103,20 +118,22 @@ the cost per 1000 pages to the summary.
103
118
 
104
119
  A served exit is kept for up to 50 queries (`--max-per-exit`). Every new exit
105
120
  pays for a warm-up, so a short cap mostly buys more warm-up traffic. A browser
106
- error on a search is retried once from Google's front page before the exit is
107
- dropped; a network error drops it at once.
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.
108
124
 
109
125
  `--workers N` searches on N exits at once, one browser each, sharing one list
110
- of queries. Each browser needs memory and CPU of its own; if exit after exit is
111
- refused on any worker, the whole run stops.
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.
112
128
 
113
129
  The command line copies the cookies of the first exit Google serves and
114
130
  starts up to 10 new exits from that copy (`--clone N`, `--clone 0` turns it
115
- off), on new sticky sessions, instead of warming each one. Google's acceptance follows the cookies more than the
116
- address, so a copy usually starts out served, and costs a few MB instead of a
117
- full warm-up. A copy refused on its first query ends the copying; the copied
118
- cookies are used for an hour at most (`--clone-max-age`). A copy waits 40-60
119
- seconds after its browser starts before the first search.
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.
120
137
 
121
138
  `--spare 1` warms the next exit in the background once the current one is
122
139
  served, so a retired exit is replaced without waiting for a warm-up. A spare
@@ -124,8 +141,9 @@ costs a warm-up's traffic whether it is used or not.
124
141
 
125
142
  Google wraps result links in encrypted `/goto` redirects. Each one is resolved
126
143
  with a small request through a separate short-lived proxy session, a few at a
127
- time, so link resolution does not touch the warmed browser session.
128
- `--no-resolve-links` skips it.
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.
129
147
 
130
148
  Traffic goes through a small local relay that counts bytes and blocks a large
131
149
  model download Chrome otherwise makes on every fresh profile.
@@ -195,9 +213,10 @@ Google refuses, and tells you what each page cost.
195
213
 
196
214
  ## Status
197
215
 
198
- Early. Tested on Windows. **Linux and Docker are experimental**: on a machine
199
- without a GPU, Chrome has no WebGL, which a normal desktop always has; the
200
- 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
201
220
  (`--engine cloak`) is experimental too; `patchright` is the default.
202
221
 
203
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.3"
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.3"
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
@@ -16,8 +19,12 @@ import shutil
16
19
  import tempfile
17
20
  import time
18
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
@@ -69,7 +76,11 @@ class BrowserSession:
69
76
  browser_args: list[str] | tuple[str, ...] = (),
70
77
  rng: random.Random | None = None,
71
78
  profile_dir: str | None = None,
79
+ input_mode: str = "type",
72
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
73
84
  if any(a.startswith(("--disable-features", "--enable-features")) for a in browser_args):
74
85
  raise ValueError(
75
86
  "--disable-features/--enable-features would replace the browser's "
@@ -163,8 +174,11 @@ class BrowserSession:
163
174
 
164
175
  # -- searching -----------------------------------------------------------
165
176
 
166
- def search(self, query: str) -> Fetched:
167
- """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)
168
182
  page = self.page
169
183
  timings: dict[str, float] = {}
170
184
  mark = time.monotonic()
@@ -200,6 +214,22 @@ class BrowserSession:
200
214
  fetched = self._snapshot(nav.value, dismissed)
201
215
  return replace(fetched, timings={**timings, **fetched.timings})
202
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.
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
+
203
233
  def _box_visible(self) -> bool:
204
234
  try:
205
235
  return any(h.is_visible() for h in self.page.query_selector_all(SEARCH_BOX))
@@ -220,9 +250,23 @@ class BrowserSession:
220
250
  # so the typing replaces it.
221
251
  if box.input_value():
222
252
  box.press("ControlOrMeta+a")
223
- box.type(query, delay=self._rng.randint(45, 140))
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))
224
264
  return box
225
265
 
266
+ def blank(self) -> None:
267
+ """Leave the current page, stopping whatever it is still loading."""
268
+ self.page.goto("about:blank")
269
+
226
270
  def next_page(self) -> Fetched | None:
227
271
  """Click "Next" on the current results page, if there is one."""
228
272
  link = self.page.query_selector("a#pnnext")
@@ -9,13 +9,13 @@ import shlex
9
9
  import sys
10
10
  from pathlib import Path
11
11
 
12
- from . import __version__, engines, warmup
12
+ from . import __version__, browser, engines, warmup
13
13
  from .classify import classify
14
14
  from .output import WRITERS, open_writer, use_utf8
15
15
  from .parse import parse_serp
16
16
  from .pool import Pool
17
17
  from .proxy import NodeMavenSource, ProxyTemplate
18
- from .scraper import ExitsRefused, Scraper, Settings
18
+ from .scraper import DIRECT_MODES, ExitsRefused, Scraper, Settings
19
19
 
20
20
 
21
21
  def main(argv: list[str] | None = None) -> int:
@@ -138,9 +138,29 @@ def _scraper_options(p: argparse.ArgumentParser) -> None:
138
138
  p.add_argument(
139
139
  "--clone-max-age",
140
140
  type=float,
141
- default=60.0,
141
+ default=180.0,
142
142
  metavar="MIN",
143
- help="minutes copied cookies stay in use (default 60)",
143
+ help="minutes copied cookies stay in use (default 180)",
144
+ )
145
+ p.add_argument(
146
+ "--input",
147
+ choices=browser.INPUT_MODES,
148
+ default="type",
149
+ help="how a query is entered into Google's search box: typed key by key "
150
+ "(default), inserted at once, or inserted word by word",
151
+ )
152
+ p.add_argument(
153
+ "--direct",
154
+ choices=DIRECT_MODES,
155
+ default="all",
156
+ help="which queries open the results page directly instead of going through "
157
+ "the search box: all (default), all but an exit's first, or none",
158
+ )
159
+ p.add_argument(
160
+ "--stop-after-read",
161
+ action="store_true",
162
+ help="leave each results page for about:blank once it is read (experimental: "
163
+ "saves traffic Google's late scripts would spend)",
144
164
  )
145
165
  p.add_argument(
146
166
  "--gap",
@@ -214,6 +234,9 @@ def _build(args, *, pages: int = 1, worker: int | None = None) -> tuple[Scraper,
214
234
  spare=args.spare,
215
235
  clones=args.clone,
216
236
  clone_max_age=args.clone_max_age * 60,
237
+ input=args.input,
238
+ direct=args.direct,
239
+ stop_after_read=args.stop_after_read,
217
240
  gap_seconds=args.gap,
218
241
  headless=args.headless,
219
242
  channel=args.channel,
@@ -93,6 +93,9 @@ class LinkResolver:
93
93
  def bytes(self) -> int:
94
94
  return self._relay.total if self._relay is not None else 0
95
95
 
96
+ def hosts(self) -> dict[str, int]:
97
+ return self._relay.hosts() if self._relay is not None else {}
98
+
96
99
  def resolve(self, goto_url: str) -> str | None:
97
100
  if not self.enabled:
98
101
  return None
@@ -5,15 +5,21 @@ page and the gap between queries, not the machine. Running `workers` scrapers
5
5
  side by side multiplies throughput by roughly that number until the machine
6
6
  runs out of CPU or memory for the browsers.
7
7
 
8
- Each worker is an ordinary `Scraper` with its own identities, spares, breaker
9
- and traffic count; the workers share only the queue of queries. Playwright's
10
- sync API is bound to the thread that starts it, and every worker starts its
11
- browsers on its own thread, so nothing is shared across threads.
12
-
13
- When one worker's breaker trips, the whole pool stops: exit after exit being
14
- refused is a statement about the hour or the pool, not about one worker, and
15
- the other workers would only spend traffic finding the same thing. Queries
16
- nobody answered by then are listed in `not_run`.
8
+ Each worker is an ordinary `Scraper` with its own identities, spares and
9
+ traffic count. The workers share the queue of queries, one breaker and one
10
+ cookie-jar template. Playwright's sync API is bound to the thread that starts
11
+ it, and every worker starts its browsers on its own thread, so no browser is
12
+ shared across threads.
13
+
14
+ The breaker counts refused exits across the pool and any served page resets
15
+ it: one worker drawing bad exits while another keeps being served is not a bad
16
+ hour. When nobody is served for `breaker` exits in a row the whole pool stops,
17
+ since the other workers would only spend traffic finding the same thing.
18
+ Queries nobody answered by then are listed in `not_run`.
19
+
20
+ The template is shared for the same reason: a served cookie jar is worth as
21
+ much to every worker, and a worker whose copy ran out should start from the
22
+ other worker's jar rather than warm fresh exits.
17
23
  """
18
24
 
19
25
  from __future__ import annotations
@@ -24,7 +30,7 @@ import time
24
30
  from collections.abc import Callable, Iterable, Iterator
25
31
  from typing import Any
26
32
 
27
- from .scraper import ExitsRefused, Scraper, Stats
33
+ from .scraper import Breaker, ExitsRefused, Scraper, Stats, Templates
28
34
 
29
35
  MAX_WORKERS = 16
30
36
  _DONE = object()
@@ -75,6 +81,22 @@ class Pool:
75
81
  out: queue.Queue[Any] = queue.Queue()
76
82
  stopped: list[ExitsRefused] = []
77
83
  self._scrapers = [self._make(i) for i in range(self.workers)]
84
+ first = self._scrapers[0].settings
85
+ for scraper in self._scrapers[1:]:
86
+ s = scraper.settings
87
+ if (s.clones, s.clone_max_age, s.breaker) != (
88
+ first.clones,
89
+ first.clone_max_age,
90
+ first.breaker,
91
+ ):
92
+ raise ValueError(
93
+ "workers share one cookie template and one breaker, so clones, "
94
+ "clone_max_age and breaker must be the same for every worker"
95
+ )
96
+ templates = Templates(first.clones, first.clone_max_age)
97
+ breaker = Breaker(first.breaker)
98
+ for scraper in self._scrapers:
99
+ scraper.share(breaker, templates)
78
100
  threads = [
79
101
  threading.Thread(
80
102
  target=self._work,
@@ -100,6 +122,7 @@ class Pool:
100
122
  self._stop.set()
101
123
  for t in threads:
102
124
  t.join()
125
+ templates.drop("run finished", lambda event: None)
103
126
  while not todo.empty():
104
127
  self.not_run.append(todo.get_nowait())
105
128
  if stopped:
@@ -158,5 +181,7 @@ def merge_stats(parts: Iterable[Stats]) -> Stats:
158
181
  ):
159
182
  setattr(merged, name, getattr(merged, name) + getattr(s, name))
160
183
  merged.counted = merged.counted and s.counted
184
+ for host, n in s.bytes_by_host.items():
185
+ merged.bytes_by_host[host] = merged.bytes_by_host.get(host, 0) + n
161
186
  merged._open.extend(s._open)
162
187
  return merged
@@ -60,6 +60,7 @@ class Relay:
60
60
  self.tunnels = 0
61
61
  self.failures = 0
62
62
  self.blocked = 0
63
+ self._hosts: dict[str, int] = {}
63
64
  #: Status line of the last CONNECT the upstream proxy refused, such as
64
65
  #: `HTTP/1.1 402 traffic exhausted`. The browser only reports a failed
65
66
  #: tunnel; this is the proxy's reason.
@@ -100,6 +101,15 @@ class Relay:
100
101
  with self._lock:
101
102
  return self.bytes_up + self.bytes_down
102
103
 
104
+ def hosts(self) -> dict[str, int]:
105
+ """Bytes both ways per target host, the CONNECT exchange included.
106
+
107
+ A plain-HTTP connection the browser keeps alive for requests to other
108
+ hosts is counted under its first host; that traffic is a few small
109
+ revocation checks and redirects."""
110
+ with self._lock:
111
+ return dict(self._hosts)
112
+
103
113
  # -- serving ---------------------------------------------------------------
104
114
 
105
115
  def _accept_loop(self) -> None:
@@ -162,9 +172,9 @@ class Relay:
162
172
  f"{self._auth}Proxy-Connection: Keep-Alive\r\n\r\n"
163
173
  ).encode()
164
174
  upstream.sendall(request)
165
- self._count(up=len(request))
175
+ self._count(up=len(request), host=host)
166
176
  reply = _read_head(upstream)
167
- self._count(down=len(reply))
177
+ self._count(down=len(reply), host=host)
168
178
  if _status(reply) != 200:
169
179
  # Pass the gateway's own answer through: its status is the
170
180
  # evidence (a bad parameter is 406 or 407 on some gateways).
@@ -184,12 +194,13 @@ class Relay:
184
194
  except OSError:
185
195
  _close(upstream)
186
196
  return
187
- self._pump(client, upstream)
197
+ self._pump(client, upstream, host)
188
198
 
189
199
  def _forward(self, client: socket.socket, head: str) -> None:
190
200
  """Plain HTTP in absolute-URI form (revocation checks, http:// redirects)."""
191
201
  target = urlsplit(head.split("\r\n", 1)[0].split(" ")[1])
192
- if (target.hostname or "") in self.block_hosts:
202
+ host = (target.hostname or "").lower()
203
+ if host in self.block_hosts:
193
204
  self._refuse(client)
194
205
  return
195
206
  lines = [ln for ln in head.split("\r\n") if not ln.lower().startswith("proxy-")]
@@ -207,13 +218,13 @@ class Relay:
207
218
  rebuilt = lines[0] + "\r\n" + self._auth + "\r\n".join(lines[1:])
208
219
  data = rebuilt.encode("latin-1")
209
220
  upstream.sendall(data)
210
- self._count(up=len(data))
221
+ self._count(up=len(data), host=host)
211
222
  except OSError:
212
223
  self._fail()
213
224
  return
214
- self._pump(client, upstream)
225
+ self._pump(client, upstream, host)
215
226
 
216
- def _pump(self, client: socket.socket, upstream: socket.socket) -> None:
227
+ def _pump(self, client: socket.socket, upstream: socket.socket, host: str) -> None:
217
228
  """Copy both ways until either end closes, counting per chunk so a
218
229
  keep-alive tunnel's bytes land on the page that used them."""
219
230
  client.settimeout(None)
@@ -231,19 +242,21 @@ class Relay:
231
242
  return
232
243
  if source is client:
233
244
  upstream.sendall(data)
234
- self._count(up=len(data))
245
+ self._count(up=len(data), host=host)
235
246
  else:
236
247
  client.sendall(data)
237
- self._count(down=len(data))
248
+ self._count(down=len(data), host=host)
238
249
  except OSError:
239
250
  pass
240
251
  finally:
241
252
  _close(upstream)
242
253
 
243
- def _count(self, *, up: int = 0, down: int = 0) -> None:
254
+ def _count(self, *, up: int = 0, down: int = 0, host: str = "") -> None:
244
255
  with self._lock:
245
256
  self.bytes_up += up
246
257
  self.bytes_down += down
258
+ if host:
259
+ self._hosts[host] = self._hosts.get(host, 0) + up + down
247
260
 
248
261
 
249
262
  def _read_head(sock: socket.socket) -> str: