selenium-turnstile 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,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 Peak
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,103 @@
1
+ Metadata-Version: 2.5
2
+ Name: selenium-turnstile
3
+ Version: 0.1.0
4
+ Summary: Solve Cloudflare Turnstile in Selenium via the Peak API, for when SeleniumBase UC-mode or undetected-chromedriver stall headless.
5
+ Project-URL: Homepage, https://peak.fo/?utm_source=github&utm_medium=readme&utm_campaign=packages&utm_content=selenium-turnstile
6
+ Project-URL: Documentation, https://peak.fo/docs/turnstile?utm_source=github&utm_medium=readme&utm_campaign=packages&utm_content=selenium-turnstile
7
+ Project-URL: Pricing, https://peak.fo/pricing?utm_source=github&utm_medium=readme&utm_campaign=packages&utm_content=selenium-turnstile
8
+ Project-URL: Source, https://github.com/CircuitSavage/selenium-turnstile
9
+ Author: Peak
10
+ License: MIT
11
+ License-File: LICENSE
12
+ Keywords: bypass,captcha,cloudflare,selenium,seleniumbase,solver,turnstile,undetected-chromedriver
13
+ Classifier: Development Status :: 4 - Beta
14
+ Classifier: Intended Audience :: Developers
15
+ Classifier: License :: OSI Approved :: MIT License
16
+ Classifier: Programming Language :: Python :: 3
17
+ Classifier: Programming Language :: Python :: 3 :: Only
18
+ Classifier: Topic :: Internet :: WWW/HTTP :: Browsers
19
+ Classifier: Topic :: Software Development :: Testing
20
+ Requires-Python: >=3.8
21
+ Provides-Extra: selenium
22
+ Requires-Dist: selenium>=4.0; extra == 'selenium'
23
+ Description-Content-Type: text/markdown
24
+
25
+ <a href="https://peak.fo/?utm_source=github&utm_medium=readme&utm_campaign=packages&utm_content=selenium-turnstile">
26
+ <img src="https://raw.githubusercontent.com/CircuitSavage/selenium-turnstile/main/assets/peak-banner.png" alt="Peak — solve Cloudflare Turnstile & the 5s challenge in ~1s" width="100%">
27
+ </a>
28
+
29
+ # selenium-turnstile
30
+
31
+ **selenium-turnstile** is a Selenium helper that solves Cloudflare Turnstile by injecting a Peak-solved token, so a protected form or navigation proceeds without a real mouse click.
32
+
33
+ ## Why
34
+
35
+ SeleniumBase's UC-mode click-solve (`uc_gui_click_captcha`) drives a physical click on the widget, which needs a real display and fails headless on datacenter IPs. That failure mode fills Cloudflare-related SeleniumBase issues (200+ open). undetected-chromedriver has no click-solve at all and gets stuck in the "Just a moment" loop, reloading the interstitial even after the browser has otherwise passed.
36
+
37
+ This helper skips the click entirely. It reads the sitekey from the DOM, gets a token from Peak, and injects that token as the `cf-turnstile-response` value plus the widget's success callback. When your setup gets blocked, drop in a Peak API key and it just works.
38
+
39
+ ## Powered by Peak
40
+
41
+ This package uses [Peak](https://peak.fo/?utm_source=github&utm_medium=readme&utm_campaign=packages&utm_content=selenium-turnstile) to solve Turnstile.
42
+ - Solve Cloudflare Turnstile & the 5s challenge in about a second
43
+ - Pay only for successful solves, from $1 / 1,000
44
+ - 1,000 free solves to start, no card.
45
+ → [Get your free API key](https://peak.fo/?utm_source=github&utm_medium=readme&utm_campaign=packages&utm_content=selenium-turnstile) · [Docs](https://peak.fo/docs/turnstile?utm_source=github&utm_medium=readme&utm_campaign=packages&utm_content=selenium-turnstile) · [Pricing](https://peak.fo/pricing?utm_source=github&utm_medium=readme&utm_campaign=packages&utm_content=selenium-turnstile)
46
+
47
+ ## Install
48
+
49
+ ```bash
50
+ pip install selenium-turnstile selenium
51
+ ```
52
+
53
+ ## Quickstart
54
+
55
+ ```python
56
+ import os
57
+ from selenium import webdriver
58
+ from selenium_turnstile import solve_turnstile
59
+
60
+ os.environ["PEAK_API_KEY"] = "pk_your_api_key"
61
+
62
+ driver = webdriver.Chrome()
63
+ driver.get("https://protected.example/login")
64
+
65
+ # Reads the sitekey from the page, solves via Peak, injects the token.
66
+ token = solve_turnstile(driver)
67
+
68
+ driver.find_element("css selector", "form").submit()
69
+ ```
70
+
71
+ Works unchanged with a plain Selenium driver, a SeleniumBase `Driver`, or an undetected-chromedriver `Chrome` — all three expose the `current_url` / `find_element` / `execute_script` interface the helper uses.
72
+
73
+ ## How it works
74
+
75
+ 1. **Read the sitekey.** `find_element` looks for the `data-sitekey` attribute on the `.cf-turnstile` widget; if the sitekey is only set through a JS `render()` call, an `execute_script` DOM scan finds it.
76
+ 2. **Solve via Peak.** `POST https://api.peak.fo/solve` with `task_type: "turnstiletask"`, the sitekey, and `url=driver.current_url`. Pass `proxy=` to mint the token from your browser's egress IP.
77
+ 3. **Inject the token.** `execute_script` sets every `cf-turnstile-response` field, fires `input`/`change` events, and calls the widget's `data-callback` success handler so the form or navigation continues.
78
+
79
+ ## API
80
+
81
+ ```python
82
+ solve_turnstile(
83
+ driver, # any Selenium-like WebDriver
84
+ api_key=None, # defaults to env PEAK_API_KEY
85
+ proxy=None, # optional http://user:pass@ip:port, forwarded to Peak
86
+ sitekey=None, # override auto-detection (e.g. widget in a frame)
87
+ url=None, # defaults to driver.current_url
88
+ task_type="turnstiletask",
89
+ timeout=180.0,
90
+ ) -> str # the injected token
91
+ ```
92
+
93
+ Helpers `read_sitekey(driver)` and `inject_token(driver, token)` are exposed for finer control. Peak also supports `task_type="cloudflare5stask"` for the 5s interstitial challenge.
94
+
95
+ The API key is read from the `PEAK_API_KEY` environment variable by default; you can also pass it explicitly. Never hardcode a real key.
96
+
97
+ ## Legitimate use
98
+
99
+ For automation, QA, and scraping public data you are allowed to access. Respect each target's Terms of Service and robots directives, and do not use this for credential-stuffing or other abuse.
100
+
101
+ ## License
102
+
103
+ MIT — see [LICENSE](./LICENSE).
@@ -0,0 +1,79 @@
1
+ <a href="https://peak.fo/?utm_source=github&utm_medium=readme&utm_campaign=packages&utm_content=selenium-turnstile">
2
+ <img src="https://raw.githubusercontent.com/CircuitSavage/selenium-turnstile/main/assets/peak-banner.png" alt="Peak — solve Cloudflare Turnstile & the 5s challenge in ~1s" width="100%">
3
+ </a>
4
+
5
+ # selenium-turnstile
6
+
7
+ **selenium-turnstile** is a Selenium helper that solves Cloudflare Turnstile by injecting a Peak-solved token, so a protected form or navigation proceeds without a real mouse click.
8
+
9
+ ## Why
10
+
11
+ SeleniumBase's UC-mode click-solve (`uc_gui_click_captcha`) drives a physical click on the widget, which needs a real display and fails headless on datacenter IPs. That failure mode fills Cloudflare-related SeleniumBase issues (200+ open). undetected-chromedriver has no click-solve at all and gets stuck in the "Just a moment" loop, reloading the interstitial even after the browser has otherwise passed.
12
+
13
+ This helper skips the click entirely. It reads the sitekey from the DOM, gets a token from Peak, and injects that token as the `cf-turnstile-response` value plus the widget's success callback. When your setup gets blocked, drop in a Peak API key and it just works.
14
+
15
+ ## Powered by Peak
16
+
17
+ This package uses [Peak](https://peak.fo/?utm_source=github&utm_medium=readme&utm_campaign=packages&utm_content=selenium-turnstile) to solve Turnstile.
18
+ - Solve Cloudflare Turnstile & the 5s challenge in about a second
19
+ - Pay only for successful solves, from $1 / 1,000
20
+ - 1,000 free solves to start, no card.
21
+ → [Get your free API key](https://peak.fo/?utm_source=github&utm_medium=readme&utm_campaign=packages&utm_content=selenium-turnstile) · [Docs](https://peak.fo/docs/turnstile?utm_source=github&utm_medium=readme&utm_campaign=packages&utm_content=selenium-turnstile) · [Pricing](https://peak.fo/pricing?utm_source=github&utm_medium=readme&utm_campaign=packages&utm_content=selenium-turnstile)
22
+
23
+ ## Install
24
+
25
+ ```bash
26
+ pip install selenium-turnstile selenium
27
+ ```
28
+
29
+ ## Quickstart
30
+
31
+ ```python
32
+ import os
33
+ from selenium import webdriver
34
+ from selenium_turnstile import solve_turnstile
35
+
36
+ os.environ["PEAK_API_KEY"] = "pk_your_api_key"
37
+
38
+ driver = webdriver.Chrome()
39
+ driver.get("https://protected.example/login")
40
+
41
+ # Reads the sitekey from the page, solves via Peak, injects the token.
42
+ token = solve_turnstile(driver)
43
+
44
+ driver.find_element("css selector", "form").submit()
45
+ ```
46
+
47
+ Works unchanged with a plain Selenium driver, a SeleniumBase `Driver`, or an undetected-chromedriver `Chrome` — all three expose the `current_url` / `find_element` / `execute_script` interface the helper uses.
48
+
49
+ ## How it works
50
+
51
+ 1. **Read the sitekey.** `find_element` looks for the `data-sitekey` attribute on the `.cf-turnstile` widget; if the sitekey is only set through a JS `render()` call, an `execute_script` DOM scan finds it.
52
+ 2. **Solve via Peak.** `POST https://api.peak.fo/solve` with `task_type: "turnstiletask"`, the sitekey, and `url=driver.current_url`. Pass `proxy=` to mint the token from your browser's egress IP.
53
+ 3. **Inject the token.** `execute_script` sets every `cf-turnstile-response` field, fires `input`/`change` events, and calls the widget's `data-callback` success handler so the form or navigation continues.
54
+
55
+ ## API
56
+
57
+ ```python
58
+ solve_turnstile(
59
+ driver, # any Selenium-like WebDriver
60
+ api_key=None, # defaults to env PEAK_API_KEY
61
+ proxy=None, # optional http://user:pass@ip:port, forwarded to Peak
62
+ sitekey=None, # override auto-detection (e.g. widget in a frame)
63
+ url=None, # defaults to driver.current_url
64
+ task_type="turnstiletask",
65
+ timeout=180.0,
66
+ ) -> str # the injected token
67
+ ```
68
+
69
+ Helpers `read_sitekey(driver)` and `inject_token(driver, token)` are exposed for finer control. Peak also supports `task_type="cloudflare5stask"` for the 5s interstitial challenge.
70
+
71
+ The API key is read from the `PEAK_API_KEY` environment variable by default; you can also pass it explicitly. Never hardcode a real key.
72
+
73
+ ## Legitimate use
74
+
75
+ For automation, QA, and scraping public data you are allowed to access. Respect each target's Terms of Service and robots directives, and do not use this for credential-stuffing or other abuse.
76
+
77
+ ## License
78
+
79
+ MIT — see [LICENSE](./LICENSE).
@@ -0,0 +1,56 @@
1
+ # Testing
2
+
3
+ The suite mocks Peak's `/solve` HTTP response and uses a fake WebDriver, so it
4
+ needs neither a live Peak API key nor a real browser. Selenium is an optional
5
+ import, so the tests also run with Selenium not installed.
6
+
7
+ ## What is covered
8
+
9
+ `tests/test_solver.py` drives `solve_turnstile()` against a `FakeDriver` that
10
+ exposes `current_url`, `find_element` (returns an element with a sample
11
+ `data-sitekey`), and `execute_script` (records every call). `PeakClient._post`
12
+ is patched to return `{"success": true, "data": {"token": "XXXX.TEST"}}`.
13
+
14
+ Assertions:
15
+ - Sitekey is read from the DOM (`find_element` is called).
16
+ - Peak is called exactly once with the correct body: `task_type=turnstiletask`,
17
+ the sample `sitekey`, and `url` equal to `driver.current_url`; `proxy` is
18
+ omitted when not supplied.
19
+ - The token is injected via `execute_script`, passed as the script argument,
20
+ and the injection script sets `cf-turnstile-response`.
21
+ - `proxy=` is forwarded into the Peak body when provided.
22
+ - `api_key` falls back to the `PEAK_API_KEY` environment variable.
23
+ - Sitekey detection falls back to the `execute_script` DOM scan when
24
+ `find_element` finds nothing.
25
+ - Missing sitekey, a Peak `success:false` response, and a missing API key each
26
+ raise `PeakError`.
27
+
28
+ ## Run
29
+
30
+ ```bash
31
+ cd selenium-turnstile
32
+ python -m unittest discover -s tests -v
33
+ ```
34
+
35
+ Python 3.12 = `python`. No third-party packages required.
36
+
37
+ ## Observed output
38
+
39
+ ```
40
+ $ python --version
41
+ Python 3.12.0
42
+
43
+ $ python -m unittest discover -s tests -v
44
+ test_api_key_from_env (test_solver.SolveTurnstileTests.test_api_key_from_env) ... ok
45
+ test_missing_sitekey_raises (test_solver.SolveTurnstileTests.test_missing_sitekey_raises) ... ok
46
+ test_no_api_key_raises (test_solver.SolveTurnstileTests.test_no_api_key_raises) ... ok
47
+ test_peak_failure_raises (test_solver.SolveTurnstileTests.test_peak_failure_raises) ... ok
48
+ test_proxy_forwarded_to_peak (test_solver.SolveTurnstileTests.test_proxy_forwarded_to_peak) ... ok
49
+ test_reads_sitekey_calls_peak_and_injects (test_solver.SolveTurnstileTests.test_reads_sitekey_calls_peak_and_injects) ... ok
50
+ test_sitekey_fallback_via_execute_script (test_solver.SolveTurnstileTests.test_sitekey_fallback_via_execute_script) ... ok
51
+
52
+ ----------------------------------------------------------------------
53
+ Ran 7 tests in 0.008s
54
+
55
+ OK
56
+ ```
@@ -0,0 +1,45 @@
1
+ """Solve a Turnstile-protected page with Selenium + Peak.
2
+
3
+ Run:
4
+ pip install selenium selenium-turnstile
5
+ export PEAK_API_KEY=pk_your_api_key
6
+ python examples/solve_login.py
7
+
8
+ Works the same with a plain Selenium Chrome/Firefox driver, a
9
+ SeleniumBase ``Driver``, or an undetected-chromedriver ``Chrome`` -- they
10
+ all expose the ``current_url`` / ``find_element`` / ``execute_script``
11
+ interface that solve_turnstile needs.
12
+ """
13
+
14
+ import os
15
+
16
+ from selenium import webdriver
17
+
18
+ from selenium_turnstile import solve_turnstile
19
+
20
+ TARGET = "https://protected.example/login"
21
+
22
+
23
+ def main():
24
+ api_key = os.environ.get("PEAK_API_KEY", "pk_your_api_key")
25
+
26
+ driver = webdriver.Chrome()
27
+ try:
28
+ driver.get(TARGET)
29
+
30
+ # Optional: route the solve through the same proxy as the browser so
31
+ # the token is minted from your egress IP.
32
+ # proxy = "http://user:pass@ip:port"
33
+ token = solve_turnstile(driver, api_key=api_key, proxy=None)
34
+ print("Injected Turnstile token:", token[:24], "...")
35
+
36
+ # The hidden cf-turnstile-response field is now set and the widget's
37
+ # success callback has fired. Submit the form / continue navigation.
38
+ driver.find_element("css selector", "form").submit()
39
+ print("Now on:", driver.current_url)
40
+ finally:
41
+ driver.quit()
42
+
43
+
44
+ if __name__ == "__main__":
45
+ main()
@@ -0,0 +1,55 @@
1
+ [build-system]
2
+ requires = ["hatchling"]
3
+ build-backend = "hatchling.build"
4
+
5
+ [project]
6
+ name = "selenium-turnstile"
7
+ version = "0.1.0"
8
+ description = "Solve Cloudflare Turnstile in Selenium via the Peak API, for when SeleniumBase UC-mode or undetected-chromedriver stall headless."
9
+ readme = "README.md"
10
+ license = { text = "MIT" }
11
+ requires-python = ">=3.8"
12
+ authors = [{ name = "Peak" }]
13
+ keywords = [
14
+ "selenium",
15
+ "seleniumbase",
16
+ "undetected-chromedriver",
17
+ "cloudflare",
18
+ "turnstile",
19
+ "captcha",
20
+ "solver",
21
+ "bypass",
22
+ ]
23
+ classifiers = [
24
+ "Development Status :: 4 - Beta",
25
+ "Intended Audience :: Developers",
26
+ "License :: OSI Approved :: MIT License",
27
+ "Programming Language :: Python :: 3",
28
+ "Programming Language :: Python :: 3 :: Only",
29
+ "Topic :: Internet :: WWW/HTTP :: Browsers",
30
+ "Topic :: Software Development :: Testing",
31
+ ]
32
+ dependencies = []
33
+
34
+ [project.optional-dependencies]
35
+ selenium = ["selenium>=4.0"]
36
+
37
+ [project.urls]
38
+ Homepage = "https://peak.fo/?utm_source=github&utm_medium=readme&utm_campaign=packages&utm_content=selenium-turnstile"
39
+ Documentation = "https://peak.fo/docs/turnstile?utm_source=github&utm_medium=readme&utm_campaign=packages&utm_content=selenium-turnstile"
40
+ Pricing = "https://peak.fo/pricing?utm_source=github&utm_medium=readme&utm_campaign=packages&utm_content=selenium-turnstile"
41
+ Source = "https://github.com/CircuitSavage/selenium-turnstile"
42
+
43
+ [tool.hatch.build.targets.wheel]
44
+ packages = ["selenium_turnstile"]
45
+
46
+ [tool.hatch.build.targets.sdist]
47
+ include = [
48
+ "selenium_turnstile",
49
+ "examples",
50
+ "tests",
51
+ "assets",
52
+ "README.md",
53
+ "LICENSE",
54
+ "TEST.md",
55
+ ]
@@ -0,0 +1,34 @@
1
+ """selenium-turnstile: solve Cloudflare Turnstile in Selenium via Peak.
2
+
3
+ Public API::
4
+
5
+ from selenium_turnstile import solve_turnstile
6
+ token = solve_turnstile(driver, api_key="pk_...")
7
+ """
8
+
9
+ from __future__ import annotations
10
+
11
+ from .peak import (
12
+ DEFAULT_API_URL,
13
+ TASK_CLOUDFLARE_5S,
14
+ TASK_TURNSTILE,
15
+ PeakClient,
16
+ PeakError,
17
+ build_solve_payload,
18
+ )
19
+ from .solver import inject_token, read_sitekey, solve_turnstile
20
+
21
+ __version__ = "0.1.0"
22
+
23
+ __all__ = [
24
+ "solve_turnstile",
25
+ "read_sitekey",
26
+ "inject_token",
27
+ "PeakClient",
28
+ "PeakError",
29
+ "build_solve_payload",
30
+ "DEFAULT_API_URL",
31
+ "TASK_TURNSTILE",
32
+ "TASK_CLOUDFLARE_5S",
33
+ "__version__",
34
+ ]
@@ -0,0 +1,114 @@
1
+ """Peak API client for solving Cloudflare Turnstile.
2
+
3
+ Kept free of any Selenium dependency so it can be unit tested and reused
4
+ on its own. The HTTP call uses the standard library only.
5
+ """
6
+
7
+ from __future__ import annotations
8
+
9
+ import json
10
+ import urllib.error
11
+ import urllib.request
12
+ from typing import Optional
13
+
14
+ DEFAULT_API_URL = "https://api.peak.fo/solve"
15
+
16
+ # task_type sent to Peak for the Turnstile widget challenge.
17
+ TASK_TURNSTILE = "turnstiletask"
18
+ # Peak also supports the 5s interstitial via this task_type.
19
+ TASK_CLOUDFLARE_5S = "cloudflare5stask"
20
+
21
+
22
+ class PeakError(RuntimeError):
23
+ """Raised when Peak cannot solve the challenge or the call fails."""
24
+
25
+
26
+ def build_solve_payload(
27
+ sitekey: str,
28
+ url: str,
29
+ proxy: Optional[str] = None,
30
+ task_type: str = TASK_TURNSTILE,
31
+ ) -> dict:
32
+ """Build the JSON body for POST https://api.peak.fo/solve.
33
+
34
+ ``proxy`` is omitted entirely when not provided, per the Peak contract.
35
+ """
36
+ payload = {
37
+ "task_type": task_type,
38
+ "sitekey": sitekey,
39
+ "url": url,
40
+ }
41
+ if proxy:
42
+ payload["proxy"] = proxy
43
+ return payload
44
+
45
+
46
+ class PeakClient:
47
+ """Thin client around the Peak solve endpoint."""
48
+
49
+ def __init__(
50
+ self,
51
+ api_key: str,
52
+ api_url: str = DEFAULT_API_URL,
53
+ proxy: Optional[str] = None,
54
+ timeout: float = 180.0,
55
+ ) -> None:
56
+ if not api_key:
57
+ raise PeakError(
58
+ "No Peak API key. Set PEAK_API_KEY (get a free key at "
59
+ "https://peak.fo)."
60
+ )
61
+ self.api_key = api_key
62
+ self.api_url = api_url
63
+ self.proxy = proxy
64
+ self.timeout = timeout
65
+
66
+ def _post(self, payload: dict) -> dict:
67
+ """Send the payload to Peak and return the parsed JSON response.
68
+
69
+ Isolated so tests can monkeypatch a single method instead of the
70
+ network. Uses only the standard library.
71
+ """
72
+ data = json.dumps(payload).encode("utf-8")
73
+ req = urllib.request.Request(
74
+ self.api_url,
75
+ data=data,
76
+ method="POST",
77
+ headers={
78
+ "Content-Type": "application/json",
79
+ "X-API-Key": self.api_key,
80
+ },
81
+ )
82
+ try:
83
+ with urllib.request.urlopen(req, timeout=self.timeout) as resp:
84
+ body = resp.read().decode("utf-8")
85
+ except urllib.error.HTTPError as exc: # pragma: no cover - network path
86
+ body = exc.read().decode("utf-8", "replace")
87
+ except urllib.error.URLError as exc: # pragma: no cover - network path
88
+ raise PeakError(f"Peak request failed: {exc}") from exc
89
+ try:
90
+ return json.loads(body)
91
+ except ValueError as exc: # pragma: no cover - defensive
92
+ raise PeakError(f"Peak returned non-JSON response: {body!r}") from exc
93
+
94
+ def solve(
95
+ self,
96
+ sitekey: str,
97
+ url: str,
98
+ proxy: Optional[str] = None,
99
+ task_type: str = TASK_TURNSTILE,
100
+ ) -> str:
101
+ """Solve a Turnstile challenge and return the token string.
102
+
103
+ Raises :class:`PeakError` on failure.
104
+ """
105
+ payload = build_solve_payload(
106
+ sitekey, url, proxy=proxy or self.proxy, task_type=task_type
107
+ )
108
+ result = self._post(payload)
109
+ if not result.get("success"):
110
+ raise PeakError(result.get("error") or "Peak solve failed")
111
+ token = (result.get("data") or {}).get("token")
112
+ if not token:
113
+ raise PeakError("Peak response missing data.token")
114
+ return token
@@ -0,0 +1,150 @@
1
+ """Solve Cloudflare Turnstile on a live Selenium page via the Peak API.
2
+
3
+ The flow is: read the Turnstile sitekey out of the DOM, ask Peak for a
4
+ token, then inject that token back into the page (hidden input + widget
5
+ success callback) so the form or navigation proceeds.
6
+
7
+ Selenium itself is an optional import. The functions accept any object
8
+ that quacks like a Selenium ``WebDriver`` (``current_url``,
9
+ ``find_element``, ``execute_script``), which keeps them unit testable
10
+ without a real browser.
11
+ """
12
+
13
+ from __future__ import annotations
14
+
15
+ import os
16
+ from typing import Optional
17
+
18
+ from .peak import DEFAULT_API_URL, TASK_TURNSTILE, PeakClient, PeakError
19
+
20
+ # CSS selectors that carry the Turnstile sitekey as a data attribute.
21
+ _SITEKEY_SELECTORS = (
22
+ ".cf-turnstile[data-sitekey]",
23
+ "[data-sitekey]",
24
+ "div.cf-turnstile",
25
+ )
26
+
27
+ # JS that scans the DOM for a Turnstile sitekey. Used as a fallback when
28
+ # find_element does not turn one up (for example, sitekey configured only
29
+ # through the JS render() call).
30
+ _READ_SITEKEY_JS = r"""
31
+ var el = document.querySelector('[data-sitekey]');
32
+ if (el) { return el.getAttribute('data-sitekey'); }
33
+ var m = (document.documentElement.outerHTML || '').match(
34
+ /(?:sitekey|render)\s*[:=]\s*["']((?:0x|1x)[^"']+)["']/i);
35
+ return m ? m[1] : null;
36
+ """
37
+
38
+ # JS that injects a solved token. Sets every cf-turnstile-response field,
39
+ # fires input/change events so listeners react, and calls the widget's
40
+ # success callback (data-callback global, or window.turnstile internals).
41
+ _INJECT_TOKEN_JS = r"""
42
+ var token = arguments[0];
43
+ var fields = document.querySelectorAll(
44
+ 'input[name="cf-turnstile-response"], textarea[name="cf-turnstile-response"], ' +
45
+ 'input#cf-turnstile-response, [name="cf-turnstile-response"]');
46
+ if (!fields.length) {
47
+ var holder = document.querySelector('.cf-turnstile') || document.body;
48
+ var inp = document.createElement('input');
49
+ inp.type = 'hidden';
50
+ inp.name = 'cf-turnstile-response';
51
+ holder.appendChild(inp);
52
+ fields = [inp];
53
+ }
54
+ fields.forEach(function (f) {
55
+ f.value = token;
56
+ f.dispatchEvent(new Event('input', { bubbles: true }));
57
+ f.dispatchEvent(new Event('change', { bubbles: true }));
58
+ });
59
+ var called = false;
60
+ document.querySelectorAll('[data-callback]').forEach(function (w) {
61
+ var name = w.getAttribute('data-callback');
62
+ if (name && typeof window[name] === 'function') {
63
+ window[name](token);
64
+ called = true;
65
+ }
66
+ });
67
+ return called;
68
+ """
69
+
70
+
71
+ def read_sitekey(driver) -> Optional[str]:
72
+ """Return the Turnstile sitekey rendered on the current page, or None.
73
+
74
+ Tries ``find_element`` against the widget's ``data-sitekey`` attribute
75
+ first, then falls back to an ``execute_script`` DOM scan.
76
+ """
77
+ try:
78
+ from selenium.webdriver.common.by import By
79
+
80
+ css = By.CSS_SELECTOR
81
+ except Exception: # selenium not installed; use the raw locator string
82
+ css = "css selector"
83
+
84
+ for selector in _SITEKEY_SELECTORS:
85
+ try:
86
+ element = driver.find_element(css, selector)
87
+ except Exception:
88
+ continue
89
+ if element is None:
90
+ continue
91
+ sitekey = element.get_attribute("data-sitekey")
92
+ if sitekey:
93
+ return sitekey.strip()
94
+
95
+ sitekey = driver.execute_script(_READ_SITEKEY_JS)
96
+ if sitekey:
97
+ return str(sitekey).strip()
98
+ return None
99
+
100
+
101
+ def inject_token(driver, token: str) -> None:
102
+ """Inject a solved token into the page's Turnstile widget."""
103
+ driver.execute_script(_INJECT_TOKEN_JS, token)
104
+
105
+
106
+ def solve_turnstile(
107
+ driver,
108
+ api_key: Optional[str] = None,
109
+ proxy: Optional[str] = None,
110
+ sitekey: Optional[str] = None,
111
+ url: Optional[str] = None,
112
+ task_type: str = TASK_TURNSTILE,
113
+ api_url: Optional[str] = None,
114
+ timeout: float = 180.0,
115
+ ) -> str:
116
+ """Solve the Turnstile challenge on ``driver``'s current page.
117
+
118
+ Reads the sitekey from the DOM (unless ``sitekey`` is given), asks Peak
119
+ for a token, injects it, and returns the token.
120
+
121
+ ``api_key`` defaults to the ``PEAK_API_KEY`` environment variable.
122
+ ``url`` defaults to ``driver.current_url``. ``proxy`` is forwarded to
123
+ Peak so the token is minted from the same egress IP as the browser;
124
+ omit it and Peak solves without one.
125
+
126
+ Raises :class:`~selenium_turnstile.peak.PeakError` if no sitekey is
127
+ found or Peak cannot solve the challenge.
128
+ """
129
+ api_key = api_key or os.environ.get("PEAK_API_KEY")
130
+
131
+ if sitekey is None:
132
+ sitekey = read_sitekey(driver)
133
+ if not sitekey:
134
+ raise PeakError(
135
+ "No Turnstile sitekey found on the page. Pass sitekey= "
136
+ "explicitly if the widget renders in a frame or after load."
137
+ )
138
+
139
+ if url is None:
140
+ url = driver.current_url
141
+
142
+ client = PeakClient(
143
+ api_key,
144
+ api_url=api_url or DEFAULT_API_URL,
145
+ proxy=proxy,
146
+ timeout=timeout,
147
+ )
148
+ token = client.solve(sitekey, url, proxy=proxy, task_type=task_type)
149
+ inject_token(driver, token)
150
+ return token
@@ -0,0 +1,152 @@
1
+ """Unit tests for selenium-turnstile.
2
+
3
+ These mock Peak's HTTP response and use a fake WebDriver, so they need
4
+ neither a live Peak key nor a real browser. Run with::
5
+
6
+ python -m unittest discover -s tests -v
7
+ """
8
+
9
+ from __future__ import annotations
10
+
11
+ import os
12
+ import sys
13
+ import unittest
14
+ from unittest import mock
15
+
16
+ # Make the package importable when run from the repo root or tests/ dir.
17
+ sys.path.insert(0, os.path.abspath(os.path.join(os.path.dirname(__file__), "..")))
18
+
19
+ from selenium_turnstile import PeakError, solve_turnstile # noqa: E402
20
+ from selenium_turnstile.peak import PeakClient # noqa: E402
21
+
22
+ SAMPLE_SITEKEY = "0x4AAAAAAADnPIDROzbs0Aaj"
23
+ SAMPLE_URL = "https://protected.example/login"
24
+ TEST_TOKEN = "XXXX.TEST"
25
+
26
+
27
+ class FakeElement:
28
+ """Stands in for a Selenium WebElement carrying data-sitekey."""
29
+
30
+ def __init__(self, sitekey):
31
+ self._sitekey = sitekey
32
+
33
+ def get_attribute(self, name):
34
+ if name == "data-sitekey":
35
+ return self._sitekey
36
+ return None
37
+
38
+
39
+ class FakeDriver:
40
+ """Minimal WebDriver double.
41
+
42
+ Records every execute_script call so tests can assert the injection,
43
+ and returns a sitekey-bearing element from find_element.
44
+ """
45
+
46
+ def __init__(self, sitekey=SAMPLE_SITEKEY, url=SAMPLE_URL):
47
+ self.current_url = url
48
+ self._sitekey = sitekey
49
+ self.find_element_calls = []
50
+ self.execute_script_calls = []
51
+
52
+ def find_element(self, by, value):
53
+ self.find_element_calls.append((by, value))
54
+ if self._sitekey is None:
55
+ raise RuntimeError("no such element")
56
+ return FakeElement(self._sitekey)
57
+
58
+ def execute_script(self, script, *args):
59
+ self.execute_script_calls.append((script, args))
60
+ # The sitekey-scan fallback expects a return value; injection ignores it.
61
+ if "querySelector('[data-sitekey]')" in script:
62
+ return self._sitekey
63
+ return True
64
+
65
+
66
+ def _peak_success(*_args, **_kwargs):
67
+ return {"success": True, "data": {"token": TEST_TOKEN}, "cost": 0.001}
68
+
69
+
70
+ class SolveTurnstileTests(unittest.TestCase):
71
+ def test_reads_sitekey_calls_peak_and_injects(self):
72
+ driver = FakeDriver()
73
+ with mock.patch.object(PeakClient, "_post", side_effect=_peak_success) as post:
74
+ token = solve_turnstile(driver, api_key="pk_test_key")
75
+
76
+ # Token returned unchanged from the mocked Peak response.
77
+ self.assertEqual(token, TEST_TOKEN)
78
+
79
+ # Sitekey was read from the DOM via find_element.
80
+ self.assertTrue(driver.find_element_calls)
81
+
82
+ # Peak was called exactly once with the correct body.
83
+ self.assertEqual(post.call_count, 1)
84
+ payload = post.call_args.args[0]
85
+ self.assertEqual(payload["task_type"], "turnstiletask")
86
+ self.assertEqual(payload["sitekey"], SAMPLE_SITEKEY)
87
+ self.assertEqual(payload["url"], SAMPLE_URL)
88
+ self.assertNotIn("proxy", payload) # omitted when not provided
89
+
90
+ # Token was injected via execute_script, passed as the script arg.
91
+ inject_calls = [
92
+ c for c in driver.execute_script_calls if TEST_TOKEN in c[1]
93
+ ]
94
+ self.assertEqual(len(inject_calls), 1)
95
+ script, args = inject_calls[0]
96
+ self.assertIn("cf-turnstile-response", script)
97
+ self.assertEqual(args, (TEST_TOKEN,))
98
+
99
+ def test_proxy_forwarded_to_peak(self):
100
+ driver = FakeDriver()
101
+ proxy = "http://user:pass@1.2.3.4:8080"
102
+ with mock.patch.object(PeakClient, "_post", side_effect=_peak_success) as post:
103
+ solve_turnstile(driver, api_key="pk_test_key", proxy=proxy)
104
+ payload = post.call_args.args[0]
105
+ self.assertEqual(payload["proxy"], proxy)
106
+
107
+ def test_api_key_from_env(self):
108
+ driver = FakeDriver()
109
+ with mock.patch.dict(os.environ, {"PEAK_API_KEY": "pk_env_key"}):
110
+ with mock.patch.object(PeakClient, "_post", side_effect=_peak_success):
111
+ token = solve_turnstile(driver)
112
+ self.assertEqual(token, TEST_TOKEN)
113
+
114
+ def test_sitekey_fallback_via_execute_script(self):
115
+ # find_element finds nothing; the JS DOM scan supplies the sitekey.
116
+ driver = FakeDriver(sitekey=None)
117
+ driver._sitekey = None
118
+
119
+ def exec_script(script, *args):
120
+ driver.execute_script_calls.append((script, args))
121
+ if "querySelector('[data-sitekey]')" in script:
122
+ return SAMPLE_SITEKEY
123
+ return True
124
+
125
+ driver.execute_script = exec_script
126
+ with mock.patch.object(PeakClient, "_post", side_effect=_peak_success) as post:
127
+ token = solve_turnstile(driver, api_key="pk_test_key")
128
+ self.assertEqual(token, TEST_TOKEN)
129
+ self.assertEqual(post.call_args.args[0]["sitekey"], SAMPLE_SITEKEY)
130
+
131
+ def test_missing_sitekey_raises(self):
132
+ driver = FakeDriver(sitekey=None)
133
+ driver.execute_script = lambda script, *args: None
134
+ with self.assertRaises(PeakError):
135
+ solve_turnstile(driver, api_key="pk_test_key")
136
+
137
+ def test_peak_failure_raises(self):
138
+ driver = FakeDriver()
139
+ fail = {"success": False, "error": "insufficient balance"}
140
+ with mock.patch.object(PeakClient, "_post", return_value=fail):
141
+ with self.assertRaises(PeakError):
142
+ solve_turnstile(driver, api_key="pk_test_key")
143
+
144
+ def test_no_api_key_raises(self):
145
+ driver = FakeDriver()
146
+ with mock.patch.dict(os.environ, {}, clear=True):
147
+ with self.assertRaises(PeakError):
148
+ solve_turnstile(driver)
149
+
150
+
151
+ if __name__ == "__main__":
152
+ unittest.main(verbosity=2)