google-browser-scraper 0.0.2__tar.gz → 0.0.3__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/MANIFEST.in +1 -0
  2. {google_browser_scraper-0.0.2/src/google_browser_scraper.egg-info → google_browser_scraper-0.0.3}/PKG-INFO +28 -5
  3. {google_browser_scraper-0.0.2 → google_browser_scraper-0.0.3}/README.md +27 -4
  4. {google_browser_scraper-0.0.2 → google_browser_scraper-0.0.3}/pyproject.toml +1 -1
  5. {google_browser_scraper-0.0.2 → google_browser_scraper-0.0.3}/src/google_browser_scraper/__init__.py +1 -1
  6. {google_browser_scraper-0.0.2 → google_browser_scraper-0.0.3}/src/google_browser_scraper/browser.py +93 -23
  7. {google_browser_scraper-0.0.2 → google_browser_scraper-0.0.3}/src/google_browser_scraper/cli.py +65 -10
  8. {google_browser_scraper-0.0.2 → google_browser_scraper-0.0.3}/src/google_browser_scraper/links.py +28 -9
  9. {google_browser_scraper-0.0.2 → google_browser_scraper-0.0.3}/src/google_browser_scraper/output.py +4 -0
  10. google_browser_scraper-0.0.3/src/google_browser_scraper/pool.py +162 -0
  11. {google_browser_scraper-0.0.2 → google_browser_scraper-0.0.3}/src/google_browser_scraper/relay.py +19 -3
  12. google_browser_scraper-0.0.3/src/google_browser_scraper/scraper.py +869 -0
  13. {google_browser_scraper-0.0.2 → google_browser_scraper-0.0.3/src/google_browser_scraper.egg-info}/PKG-INFO +28 -5
  14. {google_browser_scraper-0.0.2 → google_browser_scraper-0.0.3}/src/google_browser_scraper.egg-info/SOURCES.txt +6 -0
  15. google_browser_scraper-0.0.3/tests/fakegoogle.py +80 -0
  16. google_browser_scraper-0.0.3/tests/pages.py +93 -0
  17. google_browser_scraper-0.0.3/tests/stdio_mcp_child.py +33 -0
  18. {google_browser_scraper-0.0.2 → google_browser_scraper-0.0.3}/tests/test_browser_local.py +28 -0
  19. {google_browser_scraper-0.0.2 → google_browser_scraper-0.0.3}/tests/test_cli_and_output.py +26 -0
  20. {google_browser_scraper-0.0.2 → google_browser_scraper-0.0.3}/tests/test_engines_local.py +75 -0
  21. {google_browser_scraper-0.0.2 → google_browser_scraper-0.0.3}/tests/test_links.py +24 -0
  22. google_browser_scraper-0.0.3/tests/test_pool.py +105 -0
  23. {google_browser_scraper-0.0.2 → google_browser_scraper-0.0.3}/tests/test_relay.py +53 -6
  24. google_browser_scraper-0.0.3/tests/test_scraper.py +672 -0
  25. google_browser_scraper-0.0.2/src/google_browser_scraper/scraper.py +0 -449
  26. google_browser_scraper-0.0.2/tests/test_scraper.py +0 -333
  27. {google_browser_scraper-0.0.2 → google_browser_scraper-0.0.3}/LICENSE +0 -0
  28. {google_browser_scraper-0.0.2 → google_browser_scraper-0.0.3}/setup.cfg +0 -0
  29. {google_browser_scraper-0.0.2 → google_browser_scraper-0.0.3}/src/google_browser_scraper/__main__.py +0 -0
  30. {google_browser_scraper-0.0.2 → google_browser_scraper-0.0.3}/src/google_browser_scraper/classify.py +0 -0
  31. {google_browser_scraper-0.0.2 → google_browser_scraper-0.0.3}/src/google_browser_scraper/doctor.py +0 -0
  32. {google_browser_scraper-0.0.2 → google_browser_scraper-0.0.3}/src/google_browser_scraper/engines.py +0 -0
  33. {google_browser_scraper-0.0.2 → google_browser_scraper-0.0.3}/src/google_browser_scraper/mcp.py +0 -0
  34. {google_browser_scraper-0.0.2 → google_browser_scraper-0.0.3}/src/google_browser_scraper/parse.py +0 -0
  35. {google_browser_scraper-0.0.2 → google_browser_scraper-0.0.3}/src/google_browser_scraper/proxy.py +0 -0
  36. {google_browser_scraper-0.0.2 → google_browser_scraper-0.0.3}/src/google_browser_scraper/server.py +0 -0
  37. {google_browser_scraper-0.0.2 → google_browser_scraper-0.0.3}/src/google_browser_scraper/warmup.py +0 -0
  38. {google_browser_scraper-0.0.2 → google_browser_scraper-0.0.3}/src/google_browser_scraper.egg-info/dependency_links.txt +0 -0
  39. {google_browser_scraper-0.0.2 → google_browser_scraper-0.0.3}/src/google_browser_scraper.egg-info/entry_points.txt +0 -0
  40. {google_browser_scraper-0.0.2 → google_browser_scraper-0.0.3}/src/google_browser_scraper.egg-info/requires.txt +0 -0
  41. {google_browser_scraper-0.0.2 → google_browser_scraper-0.0.3}/src/google_browser_scraper.egg-info/top_level.txt +0 -0
  42. {google_browser_scraper-0.0.2 → google_browser_scraper-0.0.3}/tests/test_classify.py +0 -0
  43. {google_browser_scraper-0.0.2 → google_browser_scraper-0.0.3}/tests/test_parse.py +0 -0
  44. {google_browser_scraper-0.0.2 → google_browser_scraper-0.0.3}/tests/test_proxy.py +0 -0
  45. {google_browser_scraper-0.0.2 → google_browser_scraper-0.0.3}/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.3
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
@@ -115,7 +115,9 @@ simple consumers can often switch with minimal mapping. Knowledge panels,
115
115
  shopping, images and other special blocks are not parsed.
