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.
@@ -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,8 @@
1
+ __pycache__/
2
+ *.py[cod]
3
+ *.egg-info/
4
+ .venv/
5
+ dist/
6
+ build/
7
+ .pytest_cache/
8
+ .ruff_cache/
@@ -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
+ [![CI](https://github.com/ProxyHatCom/nodriver-proxyhat/actions/workflows/ci.yml/badge.svg)](https://github.com/ProxyHatCom/nodriver-proxyhat/actions/workflows/ci.yml)
35
+ [![Compatible with nodriver latest](https://github.com/ProxyHatCom/nodriver-proxyhat/actions/workflows/compat.yml/badge.svg)](https://github.com/ProxyHatCom/nodriver-proxyhat/actions/workflows/compat.yml)
36
+ [![PyPI](https://img.shields.io/pypi/v/nodriver-proxyhat)](https://pypi.org/project/nodriver-proxyhat/)
37
+ [![License: MIT](https://img.shields.io/badge/License-MIT-yellow.svg)](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
+ [![CI](https://github.com/ProxyHatCom/nodriver-proxyhat/actions/workflows/ci.yml/badge.svg)](https://github.com/ProxyHatCom/nodriver-proxyhat/actions/workflows/ci.yml)
6
+ [![Compatible with nodriver latest](https://github.com/ProxyHatCom/nodriver-proxyhat/actions/workflows/compat.yml/badge.svg)](https://github.com/ProxyHatCom/nodriver-proxyhat/actions/workflows/compat.yml)
7
+ [![PyPI](https://img.shields.io/pypi/v/nodriver-proxyhat)](https://pypi.org/project/nodriver-proxyhat/)
8
+ [![License: MIT](https://img.shields.io/badge/License-MIT-yellow.svg)](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"