diff --git a/README.md b/README.md index aafbdb4..3c267bb 100644 --- a/README.md +++ b/README.md @@ -86,7 +86,7 @@ client = CapMonsterClient(options=options) async def run_scraper(): # Solve Turnstile challenge via CapMonster Cloud turnstile_req = TurnstileRequest( - websiteUrl="https://target-website.com/login", + websiteURL="https://target-website.com/login", websiteKey="0x4AAAAAAABnPIDnK2k_e-2" ) solution = await client.solve_captcha(turnstile_req) diff --git a/capmonstercloud_client/requests/AlibabaCustomTaskRequest.py b/capmonstercloud_client/requests/AlibabaCustomTaskRequest.py index 4545bde..5e29f74 100644 --- a/capmonstercloud_client/requests/AlibabaCustomTaskRequest.py +++ b/capmonstercloud_client/requests/AlibabaCustomTaskRequest.py @@ -5,7 +5,7 @@ ALLOWED_METADATA_KEYS = { 'sceneId', 'prefix', 'userId', 'userUserId', 'verifyType', - 'region', 'UserCertifyId', 'apiGetLib', 'cookieRequired', + 'region', 'UserCertifyId', 'apiGetLib', 'cookieRequired', 'punishUrl', } class AlibabaCustomTaskRequest(CustomTaskRequestBase): @@ -15,29 +15,35 @@ class AlibabaCustomTaskRequest(CustomTaskRequestBase): Attributes: captchaClass: The constant string value identifying the captcha class as "alibaba". - metadata: A dictionary of Alibaba-specific parameters. Requires sceneId - and prefix; userId, userUserId, verifyType, region, UserCertifyId, - apiGetLib, and cookieRequired are optional, needed only for sites - that use them. + metadata: A dictionary of Alibaba-specific parameters. Standard mode + requires sceneId and prefix; userId, userUserId, verifyType, region, + UserCertifyId, apiGetLib, and cookieRequired are optional, needed + only for sites that use them. Alternatively, for websites that + trigger verification through a separate "/punish" URL, pass only + punishUrl (the full punish page URL) instead of sceneId/prefix. """ captchaClass: str = Field(default='alibaba', description='The constant string value identifying the captcha class as "alibaba".') - metadata: Dict[str, Union[str, bool]] = Field(..., description='A dictionary of Alibaba-specific parameters. Requires sceneId and prefix; userId, userUserId, verifyType, region, UserCertifyId, apiGetLib, and cookieRequired are optional, needed only for sites that use them.') + metadata: Dict[str, Union[str, bool]] = Field(..., description='A dictionary of Alibaba-specific parameters. Requires sceneId and prefix, unless punishUrl is provided instead. userId, userUserId, verifyType, region, UserCertifyId, apiGetLib, and cookieRequired are optional, needed only for sites that use them.') @field_validator('metadata') @classmethod def validate_metadata(cls, value): if not set(value.keys()).issubset(ALLOWED_METADATA_KEYS): raise TypeError(f'Allowed keys for metadata are {sorted(ALLOWED_METADATA_KEYS)}') - if value.get('sceneId') is None: - raise TypeError(f'sceneId must be defined inside metadata.') + if value.get('punishUrl') is not None: + if not isinstance(value.get('punishUrl'), str): + raise TypeError(f'punishUrl must be str.') else: - if not isinstance(value.get('sceneId'), str): - raise TypeError(f'sceneId must be str.') - if value.get('prefix') is None: - raise TypeError(f'prefix must be defined inside metadata.') - else: - if not isinstance(value.get('prefix'), str): - raise TypeError(f'prefix must be str.') + if value.get('sceneId') is None: + raise TypeError(f'sceneId must be defined inside metadata (unless punishUrl is used).') + else: + if not isinstance(value.get('sceneId'), str): + raise TypeError(f'sceneId must be str.') + if value.get('prefix') is None: + raise TypeError(f'prefix must be defined inside metadata (unless punishUrl is used).') + else: + if not isinstance(value.get('prefix'), str): + raise TypeError(f'prefix must be str.') if value.get('cookieRequired') is not None and not isinstance(value.get('cookieRequired'), bool): raise TypeError(f'cookieRequired must be bool.') return value diff --git a/capmonstercloud_client/requests/FuncaptchaRequest.py b/capmonstercloud_client/requests/FuncaptchaRequest.py index ba66971..4f3e0d0 100644 --- a/capmonstercloud_client/requests/FuncaptchaRequest.py +++ b/capmonstercloud_client/requests/FuncaptchaRequest.py @@ -1,5 +1,5 @@ from typing import Dict, Union, Optional -from pydantic import Field +from pydantic import Field, model_validator from .baseRequestWithProxy import BaseRequestWithProxy @@ -22,6 +22,7 @@ class FuncaptchaRequest(BaseRequestWithProxy): provided as a string of key-value pairs. userAgent: Browser User-Agent to emulate. Pass only a current Windows OS UA. + proxy: Required. Your own proxy used to solve the task. """ type: str = Field(default='FunCaptchaTask', description='The task type identifier, "FunCaptchaTask".') websiteUrl: str = Field(..., description='The URL of the webpage containing the FunCaptcha challenge.') @@ -31,18 +32,23 @@ class FuncaptchaRequest(BaseRequestWithProxy): cookies: Optional[str] = Field(default=None, description='Cookies to be used when accessing the target webpage, provided as a string of key-value pairs.') userAgent: Optional[str] = Field(default=None, description='Browser User-Agent to emulate. Pass only a current Windows OS UA.') + @model_validator(mode='before') + def validate_funcaptcha_proxy(cls, values): + if values.get('proxy') is None: + raise RuntimeError(f'You are required to use your own proxies to solve FunCaptcha.') + return values + def getTaskDict(self) -> Dict[str, Union[str, int, bool]]: task = {} task['type'] = self.type task['websiteURL'] = self.websiteUrl task['websitePublicKey'] = self.websitePublicKey - if self.proxy: - task['proxyType'] = self.proxy.proxyType - task['proxyAddress'] = self.proxy.proxyAddress - task['proxyPort'] = self.proxy.proxyPort - task['proxyLogin'] = self.proxy.proxyLogin - task['proxyPassword'] = self.proxy.proxyPassword - + task['proxyType'] = self.proxy.proxyType + task['proxyAddress'] = self.proxy.proxyAddress + task['proxyPort'] = self.proxy.proxyPort + task['proxyLogin'] = self.proxy.proxyLogin + task['proxyPassword'] = self.proxy.proxyPassword + if self.funcaptchaApiJSSubdomain is not None: task['funcaptchaApiJSSubdomain'] = self.funcaptchaApiJSSubdomain if self.data is not None: diff --git a/capmonstercloud_client/requests/HuntCustomTaskRequest.py b/capmonstercloud_client/requests/HuntCustomTaskRequest.py index 8b34921..f87e0a3 100644 --- a/capmonstercloud_client/requests/HuntCustomTaskRequest.py +++ b/capmonstercloud_client/requests/HuntCustomTaskRequest.py @@ -12,13 +12,14 @@ class HuntCustomTaskRequest(CustomTaskRequestBase): captcha class as "HUNT". metadata: A dictionary of parameters required by the HUNT solver. Always requires "apiGetLib" (the URL of the HUNT JS script on - the page). HUNT has two solving modes: fingerprint generation - (only "apiGetLib" needed) and captcha solving (also requires - "data", which must hold the "meta.token" value extracted from - the page). + the page). HUNT has three solving modes: fingerprint generation + (only "apiGetLib" needed), captcha solving via "data" (which must + hold the "meta.token" value extracted from the page), and captcha + solving via "widgetUrl" (the full Hunt CAPTCHA widget URL). "data" + and "widgetUrl" must not be used together. """ captchaClass: str = Field(default='HUNT', description='The constant string value identifying the underlying captcha class as "HUNT".') - metadata: Dict[str, str] = Field(..., description='Dictionary of HUNT parameters. Always requires "apiGetLib". Also requires "data" (the "meta.token" value from the page) when solving a captcha rather than just generating a fingerprint.') + metadata: Dict[str, str] = Field(..., description='Dictionary of HUNT parameters. Always requires "apiGetLib". Also requires exactly one of "data" (the "meta.token" value from the page) or "widgetUrl" (the full Hunt CAPTCHA widget URL) when solving a captcha rather than just generating a fingerprint.') @field_validator('metadata') @classmethod @@ -30,6 +31,10 @@ def validate_metadata(cls, value): raise TypeError(f'apiGetLib must be str.') if value.get('data') is not None and not isinstance(value.get('data'), str): raise TypeError(f'data must be str.') + if value.get('widgetUrl') is not None and not isinstance(value.get('widgetUrl'), str): + raise TypeError(f'widgetUrl must be str.') + if value.get('data') is not None and value.get('widgetUrl') is not None: + raise TypeError(f'"data" and "widgetUrl" must not be used together.') return value @model_validator(mode='before') diff --git a/capmonstercloud_client/requests/RecaptchaComplexImageTask.py b/capmonstercloud_client/requests/RecaptchaComplexImageTask.py index 7862bc6..d8b7fd5 100644 --- a/capmonstercloud_client/requests/RecaptchaComplexImageTask.py +++ b/capmonstercloud_client/requests/RecaptchaComplexImageTask.py @@ -10,26 +10,27 @@ class RecaptchaComplexImageTaskRequest(ComplexImageTaskRequestBase): image classification challenges. Attributes: - metadata: A dictionary describing the challenge. Must contain - "Task" (English task name, e.g. "Click on traffic lights") and - "TaskDefinition" (its technical identifier, e.g. "/m/015qff") — - both required together — along with "Grid" (e.g. "3x3", "4x4", - "1x1") specifying the image grid layout. + metadata: A dictionary describing the challenge. Must contain at + least one of "Task" (English task name, e.g. "Click on traffic + lights") or "TaskDefinition" (its technical identifier, e.g. + "/m/015qff") — each is required only if the other is not filled — + along with "Grid" (e.g. "3x3", "4x4", "1x1") specifying the image + grid layout. captchaClass: The constant string identifying the captcha family as "recaptcha". imagesUrls: A collection of image URLs to be recognized. Must be populated if imagesBase64 is not. """ - metadata : Dict[str, str] = Field(..., description='Dictionary describing the challenge. Must contain "Task" and "TaskDefinition" (both required together) plus "Grid".') + metadata : Dict[str, str] = Field(..., description='Dictionary describing the challenge. Must contain at least one of "Task" or "TaskDefinition" (each required only if the other is not filled) plus "Grid".') captchaClass: str = Field(default='recaptcha', description='Constant string identifying the captcha family as "recaptcha".') imagesUrls: Optional[List[str]] = Field(default=None, description='Collection with image urls. Must be populated if imagesBase64 is not.') @field_validator('metadata') @classmethod def validate_metadata(cls, value): - if value.get('Task') is None or value.get('TaskDefinition') is None: - raise TaskNotDefinedError(f'"Task" and "TaskDefinition" must both be filled.') + if value.get('Task') is None and value.get('TaskDefinition') is None: + raise TaskNotDefinedError(f'At least one of "Task" or "TaskDefinition" must be filled.') elif value.get('Grid') is None: raise TaskNotDefinedError(f'"Grid" must be filled (3x3, 4x4, 1x1).') else: diff --git a/capmonstercloud_client/version.txt b/capmonstercloud_client/version.txt index ef8d756..8191138 100644 --- a/capmonstercloud_client/version.txt +++ b/capmonstercloud_client/version.txt @@ -1 +1 @@ -4.2.0 \ No newline at end of file +4.3.0 \ No newline at end of file diff --git a/examples/funCaptcha.py b/examples/funCaptcha.py index 44db252..9e1772e 100644 --- a/examples/funCaptcha.py +++ b/examples/funCaptcha.py @@ -2,7 +2,7 @@ import time import asyncio -from capmonstercloudclient.requests import FuncaptchaRequest +from capmonstercloudclient.requests import FuncaptchaRequest, ProxyInfo from capmonstercloudclient import ClientOptions, CapMonsterClient async def solve_captcha_sync(num_requests): @@ -17,9 +17,18 @@ async def solve_captcha_async(num_requests): key = os.getenv('API_KEY') client_options = ClientOptions(api_key=key) cap_monster_client = CapMonsterClient(options=client_options) + # FunCaptcha requires your own proxy. + proxy = ProxyInfo( + proxyType="http", + proxyAddress="8.8.8.8", + proxyPort=8080, + proxyLogin="proxyLoginHere", + proxyPassword="proxyPasswordHere" + ) funcaptcha_request = FuncaptchaRequest(websiteUrl='https://example.com/', websitePublicKey='websitePublicKey', - funcaptchaApiJSSubdomain='funcaptchaApiJSSubdomain') + funcaptchaApiJSSubdomain='funcaptchaApiJSSubdomain', + proxy=proxy) nums = 3 diff --git a/setup.py b/setup.py index 8318049..5c59c7c 100644 --- a/setup.py +++ b/setup.py @@ -20,7 +20,7 @@ URL='https://github.com/ZennoLab/capmonstercloud-client-python' this_directory = Path(__file__).parent -long_description = (this_directory / "README.md").read_text() +long_description = (this_directory / "README.md").read_text(encoding="utf-8") setup( diff --git a/test/alibaba_test.py b/test/alibaba_test.py index c0dd6cb..0fd1888 100644 --- a/test/alibaba_test.py +++ b/test/alibaba_test.py @@ -9,6 +9,7 @@ class AlibabaCustomTaskRequestTest(unittest.TestCase): userAgentExample = "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/126.0.0.0 Safari/537.36" sceneIdExample = "1ww7426c" prefixExample = "dlw3kug" + punishUrlExample = "https://example.com:443//api/example/testlogin/_____tmd_____/punish?x5secdata=xgf6888&x5step=2&action=captchaclick&pureCaptcha=" def setUp(self): self.proxy = ProxyInfo( @@ -112,6 +113,26 @@ def test_alibaba_optional_proxy_and_useragent(self): self.assertEqual(task_dictionary["proxyType"], self.proxy.proxyType) self.assertEqual(task_dictionary["userAgent"], self.userAgentExample) + def test_alibaba_punish_url_mode(self): + metadata_example = { + "punishUrl": self.punishUrlExample, + } + request = AlibabaCustomTaskRequest( + websiteUrl=self.websiteUrlExample, + metadata=metadata_example, + ) + task_dictionary = request.getTaskDict() + self.assertEqual(task_dictionary["metadata"]["punishUrl"], self.punishUrlExample) + self.assertNotIn("sceneId", task_dictionary["metadata"]) + self.assertNotIn("prefix", task_dictionary["metadata"]) + + def test_alibaba_punish_url_type(self): + base_kwargs = { + "websiteUrl": self.websiteUrlExample, + "metadata": {"punishUrl": 12345}, + } + self.assertRaises(ValidationError, AlibabaCustomTaskRequest, **base_kwargs) + def test_alibaba_proxy_not_required(self): metadata_example = { "sceneId": self.sceneIdExample, diff --git a/test/hunt_test.py b/test/hunt_test.py index ecaa60e..0d6a65e 100644 --- a/test/hunt_test.py +++ b/test/hunt_test.py @@ -9,6 +9,7 @@ class HuntCustomTaskRequestTest(unittest.TestCase): userAgentExample = "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/126.0.0.0 Safari/537.36" apiGetLibExample = "https://example.com/hd-api/external/apps/a2157wab1045d68672a63557e0n2a77edbfd15ea/api.js" dataExample = "eyJ0eXAiOiJKV1QiLCJhbGciOiJIUzI1NiJ9" + widgetUrlExample = "https://captcha.example.com/widget?hash=abc123" def setUp(self): self.proxy = ProxyInfo( @@ -83,6 +84,33 @@ def test_hunt_missing_fields(self): base_kwargs.update({"metadata": metadata_example}) HuntCustomTaskRequest(**base_kwargs) + def test_hunt_metadata_with_widget_url(self): + metadata_example = { + "apiGetLib": self.apiGetLibExample, + "widgetUrl": self.widgetUrlExample, + } + request = HuntCustomTaskRequest( + websiteUrl=self.websiteUrlExample, + metadata=metadata_example, + proxy=self.proxy, + ) + task_dictionary = request.getTaskDict() + self.assertEqual(task_dictionary["metadata"]["widgetUrl"], self.widgetUrlExample) + + def test_hunt_data_and_widget_url_mutually_exclusive(self): + metadata_example = { + "apiGetLib": self.apiGetLibExample, + "data": self.dataExample, + "widgetUrl": self.widgetUrlExample, + } + self.assertRaises( + TypeError, + HuntCustomTaskRequest, + websiteUrl=self.websiteUrlExample, + metadata=metadata_example, + proxy=self.proxy, + ) + def test_hunt_requires_proxy(self): metadata_example = { "apiGetLib": self.apiGetLibExample, diff --git a/test/recaptcha_image_test.py b/test/recaptcha_image_test.py index 050ddc5..efb7e15 100644 --- a/test/recaptcha_image_test.py +++ b/test/recaptcha_image_test.py @@ -134,6 +134,35 @@ def testTaskDefined(self): userAgent="Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/103.0.0.0 Safari/537.36.", ) + def testTaskOrTaskDefinitionAlone(self): + # "Task" alone (no "TaskDefinition") must be accepted. + request = RecaptchaComplexImageTaskRequest( + websiteUrl=RecaptchaImageRequestTest.websiteUrlExample, + metadata={"Task": "Click on traffic lights", "Grid": "3x3"}, + imagesUrls=RecaptchaImageRequestTest.imageUrlsExamples, + ) + task_dictionary = request.getTaskDict() + self.assertIn("Task", task_dictionary["metadata"]) + self.assertNotIn("TaskDefinition", task_dictionary["metadata"]) + + # "TaskDefinition" alone (no "Task") must be accepted. + request = RecaptchaComplexImageTaskRequest( + websiteUrl=RecaptchaImageRequestTest.websiteUrlExample, + metadata={"TaskDefinition": "/m/015qff", "Grid": "3x3"}, + imagesUrls=RecaptchaImageRequestTest.imageUrlsExamples, + ) + task_dictionary = request.getTaskDict() + self.assertIn("TaskDefinition", task_dictionary["metadata"]) + self.assertNotIn("Task", task_dictionary["metadata"]) + + # Neither "Task" nor "TaskDefinition" must still fail. + with self.assertRaises(TaskNotDefinedError): + RecaptchaComplexImageTaskRequest( + websiteUrl=RecaptchaImageRequestTest.websiteUrlExample, + metadata={"Grid": "3x3"}, + imagesUrls=RecaptchaImageRequestTest.imageUrlsExamples, + ) + @unittest.skip("Doesnt work right now") def testUserAgentWithUrl(self): with self.assertRaises(UserAgentNotDefinedError): diff --git a/test/requests_generation_test.py b/test/requests_generation_test.py index d2890ed..08a4f81 100644 --- a/test/requests_generation_test.py +++ b/test/requests_generation_test.py @@ -193,7 +193,7 @@ def test_rcv2_enterprise(self): ) def test_fc(self): - noproxy_type = "FunCaptchaTask" + # FunCaptcha now requires the caller's own proxy; no-proxy requests must fail. default_keys = [ "type", "websiteURL", @@ -201,22 +201,14 @@ def test_fc(self): "websitePublicKey", "data", ] - request = requests.FuncaptchaRequest( + self.assertRaises( + RuntimeError, + requests.FuncaptchaRequest, websiteUrl="some_url", websitePublicKey="some_key", funcaptchaApiJSSubdomain="domain", data="asdfasdf", ) - task = request.getTaskDict() - for key in default_keys: - self.assertIsNotNone( - task.get(key), msg=f"Missing {key} for FunCaptchaTask request." - ) - self.assertEqual( - noproxy_type, - task.get("type"), - msg=f"Task type of FunCaptchaTask not equal to {noproxy_type}", - ) proxy_type = "FunCaptchaTask" proxy_keys = default_keys + PROXY_LIST @@ -243,7 +235,7 @@ def test_fc(self): self.assertEqual( proxy_type, proxy_task.get("type"), - msg=f"Task type of FunCaptchaTask not equal to {noproxy_type}", + msg=f"Task type of FunCaptchaTask not equal to {proxy_type}", ) def test_hc(self):