116
116
 
117
117
  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`.
118
+ 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
120
+ that failed is a record too, with an `error`.
119
121
  `--format csv` writes the organic results as a table, and `--price-per-gb` adds
120
122
  the cost per 1000 pages to the summary.
121
123
 
@@ -130,10 +132,31 @@ the cost per 1000 pages to the summary.
130
132
  exit it refuses is dropped and the query is retried on a new one. If exit
131
133
  after exit is refused, the run stops instead of spending more traffic.
132
134
 
135
+ A served exit is kept for up to 50 queries (`--max-per-exit`). Every new exit
136
+ 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.
139
+
140
+ `--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.
143
+
144
+ The command line copies the cookies of the first exit Google serves and
145
+ 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.
151
+
152
+ `--spare 1` warms the next exit in the background once the current one is
153
+ served, so a retired exit is replaced without waiting for a warm-up. A spare
154
+ costs a warm-up's traffic whether it is used or not.
155
+
133
156
  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.
157
+ 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.
137
160
 
138
161
  Traffic goes through a small local relay that counts bytes and blocks a large
139
162
  model download Chrome otherwise makes on every fresh profile.
@@ -84,7 +84,9 @@ simple consumers can often switch with minimal mapping. Knowledge panels,
84
84
  shopping, images and other special blocks are not parsed.
85
85
 
86
86
  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`.
87
+ 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
89
+ that failed is a record too, with an `error`.
88
90
  `--format csv` writes the organic results as a table, and `--price-per-gb` adds
89
91
  the cost per 1000 pages to the summary.
90
92
 
@@ -99,10 +101,31 @@ the cost per 1000 pages to the summary.
99
101
  exit it refuses is dropped and the query is retried on a new one. If exit
100
102
  after exit is refused, the run stops instead of spending more traffic.
101
103
 
104
+ A served exit is kept for up to 50 queries (`--max-per-exit`). Every new exit
105
+ 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.
108
+
109
+ `--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.
112
+
113
+ The command line copies the cookies of the first exit Google serves and
114
+ 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.
120
+
121
+ `--spare 1` warms the next exit in the background once the current one is
122
+ served, so a retired exit is replaced without waiting for a warm-up. A spare
123
+ costs a warm-up's traffic whether it is used or not.
124
+
102
125
  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.
