nodriver-proxyhat 0.1.0__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.
- nodriver_proxyhat-0.1.0/.github/workflows/ci.yml +59 -0
- nodriver_proxyhat-0.1.0/.github/workflows/compat.yml +68 -0
- nodriver_proxyhat-0.1.0/.gitignore +8 -0
- nodriver_proxyhat-0.1.0/LICENSE +21 -0
- nodriver_proxyhat-0.1.0/PKG-INFO +150 -0
- nodriver_proxyhat-0.1.0/README.md +121 -0
- nodriver_proxyhat-0.1.0/examples/basic.py +27 -0
- nodriver_proxyhat-0.1.0/pyproject.toml +59 -0
- nodriver_proxyhat-0.1.0/renovate.json +19 -0
- nodriver_proxyhat-0.1.0/src/nodriver_proxyhat/__init__.py +15 -0
- nodriver_proxyhat-0.1.0/src/nodriver_proxyhat/_auth.py +55 -0
- nodriver_proxyhat-0.1.0/src/nodriver_proxyhat/_resolve.py +71 -0
- nodriver_proxyhat-0.1.0/src/nodriver_proxyhat/proxy.py +162 -0
- nodriver_proxyhat-0.1.0/tests/test_browser.py +190 -0
- nodriver_proxyhat-0.1.0/tests/test_proxy.py +117 -0
|
@@ -0,0 +1,59 @@
|
|
|
1
|
+
name: CI
|
|
2
|
+
|
|
3
|
+
on:
|
|
4
|
+
push:
|
|
5
|
+
branches: [main]
|
|
6
|
+
tags: ["v*"]
|
|
7
|
+
pull_request:
|
|
8
|
+
branches: [main]
|
|
9
|
+
|
|
10
|
+
jobs:
|
|
11
|
+
lint:
|
|
12
|
+
runs-on: ubuntu-latest
|
|
13
|
+
steps:
|
|
14
|
+
- uses: actions/checkout@v4
|
|
15
|
+
- uses: actions/setup-python@v5
|
|
16
|
+
with:
|
|
17
|
+
python-version: "3.12"
|
|
18
|
+
- run: pip install ruff
|
|
19
|
+
- run: ruff check src/ tests/
|
|
20
|
+
- run: ruff format --check src/ tests/
|
|
21
|
+
|
|
22
|
+
test:
|
|
23
|
+
runs-on: ubuntu-latest
|
|
24
|
+
strategy:
|
|
25
|
+
matrix:
|
|
26
|
+
python-version: ["3.11", "3.12", "3.13"]
|
|
27
|
+
steps:
|
|
28
|
+
- uses: actions/checkout@v4
|
|
29
|
+
- uses: actions/setup-python@v5
|
|
30
|
+
with:
|
|
31
|
+
python-version: ${{ matrix.python-version }}
|
|
32
|
+
# Tests stub nodriver, so [dev] alone (no Chrome) is enough.
|
|
33
|
+
- run: pip install -e ".[dev]"
|
|
34
|
+
- run: pytest -v
|
|
35
|
+
|
|
36
|
+
build:
|
|
37
|
+
runs-on: ubuntu-latest
|
|
38
|
+
steps:
|
|
39
|
+
- uses: actions/checkout@v4
|
|
40
|
+
- uses: actions/setup-python@v5
|
|
41
|
+
with:
|
|
42
|
+
python-version: "3.12"
|
|
43
|
+
- run: pip install build
|
|
44
|
+
- run: python -m build
|
|
45
|
+
|
|
46
|
+
publish:
|
|
47
|
+
needs: [lint, test, build]
|
|
48
|
+
runs-on: ubuntu-latest
|
|
49
|
+
if: startsWith(github.ref, 'refs/tags/v')
|
|
50
|
+
permissions:
|
|
51
|
+
id-token: write
|
|
52
|
+
steps:
|
|
53
|
+
- uses: actions/checkout@v4
|
|
54
|
+
- uses: actions/setup-python@v5
|
|
55
|
+
with:
|
|
56
|
+
python-version: "3.12"
|
|
57
|
+
- run: pip install build
|
|
58
|
+
- run: python -m build
|
|
59
|
+
- uses: pypa/gh-action-pypi-publish@release/v1
|
|
@@ -0,0 +1,68 @@
|
|
|
1
|
+
name: Compat (nodriver latest)
|
|
2
|
+
|
|
3
|
+
# Autonomy watchdog: weekly (and on demand) it installs the newest nodriver and
|
|
4
|
+
# our newest proxyhat SDK, then runs our tests against them. A red run means
|
|
5
|
+
# upstream shipped a breaking change to the CDP Fetch auth surface we rely on —
|
|
6
|
+
# it opens/updates a tracking issue so we notice before users do. The proactive
|
|
7
|
+
# path is Renovate (renovate.json), which opens a version-bump PR that must pass
|
|
8
|
+
# CI before merging.
|
|
9
|
+
on:
|
|
10
|
+
schedule:
|
|
11
|
+
- cron: "0 6 * * 1" # Mondays 06:00 UTC
|
|
12
|
+
workflow_dispatch:
|
|
13
|
+
|
|
14
|
+
permissions:
|
|
15
|
+
contents: read
|
|
16
|
+
issues: write
|
|
17
|
+
|
|
18
|
+
jobs:
|
|
19
|
+
compat:
|
|
20
|
+
runs-on: ubuntu-latest
|
|
21
|
+
steps:
|
|
22
|
+
- uses: actions/checkout@v4
|
|
23
|
+
- uses: actions/setup-python@v5
|
|
24
|
+
with:
|
|
25
|
+
python-version: "3.12"
|
|
26
|
+
- run: pip install -e ".[dev]"
|
|
27
|
+
- name: Install latest upstreams
|
|
28
|
+
run: pip install --upgrade nodriver proxyhat
|
|
29
|
+
- name: Import the real nodriver CDP auth surface
|
|
30
|
+
# Fails loudly if nodriver renamed the Fetch/Network CDP auth primitives.
|
|
31
|
+
run: |
|
|
32
|
+
python - <<'PY'
|
|
33
|
+
import inspect
|
|
34
|
+
import nodriver
|
|
35
|
+
from nodriver import cdp
|
|
36
|
+
|
|
37
|
+
assert hasattr(nodriver, "start"), "nodriver.start is gone"
|
|
38
|
+
assert "browser_args" in inspect.signature(nodriver.start).parameters, \
|
|
39
|
+
"nodriver.start no longer accepts browser_args"
|
|
40
|
+
for attr in ("enable", "continue_with_auth", "continue_request", "AuthRequired", "RequestPaused"):
|
|
41
|
+
assert hasattr(cdp.fetch, attr), f"nodriver cdp.fetch.{attr} is gone"
|
|
42
|
+
assert hasattr(cdp.network, "AuthChallengeResponse"), \
|
|
43
|
+
"nodriver cdp.network.AuthChallengeResponse is gone"
|
|
44
|
+
print("nodriver CDP auth surface OK")
|
|
45
|
+
PY
|
|
46
|
+
- run: pytest -v
|
|
47
|
+
- name: Open an issue on failure
|
|
48
|
+
if: failure()
|
|
49
|
+
uses: actions/github-script@v7
|
|
50
|
+
with:
|
|
51
|
+
script: |
|
|
52
|
+
const title = "Compat break: nodriver@latest / proxyhat@latest";
|
|
53
|
+
const { data: issues } = await github.rest.issues.listForRepo({
|
|
54
|
+
owner: context.repo.owner, repo: context.repo.repo,
|
|
55
|
+
state: "open", labels: "compat",
|
|
56
|
+
});
|
|
57
|
+
const body = `The weekly compatibility check failed against the latest upstream releases.\n\nRun: ${context.serverUrl}/${context.repo.owner}/${context.repo.repo}/actions/runs/${context.runId}`;
|
|
58
|
+
if (issues.length === 0) {
|
|
59
|
+
await github.rest.issues.create({
|
|
60
|
+
owner: context.repo.owner, repo: context.repo.repo,
|
|
61
|
+
title, body, labels: ["compat"],
|
|
62
|
+
});
|
|
63
|
+
} else {
|
|
64
|
+
await github.rest.issues.createComment({
|
|
65
|
+
owner: context.repo.owner, repo: context.repo.repo,
|
|
66
|
+
issue_number: issues[0].number, body,
|
|
67
|
+
});
|
|
68
|
+
}
|
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 ProxyHat
|
|
4
|
+
|
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
6
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
7
|
+
in the Software without restriction, including without limitation the rights
|
|
8
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
9
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
10
|
+
furnished to do so, subject to the following conditions:
|
|
11
|
+
|
|
12
|
+
The above copyright notice and this permission notice shall be included in all
|
|
13
|
+
copies or substantial portions of the Software.
|
|
14
|
+
|
|
15
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
16
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
17
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
18
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
19
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
20
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
21
|
+
SOFTWARE.
|
|
@@ -0,0 +1,150 @@
|
|
|
1
|
+
Metadata-Version: 2.4
|
|
2
|
+
Name: nodriver-proxyhat
|
|
3
|
+
Version: 0.1.0
|
|
4
|
+
Summary: ProxyHat residential proxies for nodriver (undetected Chrome) — CDP proxy auth, sticky sessions, geo-targeting, rotation.
|
|
5
|
+
Project-URL: Homepage, https://proxyhat.com
|
|
6
|
+
Project-URL: Documentation, https://docs.proxyhat.com
|
|
7
|
+
Project-URL: Repository, https://github.com/ProxyHatCom/nodriver-proxyhat
|
|
8
|
+
Author-email: ProxyHat <support@proxyhat.com>
|
|
9
|
+
License-Expression: MIT
|
|
10
|
+
License-File: LICENSE
|
|
11
|
+
Keywords: browser-automation,cdp,nodriver,proxy,proxyhat,residential-proxy,sticky-session,undetected-chromedriver,web-scraping
|
|
12
|
+
Classifier: Development Status :: 4 - Beta
|
|
13
|
+
Classifier: Intended Audience :: Developers
|
|
14
|
+
Classifier: License :: OSI Approved :: MIT License
|
|
15
|
+
Classifier: Programming Language :: Python :: 3
|
|
16
|
+
Classifier: Programming Language :: Python :: 3.11
|
|
17
|
+
Classifier: Programming Language :: Python :: 3.12
|
|
18
|
+
Classifier: Programming Language :: Python :: 3.13
|
|
19
|
+
Classifier: Topic :: Internet :: Proxy Servers
|
|
20
|
+
Classifier: Topic :: Internet :: WWW/HTTP
|
|
21
|
+
Requires-Python: >=3.11
|
|
22
|
+
Requires-Dist: proxyhat>=0.2.0
|
|
23
|
+
Provides-Extra: dev
|
|
24
|
+
Requires-Dist: pytest>=7.0; extra == 'dev'
|
|
25
|
+
Requires-Dist: ruff>=0.4; extra == 'dev'
|
|
26
|
+
Provides-Extra: nodriver
|
|
27
|
+
Requires-Dist: nodriver>=0.32; extra == 'nodriver'
|
|
28
|
+
Description-Content-Type: text/markdown
|
|
29
|
+
|
|
30
|
+
# nodriver-proxyhat
|
|
31
|
+
|
|
32
|
+
Route [nodriver](https://github.com/ultrafunkamsterdam/nodriver) — the successor to undetected-chromedriver — through [ProxyHat](https://proxyhat.com?utm_source=github&utm_medium=readme&utm_campaign=nodriver) residential proxies. **Authenticated** gateway proxies wired straight into nodriver over CDP, plus a sticky residential IP pinned for the whole session, geo-targeting, and rotation.
|
|
33
|
+
|
|
34
|
+
[](https://github.com/ProxyHatCom/nodriver-proxyhat/actions/workflows/ci.yml)
|
|
35
|
+
[](https://github.com/ProxyHatCom/nodriver-proxyhat/actions/workflows/compat.yml)
|
|
36
|
+
[](https://pypi.org/project/nodriver-proxyhat/)
|
|
37
|
+
[](LICENSE)
|
|
38
|
+
|
|
39
|
+
## Why
|
|
40
|
+
|
|
41
|
+
Chrome's `--proxy-server` flag **can't carry a username and password**, so pointing an undetected browser at a credentialed residential gateway normally means a manual CDP dance. And running a real browser from a datacenter IP gets flagged, CAPTCHA-walled, and blocked anyway — exactly what you're using nodriver to avoid.
|
|
42
|
+
|
|
43
|
+
`nodriver-proxyhat` fixes both. It plugs ProxyHat's residential IPs (50M+ across 148+ countries) into nodriver and handles gateway auth the CDP way nodriver is built for: it enables the **Fetch** domain and answers the proxy's `Fetch.authRequired` challenge with your credentials. One pinned residential IP per session by default, so cookies and fingerprint stay consistent while your script works.
|
|
44
|
+
|
|
45
|
+
## Install
|
|
46
|
+
|
|
47
|
+
```bash
|
|
48
|
+
pip install nodriver-proxyhat nodriver
|
|
49
|
+
```
|
|
50
|
+
|
|
51
|
+
`nodriver` is a peer dependency — bring your own version (and its Chrome). The `proxyhat_browser_args()` / `proxyhat_auth()` helpers work without it; `proxyhat_browser()` needs nodriver installed.
|
|
52
|
+
|
|
53
|
+
## Quick start
|
|
54
|
+
|
|
55
|
+
```python
|
|
56
|
+
import nodriver as uc
|
|
57
|
+
from nodriver_proxyhat import proxyhat_browser
|
|
58
|
+
|
|
59
|
+
async def main():
|
|
60
|
+
# An API key (PROXYHAT_API_KEY) auto-selects an active residential sub-user:
|
|
61
|
+
browser = await proxyhat_browser(country="us") # sticky US IP for the whole session
|
|
62
|
+
page = await browser.get("https://httpbin.org/ip")
|
|
63
|
+
print(await page.get_content())
|
|
64
|
+
browser.stop()
|
|
65
|
+
|
|
66
|
+
uc.loop().run_until_complete(main())
|
|
67
|
+
```
|
|
68
|
+
|
|
69
|
+
Get an API key at [proxyhat.com](https://proxyhat.com?utm_source=github&utm_medium=readme&utm_campaign=nodriver).
|
|
70
|
+
|
|
71
|
+
`proxyhat_browser(...)` calls `nodriver.start(...)` for you and forwards any extra keyword arguments (`headless`, `user_data_dir`, `browser_args`, …) unchanged.
|
|
72
|
+
|
|
73
|
+
## Credentials
|
|
74
|
+
|
|
75
|
+
Pass them explicitly or via environment variables — options win over env:
|
|
76
|
+
|
|
77
|
+
| Option | Env var | Notes |
|
|
78
|
+
|---|---|---|
|
|
79
|
+
| `api_key` | `PROXYHAT_API_KEY` | Auto-selects an active sub-user with remaining traffic |
|
|
80
|
+
| `sub_user` | `PROXYHAT_SUBUSER` | Pick a specific sub-user by uuid or name (with an API key) |
|
|
81
|
+
| `username` | `PROXYHAT_USERNAME` | Explicit gateway `proxy_username` (skips the API) |
|
|
82
|
+
| `password` | `PROXYHAT_PASSWORD` | Explicit gateway `proxy_password` |
|
|
83
|
+
|
|
84
|
+
## Targeting
|
|
85
|
+
|
|
86
|
+
```python
|
|
87
|
+
await proxyhat_browser(
|
|
88
|
+
country="us", # ISO code or "any" (default)
|
|
89
|
+
region="california",
|
|
90
|
+
city="new_york",
|
|
91
|
+
filter="high", # AI IP-quality tier
|
|
92
|
+
sticky="30m", # session lifetime (default); sticky=False rotates every request
|
|
93
|
+
headless=True, # any extra kwarg is forwarded to nodriver.start
|
|
94
|
+
)
|
|
95
|
+
```
|
|
96
|
+
|
|
97
|
+
The same targeting keyword arguments work on `proxyhat_auth(...)`.
|
|
98
|
+
|
|
99
|
+
### Sticky IP per session (default)
|
|
100
|
+
|
|
101
|
+
A browser session takes many steps against the same site — logging in, clicking, scrolling. If the exit IP changed mid-session the site would see a user teleporting between cities and block it. So this package is **sticky by default**: one residential IP is pinned for the whole session (`sticky="30m"`, renewed as you work), keeping cookies and fingerprint coherent.
|
|
102
|
+
|
|
103
|
+
Want a fresh IP on **every** request instead (e.g. many independent one-shot fetches)? Turn stickiness off:
|
|
104
|
+
|
|
105
|
+
```python
|
|
106
|
+
await proxyhat_browser(country="us", sticky=False) # rotating residential IP per connection
|
|
107
|
+
```
|
|
108
|
+
|
|
109
|
+
Set a custom lifetime with `sticky="2h"`.
|
|
110
|
+
|
|
111
|
+
## How authentication works
|
|
112
|
+
|
|
113
|
+
nodriver takes the proxy host/port from `--proxy-server=gate.proxyhat.com:8080`, but a residential gateway also needs a username (the ProxyHat targeting string) and password. Since the flag can't carry them, `proxyhat_browser` uses nodriver's raw Chrome DevTools Protocol access instead:
|
|
114
|
+
|
|
115
|
+
1. it enables the **Fetch** domain on the main tab with `handle_auth_requests=True`;
|
|
116
|
+
2. it registers a `Fetch.authRequired` handler that answers with your targeting username + sub-user password via `Fetch.continueWithAuth` (`ProvideCredentials`);
|
|
117
|
+
3. it resumes every other paused request with `Fetch.continueRequest` (enabling Fetch pauses all requests, so non-auth ones must be continued too).
|
|
118
|
+
|
|
119
|
+
The targeting username (e.g. `<user>-country-us-sid-<id>-ttl-30m`) is built by the official [`proxyhat`](https://pypi.org/project/proxyhat/) SDK, so a sticky session mints a single session id shared across the run.
|
|
120
|
+
|
|
121
|
+
This is the **HTTP gateway** (port 8080) — CDP proxy auth answers the HTTP proxy's basic-auth challenge.
|
|
122
|
+
|
|
123
|
+
## Wiring it yourself
|
|
124
|
+
|
|
125
|
+
Prefer to drive `nodriver.start()` your way? Grab the launch flag and the resolved credentials and wire the CDP handler yourself:
|
|
126
|
+
|
|
127
|
+
```python
|
|
128
|
+
import nodriver as uc
|
|
129
|
+
from nodriver import cdp
|
|
130
|
+
from nodriver_proxyhat import proxyhat_auth, proxyhat_browser_args, enable_proxy_auth
|
|
131
|
+
|
|
132
|
+
async def main():
|
|
133
|
+
username, password = proxyhat_auth(country="de", sticky="1h")
|
|
134
|
+
|
|
135
|
+
browser = await uc.start(browser_args=proxyhat_browser_args() + ["--headless=new"])
|
|
136
|
+
# enable_proxy_auth does the Fetch.enable + authRequired dance for you:
|
|
137
|
+
await enable_proxy_auth(browser.main_tab, username, password)
|
|
138
|
+
|
|
139
|
+
page = await browser.get("https://httpbin.org/ip")
|
|
140
|
+
print(await page.get_content())
|
|
141
|
+
browser.stop()
|
|
142
|
+
|
|
143
|
+
uc.loop().run_until_complete(main())
|
|
144
|
+
```
|
|
145
|
+
|
|
146
|
+
`proxyhat_browser_args()` returns `["--proxy-server=gate.proxyhat.com:8080"]`; `proxyhat_auth()` returns the `(username, password)` for a `Fetch.authRequired` handler.
|
|
147
|
+
|
|
148
|
+
## License
|
|
149
|
+
|
|
150
|
+
MIT © [ProxyHat](https://proxyhat.com)
|
|
@@ -0,0 +1,121 @@
|
|
|
1
|
+
# nodriver-proxyhat
|
|
2
|
+
|
|
3
|
+
Route [nodriver](https://github.com/ultrafunkamsterdam/nodriver) — the successor to undetected-chromedriver — through [ProxyHat](https://proxyhat.com?utm_source=github&utm_medium=readme&utm_campaign=nodriver) residential proxies. **Authenticated** gateway proxies wired straight into nodriver over CDP, plus a sticky residential IP pinned for the whole session, geo-targeting, and rotation.
|
|
4
|
+
|
|
5
|
+
[](https://github.com/ProxyHatCom/nodriver-proxyhat/actions/workflows/ci.yml)
|
|
6
|
+
[](https://github.com/ProxyHatCom/nodriver-proxyhat/actions/workflows/compat.yml)
|
|
7
|
+
[](https://pypi.org/project/nodriver-proxyhat/)
|
|
8
|
+
[](LICENSE)
|
|
9
|
+
|
|
10
|
+
## Why
|
|
11
|
+
|
|
12
|
+
Chrome's `--proxy-server` flag **can't carry a username and password**, so pointing an undetected browser at a credentialed residential gateway normally means a manual CDP dance. And running a real browser from a datacenter IP gets flagged, CAPTCHA-walled, and blocked anyway — exactly what you're using nodriver to avoid.
|
|
13
|
+
|
|
14
|
+
`nodriver-proxyhat` fixes both. It plugs ProxyHat's residential IPs (50M+ across 148+ countries) into nodriver and handles gateway auth the CDP way nodriver is built for: it enables the **Fetch** domain and answers the proxy's `Fetch.authRequired` challenge with your credentials. One pinned residential IP per session by default, so cookies and fingerprint stay consistent while your script works.
|
|
15
|
+
|
|
16
|
+
## Install
|
|
17
|
+
|
|
18
|
+
```bash
|
|
19
|
+
pip install nodriver-proxyhat nodriver
|
|
20
|
+
```
|
|
21
|
+
|
|
22
|
+
`nodriver` is a peer dependency — bring your own version (and its Chrome). The `proxyhat_browser_args()` / `proxyhat_auth()` helpers work without it; `proxyhat_browser()` needs nodriver installed.
|
|
23
|
+
|
|
24
|
+
## Quick start
|
|
25
|
+
|
|
26
|
+
```python
|
|
27
|
+
import nodriver as uc
|
|
28
|
+
from nodriver_proxyhat import proxyhat_browser
|
|
29
|
+
|
|
30
|
+
async def main():
|
|
31
|
+
# An API key (PROXYHAT_API_KEY) auto-selects an active residential sub-user:
|
|
32
|
+
browser = await proxyhat_browser(country="us") # sticky US IP for the whole session
|
|
33
|
+
page = await browser.get("https://httpbin.org/ip")
|
|
34
|
+
print(await page.get_content())
|
|
35
|
+
browser.stop()
|
|
36
|
+
|
|
37
|
+
uc.loop().run_until_complete(main())
|
|
38
|
+
```
|
|
39
|
+
|
|
40
|
+
Get an API key at [proxyhat.com](https://proxyhat.com?utm_source=github&utm_medium=readme&utm_campaign=nodriver).
|
|
41
|
+
|
|
42
|
+
`proxyhat_browser(...)` calls `nodriver.start(...)` for you and forwards any extra keyword arguments (`headless`, `user_data_dir`, `browser_args`, …) unchanged.
|
|
43
|
+
|
|
44
|
+
## Credentials
|
|
45
|
+
|
|
46
|
+
Pass them explicitly or via environment variables — options win over env:
|
|
47
|
+
|
|
48
|
+
| Option | Env var | Notes |
|
|
49
|
+
|---|---|---|
|
|
50
|
+
| `api_key` | `PROXYHAT_API_KEY` | Auto-selects an active sub-user with remaining traffic |
|
|
51
|
+
| `sub_user` | `PROXYHAT_SUBUSER` | Pick a specific sub-user by uuid or name (with an API key) |
|
|
52
|
+
| `username` | `PROXYHAT_USERNAME` | Explicit gateway `proxy_username` (skips the API) |
|
|
53
|
+
| `password` | `PROXYHAT_PASSWORD` | Explicit gateway `proxy_password` |
|
|
54
|
+
|
|
55
|
+
## Targeting
|
|
56
|
+
|
|
57
|
+
```python
|
|
58
|
+
await proxyhat_browser(
|
|
59
|
+
country="us", # ISO code or "any" (default)
|
|
60
|
+
region="california",
|
|
61
|
+
city="new_york",
|
|
62
|
+
filter="high", # AI IP-quality tier
|
|
63
|
+
sticky="30m", # session lifetime (default); sticky=False rotates every request
|
|
64
|
+
headless=True, # any extra kwarg is forwarded to nodriver.start
|
|
65
|
+
)
|
|
66
|
+
```
|
|
67
|
+
|
|
68
|
+
The same targeting keyword arguments work on `proxyhat_auth(...)`.
|
|
69
|
+
|
|
70
|
+
### Sticky IP per session (default)
|
|
71
|
+
|
|
72
|
+
A browser session takes many steps against the same site — logging in, clicking, scrolling. If the exit IP changed mid-session the site would see a user teleporting between cities and block it. So this package is **sticky by default**: one residential IP is pinned for the whole session (`sticky="30m"`, renewed as you work), keeping cookies and fingerprint coherent.
|
|
73
|
+
|
|
74
|
+
Want a fresh IP on **every** request instead (e.g. many independent one-shot fetches)? Turn stickiness off:
|
|
75
|
+
|
|
76
|
+
```python
|
|
77
|
+
await proxyhat_browser(country="us", sticky=False) # rotating residential IP per connection
|
|
78
|
+
```
|
|
79
|
+
|
|
80
|
+
Set a custom lifetime with `sticky="2h"`.
|
|
81
|
+
|
|
82
|
+
## How authentication works
|
|
83
|
+
|
|
84
|
+
nodriver takes the proxy host/port from `--proxy-server=gate.proxyhat.com:8080`, but a residential gateway also needs a username (the ProxyHat targeting string) and password. Since the flag can't carry them, `proxyhat_browser` uses nodriver's raw Chrome DevTools Protocol access instead:
|
|
85
|
+
|
|
86
|
+
1. it enables the **Fetch** domain on the main tab with `handle_auth_requests=True`;
|
|
87
|
+
2. it registers a `Fetch.authRequired` handler that answers with your targeting username + sub-user password via `Fetch.continueWithAuth` (`ProvideCredentials`);
|
|
88
|
+
3. it resumes every other paused request with `Fetch.continueRequest` (enabling Fetch pauses all requests, so non-auth ones must be continued too).
|
|
89
|
+
|
|
90
|
+
The targeting username (e.g. `<user>-country-us-sid-<id>-ttl-30m`) is built by the official [`proxyhat`](https://pypi.org/project/proxyhat/) SDK, so a sticky session mints a single session id shared across the run.
|
|
91
|
+
|
|
92
|
+
This is the **HTTP gateway** (port 8080) — CDP proxy auth answers the HTTP proxy's basic-auth challenge.
|
|
93
|
+
|
|
94
|
+
## Wiring it yourself
|
|
95
|
+
|
|
96
|
+
Prefer to drive `nodriver.start()` your way? Grab the launch flag and the resolved credentials and wire the CDP handler yourself:
|
|
97
|
+
|
|
98
|
+
```python
|
|
99
|
+
import nodriver as uc
|
|
100
|
+
from nodriver import cdp
|
|
101
|
+
from nodriver_proxyhat import proxyhat_auth, proxyhat_browser_args, enable_proxy_auth
|
|
102
|
+
|
|
103
|
+
async def main():
|
|
104
|
+
username, password = proxyhat_auth(country="de", sticky="1h")
|
|
105
|
+
|
|
106
|
+
browser = await uc.start(browser_args=proxyhat_browser_args() + ["--headless=new"])
|
|
107
|
+
# enable_proxy_auth does the Fetch.enable + authRequired dance for you:
|
|
108
|
+
await enable_proxy_auth(browser.main_tab, username, password)
|
|
109
|
+
|
|
110
|
+
page = await browser.get("https://httpbin.org/ip")
|
|
111
|
+
print(await page.get_content())
|
|
112
|
+
browser.stop()
|
|
113
|
+
|
|
114
|
+
uc.loop().run_until_complete(main())
|
|
115
|
+
```
|
|
116
|
+
|
|
117
|
+
`proxyhat_browser_args()` returns `["--proxy-server=gate.proxyhat.com:8080"]`; `proxyhat_auth()` returns the `(username, password)` for a `Fetch.authRequired` handler.
|
|
118
|
+
|
|
119
|
+
## License
|
|
120
|
+
|
|
121
|
+
MIT © [ProxyHat](https://proxyhat.com)
|
|
@@ -0,0 +1,27 @@
|
|
|
1
|
+
"""Minimal nodriver + ProxyHat example.
|
|
2
|
+
|
|
3
|
+
PROXYHAT_API_KEY=ph_xxx python examples/basic.py
|
|
4
|
+
|
|
5
|
+
nodriver launches an undetected Chrome routed through a US residential IP, pinned
|
|
6
|
+
for the whole session (sticky by default) so cookies and fingerprint stay
|
|
7
|
+
consistent. Gateway auth is handled over CDP — no extension, no proxy wrapper.
|
|
8
|
+
"""
|
|
9
|
+
|
|
10
|
+
import nodriver as uc
|
|
11
|
+
|
|
12
|
+
from nodriver_proxyhat import proxyhat_browser
|
|
13
|
+
|
|
14
|
+
|
|
15
|
+
async def main() -> None:
|
|
16
|
+
# api_key defaults to PROXYHAT_API_KEY; auto-selects an active sub-user.
|
|
17
|
+
browser = await proxyhat_browser(country="us", headless=False)
|
|
18
|
+
|
|
19
|
+
page = await browser.get("https://httpbin.org/ip")
|
|
20
|
+
print(await page.get_content())
|
|
21
|
+
|
|
22
|
+
browser.stop()
|
|
23
|
+
|
|
24
|
+
|
|
25
|
+
if __name__ == "__main__":
|
|
26
|
+
# nodriver ships its own loop helper (asyncio.run never worked reliably for it).
|
|
27
|
+
uc.loop().run_until_complete(main())
|
|
@@ -0,0 +1,59 @@
|
|
|
1
|
+
[build-system]
|
|
2
|
+
requires = ["hatchling"]
|
|
3
|
+
build-backend = "hatchling.build"
|
|
4
|
+
|
|
5
|
+
[project]
|
|
6
|
+
name = "nodriver-proxyhat"
|
|
7
|
+
version = "0.1.0"
|
|
8
|
+
description = "ProxyHat residential proxies for nodriver (undetected Chrome) — CDP proxy auth, sticky sessions, geo-targeting, rotation."
|
|
9
|
+
readme = "README.md"
|
|
10
|
+
license = "MIT"
|
|
11
|
+
requires-python = ">=3.11"
|
|
12
|
+
authors = [{ name = "ProxyHat", email = "support@proxyhat.com" }]
|
|
13
|
+
keywords = [
|
|
14
|
+
"nodriver",
|
|
15
|
+
"undetected-chromedriver",
|
|
16
|
+
"proxy",
|
|
17
|
+
"residential-proxy",
|
|
18
|
+
"proxyhat",
|
|
19
|
+
"browser-automation",
|
|
20
|
+
"web-scraping",
|
|
21
|
+
"cdp",
|
|
22
|
+
"sticky-session",
|
|
23
|
+
]
|
|
24
|
+
classifiers = [
|
|
25
|
+
"Development Status :: 4 - Beta",
|
|
26
|
+
"Intended Audience :: Developers",
|
|
27
|
+
"License :: OSI Approved :: MIT License",
|
|
28
|
+
"Programming Language :: Python :: 3",
|
|
29
|
+
"Programming Language :: Python :: 3.11",
|
|
30
|
+
"Programming Language :: Python :: 3.12",
|
|
31
|
+
"Programming Language :: Python :: 3.13",
|
|
32
|
+
"Topic :: Internet :: WWW/HTTP",
|
|
33
|
+
"Topic :: Internet :: Proxy Servers",
|
|
34
|
+
]
|
|
35
|
+
dependencies = ["proxyhat>=0.2.0"]
|
|
36
|
+
|
|
37
|
+
[project.urls]
|
|
38
|
+
Homepage = "https://proxyhat.com"
|
|
39
|
+
Documentation = "https://docs.proxyhat.com"
|
|
40
|
+
Repository = "https://github.com/ProxyHatCom/nodriver-proxyhat"
|
|
41
|
+
|
|
42
|
+
[project.optional-dependencies]
|
|
43
|
+
# nodriver is a peer/optional dependency: install it yourself to launch a real
|
|
44
|
+
# browser. proxyhat_browser_args() and proxyhat_auth() work without it.
|
|
45
|
+
nodriver = ["nodriver>=0.32"]
|
|
46
|
+
dev = ["pytest>=7.0", "ruff>=0.4"]
|
|
47
|
+
|
|
48
|
+
[tool.hatch.build.targets.wheel]
|
|
49
|
+
packages = ["src/nodriver_proxyhat"]
|
|
50
|
+
|
|
51
|
+
[tool.ruff]
|
|
52
|
+
target-version = "py311"
|
|
53
|
+
line-length = 120
|
|
54
|
+
|
|
55
|
+
[tool.ruff.lint]
|
|
56
|
+
select = ["E", "F", "I", "N", "W", "UP"]
|
|
57
|
+
|
|
58
|
+
[tool.pytest.ini_options]
|
|
59
|
+
testpaths = ["tests"]
|
|
@@ -0,0 +1,19 @@
|
|
|
1
|
+
{
|
|
2
|
+
"$schema": "https://docs.renovatebot.com/renovate-schema.json",
|
|
3
|
+
"extends": ["config:recommended", ":semanticCommits"],
|
|
4
|
+
"labels": ["dependencies"],
|
|
5
|
+
"schedule": ["before 6am on monday"],
|
|
6
|
+
"packageRules": [
|
|
7
|
+
{
|
|
8
|
+
"description": "Auto-merge dev-dependency and upstream bumps once CI (incl. compat) is green.",
|
|
9
|
+
"matchDepTypes": ["build-system.requires", "project.dependencies", "project.optional-dependencies"],
|
|
10
|
+
"automerge": true,
|
|
11
|
+
"automergeType": "pr"
|
|
12
|
+
},
|
|
13
|
+
{
|
|
14
|
+
"description": "Track the nodriver range but never auto-widen it silently.",
|
|
15
|
+
"matchPackageNames": ["nodriver"],
|
|
16
|
+
"automerge": false
|
|
17
|
+
}
|
|
18
|
+
]
|
|
19
|
+
}
|
|
@@ -0,0 +1,15 @@
|
|
|
1
|
+
"""nodriver-proxyhat — route nodriver (undetected Chrome) through ProxyHat residential proxies."""
|
|
2
|
+
|
|
3
|
+
from nodriver_proxyhat._auth import enable_proxy_auth
|
|
4
|
+
from nodriver_proxyhat._resolve import ProxyHatConfigError, resolve_credentials
|
|
5
|
+
from nodriver_proxyhat.proxy import proxyhat_auth, proxyhat_browser, proxyhat_browser_args
|
|
6
|
+
|
|
7
|
+
__all__ = [
|
|
8
|
+
"ProxyHatConfigError",
|
|
9
|
+
"enable_proxy_auth",
|
|
10
|
+
"proxyhat_auth",
|
|
11
|
+
"proxyhat_browser",
|
|
12
|
+
"proxyhat_browser_args",
|
|
13
|
+
"resolve_credentials",
|
|
14
|
+
]
|
|
15
|
+
__version__ = "0.1.0"
|
|
@@ -0,0 +1,55 @@
|
|
|
1
|
+
"""Wire ProxyHat gateway authentication onto a nodriver tab over raw CDP.
|
|
2
|
+
|
|
3
|
+
Chrome's ``--proxy-server`` flag can't carry a username/password, so a credentialed
|
|
4
|
+
residential gateway needs another way to answer the proxy's auth challenge. nodriver
|
|
5
|
+
speaks the Chrome DevTools Protocol directly, so we authenticate the CDP way:
|
|
6
|
+
|
|
7
|
+
1. enable the ``Fetch`` domain with ``handle_auth_requests=True``,
|
|
8
|
+
2. answer every ``Fetch.authRequired`` with the ProxyHat targeting username +
|
|
9
|
+
sub-user password via ``Fetch.continueWithAuth`` (``ProvideCredentials``),
|
|
10
|
+
3. resume every other paused request with ``Fetch.continueRequest``.
|
|
11
|
+
|
|
12
|
+
Enabling ``Fetch`` pauses *every* request (a ``requestPaused`` event), so the
|
|
13
|
+
non-auth requests must be continued too or the page hangs waiting on us.
|
|
14
|
+
|
|
15
|
+
This is the HTTP gateway path (port 8080) — CDP proxy auth answers the HTTP
|
|
16
|
+
proxy's basic-auth challenge. ``nodriver`` is imported lazily so importing this
|
|
17
|
+
module never requires it.
|
|
18
|
+
"""
|
|
19
|
+
|
|
20
|
+
from __future__ import annotations
|
|
21
|
+
|
|
22
|
+
from typing import TYPE_CHECKING
|
|
23
|
+
|
|
24
|
+
if TYPE_CHECKING:
|
|
25
|
+
from nodriver import Tab
|
|
26
|
+
|
|
27
|
+
|
|
28
|
+
async def enable_proxy_auth(tab: Tab, username: str, password: str) -> None:
|
|
29
|
+
"""Register the CDP proxy-auth handlers on ``tab`` and enable ``Fetch``.
|
|
30
|
+
|
|
31
|
+
Adds a ``Fetch.authRequired`` handler that answers with ``username`` /
|
|
32
|
+
``password`` and a ``Fetch.requestPaused`` handler that resumes normal
|
|
33
|
+
requests, then enables the Fetch domain with auth handling. Call once per tab
|
|
34
|
+
before navigating so the very first request is authenticated.
|
|
35
|
+
"""
|
|
36
|
+
from nodriver import cdp
|
|
37
|
+
|
|
38
|
+
async def _on_auth_required(event: cdp.fetch.AuthRequired) -> None:
|
|
39
|
+
await tab.send(
|
|
40
|
+
cdp.fetch.continue_with_auth(
|
|
41
|
+
request_id=event.request_id,
|
|
42
|
+
auth_challenge_response=cdp.network.AuthChallengeResponse(
|
|
43
|
+
response="ProvideCredentials",
|
|
44
|
+
username=username,
|
|
45
|
+
password=password,
|
|
46
|
+
),
|
|
47
|
+
)
|
|
48
|
+
)
|
|
49
|
+
|
|
50
|
+
async def _on_request_paused(event: cdp.fetch.RequestPaused) -> None:
|
|
51
|
+
await tab.send(cdp.fetch.continue_request(request_id=event.request_id))
|
|
52
|
+
|
|
53
|
+
tab.add_handler(cdp.fetch.AuthRequired, _on_auth_required)
|
|
54
|
+
tab.add_handler(cdp.fetch.RequestPaused, _on_request_paused)
|
|
55
|
+
await tab.send(cdp.fetch.enable(handle_auth_requests=True))
|
|
@@ -0,0 +1,71 @@
|
|
|
1
|
+
"""Gateway credential resolution for nodriver-proxyhat.
|
|
2
|
+
|
|
3
|
+
Mirrors the other ProxyHat integrations: explicit ``username``/``password``
|
|
4
|
+
(or ``PROXYHAT_USERNAME``/``PROXYHAT_PASSWORD``) win; otherwise an API key
|
|
5
|
+
(``PROXYHAT_API_KEY``) looks up your sub-users via the official ``proxyhat`` SDK
|
|
6
|
+
and picks an active one with remaining traffic — or the one named by ``sub_user``.
|
|
7
|
+
Everything here except the sub-user lookup is offline.
|
|
8
|
+
"""
|
|
9
|
+
|
|
10
|
+
from __future__ import annotations
|
|
11
|
+
|
|
12
|
+
import os
|
|
13
|
+
|
|
14
|
+
from proxyhat import ProxyHat
|
|
15
|
+
|
|
16
|
+
|
|
17
|
+
class ProxyHatConfigError(RuntimeError):
|
|
18
|
+
"""Raised when ProxyHat credentials cannot be resolved."""
|
|
19
|
+
|
|
20
|
+
|
|
21
|
+
def _env(name: str) -> str | None:
|
|
22
|
+
value = os.environ.get(name)
|
|
23
|
+
return value.strip() if value and value.strip() else None
|
|
24
|
+
|
|
25
|
+
|
|
26
|
+
def resolve_credentials(
|
|
27
|
+
*,
|
|
28
|
+
api_key: str | None = None,
|
|
29
|
+
username: str | None = None,
|
|
30
|
+
password: str | None = None,
|
|
31
|
+
sub_user: str | None = None,
|
|
32
|
+
) -> tuple[str, str]:
|
|
33
|
+
"""Resolve a sub-user's ``(proxy_username, proxy_password)``.
|
|
34
|
+
|
|
35
|
+
Options win over environment variables. Precedence:
|
|
36
|
+
|
|
37
|
+
1. explicit ``username`` + ``password`` (``PROXYHAT_USERNAME`` / ``PROXYHAT_PASSWORD``)
|
|
38
|
+
2. ``api_key`` (``PROXYHAT_API_KEY``) → auto-pick an active sub-user, or the
|
|
39
|
+
one named by ``sub_user`` (``PROXYHAT_SUBUSER``).
|
|
40
|
+
"""
|
|
41
|
+
username = username or _env("PROXYHAT_USERNAME")
|
|
42
|
+
password = password or _env("PROXYHAT_PASSWORD")
|
|
43
|
+
if username and password:
|
|
44
|
+
return username, password
|
|
45
|
+
|
|
46
|
+
api_key = api_key or _env("PROXYHAT_API_KEY")
|
|
47
|
+
if not api_key:
|
|
48
|
+
raise ProxyHatConfigError(
|
|
49
|
+
"nodriver-proxyhat: no credentials. Pass api_key (or PROXYHAT_API_KEY), "
|
|
50
|
+
"or username + password (PROXYHAT_USERNAME / PROXYHAT_PASSWORD)."
|
|
51
|
+
)
|
|
52
|
+
|
|
53
|
+
return _resolve_sub_user(api_key, sub_user or _env("PROXYHAT_SUBUSER"))
|
|
54
|
+
|
|
55
|
+
|
|
56
|
+
def _resolve_sub_user(api_key: str, want: str | None) -> tuple[str, str]:
|
|
57
|
+
users = ProxyHat(api_key=api_key).sub_users.list()
|
|
58
|
+
usable = [u for u in users if not u.suspended_at and (u.traffic_limit == 0 or u.used_traffic < u.traffic_limit)]
|
|
59
|
+
if want:
|
|
60
|
+
chosen = next((u for u in users if u.uuid == want or u.name == want), None)
|
|
61
|
+
else:
|
|
62
|
+
chosen = usable[0] if usable else None
|
|
63
|
+
|
|
64
|
+
if chosen is None or not chosen.proxy_username or not chosen.proxy_password:
|
|
65
|
+
raise ProxyHatConfigError(
|
|
66
|
+
f'nodriver-proxyhat: no sub-user matched "{want}" (or it has no proxy credentials).'
|
|
67
|
+
if want
|
|
68
|
+
else "nodriver-proxyhat: no usable sub-user found (all suspended or out of traffic). "
|
|
69
|
+
"Create one, top up, or pass sub_user."
|
|
70
|
+
)
|
|
71
|
+
return chosen.proxy_username, chosen.proxy_password
|
|
@@ -0,0 +1,162 @@
|
|
|
1
|
+
"""Route a nodriver (undetected Chrome) browser through the ProxyHat gateway.
|
|
2
|
+
|
|
3
|
+
nodriver launches Chrome with ``nodriver.start(browser_args=[...])``, but Chrome's
|
|
4
|
+
``--proxy-server`` flag can't carry a username/password. nodriver gives raw CDP
|
|
5
|
+
access, so the credentialed residential gateway is authenticated the CDP way:
|
|
6
|
+
enable the Fetch domain with ``handle_auth_requests=True`` and answer
|
|
7
|
+
``Fetch.authRequired`` with the ProxyHat targeting username + sub-user password
|
|
8
|
+
(see :mod:`nodriver_proxyhat._auth`).
|
|
9
|
+
|
|
10
|
+
Three entry points:
|
|
11
|
+
|
|
12
|
+
- ``proxyhat_browser_args()`` — the ``--proxy-server`` launch flag for the gateway.
|
|
13
|
+
- ``proxyhat_auth()`` — the ``(username, password)`` for wiring the CDP auth
|
|
14
|
+
handler yourself.
|
|
15
|
+
- ``proxyhat_browser()`` — start Chrome with the flag applied *and* the CDP auth
|
|
16
|
+
handler already wired.
|
|
17
|
+
|
|
18
|
+
This is the HTTP gateway (port 8080); CDP proxy auth applies to the HTTP proxy.
|
|
19
|
+
"""
|
|
20
|
+
|
|
21
|
+
from __future__ import annotations
|
|
22
|
+
|
|
23
|
+
from typing import TYPE_CHECKING, Any
|
|
24
|
+
|
|
25
|
+
from proxyhat import (
|
|
26
|
+
PROXYHAT_GATEWAY,
|
|
27
|
+
PROXYHAT_PORT_HTTP,
|
|
28
|
+
build_proxy_username,
|
|
29
|
+
)
|
|
30
|
+
|
|
31
|
+
from nodriver_proxyhat._auth import enable_proxy_auth
|
|
32
|
+
from nodriver_proxyhat._resolve import resolve_credentials
|
|
33
|
+
|
|
34
|
+
if TYPE_CHECKING:
|
|
35
|
+
from nodriver import Browser
|
|
36
|
+
|
|
37
|
+
# A browser session takes many steps against the same site, so pin one
|
|
38
|
+
# residential IP for the whole session by default — keeps cookies and
|
|
39
|
+
# fingerprint consistent. sticky=False rotates a fresh IP per connection.
|
|
40
|
+
DEFAULT_STICKY = "30m"
|
|
41
|
+
|
|
42
|
+
|
|
43
|
+
def proxyhat_browser_args(**_targeting: Any) -> list[str]:
|
|
44
|
+
"""Return the Chrome launch args pointing nodriver at the ProxyHat gateway.
|
|
45
|
+
|
|
46
|
+
Just ``["--proxy-server=gate.proxyhat.com:8080"]`` — the HTTP gateway host and
|
|
47
|
+
port. Targeting (``country`` / ``region`` / ``city`` / ``sticky`` / ``filter``)
|
|
48
|
+
is *not* encoded here, because Chrome's ``--proxy-server`` can't carry it: it
|
|
49
|
+
lives in the CDP auth username built by :func:`proxyhat_auth` and applied by
|
|
50
|
+
:func:`proxyhat_browser`. Targeting keyword arguments are accepted (so you can
|
|
51
|
+
splat the same dict you pass to those) but do not change the returned flag.
|
|
52
|
+
|
|
53
|
+
Merge the result into your own ``nodriver.start(browser_args=[...])`` call and
|
|
54
|
+
wire auth yourself with :func:`proxyhat_auth` + ``enable_proxy_auth`` — or just
|
|
55
|
+
use :func:`proxyhat_browser`, which does both.
|
|
56
|
+
"""
|
|
57
|
+
return [f"--proxy-server={PROXYHAT_GATEWAY}:{PROXYHAT_PORT_HTTP}"]
|
|
58
|
+
|
|
59
|
+
|
|
60
|
+
def proxyhat_auth(
|
|
61
|
+
*,
|
|
62
|
+
api_key: str | None = None,
|
|
63
|
+
username: str | None = None,
|
|
64
|
+
password: str | None = None,
|
|
65
|
+
sub_user: str | None = None,
|
|
66
|
+
country: str | None = None,
|
|
67
|
+
region: str | None = None,
|
|
68
|
+
city: str | None = None,
|
|
69
|
+
sticky: bool | str | None = DEFAULT_STICKY,
|
|
70
|
+
filter: str | None = None,
|
|
71
|
+
) -> tuple[str, str]:
|
|
72
|
+
"""Resolve the ``(gateway_username, password)`` for the ProxyHat gateway.
|
|
73
|
+
|
|
74
|
+
Resolves credentials (``api_key`` / ``PROXYHAT_API_KEY`` auto-picks an active
|
|
75
|
+
sub-user, or pass explicit ``username`` / ``password``), then builds the
|
|
76
|
+
targeting username — geo and stickiness reflected — with the official
|
|
77
|
+
``proxyhat`` SDK grammar. Feed the pair to a CDP ``Fetch.authRequired`` handler
|
|
78
|
+
(``ProvideCredentials``) if you're wiring nodriver yourself; otherwise
|
|
79
|
+
:func:`proxyhat_browser` does it for you.
|
|
80
|
+
|
|
81
|
+
Sticky vs rotating:
|
|
82
|
+
|
|
83
|
+
- ``sticky="30m"`` (default) or ``sticky=True`` pins one residential IP for
|
|
84
|
+
the session — recommended for a browser.
|
|
85
|
+
- ``sticky=False`` (or ``None``) rotates: a fresh residential IP per connection.
|
|
86
|
+
- ``sticky="2h"`` sets a custom session lifetime.
|
|
87
|
+
|
|
88
|
+
Geo/quality targeting: ``country`` (ISO code or ``"any"``), ``region``,
|
|
89
|
+
``city``, ``filter`` (AI IP-quality tier).
|
|
90
|
+
"""
|
|
91
|
+
user, pw = resolve_credentials(
|
|
92
|
+
api_key=api_key,
|
|
93
|
+
username=username,
|
|
94
|
+
password=password,
|
|
95
|
+
sub_user=sub_user,
|
|
96
|
+
)
|
|
97
|
+
# Build the targeting username once so a sticky session mints a single sid
|
|
98
|
+
# shared by every request the browser makes.
|
|
99
|
+
gateway_username = build_proxy_username(
|
|
100
|
+
user,
|
|
101
|
+
country=country,
|
|
102
|
+
region=region,
|
|
103
|
+
city=city,
|
|
104
|
+
sticky=sticky,
|
|
105
|
+
filter=filter,
|
|
106
|
+
)
|
|
107
|
+
return gateway_username, pw
|
|
108
|
+
|
|
109
|
+
|
|
110
|
+
async def proxyhat_browser(
|
|
111
|
+
*,
|
|
112
|
+
api_key: str | None = None,
|
|
113
|
+
username: str | None = None,
|
|
114
|
+
password: str | None = None,
|
|
115
|
+
sub_user: str | None = None,
|
|
116
|
+
country: str | None = None,
|
|
117
|
+
region: str | None = None,
|
|
118
|
+
city: str | None = None,
|
|
119
|
+
sticky: bool | str | None = DEFAULT_STICKY,
|
|
120
|
+
filter: str | None = None,
|
|
121
|
+
**start_kwargs: Any,
|
|
122
|
+
) -> Browser:
|
|
123
|
+
"""Start a nodriver Chrome routed through ProxyHat with gateway auth wired.
|
|
124
|
+
|
|
125
|
+
Resolves credentials, calls ``nodriver.start(browser_args=[...], **start_kwargs)``
|
|
126
|
+
with the ``--proxy-server`` flag applied, then enables CDP ``Fetch`` on the
|
|
127
|
+
browser's main tab and registers a ``Fetch.authRequired`` handler that answers
|
|
128
|
+
with the ProxyHat targeting username + sub-user password. Returns the started
|
|
129
|
+
``Browser`` — navigate with ``await browser.get(url)``.
|
|
130
|
+
|
|
131
|
+
Any extra keyword arguments (``headless``, ``user_data_dir``, ``sandbox``, …)
|
|
132
|
+
are forwarded to ``nodriver.start``; if you pass your own ``browser_args`` they
|
|
133
|
+
are merged with the ProxyHat flag. Sticky by default (one pinned IP for the
|
|
134
|
+
whole session); pass ``sticky=False`` for a rotating IP. See :func:`proxyhat_auth`
|
|
135
|
+
for the full targeting keyword set.
|
|
136
|
+
|
|
137
|
+
``nodriver`` is imported lazily — install it (``pip install nodriver-proxyhat[nodriver]``)
|
|
138
|
+
to use this helper; :func:`proxyhat_browser_args` and :func:`proxyhat_auth` work
|
|
139
|
+
without it.
|
|
140
|
+
"""
|
|
141
|
+
gateway_username, pw = proxyhat_auth(
|
|
142
|
+
api_key=api_key,
|
|
143
|
+
username=username,
|
|
144
|
+
password=password,
|
|
145
|
+
sub_user=sub_user,
|
|
146
|
+
country=country,
|
|
147
|
+
region=region,
|
|
148
|
+
city=city,
|
|
149
|
+
sticky=sticky,
|
|
150
|
+
filter=filter,
|
|
151
|
+
)
|
|
152
|
+
|
|
153
|
+
caller_args = start_kwargs.pop("browser_args", None) or []
|
|
154
|
+
browser_args = proxyhat_browser_args() + list(caller_args)
|
|
155
|
+
|
|
156
|
+
# Imported lazily: nodriver is an optional (peer) dependency, so importing
|
|
157
|
+
# this module never forces a Chrome/nodriver install.
|
|
158
|
+
import nodriver
|
|
159
|
+
|
|
160
|
+
browser = await nodriver.start(browser_args=browser_args, **start_kwargs)
|
|
161
|
+
await enable_proxy_auth(browser.main_tab, gateway_username, pw)
|
|
162
|
+
return browser
|
|
@@ -0,0 +1,190 @@
|
|
|
1
|
+
"""Tests for the CDP auth wiring and proxyhat_browser.
|
|
2
|
+
|
|
3
|
+
nodriver drives a real Chrome, so instead of installing it we inject a tiny fake
|
|
4
|
+
``nodriver`` module (with a ``cdp.fetch`` / ``cdp.network`` surface) that records
|
|
5
|
+
the CDP commands our code sends. That exercises the actual auth-handler path —
|
|
6
|
+
Fetch.enable, the Fetch.authRequired answer, and request resumption — plus the
|
|
7
|
+
browser_args / start_kwargs wiring, without any browser or network.
|
|
8
|
+
"""
|
|
9
|
+
|
|
10
|
+
import asyncio
|
|
11
|
+
import sys
|
|
12
|
+
from types import ModuleType, SimpleNamespace
|
|
13
|
+
|
|
14
|
+
import pytest
|
|
15
|
+
|
|
16
|
+
from nodriver_proxyhat import enable_proxy_auth, proxyhat_browser
|
|
17
|
+
|
|
18
|
+
|
|
19
|
+
def run(coro):
|
|
20
|
+
return asyncio.run(coro)
|
|
21
|
+
|
|
22
|
+
|
|
23
|
+
class FakeTab:
|
|
24
|
+
def __init__(self):
|
|
25
|
+
self.handlers = {}
|
|
26
|
+
self.sent = []
|
|
27
|
+
|
|
28
|
+
def add_handler(self, event_type, callback):
|
|
29
|
+
self.handlers[event_type] = callback
|
|
30
|
+
|
|
31
|
+
async def send(self, command):
|
|
32
|
+
self.sent.append(command)
|
|
33
|
+
|
|
34
|
+
|
|
35
|
+
class FakeBrowser:
|
|
36
|
+
def __init__(self):
|
|
37
|
+
self.main_tab = FakeTab()
|
|
38
|
+
|
|
39
|
+
|
|
40
|
+
class AuthChallengeResponse:
|
|
41
|
+
def __init__(self, *, response, username=None, password=None):
|
|
42
|
+
self.response = response
|
|
43
|
+
self.username = username
|
|
44
|
+
self.password = password
|
|
45
|
+
|
|
46
|
+
|
|
47
|
+
class AuthRequired:
|
|
48
|
+
"""Stand-in for cdp.fetch.AuthRequired event type."""
|
|
49
|
+
|
|
50
|
+
|
|
51
|
+
class RequestPaused:
|
|
52
|
+
"""Stand-in for cdp.fetch.RequestPaused event type."""
|
|
53
|
+
|
|
54
|
+
|
|
55
|
+
@pytest.fixture
|
|
56
|
+
def fake_nodriver(monkeypatch):
|
|
57
|
+
"""Install a fake ``nodriver`` module and return a handle to inspect it."""
|
|
58
|
+
captured = {}
|
|
59
|
+
|
|
60
|
+
fetch = ModuleType("nodriver.cdp.fetch")
|
|
61
|
+
fetch.AuthRequired = AuthRequired
|
|
62
|
+
fetch.RequestPaused = RequestPaused
|
|
63
|
+
fetch.enable = lambda **kw: ("fetch.enable", kw)
|
|
64
|
+
fetch.continue_with_auth = lambda **kw: ("fetch.continue_with_auth", kw)
|
|
65
|
+
fetch.continue_request = lambda **kw: ("fetch.continue_request", kw)
|
|
66
|
+
|
|
67
|
+
network = ModuleType("nodriver.cdp.network")
|
|
68
|
+
network.AuthChallengeResponse = AuthChallengeResponse
|
|
69
|
+
|
|
70
|
+
cdp = ModuleType("nodriver.cdp")
|
|
71
|
+
cdp.fetch = fetch
|
|
72
|
+
cdp.network = network
|
|
73
|
+
|
|
74
|
+
nodriver = ModuleType("nodriver")
|
|
75
|
+
nodriver.cdp = cdp
|
|
76
|
+
|
|
77
|
+
browser = FakeBrowser()
|
|
78
|
+
|
|
79
|
+
async def start(**kwargs):
|
|
80
|
+
captured["start_kwargs"] = kwargs
|
|
81
|
+
return browser
|
|
82
|
+
|
|
83
|
+
nodriver.start = start
|
|
84
|
+
|
|
85
|
+
monkeypatch.setitem(sys.modules, "nodriver", nodriver)
|
|
86
|
+
monkeypatch.setitem(sys.modules, "nodriver.cdp", cdp)
|
|
87
|
+
monkeypatch.setitem(sys.modules, "nodriver.cdp.fetch", fetch)
|
|
88
|
+
monkeypatch.setitem(sys.modules, "nodriver.cdp.network", network)
|
|
89
|
+
return SimpleNamespace(module=nodriver, browser=browser, captured=captured)
|
|
90
|
+
|
|
91
|
+
|
|
92
|
+
class TestEnableProxyAuth:
|
|
93
|
+
def test_registers_handlers_and_enables_fetch(self, fake_nodriver):
|
|
94
|
+
tab = FakeTab()
|
|
95
|
+
run(enable_proxy_auth(tab, "ph-1-country-us", "pw"))
|
|
96
|
+
|
|
97
|
+
assert AuthRequired in tab.handlers
|
|
98
|
+
assert RequestPaused in tab.handlers
|
|
99
|
+
# Fetch enabled with auth handling requested.
|
|
100
|
+
assert ("fetch.enable", {"handle_auth_requests": True}) in tab.sent
|
|
101
|
+
|
|
102
|
+
def test_auth_handler_answers_with_credentials(self, fake_nodriver):
|
|
103
|
+
tab = FakeTab()
|
|
104
|
+
run(enable_proxy_auth(tab, "ph-1-country-us-sid-abc-ttl-30m", "s3cr3t"))
|
|
105
|
+
|
|
106
|
+
event = SimpleNamespace(request_id="req-1")
|
|
107
|
+
run(tab.handlers[AuthRequired](event))
|
|
108
|
+
|
|
109
|
+
name, kw = tab.sent[-1]
|
|
110
|
+
assert name == "fetch.continue_with_auth"
|
|
111
|
+
assert kw["request_id"] == "req-1"
|
|
112
|
+
acr = kw["auth_challenge_response"]
|
|
113
|
+
assert acr.response == "ProvideCredentials"
|
|
114
|
+
assert acr.username == "ph-1-country-us-sid-abc-ttl-30m"
|
|
115
|
+
assert acr.password == "s3cr3t"
|
|
116
|
+
|
|
117
|
+
def test_request_handler_resumes_paused_requests(self, fake_nodriver):
|
|
118
|
+
tab = FakeTab()
|
|
119
|
+
run(enable_proxy_auth(tab, "ph-1", "pw"))
|
|
120
|
+
|
|
121
|
+
event = SimpleNamespace(request_id="req-2")
|
|
122
|
+
run(tab.handlers[RequestPaused](event))
|
|
123
|
+
|
|
124
|
+
assert ("fetch.continue_request", {"request_id": "req-2"}) in tab.sent
|
|
125
|
+
|
|
126
|
+
|
|
127
|
+
class TestProxyhatBrowser:
|
|
128
|
+
def test_applies_proxy_flag_and_wires_auth(self, fake_nodriver):
|
|
129
|
+
browser = run(proxyhat_browser(username="ph-1", password="pw", country="us"))
|
|
130
|
+
assert browser is fake_nodriver.browser
|
|
131
|
+
|
|
132
|
+
start_kwargs = fake_nodriver.captured["start_kwargs"]
|
|
133
|
+
assert "--proxy-server=gate.proxyhat.com:8080" in start_kwargs["browser_args"]
|
|
134
|
+
|
|
135
|
+
tab = browser.main_tab
|
|
136
|
+
# Auth handler was wired and Fetch enabled on the main tab.
|
|
137
|
+
assert AuthRequired in tab.handlers
|
|
138
|
+
assert ("fetch.enable", {"handle_auth_requests": True}) in tab.sent
|
|
139
|
+
|
|
140
|
+
# The wired handler answers with the sticky, geo-targeted username.
|
|
141
|
+
run(tab.handlers[AuthRequired](SimpleNamespace(request_id="r")))
|
|
142
|
+
_, kw = tab.sent[-1]
|
|
143
|
+
acr = kw["auth_challenge_response"]
|
|
144
|
+
assert acr.username.startswith("ph-1-country-us")
|
|
145
|
+
assert "-sid-" in acr.username # sticky by default
|
|
146
|
+
assert acr.password == "pw"
|
|
147
|
+
|
|
148
|
+
def test_forwards_start_kwargs_and_merges_browser_args(self, fake_nodriver):
|
|
149
|
+
run(
|
|
150
|
+
proxyhat_browser(
|
|
151
|
+
username="ph-1",
|
|
152
|
+
password="pw",
|
|
153
|
+
headless=True,
|
|
154
|
+
browser_args=["--window-size=1920,1080"],
|
|
155
|
+
)
|
|
156
|
+
)
|
|
157
|
+
start_kwargs = fake_nodriver.captured["start_kwargs"]
|
|
158
|
+
assert start_kwargs["headless"] is True
|
|
159
|
+
assert "--proxy-server=gate.proxyhat.com:8080" in start_kwargs["browser_args"]
|
|
160
|
+
assert "--window-size=1920,1080" in start_kwargs["browser_args"]
|
|
161
|
+
|
|
162
|
+
def test_rotating_option(self, fake_nodriver):
|
|
163
|
+
browser = run(proxyhat_browser(username="ph-1", password="pw", sticky=False))
|
|
164
|
+
tab = browser.main_tab
|
|
165
|
+
run(tab.handlers[AuthRequired](SimpleNamespace(request_id="r")))
|
|
166
|
+
_, kw = tab.sent[-1]
|
|
167
|
+
assert "-sid-" not in kw["auth_challenge_response"].username
|
|
168
|
+
|
|
169
|
+
def test_resolves_via_api_key(self, fake_nodriver, monkeypatch):
|
|
170
|
+
users = [
|
|
171
|
+
SimpleNamespace(
|
|
172
|
+
uuid="u",
|
|
173
|
+
name=None,
|
|
174
|
+
proxy_username="good",
|
|
175
|
+
proxy_password="secret",
|
|
176
|
+
traffic_limit=0,
|
|
177
|
+
used_traffic=0,
|
|
178
|
+
suspended_at=None,
|
|
179
|
+
)
|
|
180
|
+
]
|
|
181
|
+
fake_client = SimpleNamespace(sub_users=SimpleNamespace(list=lambda: users))
|
|
182
|
+
monkeypatch.setattr("nodriver_proxyhat._resolve.ProxyHat", lambda **kw: fake_client)
|
|
183
|
+
|
|
184
|
+
browser = run(proxyhat_browser(api_key="ph_key", country="us", sticky=False))
|
|
185
|
+
tab = browser.main_tab
|
|
186
|
+
run(tab.handlers[AuthRequired](SimpleNamespace(request_id="r")))
|
|
187
|
+
_, kw = tab.sent[-1]
|
|
188
|
+
acr = kw["auth_challenge_response"]
|
|
189
|
+
assert acr.username == "good-country-us"
|
|
190
|
+
assert acr.password == "secret"
|
|
@@ -0,0 +1,117 @@
|
|
|
1
|
+
"""Offline tests for the proxy-args / auth / credential surface.
|
|
2
|
+
|
|
3
|
+
No browser is launched and no network call is made: the ProxyHat SDK's
|
|
4
|
+
``sub_users.list()`` is mocked, and we assert the ``--proxy-server`` flag and the
|
|
5
|
+
targeting username (geo + sticky reflected) + password directly.
|
|
6
|
+
"""
|
|
7
|
+
|
|
8
|
+
from types import SimpleNamespace
|
|
9
|
+
|
|
10
|
+
import pytest
|
|
11
|
+
|
|
12
|
+
from nodriver_proxyhat import (
|
|
13
|
+
ProxyHatConfigError,
|
|
14
|
+
proxyhat_auth,
|
|
15
|
+
proxyhat_browser_args,
|
|
16
|
+
resolve_credentials,
|
|
17
|
+
)
|
|
18
|
+
|
|
19
|
+
|
|
20
|
+
def sub_user(**kw):
|
|
21
|
+
base = dict(
|
|
22
|
+
uuid="u",
|
|
23
|
+
name=None,
|
|
24
|
+
proxy_username="ph-1",
|
|
25
|
+
proxy_password="pw",
|
|
26
|
+
traffic_limit=0,
|
|
27
|
+
used_traffic=0,
|
|
28
|
+
suspended_at=None,
|
|
29
|
+
)
|
|
30
|
+
base.update(kw)
|
|
31
|
+
return SimpleNamespace(**base)
|
|
32
|
+
|
|
33
|
+
|
|
34
|
+
class TestBrowserArgs:
|
|
35
|
+
def test_sets_http_gateway_proxy_server(self):
|
|
36
|
+
assert proxyhat_browser_args() == ["--proxy-server=gate.proxyhat.com:8080"]
|
|
37
|
+
|
|
38
|
+
def test_targeting_kwargs_do_not_change_the_flag(self):
|
|
39
|
+
# Targeting lives in the CDP auth username, not the --proxy-server flag.
|
|
40
|
+
args = proxyhat_browser_args(country="de", sticky=False, filter="high")
|
|
41
|
+
assert args == ["--proxy-server=gate.proxyhat.com:8080"]
|
|
42
|
+
|
|
43
|
+
|
|
44
|
+
class TestAuth:
|
|
45
|
+
def test_returns_targeting_username_and_password(self):
|
|
46
|
+
user, pw = proxyhat_auth(username="ph-1", password="pw", country="us")
|
|
47
|
+
assert user.startswith("ph-1-country-us")
|
|
48
|
+
assert pw == "pw"
|
|
49
|
+
|
|
50
|
+
def test_sticky_default_pins_session(self):
|
|
51
|
+
user, _ = proxyhat_auth(username="ph-1", password="pw")
|
|
52
|
+
# Default is sticky: a session id + 30m TTL is present.
|
|
53
|
+
assert "-sid-" in user
|
|
54
|
+
assert "-ttl-30m" in user
|
|
55
|
+
|
|
56
|
+
def test_sticky_false_is_rotating(self):
|
|
57
|
+
user, _ = proxyhat_auth(username="ph-1", password="pw", sticky=False)
|
|
58
|
+
assert "-sid-" not in user
|
|
59
|
+
assert "-ttl-" not in user
|
|
60
|
+
|
|
61
|
+
def test_custom_sticky_ttl(self):
|
|
62
|
+
user, _ = proxyhat_auth(username="ph-1", password="pw", sticky="2h")
|
|
63
|
+
assert "-ttl-2h" in user
|
|
64
|
+
|
|
65
|
+
def test_geo_targeting(self):
|
|
66
|
+
user, _ = proxyhat_auth(
|
|
67
|
+
username="ph-1",
|
|
68
|
+
password="pw",
|
|
69
|
+
country="de",
|
|
70
|
+
region="berlin",
|
|
71
|
+
city="berlin",
|
|
72
|
+
filter="high",
|
|
73
|
+
sticky=False,
|
|
74
|
+
)
|
|
75
|
+
assert user == "ph-1-country-de-region-berlin-city-berlin-filter-high"
|
|
76
|
+
|
|
77
|
+
|
|
78
|
+
class TestCredentialResolution:
|
|
79
|
+
def test_explicit_username_password(self):
|
|
80
|
+
assert resolve_credentials(username="ph-1", password="pw") == ("ph-1", "pw")
|
|
81
|
+
|
|
82
|
+
def test_raises_without_credentials(self, monkeypatch):
|
|
83
|
+
for var in ("PROXYHAT_API_KEY", "PROXYHAT_USERNAME", "PROXYHAT_PASSWORD", "PROXYHAT_SUBUSER"):
|
|
84
|
+
monkeypatch.delenv(var, raising=False)
|
|
85
|
+
with pytest.raises(ProxyHatConfigError):
|
|
86
|
+
resolve_credentials()
|
|
87
|
+
|
|
88
|
+
def test_api_key_picks_active_sub_user(self, monkeypatch):
|
|
89
|
+
users = [
|
|
90
|
+
sub_user(uuid="s", proxy_username="susp", suspended_at="2026-01-01"),
|
|
91
|
+
sub_user(uuid="g", proxy_username="good", traffic_limit=100, used_traffic=100),
|
|
92
|
+
sub_user(uuid="ok", proxy_username="ok", traffic_limit=100, used_traffic=1),
|
|
93
|
+
]
|
|
94
|
+
fake_client = SimpleNamespace(sub_users=SimpleNamespace(list=lambda: users))
|
|
95
|
+
monkeypatch.setattr("nodriver_proxyhat._resolve.ProxyHat", lambda **kw: fake_client)
|
|
96
|
+
assert resolve_credentials(api_key="ph_key") == ("ok", "pw")
|
|
97
|
+
|
|
98
|
+
def test_api_key_named_sub_user(self, monkeypatch):
|
|
99
|
+
users = [sub_user(uuid="a", proxy_username="a"), sub_user(uuid="b", name="prod", proxy_username="b")]
|
|
100
|
+
fake_client = SimpleNamespace(sub_users=SimpleNamespace(list=lambda: users))
|
|
101
|
+
monkeypatch.setattr("nodriver_proxyhat._resolve.ProxyHat", lambda **kw: fake_client)
|
|
102
|
+
assert resolve_credentials(api_key="ph_key", sub_user="prod") == ("b", "pw")
|
|
103
|
+
|
|
104
|
+
def test_api_key_no_usable_sub_user(self, monkeypatch):
|
|
105
|
+
users = [sub_user(traffic_limit=100, used_traffic=100)]
|
|
106
|
+
fake_client = SimpleNamespace(sub_users=SimpleNamespace(list=lambda: users))
|
|
107
|
+
monkeypatch.setattr("nodriver_proxyhat._resolve.ProxyHat", lambda **kw: fake_client)
|
|
108
|
+
with pytest.raises(ProxyHatConfigError):
|
|
109
|
+
resolve_credentials(api_key="ph_key")
|
|
110
|
+
|
|
111
|
+
def test_proxyhat_auth_resolves_via_api_key(self, monkeypatch):
|
|
112
|
+
users = [sub_user(proxy_username="good", proxy_password="secret")]
|
|
113
|
+
fake_client = SimpleNamespace(sub_users=SimpleNamespace(list=lambda: users))
|
|
114
|
+
monkeypatch.setattr("nodriver_proxyhat._resolve.ProxyHat", lambda **kw: fake_client)
|
|
115
|
+
user, pw = proxyhat_auth(api_key="ph_key", country="us", sticky=False)
|
|
116
|
+
assert user == "good-country-us"
|
|
117
|
+
assert pw == "secret"
|