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.
- google_browser_scraper-0.0.3/MANIFEST.in +1 -0
- {google_browser_scraper-0.0.2/src/google_browser_scraper.egg-info → google_browser_scraper-0.0.3}/PKG-INFO +28 -5
- {google_browser_scraper-0.0.2 → google_browser_scraper-0.0.3}/README.md +27 -4
- {google_browser_scraper-0.0.2 → google_browser_scraper-0.0.3}/pyproject.toml +1 -1
- {google_browser_scraper-0.0.2 → google_browser_scraper-0.0.3}/src/google_browser_scraper/__init__.py +1 -1
- {google_browser_scraper-0.0.2 → google_browser_scraper-0.0.3}/src/google_browser_scraper/browser.py +93 -23
- {google_browser_scraper-0.0.2 → google_browser_scraper-0.0.3}/src/google_browser_scraper/cli.py +65 -10
- {google_browser_scraper-0.0.2 → google_browser_scraper-0.0.3}/src/google_browser_scraper/links.py +28 -9
- {google_browser_scraper-0.0.2 → google_browser_scraper-0.0.3}/src/google_browser_scraper/output.py +4 -0
- google_browser_scraper-0.0.3/src/google_browser_scraper/pool.py +162 -0
- {google_browser_scraper-0.0.2 → google_browser_scraper-0.0.3}/src/google_browser_scraper/relay.py +19 -3
- google_browser_scraper-0.0.3/src/google_browser_scraper/scraper.py +869 -0
- {google_browser_scraper-0.0.2 → google_browser_scraper-0.0.3/src/google_browser_scraper.egg-info}/PKG-INFO +28 -5
- {google_browser_scraper-0.0.2 → google_browser_scraper-0.0.3}/src/google_browser_scraper.egg-info/SOURCES.txt +6 -0
- google_browser_scraper-0.0.3/tests/fakegoogle.py +80 -0
- google_browser_scraper-0.0.3/tests/pages.py +93 -0
- google_browser_scraper-0.0.3/tests/stdio_mcp_child.py +33 -0
- {google_browser_scraper-0.0.2 → google_browser_scraper-0.0.3}/tests/test_browser_local.py +28 -0
- {google_browser_scraper-0.0.2 → google_browser_scraper-0.0.3}/tests/test_cli_and_output.py +26 -0
- {google_browser_scraper-0.0.2 → google_browser_scraper-0.0.3}/tests/test_engines_local.py +75 -0
- {google_browser_scraper-0.0.2 → google_browser_scraper-0.0.3}/tests/test_links.py +24 -0
- google_browser_scraper-0.0.3/tests/test_pool.py +105 -0
- {google_browser_scraper-0.0.2 → google_browser_scraper-0.0.3}/tests/test_relay.py +53 -6
- google_browser_scraper-0.0.3/tests/test_scraper.py +672 -0
- google_browser_scraper-0.0.2/src/google_browser_scraper/scraper.py +0 -449
- google_browser_scraper-0.0.2/tests/test_scraper.py +0 -333
- {google_browser_scraper-0.0.2 → google_browser_scraper-0.0.3}/LICENSE +0 -0
- {google_browser_scraper-0.0.2 → google_browser_scraper-0.0.3}/setup.cfg +0 -0
- {google_browser_scraper-0.0.2 → google_browser_scraper-0.0.3}/src/google_browser_scraper/__main__.py +0 -0
- {google_browser_scraper-0.0.2 → google_browser_scraper-0.0.3}/src/google_browser_scraper/classify.py +0 -0
- {google_browser_scraper-0.0.2 → google_browser_scraper-0.0.3}/src/google_browser_scraper/doctor.py +0 -0
- {google_browser_scraper-0.0.2 → google_browser_scraper-0.0.3}/src/google_browser_scraper/engines.py +0 -0
- {google_browser_scraper-0.0.2 → google_browser_scraper-0.0.3}/src/google_browser_scraper/mcp.py +0 -0
- {google_browser_scraper-0.0.2 → google_browser_scraper-0.0.3}/src/google_browser_scraper/parse.py +0 -0
- {google_browser_scraper-0.0.2 → google_browser_scraper-0.0.3}/src/google_browser_scraper/proxy.py +0 -0
- {google_browser_scraper-0.0.2 → google_browser_scraper-0.0.3}/src/google_browser_scraper/server.py +0 -0
- {google_browser_scraper-0.0.2 → google_browser_scraper-0.0.3}/src/google_browser_scraper/warmup.py +0 -0
- {google_browser_scraper-0.0.2 → google_browser_scraper-0.0.3}/src/google_browser_scraper.egg-info/dependency_links.txt +0 -0
- {google_browser_scraper-0.0.2 → google_browser_scraper-0.0.3}/src/google_browser_scraper.egg-info/entry_points.txt +0 -0
- {google_browser_scraper-0.0.2 → google_browser_scraper-0.0.3}/src/google_browser_scraper.egg-info/requires.txt +0 -0
- {google_browser_scraper-0.0.2 → google_browser_scraper-0.0.3}/src/google_browser_scraper.egg-info/top_level.txt +0 -0
- {google_browser_scraper-0.0.2 → google_browser_scraper-0.0.3}/tests/test_classify.py +0 -0
- {google_browser_scraper-0.0.2 → google_browser_scraper-0.0.3}/tests/test_parse.py +0 -0
- {google_browser_scraper-0.0.2 → google_browser_scraper-0.0.3}/tests/test_proxy.py +0 -0
- {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.
|
|
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.
|
|
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,
|
|
135
|
-
resolution does not touch the warmed browser session.
|
|
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.
|
|
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,
|
|
104
|
-
resolution does not touch the warmed browser session.
|
|
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.
|
|
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" }
|
{google_browser_scraper-0.0.2 → google_browser_scraper-0.0.3}/src/google_browser_scraper/__init__.py
RENAMED
|
@@ -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.
|
|
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
|
{google_browser_scraper-0.0.2 → google_browser_scraper-0.0.3}/src/google_browser_scraper/browser.py
RENAMED
|
@@ -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
|
-
|
|
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
|
|
96
|
+
if self._profile is None:
|
|
97
|
+
self._profile = tempfile.mkdtemp(prefix="gbs-profile-")
|
|
87
98
|
try:
|
|
88
|
-
self.
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
|
150
|
-
#
|
|
151
|
-
#
|
|
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
|
-
|
|
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
|
-
|
|
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=
|
|
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:
|
{google_browser_scraper-0.0.2 → google_browser_scraper-0.0.3}/src/google_browser_scraper/cli.py
RENAMED
|
@@ -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
|
-
|
|
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=(
|
|
148
|
+
default=(2.0, 5.0),
|
|
118
149
|
metavar="LOW,HIGH",
|
|
119
|
-
help="seconds between queries on one exit (default
|
|
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=
|
|
223
|
+
save_html=_html_dir(args.save_html, worker),
|
|
190
224
|
resolve_links=not args.no_resolve_links,
|
|
191
225
|
)
|
|
192
|
-
log =
|
|
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
|
-
|
|
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.
|
|
331
|
+
raise argparse.ArgumentTypeError("expected LOW,HIGH, e.g. 2,5") from None
|
|
277
332
|
return low, high
|
|
278
333
|
|
|
279
334
|
|
{google_browser_scraper-0.0.2 → google_browser_scraper-0.0.3}/src/google_browser_scraper/links.py
RENAMED
|
@@ -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.
|
|
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.
|
|
111
|
-
|
|
116
|
+
with self._lock:
|
|
117
|
+
self.failed += 1
|
|
118
|
+
self.enabled = False
|
|
112
119
|
return None
|
|
113
|
-
self.
|
|
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
|
-
|
|
119
|
-
|
|
120
|
-
|
|
121
|
-
|
|
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:
|
{google_browser_scraper-0.0.2 → google_browser_scraper-0.0.3}/src/google_browser_scraper/output.py
RENAMED
|
@@ -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
|