126
+ 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.
106
129
 
107
130
  Traffic goes through a small local relay that counts bytes and blocks a large
108
131
  model download Chrome otherwise makes on every fresh profile.
@@ -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.3"
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.3"
11
11
 
12
12
  from .classify import Verdict, classify # noqa: E402
13
13
  from .parse import parse_serp # noqa: E402
@@ -15,7 +15,7 @@ import random
15
15
  import shutil
16
16
  import tempfile
17
17
  import time
18
- from dataclasses import dataclass
18
+ from dataclasses import dataclass, field, replace
19
19
 
20
20
  HOME_URL = "https://www.google.com/?hl=en"
21
21
  SEARCH_BOX = "textarea[name='q'], input[name='q']"
@@ -30,6 +30,7 @@ CONSENT_BUTTONS = (
30
30
  )
31
31
  NAV_TIMEOUT_MS = 60_000
32
32
  WARM_TIMEOUT_MS = 30_000
33
+ TYPE_ATTEMPTS = 2
33
34
 
34
35
 
35
36
  @dataclass(frozen=True)
@@ -40,6 +41,8 @@ class Fetched:
40
41
  status: int | None
41
42
  html: str
42
43
  consent_dismissed: bool = False
44
+ #: Seconds per step, e.g. {"type": 3.1, "load": 4.2}: where the time went.
45
+ timings: dict[str, float] = field(default_factory=dict)
43
46
 
44
47
 
45
48
  class BrowserSession:
@@ -65,6 +68,7 @@ class BrowserSession:
65
68
  timezone_id: str | None = None,
66
69
  browser_args: list[str] | tuple[str, ...] = (),
67
70
  rng: random.Random | None = None,
71
+ profile_dir: str | None = None,
68
72
  ) -> None:
69
73
  if any(a.startswith(("--disable-features", "--enable-features")) for a in browser_args):
