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