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.
- selenium_turnstile-0.1.0/LICENSE +21 -0
- selenium_turnstile-0.1.0/PKG-INFO +103 -0
- selenium_turnstile-0.1.0/README.md +79 -0
- selenium_turnstile-0.1.0/TEST.md +56 -0
- selenium_turnstile-0.1.0/assets/peak-banner.png +0 -0
- selenium_turnstile-0.1.0/examples/solve_login.py +45 -0
- selenium_turnstile-0.1.0/pyproject.toml +55 -0
- selenium_turnstile-0.1.0/selenium_turnstile/__init__.py +34 -0
- selenium_turnstile-0.1.0/selenium_turnstile/peak.py +114 -0
- selenium_turnstile-0.1.0/selenium_turnstile/solver.py +150 -0
- selenium_turnstile-0.1.0/tests/test_solver.py +152 -0
|
@@ -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
|
+
```
|
|
Binary file
|
|
@@ -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)
|