70
74
  raise ValueError(
@@ -79,19 +83,38 @@ class BrowserSession:
79
83
  self._rng = rng or random.Random()
80
84
  self._pw = None
81
85
  self._context = None
82
- self._profile = None
86
+ # A given directory (a copy of another identity's profile) is used as
87
+ # it is; either way the profile is removed on close.
88
+ self._profile = profile_dir
83
89
  self.page = None
84
90
 
91
+ @property
92
+ def profile_dir(self) -> str | None:
93
+ return self._profile
94
+
85
95
  def __enter__(self) -> BrowserSession:
86
- self._profile = tempfile.mkdtemp(prefix="gbs-profile-")
96
+ if self._profile is None:
97
+ self._profile = tempfile.mkdtemp(prefix="gbs-profile-")
87
98
  try:
88
- self._context = self._launch(self._profile)
99
+ self._open()
89
100
  except Exception:
90
101
  self.close()
91
102
  raise
103
+ return self
104
+
105
+ def _open(self) -> None:
106
+ self._context = self._launch(self._profile)
92
107
  pages = self._context.pages
93
108
  self.page = pages[0] if pages else self._context.new_page()
94
- return self
109
+
110
+ def suspend(self) -> str:
111
+ """Close the browser and keep its profile on disk, so it can be
112
+ copied; `resume()` opens it again on the same proxy."""
113
+ self._shut()
114
+ return self._profile
115
+
116
+ def resume(self) -> None:
117
+ self._open()
95
118
 
96
119
  def _launch(self, profile: str):
97
120
  from patchright.sync_api import sync_playwright
@@ -111,6 +134,12 @@ class BrowserSession:
111
134
  self.close()
112
135
 
113
136
  def close(self) -> None:
137
+ self._shut()
138
+ if self._profile:
139
+ shutil.rmtree(self._profile, ignore_errors=True)
140
+ self._profile = None
141
+
142
+ def _shut(self) -> None:
114
143
  for step in (
115
144
  lambda: self._context and self._context.close(),
116
145
  lambda: self._pw and self._pw.stop(),
@@ -119,9 +148,7 @@ class BrowserSession:
119
148
  step()
120
149
  except Exception:
121
150
  pass
122
- if self._profile:
123
- shutil.rmtree(self._profile, ignore_errors=True)
124
- self._context = self._pw = self._profile = self.page = None
151
+ self._context = self._pw = self.page = None
125
152
 
126
153
  # -- warming -------------------------------------------------------------
127
154
 
@@ -139,52 +166,95 @@ class BrowserSession:
139
166
  def search(self, query: str) -> Fetched:
140
167
  """Type `query` into Google's box and return the page it led to."""
141
168
  page = self.page
142
- if not any(h.is_visible() for h in page.query_selector_all(SEARCH_BOX)):
169
+ timings: dict[str, float] = {}
170
+ mark = time.monotonic()
171
+ if not self._box_visible():
143
172
  page.goto(HOME_URL, wait_until="domcontentloaded", timeout=NAV_TIMEOUT_MS)
144
- dismissed = self._dismiss_consent()
173
+ timings["home"] = round(time.monotonic() - mark, 2)
174
+ mark = time.monotonic()
175
+ dismissed, typed = False, None
176
+ # The page can take focus or swap the box while the query is being
177
+ # typed, which leaves only its first characters behind. The box is
178
+ # found again and the query retyped once before giving up.
179
+ for _ in range(TYPE_ATTEMPTS):
180
+ dismissed = self._dismiss_consent() or dismissed
181
+ box = self._type_query(query)
182
+ try:
183
+ typed = box.input_value()
184
+ except Exception:
185
+ typed = None # the box was replaced and this handle is gone
186
+ if typed == query:
187
+ break
188
+ else:
189
+ raise RuntimeError(
190
+ f"search box holds {typed!r} after typing {query!r} "
191
+ f"{TYPE_ATTEMPTS} times; not submitting"
192
+ )
193
+ timings["type"] = round(time.monotonic() - mark, 2)
194
+ mark = time.monotonic()
195
+ # `no_wait_after=True` keeps `press` from waiting for the navigation a
196
+ # second time with Playwright's own 30 s default under ours.
197
+ with page.expect_navigation(wait_until="domcontentloaded", timeout=NAV_TIMEOUT_MS) as nav:
198
+ box.press("Enter", no_wait_after=True, timeout=NAV_TIMEOUT_MS)
199
+ timings["load"] = round(time.monotonic() - mark, 2)
200
+ fetched = self._snapshot(nav.value, dismissed)
201
+ return replace(fetched, timings={**timings, **fetched.timings})
202
+
203
+ def _box_visible(self) -> bool:
204
+ try:
205
+ return any(h.is_visible() for h in self.page.query_selector_all(SEARCH_BOX))
206
+ except Exception:
207
+ # The page navigated while it was being read, as a warm-up page
208
+ # left idle can; the front page is loaded instead.
209
+ return False
210
+
211
+ def _type_query(self, query: str):
212
+ page = self.page
145
213
  # Act on the handle that was found, never on the selector again: the
146
214
  # selector also matches a hidden field, and `page.click` would pick it.
147
215
  box = page.wait_for_selector(SEARCH_BOX, state="visible", timeout=NAV_TIMEOUT_MS)
148
216
  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.
217
+ # A results page keeps the previous query in the box, and a failed
218
+ # attempt leaves part of this one. Typing after either appends, and the
219
+ # appended query returns a real page for the wrong question. Select it
220
+ # so the typing replaces it.
152
221
  if box.input_value():
153
222
  box.press("ControlOrMeta+a")
154
223
  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)
224
+ return box
163
225
 
164
226
  def next_page(self) -> Fetched | None:
165
227
  """Click "Next" on the current results page, if there is one."""
166
228
  link = self.page.query_selector("a#pnnext")
167
229
  if link is None or not link.is_visible():
168
230
  return None
231
+ mark = time.monotonic()
169
232
  with self.page.expect_navigation(
170
233
  wait_until="domcontentloaded", timeout=NAV_TIMEOUT_MS
171
234
  ) as nav:
172
235
  link.click()
173
- return self._snapshot(nav.value, False)
236
+ load = round(time.monotonic() - mark, 2)
237
+ fetched = self._snapshot(nav.value, False)
238
+ return replace(fetched, timings={"load": load, **fetched.timings})
174
239
 
175
240
  def _snapshot(self, response, dismissed: bool) -> Fetched:
176
241
  # Google builds results in the browser; reading at domcontentloaded
177
242
  # catches the "enable JavaScript" scaffold. A refusal never grows the
178
243
  # container, so the short timeout is expected to expire on those.
244
+ mark = time.monotonic()
179
245
  try:
180
246
  self.page.wait_for_selector(READY, state="attached", timeout=8_000)
181
247
  except Exception:
182
248
  pass
249
+ ready = round(time.monotonic() - mark, 2)
250
+ mark = time.monotonic()
251
+ html = self.page.content()
183
252
  return Fetched(
184
253
  url=self.page.url,
185
254
  status=response.status if response is not None else None,
186
- html=self.page.content(),
255
+ html=html,
187
256
  consent_dismissed=dismissed,
257
+ timings={"ready": ready, "read": round(time.monotonic() - mark, 2)},
188
258
  )
189
259
 
190
260
  def _dismiss_consent(self) -> bool:
@@ -13,6 +13,7 @@ from . import __version__, 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
+ from .pool import Pool
16
17
  from .proxy import NodeMavenSource, ProxyTemplate
17
18
  from .scraper import ExitsRefused, Scraper, Settings
18
19
 
@@ -45,6 +46,13 @@ def _parser() -> argparse.ArgumentParser:
45
46
  search.add_argument("-f", "--file", type=Path, help="one query per line")
46
47
  _scraper_options(search)
47
48
  search.add_argument("--pages", type=int, default=1, help="result pages per query (default 1)")
49
+ search.add_argument(
50
+ "--workers",
51
+ type=int,
52
+ default=1,
53
+ metavar="N",
54
+ help="exits to search on at once, one browser each (default 1)",
55
+ )
48
56
  search.add_argument("--format", choices=list(WRITERS), default="jsonl")
49
57
  search.add_argument("-o", "--output", type=Path, help="output file (default stdout)")
50
58
  search.add_argument(
@@ -108,15 +116,38 @@ def _scraper_options(p: argparse.ArgumentParser) -> None:
108
116
  p.add_argument(
109
117
  "--max-per-exit",
110
118
  type=int,
119
+ default=50,
120
+ help="queries before an exit is retired even if still served (default 50)",
121
+ )
122
+ p.add_argument(
123
+ "--spare",
124
+ type=int,
125
+ default=0,
126
+ metavar="N",
127
+ help="exits to warm in the background while the current one serves, so a "
128
+ "retired exit is replaced without waiting (default 0; each costs a warm-up)",
129
+ )
130
+ p.add_argument(
131
+ "--clone",
132
+ type=int,
111
133
  default=10,
112
- help="queries before an exit is retired even if still served",
134
+ metavar="N",
135
+ help="start up to N new exits from copies of the first served exit's cookies "
136
+ "instead of warming each one (default 10; 0 turns it off)",
137
+ )
138
+ p.add_argument(
139
+ "--clone-max-age",
140
+ type=float,
141
+ default=60.0,
142
+ metavar="MIN",
143
+ help="minutes copied cookies stay in use (default 60)",
113
144
  )
114
145
  p.add_argument(
115
146
  "--gap",
116
147
  type=_pair,
117
- default=(8.0, 20.0),
148
+ default=(2.0, 5.0),
118
149
  metavar="LOW,HIGH",
119
- help="seconds between queries on one exit (default 8,20)",
150
+ help="seconds between queries on one exit (default 2,5)",
120
151
  )
121
152
  p.add_argument(
122
153
  "--headless",
@@ -151,7 +182,7 @@ def _scraper_options(p: argparse.ArgumentParser) -> None:
151
182
  p.add_argument("-v", "--verbose", action="store_true", help="log identities to stderr")
152
183
 
153
184
 
154
- def _build(args, *, pages: int = 1) -> tuple[Scraper, str]:
185
+ def _build(args, *, pages: int = 1, worker: int | None = None) -> tuple[Scraper, str]:
155
186
  if args.nodemaven:
156
187
  proxy = NodeMavenSource(country=args.country)
157
188
  elif args.no_proxy:
@@ -180,18 +211,29 @@ def _build(args, *, pages: int = 1) -> tuple[Scraper, str]:
180
211
  warmup=args.warmup,
181
212
  pages=pages,
182
213
  max_queries_per_exit=args.max_per_exit,
214
+ spare=args.spare,
215
+ clones=args.clone,
216
+ clone_max_age=args.clone_max_age * 60,
183
217
  gap_seconds=args.gap,
184
218
  headless=args.headless,
185
219
  channel=args.channel,
186
220
  timezone_id=args.timezone,
187
221
  browser_args=browser_args,
188
222
  use_relay=not args.no_relay,
189
- save_html=str(args.save_html) if args.save_html else None,
223
+ save_html=_html_dir(args.save_html, worker),
190
224
  resolve_links=not args.no_resolve_links,
191
225
  )
192
- log = (lambda e: print(json.dumps(e), file=sys.stderr)) if args.verbose else None
193
- described = proxy.describe() if proxy else "none (this machine's own address)"
226
+ log = None
194
227
  if args.verbose:
228
+ tag = {} if worker is None else {"worker": worker}
229
+
230
+ def _log(event: dict) -> None:
231
+ print(json.dumps({**tag, **event}), file=sys.stderr, flush=True)
232
+
233
+ log = _log
234
+
235
+ described = proxy.describe() if proxy else "none (this machine's own address)"
236
+ if args.verbose and not worker:
195
237
  print(f"proxy: {described}; engine: {args.engine}", file=sys.stderr)
196
238
  return Scraper(proxy, settings, on_event=log), described
197
239
 
@@ -199,10 +241,13 @@ def _build(args, *, pages: int = 1) -> tuple[Scraper, str]:
199
241
  def _search(args) -> int:
200
242
  queries = list(args.queries)
201
243
  if args.file:
202
- queries += args.file.read_text(encoding="utf-8").splitlines()
244
+ queries += args.file.read_text(encoding="utf-8-sig").splitlines()
203
245
  if not any(q.strip() for q in queries):
204
246
  raise ValueError("no queries given")
205
- scraper, _ = _build(args, pages=args.pages)
247
+ if args.workers != 1:
248
+ scraper = Pool(lambda i: _build(args, pages=args.pages, worker=i)[0], args.workers)
249
+ else:
250
+ scraper, _ = _build(args, pages=args.pages)
206
251
  stream = args.output.open("w", encoding="utf-8", newline="") if args.output else sys.stdout
207
252
  writer = open_writer(args.format, stream)
208
253
  failed, code = 0, 0
@@ -215,6 +260,9 @@ def _search(args) -> int:
215
260
  code = 3
216
261
  finally:
217
262
  writer.close()
263
+ not_run = getattr(scraper, "not_run", [])
264
+ if not_run:
265
+ print(f"not run: {len(not_run)} queries", file=sys.stderr)
218
266
  if args.output:
219
267
  stream.close()
220
268
  print("summary: " + json.dumps(scraper.stats.summary(args.price_per_gb)), file=sys.stderr)
@@ -269,11 +317,18 @@ def _doctor(args) -> int:
269
317
  return 1 if any(c.status == FAIL for c in checks) else 0
270
318
 
271
319
 
320
+ def _html_dir(folder: Path | None, worker: int | None) -> str | None:
321
+ """Each worker numbers its pages from 1, so each gets a folder of its own."""
322
+ if folder is None:
323
+ return None
324
+ return str(folder if worker is None else folder / f"w{worker}")
325
+
326
+
272
327
  def _pair(text: str) -> tuple[float, float]:
273
328
  try:
274
329
  low, high = (float(x) for x in text.split(","))
275
330
  except ValueError:
276
- raise argparse.ArgumentTypeError("expected LOW,HIGH, e.g. 8,20") from None
331
+ raise argparse.ArgumentTypeError("expected LOW,HIGH, e.g. 2,5") from None
277
332
  return low, high
278
333
 
279
334
 
@@ -11,9 +11,11 @@ through a relay so it is counted like everything else.
11
11
 
12
12
  from __future__ import annotations
13
13
 
14
+ import threading
14
15
  import urllib.error
15
16
  import urllib.request
16
17
  from collections.abc import Callable
18
+ from concurrent.futures import ThreadPoolExecutor
17
19
  from typing import Any
18
20
  from urllib.parse import urlsplit
19
21
 
@@ -24,6 +26,8 @@ UA = (
24
26
  "(KHTML, like Gecko) Chrome/151.0.0.0 Safari/537.36"
25
27
  )
26
28
  TIMEOUT_S = 30
29
+ #: /goto links resolved at once for one page.
30
+ PARALLEL = 4
27
31
 
28
32
 
29
33
  class _AlwaysProxy(urllib.request.ProxyHandler):
@@ -61,6 +65,7 @@ class LinkResolver:
61
65
  timeout: float = TIMEOUT_S,
62
66
  ) -> None:
63
67
  self._relay = None
68
+ self._lock = threading.Lock()
64
69
  self.resolved = 0
65
70
  self.failed = 0
66
71
  proxy_url = None
@@ -103,22 +108,36 @@ class LinkResolver:
103
108
  location = err.headers.get("Location") if 300 <= err.code < 400 else None
104
109
  err.read()
105
110
  except Exception:
106
- self.failed += 1
111
+ with self._lock:
112
+ self.failed += 1
107
113
  return None
108
114
  host = urlsplit(location or "").netloc.lower()
109
115
  if not location or "/sorry" in location or host.startswith(("sorry.", "consent.")):
110
- self.failed += 1
111
- self.enabled = False
116
+ with self._lock:
117
+ self.failed += 1
118
+ self.enabled = False
112
119
  return None
113
- self.resolved += 1
120
+ with self._lock:
121
+ self.resolved += 1
114
122
  return location
115
123
 
116
124
  def fill(self, results: list[dict[str, Any]]) -> None:
117
- """Resolve every result that has a /goto redirect and no link yet."""
118
- for result in results:
119
- redirect = result.get("redirect_link") or ""
120
- if result.get("link") is None and "/goto?" in redirect:
121
- result["link"] = self.resolve(redirect)
125
+ """Resolve every result that has a /goto redirect and no link yet.
126
+
127
+ Up to PARALLEL requests run at once. A refusal disables the resolver,
128
+ so the requests still queued behind it are not sent.
129
+ """
130
+ todo = [
131
+ r
132
+ for r in results
133
+ if r.get("link") is None and "/goto?" in (r.get("redirect_link") or "")
134
+ ]
135
+ if not todo:
136
+ return
137
+ with ThreadPoolExecutor(max_workers=min(PARALLEL, len(todo))) as pool:
138
+ links = list(pool.map(lambda r: self.resolve(r["redirect_link"]), todo))
139
+ for result, link in zip(todo, links, strict=True):
140
+ result["link"] = link
122
141
 
123
142
  def close(self) -> None:
124
143
  if self._relay is not None:
@@ -57,6 +57,7 @@ def build_record(
57
57
  parsed: dict[str, Any] | None = None,
58
58
  error: str | None = None,
59
59
  hl: str = "en",
60
+ timings: dict[str, float] | None = None,
60
61
  ) -> dict[str, Any]:
61
62
  ok = error is None and parsed is not None
62
63
  record: dict[str, Any] = {
@@ -77,6 +78,9 @@ def build_record(
77
78
  "device": "desktop",
78
79
  },
79
80
  }
81
+ if timings:
82
+ # Seconds per step of this page: home, type, load, ready, read, parse, links.
83
+ record["search_metadata"]["timings"] = timings
80
84
  if parsed:
81
85
  record.update(parsed)
82
86
  record["exit"] = exit_info