Skip to content

Commit fcc5be5

Browse files
OpenCodeAndreiDrang
authored andcommitted
feat(altcha): add ALTCHA captcha support
Add ALTCHA captcha solver implementation following 2Captcha API specification. Includes XOR validation for challengeURL/challengeJSON parameters and support for both proxyless and proxy modes. Changes: - src/python_rucaptcha/core/enums.py: Add AltchaEnm enum with AltchaTaskProxyless and AltchaTask values - src/python_rucaptcha/altcha_captcha.py: Add AltchaCaptcha class with sync/async handlers and XOR parameter validation - tests/test_altcha.py: Add unit tests with XOR validation coverage - README.md: Document new ALTCHA captcha type Stats: - 4 files changed - +173/-0 lines
1 parent 8151060 commit fcc5be5

4 files changed

Lines changed: 356 additions & 0 deletions

File tree

README.md

Lines changed: 14 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -67,6 +67,19 @@ result = ImageCaptcha(api_key).captcha_handler(
6767
)
6868
```
6969

70+
**ALTCHA:**
71+
```python
72+
from python_rucaptcha import AltchaCaptcha
73+
from python_rucaptcha.core.enums import AltchaEnm
74+
75+
result = AltchaCaptcha(
76+
rucaptcha_key=api_key,
77+
websiteURL="https://example.com",
78+
challengeURL="https://example.com/altcha/challenge",
79+
method=AltchaEnm.AltchaTaskProxyless,
80+
).captcha_handler()
81+
```
82+
7083
**Using async:**
7184
```python
7285
import asyncio
@@ -94,6 +107,7 @@ token = asyncio.run(solve())
94107
| GeeTest | `GeeTest` | Chinese geetest puzzles |
95108
| KeyCaptcha | `KeyCaptcha` | KeyCAPTCHA service |
96109
| Amazon WAF | `AmazonWaf` | AWS WAF challenge |
110+
| ALTCHA | `AltchaCaptcha` | ALTCHA challenge |
97111
| Grid | `GridCaptcha` | Select grid cells |
98112
| Coordinates | `CoordinatesCaptcha` | Click on coordinates |
99113
| And 20+ more | ... | See [full docs](https://andreidrang.github.io/python-rucaptcha/) |
Lines changed: 152 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,152 @@
1+
from typing import Union, Optional
2+
3+
from .core.base import BaseCaptcha
4+
from .core.enums import AltchaEnm
5+
6+
7+
class AltchaCaptcha(BaseCaptcha):
8+
def __init__(
9+
self,
10+
websiteURL: str,
11+
method: Union[str, AltchaEnm] = AltchaEnm.AltchaTaskProxyless,
12+
challengeURL: Optional[str] = None,
13+
challengeJSON: Optional[str] = None,
14+
proxyType: Optional[str] = None,
15+
proxyAddress: Optional[str] = None,
16+
proxyPort: Optional[int] = None,
17+
proxyLogin: Optional[str] = None,
18+
proxyPassword: Optional[str] = None,
19+
*args,
20+
**kwargs,
21+
):
22+
"""
23+
The class is used to work with ALTCHA captcha.
24+
25+
Args:
26+
rucaptcha_key: User API key
27+
websiteURL: Full URL of the captcha page
28+
method: Captcha type
29+
challengeURL: Full URL of the page that contains ALTCHA challenge
30+
challengeJSON: JSON-encoded ALTCHA challenge data
31+
proxyType: Proxy type (http, https, socks4, socks5)
32+
proxyAddress: Proxy IP address or hostname
33+
proxyPort: Proxy port
34+
proxyLogin: Proxy login
35+
proxyPassword: Proxy password
36+
kwargs: Not required params for task creation request
37+
38+
Examples:
39+
>>> AltchaCaptcha(rucaptcha_key="aa9011f31111181111168611f1151122",
40+
... websiteURL="https://example.com",
41+
... challengeURL="https://example.com/altcha/challenge.js",
42+
... method=AltchaEnm.AltchaTaskProxyless.value,
43+
... ).captcha_handler()
44+
{
45+
"errorId":0,
46+
"status":"ready",
47+
"solution":{
48+
"token":"..."
49+
},
50+
"cost":"0.00145",
51+
"ip":"1.2.3.4",
52+
"createTime":1692863536,
53+
"endTime":1692863556,
54+
"solveCount":1,
55+
"taskId": 73243152973,
56+
}
57+
58+
>>> await AltchaCaptcha(rucaptcha_key="aa9011f31111181111168611f1151122",
59+
... websiteURL="https://example.com",
60+
... challengeJSON='{"挑战数据"}',
61+
... method=AltchaEnm.AltchaTask.value,
62+
... proxyType="http",
63+
... proxyAddress="1.2.3.4",
64+
... proxyPort=8080,
65+
... ).aio_captcha_handler()
66+
{
67+
"errorId":0,
68+
"status":"ready",
69+
"solution":{
70+
"token":"..."
71+
},
72+
"cost":"0.00145",
73+
"ip":"1.2.3.4",
74+
"createTime":1692863536,
75+
"endTime":1692863556,
76+
"solveCount":1,
77+
"taskId": 73243152973,
78+
}
79+
80+
Returns:
81+
Dict with full server response
82+
83+
Notes:
84+
https://rucaptcha.com/api-docs/altcha
85+
"""
86+
87+
super().__init__(method=method, *args, **kwargs)
88+
89+
# XOR validation: exactly one of challengeURL or challengeJSON must be provided
90+
if not (bool(challengeURL) ^ bool(challengeJSON)):
91+
raise ValueError(
92+
"Exactly one of 'challengeURL' or 'challengeJSON' must be provided, not both or neither"
93+
)
94+
95+
# Validate method
96+
if method not in AltchaEnm.list_values():
97+
raise ValueError(f"Invalid method parameter set, available - {AltchaEnm.list_values()}")
98+
99+
# Build task payload
100+
task_data = {"websiteURL": websiteURL}
101+
102+
if challengeURL:
103+
task_data["challengeURL"] = challengeURL
104+
105+
if challengeJSON:
106+
task_data["challengeJSON"] = challengeJSON
107+
108+
# Add proxy params only for non-proxyless methods
109+
if method == AltchaEnm.AltchaTask.value:
110+
if not all([proxyType, proxyAddress, proxyPort]):
111+
raise ValueError(
112+
"Proxy parameters (proxyType, proxyAddress, proxyPort) are required for AltchaTask"
113+
)
114+
task_data.update(
115+
{
116+
"proxyType": proxyType,
117+
"proxyAddress": proxyAddress,
118+
"proxyPort": proxyPort,
119+
}
120+
)
121+
if proxyLogin and proxyPassword:
122+
task_data["proxyLogin"] = proxyLogin
123+
task_data["proxyPassword"] = proxyPassword
124+
125+
self.create_task_payload["task"].update(task_data)
126+
127+
def captcha_handler(self, **kwargs) -> dict:
128+
"""
129+
Sync solving method
130+
131+
Args:
132+
kwargs: Parameters for the `requests` library
133+
134+
Returns:
135+
Dict with full server response
136+
137+
Notes:
138+
Check class docstirng for more info
139+
"""
140+
return self._processing_response(**kwargs)
141+
142+
async def aio_captcha_handler(self) -> dict:
143+
"""
144+
Async solving method
145+
146+
Returns:
147+
Dict with full server response
148+
149+
Notes:
150+
Check class docstirng for more info
151+
"""
152+
return await self._aio_processing_response()

src/python_rucaptcha/core/enums.py

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -98,6 +98,11 @@ class TurnstileCaptchaEnm(str, MyEnum):
9898
TurnstileTask = "TurnstileTask"
9999

100100

101+
class AltchaEnm(str, MyEnum):
102+
AltchaTaskProxyless = "AltchaTaskProxyless"
103+
AltchaTask = "AltchaTask"
104+
105+
101106
class AmazonWAFCaptchaEnm(str, MyEnum):
102107
AmazonTask = "AmazonTask"
103108
AmazonTaskProxyless = "AmazonTaskProxyless"

tests/test_altcha.py

Lines changed: 185 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,185 @@
1+
import pytest
2+
3+
from tests.conftest import BaseTest
4+
from python_rucaptcha.core.enums import AltchaEnm
5+
from python_rucaptcha.altcha_captcha import AltchaCaptcha
6+
from python_rucaptcha.core.serializer import GetTaskResultResponseSer
7+
8+
9+
class TestAltcha(BaseTest):
10+
pageurl = "https://example.com"
11+
challenge_url = "https://example.com/altcha/challenge.js"
12+
challenge_json = '{"challenge":"test_data"}'
13+
useragent = "Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/116.0.0.0 Safari/537.36"
14+
kwargs_params = {
15+
"proxyType": "socks5",
16+
"proxyAddress": BaseTest.proxyAddress,
17+
"proxyPort": BaseTest.proxyPort,
18+
}
19+
20+
def test_methods_exists(self):
21+
assert "captcha_handler" in AltchaCaptcha.__dict__.keys()
22+
assert "aio_captcha_handler" in AltchaCaptcha.__dict__.keys()
23+
24+
@pytest.mark.parametrize("method", AltchaEnm.list_values())
25+
def test_args(self, method: str):
26+
kwargs = {}
27+
if method == AltchaEnm.AltchaTask.value:
28+
kwargs = {"proxyType": "http", "proxyAddress": "1.2.3.4", "proxyPort": 8080}
29+
instance = AltchaCaptcha(
30+
rucaptcha_key=self.RUCAPTCHA_KEY,
31+
websiteURL=self.pageurl,
32+
challengeURL=self.challenge_url,
33+
userAgent=self.useragent,
34+
method=method,
35+
**kwargs,
36+
)
37+
assert instance.create_task_payload["clientKey"] == self.RUCAPTCHA_KEY
38+
assert instance.create_task_payload["task"]["type"] == method
39+
assert instance.create_task_payload["task"]["websiteURL"] == self.pageurl
40+
assert instance.create_task_payload["task"]["challengeURL"] == self.challenge_url
41+
42+
def test_kwargs(self):
43+
instance = AltchaCaptcha(
44+
rucaptcha_key=self.RUCAPTCHA_KEY,
45+
websiteURL=self.pageurl,
46+
challengeURL=self.challenge_url,
47+
method=AltchaEnm.AltchaTask,
48+
**self.kwargs_params,
49+
)
50+
assert set(self.kwargs_params.keys()).issubset(set(instance.create_task_payload["task"].keys()))
51+
assert set(self.kwargs_params.values()).issubset(set(instance.create_task_payload["task"].values()))
52+
53+
def test_xor_validation_both_params(self):
54+
"""Test that providing both challengeURL and challengeJSON raises ValueError"""
55+
with pytest.raises(ValueError, match="challengeURL|challengeJSON"):
56+
AltchaCaptcha(
57+
rucaptcha_key=self.RUCAPTCHA_KEY,
58+
websiteURL=self.pageurl,
59+
challengeURL=self.challenge_url,
60+
challengeJSON=self.challenge_json,
61+
method=AltchaEnm.AltchaTaskProxyless,
62+
)
63+
64+
def test_xor_validation_neither_param(self):
65+
"""Test that providing neither challengeURL nor challengeJSON raises ValueError"""
66+
with pytest.raises(ValueError, match="challengeURL|challengeJSON"):
67+
AltchaCaptcha(
68+
rucaptcha_key=self.RUCAPTCHA_KEY,
69+
websiteURL=self.pageurl,
70+
method=AltchaEnm.AltchaTaskProxyless,
71+
)
72+
73+
def test_valid_challenge_url(self):
74+
"""Test that providing only challengeURL works correctly"""
75+
instance = AltchaCaptcha(
76+
rucaptcha_key=self.RUCAPTCHA_KEY,
77+
websiteURL=self.pageurl,
78+
challengeURL=self.challenge_url,
79+
method=AltchaEnm.AltchaTaskProxyless,
80+
)
81+
assert instance.create_task_payload["task"]["challengeURL"] == self.challenge_url
82+
assert "challengeJSON" not in instance.create_task_payload["task"]
83+
84+
def test_valid_challenge_json(self):
85+
"""Test that providing only challengeJSON works correctly"""
86+
instance = AltchaCaptcha(
87+
rucaptcha_key=self.RUCAPTCHA_KEY,
88+
websiteURL=self.pageurl,
89+
challengeJSON=self.challenge_json,
90+
method=AltchaEnm.AltchaTaskProxyless,
91+
)
92+
assert instance.create_task_payload["task"]["challengeJSON"] == self.challenge_json
93+
assert "challengeURL" not in instance.create_task_payload["task"]
94+
95+
def test_proxy_params_in_payload(self):
96+
"""Test that proxy params are included in payload for AltchaTask method"""
97+
instance = AltchaCaptcha(
98+
rucaptcha_key=self.RUCAPTCHA_KEY,
99+
websiteURL=self.pageurl,
100+
challengeURL=self.challenge_url,
101+
method=AltchaEnm.AltchaTask,
102+
proxyType="http",
103+
proxyAddress="1.2.3.4",
104+
proxyPort=8080,
105+
)
106+
assert instance.create_task_payload["task"]["proxyType"] == "http"
107+
assert instance.create_task_payload["task"]["proxyAddress"] == "1.2.3.4"
108+
assert instance.create_task_payload["task"]["proxyPort"] == 8080
109+
110+
def test_wrong_method(self):
111+
with pytest.raises(ValueError):
112+
AltchaCaptcha(
113+
rucaptcha_key=self.RUCAPTCHA_KEY,
114+
websiteURL=self.pageurl,
115+
challengeURL=self.challenge_url,
116+
method=self.get_random_string(5),
117+
)
118+
119+
"""
120+
Success tests
121+
"""
122+
123+
def test_basic_data(self):
124+
instance = AltchaCaptcha(
125+
rucaptcha_key=self.RUCAPTCHA_KEY,
126+
websiteURL=self.pageurl,
127+
challengeURL=self.challenge_url,
128+
method=AltchaEnm.AltchaTaskProxyless.value,
129+
)
130+
131+
result = instance.captcha_handler()
132+
133+
assert isinstance(result, dict) is True
134+
if not result["errorId"]:
135+
assert result["status"] == "ready"
136+
assert isinstance(result["solution"], dict) is True
137+
assert isinstance(result["taskId"], int) is True
138+
else:
139+
assert result["errorId"] in (1, 12)
140+
assert result["errorCode"] == "ERROR_CAPTCHA_UNSOLVABLE"
141+
142+
assert result.keys() == GetTaskResultResponseSer().to_dict().keys()
143+
144+
async def test_aio_basic_data(self):
145+
instance = AltchaCaptcha(
146+
rucaptcha_key=self.RUCAPTCHA_KEY,
147+
websiteURL=self.pageurl,
148+
challengeURL=self.challenge_url,
149+
method=AltchaEnm.AltchaTaskProxyless.value,
150+
)
151+
152+
result = await instance.aio_captcha_handler()
153+
154+
assert isinstance(result, dict) is True
155+
if not result["errorId"]:
156+
assert result["status"] == "ready"
157+
assert isinstance(result["solution"], dict) is True
158+
assert isinstance(result["taskId"], int) is True
159+
else:
160+
assert result["errorId"] in (1, 12)
161+
assert result["errorCode"] == "ERROR_CAPTCHA_UNSOLVABLE"
162+
163+
assert result.keys() == GetTaskResultResponseSer().to_dict().keys()
164+
165+
def test_context_basic_data(self):
166+
with AltchaCaptcha(
167+
rucaptcha_key=self.RUCAPTCHA_KEY,
168+
websiteURL=self.pageurl,
169+
challengeURL=self.challenge_url,
170+
method=AltchaEnm.AltchaTaskProxyless.value,
171+
) as instance:
172+
assert instance
173+
174+
async def test_context_aio_basic_data(self):
175+
async with AltchaCaptcha(
176+
rucaptcha_key=self.RUCAPTCHA_KEY,
177+
websiteURL=self.pageurl,
178+
challengeURL=self.challenge_url,
179+
method=AltchaEnm.AltchaTaskProxyless.value,
180+
) as instance:
181+
assert instance
182+
183+
"""
184+
Fail tests
185+
"""

0 commit comments

Comments
 (0)