From 734e915eb914978b14433141da06975138c1076c Mon Sep 17 00:00:00 2001 From: Andrei Date: Thu, 19 Dec 2024 20:01:57 +0300 Subject: [PATCH 01/15] refactored --- pyproject.toml | 2 +- src/python3_capsolver/akamai.py | 13 +- src/python3_capsolver/aws_waf.py | 182 +++++++++--------- src/python3_capsolver/binance.py | 5 +- .../core/aio_captcha_instrument.py | 168 ++++++++++++++++ src/python3_capsolver/core/base.py | 165 +++------------- .../core/captcha_instrument.py | 55 ++++++ src/python3_capsolver/core/const.py | 13 ++ src/python3_capsolver/core/context_instr.py | 25 +++ src/python3_capsolver/core/enum.py | 5 + src/python3_capsolver/core/serializer.py | 130 ++++++------- .../core/sio_captcha_instrument.py | 164 ++++++++++++++++ .../core/{config.py => utils.py} | 11 -- src/python3_capsolver/image_to_text.py | 4 +- 14 files changed, 616 insertions(+), 326 deletions(-) create mode 100644 src/python3_capsolver/core/aio_captcha_instrument.py create mode 100644 src/python3_capsolver/core/captcha_instrument.py create mode 100644 src/python3_capsolver/core/const.py create mode 100644 src/python3_capsolver/core/context_instr.py create mode 100644 src/python3_capsolver/core/sio_captcha_instrument.py rename src/python3_capsolver/core/{config.py => utils.py} (58%) diff --git a/pyproject.toml b/pyproject.toml index 673e912f..5d4ac64c 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -85,7 +85,7 @@ classifiers = [ dependencies = [ "requests>=2.21.0", "aiohttp>=3.9.2", - "pydantic>=2.5.0", + "msgspec==0.18.*", "tenacity>=8,<10" ] diff --git a/src/python3_capsolver/akamai.py b/src/python3_capsolver/akamai.py index 8f70fb82..37681c13 100644 --- a/src/python3_capsolver/akamai.py +++ b/src/python3_capsolver/akamai.py @@ -1,16 +1,11 @@ from typing import Union -from python3_capsolver.core.base import BaseCaptcha -from python3_capsolver.core.enum import AntiAkamaiTaskEnm, EndpointPostfixEnm -from python3_capsolver.core.serializer import ( - PostRequestSer, - CaptchaResponseSer, - AntiAkamaiBMPTaskSer, - AntiAkamaiWebTaskSer, -) +from .core.base import CaptchaParams +from .core.enum import AntiAkamaiTaskEnm, EndpointPostfixEnm +from .core.serializer import PostRequestSer, CaptchaResponseSer, AntiAkamaiBMPTaskSer, AntiAkamaiWebTaskSer -class Akamai(BaseCaptcha): +class Akamai(CaptchaParams): """ The class is used to work with Capsolver AntiAkamai methods. diff --git a/src/python3_capsolver/aws_waf.py b/src/python3_capsolver/aws_waf.py index f78490b8..02c53487 100644 --- a/src/python3_capsolver/aws_waf.py +++ b/src/python3_capsolver/aws_waf.py @@ -1,111 +1,121 @@ -from typing import Union - -from python3_capsolver.core.base import BaseCaptcha -from python3_capsolver.core.enum import AntiAwsWafTaskTypeEnm -from python3_capsolver.core.serializer import CaptchaResponseSer, WebsiteDataOptionsSer - - -class AwsWaf(BaseCaptcha): - """ - The class is used to work with Capsolver AwsWaf methods. - - Args: - api_key: Capsolver API key - captcha_type: Captcha type name, like ``AntiAwsWafTask`` and etc. - websiteURL: Address of a webpage with AwsWaf - - Examples: - >>> AwsWaf(api_key="CAI-BA9XXXXXXXXXXXXX2702E010", - ... captcha_type='AntiAwsWafTaskProxyLess', - ... websiteURL="https://efw47fpad9.execute-api.us-east-1.amazonaws.com/latest", - ... ).captcha_handler() - CaptchaResponseSer(errorId=0, - errorCode=None, - errorDescription=None, - taskId='73bdcd28-6c77-4414-8....', - status=, - solution={'cookie': '44795sds...'} - ) - - >>> AwsWaf(api_key="CAI-BA9XXXXXXXXXXXXX2702E010", - ... captcha_type=AntiAwsWafTaskTypeEnm.AntiAwsWafTaskProxyLess, - ... websiteURL="https://efw47fpad9.execute-api.us-east-1.amazonaws.com/latest", - ... ).captcha_handler() - CaptchaResponseSer(errorId=0, - errorCode=None, - errorDescription=None, - taskId='73bdcd28-6c77-4414-8....', - status=, - solution={'cookie': '44795sds...'} - ) - - >>> AwsWaf(api_key="CAI-BA9XXXXXXXXXXXXX2702E010", - ... captcha_type=AntiAwsWafTaskTypeEnm.AntiAwsWafTask, - ... websiteURL="https://efw47fpad9.execute-api.us-east-1.amazonaws.com/latest", - ... proxy="socks5:192.191.100.10:4780:user:pwd", - ... awsKey="some key" - ... ).captcha_handler() - CaptchaResponseSer(errorId=0, - errorCode=None, - errorDescription=None, - taskId="87f149f4-1c....", - status=, - solution={'cookie': '44795sds...'} - ) - - >>> await AwsWaf(api_key="CAI-BA9650D2B9C2786B21120D512702E010", - ... captcha_type=AntiAwsWafTaskTypeEnm.AntiAwsWafTaskProxyLess, - ... websiteURL="https://efw47fpad9.execute-api.us-east-1.amazonaws.com/latest", - ... ).aio_captcha_handler() - CaptchaResponseSer(errorId=0, - errorCode=None, - errorDescription=None, - taskId='73bdcd28-6c77-4414-8....', - status=, - solution={'cookie': '44795sds...'} - ) - - Returns: - CaptchaResponseSer model with full server response - - Notes: - https://docs.capsolver.com/guide/captcha/awsWaf.html - """ - - def __init__(self, captcha_type: Union[AntiAwsWafTaskTypeEnm, str], websiteURL: str, *args, **kwargs): - super().__init__(*args, **kwargs) +from typing import Union, Optional + +from .core.base import CaptchaParams +from .core.enum import AntiAwsWafTaskTypeEnm +from .core.sio_captcha_instrument import SIOCaptchaInstrument + + +class AwsWaf(CaptchaParams): + def __init__( + self, + api_key: str, + captcha_type: Union[AntiAwsWafTaskTypeEnm, str], + websiteURL: str, + sleep_time: Optional[int] = 10, + **additional_params, + ): + """ + The class is used to work with Capsolver AwsWaf methods. + + Args: + api_key: Capsolver API key + captcha_type: Captcha type name, like ``AntiAwsWafTask`` and etc. + websiteURL: Address of a webpage with AwsWaf + additional_params: Some additional parameters that will be used in creating the task + and will be passed to the payload under ``task`` key. + Like ``proxyLogin``, ``proxyPassword`` and etc. - more info in service docs + + + Examples: + >>> AwsWaf(api_key="CAI-BA9XXXXXXXXXXXXX2702E010", + ... captcha_type='AntiAwsWafTaskProxyLess', + ... websiteURL="https://efw47fpad9.execute-api.us-east-1.amazonaws.com/latest", + ... ).captcha_handler() + CaptchaResponseSer(errorId=0, + errorCode=None, + errorDescription=None, + taskId='73bdcd28-6c77-4414-8....', + status=, + solution={'cookie': '44795sds...'} + ) + + >>> AwsWaf(api_key="CAI-BA9XXXXXXXXXXXXX2702E010", + ... captcha_type=AntiAwsWafTaskTypeEnm.AntiAwsWafTaskProxyLess, + ... websiteURL="https://efw47fpad9.execute-api.us-east-1.amazonaws.com/latest", + ... ).captcha_handler() + CaptchaResponseSer(errorId=0, + errorCode=None, + errorDescription=None, + taskId='73bdcd28-6c77-4414-8....', + status=, + solution={'cookie': '44795sds...'} + ) + + >>> AwsWaf(api_key="CAI-BA9XXXXXXXXXXXXX2702E010", + ... captcha_type=AntiAwsWafTaskTypeEnm.AntiAwsWafTask, + ... websiteURL="https://efw47fpad9.execute-api.us-east-1.amazonaws.com/latest", + ... proxy="socks5:192.191.100.10:4780:user:pwd", + ... awsKey="some key" + ... ).captcha_handler() + CaptchaResponseSer(errorId=0, + errorCode=None, + errorDescription=None, + taskId="87f149f4-1c....", + status=, + solution={'cookie': '44795sds...'} + ) + + >>> await AwsWaf(api_key="CAI-BA9650D2B9C2786B21120D512702E010", + ... captcha_type=AntiAwsWafTaskTypeEnm.AntiAwsWafTaskProxyLess, + ... websiteURL="https://efw47fpad9.execute-api.us-east-1.amazonaws.com/latest", + ... ).aio_captcha_handler() + CaptchaResponseSer(errorId=0, + errorCode=None, + errorDescription=None, + taskId='73bdcd28-6c77-4414-8....', + status=, + solution={'cookie': '44795sds...'} + ) + + Returns: + CaptchaResponseSer model with full server response + + Notes: + https://docs.capsolver.com/guide/captcha/awsWaf.html + """ + + super().__init__(api_key=api_key, sleep_time=sleep_time) if captcha_type in AntiAwsWafTaskTypeEnm.list(): - self.task_params = WebsiteDataOptionsSer(**locals()).dict() + self.task_params.update(dict(type=captcha_type, websiteURL=websiteURL, **additional_params)) else: raise ValueError( f"""Invalid `captcha_type` parameter set for `{self.__class__.__name__}`, available - {AntiAwsWafTaskTypeEnm.list_values()}""" ) - for key in kwargs: - self.task_params.update({key: kwargs[key]}) - - def captcha_handler(self) -> CaptchaResponseSer: + def captcha_handler(self) -> dict: """ Sync solving method Returns: - CaptchaResponseSer model with full service response + Dict with full service response Notes: Check class docstring for more info """ - return self._processing_captcha(create_params=self.task_params) + self.create_task_payload.task = {**self.task_params} + self._captcha_handling_instrument = SIOCaptchaInstrument(captcha_params=self) + return self._captcha_handling_instrument.processing_captcha() - async def aio_captcha_handler(self) -> CaptchaResponseSer: + async def aio_captcha_handler(self) -> dict: """ Async method for captcha solving Returns: - CaptchaResponseSer model with full service response + Dict with full service response Notes: Check class docstring for more info """ - return await self._aio_processing_captcha(create_params=self.task_params) + return await self._aio_processing_captcha() diff --git a/src/python3_capsolver/binance.py b/src/python3_capsolver/binance.py index f6f1dd22..e0d45a8f 100644 --- a/src/python3_capsolver/binance.py +++ b/src/python3_capsolver/binance.py @@ -1,8 +1,7 @@ from typing import Union -from python3_capsolver.core.base import BaseCaptcha -from python3_capsolver.core.enum import BinanceCaptchaTaskEnm -from python3_capsolver.core.serializer import CaptchaResponseSer, BinanceCaptchaTaskSer +from .core.enum import BinanceCaptchaTaskEnm +from .core.serializer import CaptchaResponseSer, BinanceCaptchaTaskSer class Binance(BaseCaptcha): diff --git a/src/python3_capsolver/core/aio_captcha_instrument.py b/src/python3_capsolver/core/aio_captcha_instrument.py new file mode 100644 index 00000000..82046083 --- /dev/null +++ b/src/python3_capsolver/core/aio_captcha_instrument.py @@ -0,0 +1,168 @@ +import base64 +import asyncio +import logging +from typing import Union, Optional +from urllib import parse +from urllib.parse import urljoin + +import aiohttp + +from .enum import SaveFormatsEnm +from .const import ASYNC_RETRIES, BASE_REQUEST_URL, GET_RESULT_POSTFIX, CREATE_TASK_POSTFIX +from .utils import attempts_generator +from .serializer import CreateTaskResponseSer +from .captcha_instrument import CaptchaInstrument + +__all__ = ("AIOCaptchaInstrument",) + + +class AIOCaptchaInstrument(CaptchaInstrument): + """ + Instrument for working with async captcha + """ + + def __init__(self, captcha_params: "CaptchaParams"): + super().__init__() + self.captcha_params = captcha_params + + async def processing_captcha(self) -> dict: + # added task params to payload + self.captcha_params.create_task_payload.task.update(self.captcha_params.task_params) + + created_task = await self._create_task() + + if created_task.errorId == 0: + self.captcha_params.get_result_params.taskId = created_task.taskId + else: + return created_task.to_dict() + + await asyncio.sleep(self.captcha_params.sleep_time) + + return await self._get_result() + + async def processing_image_captcha( + self, + save_format: Union[str, SaveFormatsEnm], + img_clearing: bool, + captcha_link: str, + captcha_file: str, + captcha_base64: bytes, + img_path: str, + ) -> dict: + await self.__body_file_processing( + save_format=save_format, + img_clearing=img_clearing, + file_path=img_path, + captcha_link=captcha_link, + captcha_file=captcha_file, + captcha_base64=captcha_base64, + ) + if not self.result.errorId: + return await self.processing_captcha() + return self.result.to_dict() + + async def __body_file_processing( + self, + save_format: SaveFormatsEnm, + img_clearing: bool, + file_path: str, + file_extension: str = "png", + captcha_link: Optional[str] = None, + captcha_file: Optional[str] = None, + captcha_base64: Optional[bytes] = None, + **kwargs, + ): + # if a local file link is passed + if captcha_file: + self.captcha_params.create_task_payload.task.update( + {"body": base64.b64encode(self._local_file_captcha(captcha_file=captcha_file)).decode("utf-8")} + ) + # if the file is transferred in base64 encoding + elif captcha_base64: + self.captcha_params.create_task_payload.task.update( + {"body": base64.b64encode(captcha_base64).decode("utf-8")} + ) + # if a URL is passed + elif captcha_link: + try: + content = await self._url_read(url=captcha_link, **kwargs) + # according to the value of the passed parameter, select the function to save the image + if save_format == SaveFormatsEnm.CONST.value: + full_file_path = self._file_const_saver(content, file_path, file_extension=file_extension) + if img_clearing: + self._file_clean(full_file_path=full_file_path) + self.captcha_params.create_task_payload.task.update({"body": base64.b64encode(content).decode("utf-8")}) + except Exception as error: + self.result.errorId = 12 + self.result.errorCode = self.NO_CAPTCHA_ERR + self.result.errorDescription = str(error) + + else: + self.result.errorId = 12 + self.result.errorCode = self.NO_CAPTCHA_ERR + + async def _url_read(self, url: str, **kwargs) -> bytes: + """ + Async method read bytes from link + """ + async with aiohttp.ClientSession() as session: + async for attempt in ASYNC_RETRIES: + with attempt: + async with session.get(url=url, **kwargs) as resp: + return await resp.content.read() + + async def _create_task(self, url_postfix: str = CREATE_TASK_POSTFIX) -> CreateTaskResponseSer: + """ + Function send SYNC request to service and wait for result + """ + async with aiohttp.ClientSession() as session: + try: + async with session.post( + parse.urljoin(BASE_REQUEST_URL, url_postfix), json=self.captcha_params.create_task_payload.to_dict() + ) as resp: + if resp.status == 200: + return CreateTaskResponseSer(**await resp.json()) + else: + raise ValueError(resp.reason) + except Exception as error: + logging.exception(error) + raise + + @staticmethod + async def send_post_request(payload: Optional[dict] = None, url_postfix: str = CREATE_TASK_POSTFIX) -> dict: + """ + Function send ASYNC request to service and wait for result + """ + + async with aiohttp.ClientSession() as session: + try: + async with session.post(parse.urljoin(BASE_REQUEST_URL, url_postfix), json=payload) as resp: + if resp.status == 200: + return await resp.json() + else: + raise ValueError(resp.reason) + except Exception as error: + logging.exception(error) + raise + + async def _get_result(self, url_response: str = GET_RESULT_POSTFIX) -> dict: + attempts = attempts_generator() + # Send request for status of captcha solution. + async with aiohttp.ClientSession() as session: + for _ in attempts: + async with session.post( + url=urljoin(BASE_REQUEST_URL, url_response), json=self.captcha_params.get_result_params.to_dict() + ) as resp: + json_result = await resp.json() + # if there is no error, check CAPTCHA status + if json_result["errorId"] == 0: + # If not yet resolved, wait + if json_result["status"] == "processing": + await asyncio.sleep(self.captcha_params.sleep_time) + # otherwise return response + else: + json_result.update({"taskId": self.captcha_params.get_result_params.taskId}) + return json_result + else: + json_result.update({"taskId": self.captcha_params.get_result_params.taskId}) + return json_result diff --git a/src/python3_capsolver/core/base.py b/src/python3_capsolver/core/base.py index 85bf3bf8..6de60275 100644 --- a/src/python3_capsolver/core/base.py +++ b/src/python3_capsolver/core/base.py @@ -1,25 +1,20 @@ -import time import asyncio import logging -from typing import Any, Dict, Type from urllib import parse import aiohttp -import requests -from pydantic import BaseModel -from requests.adapters import HTTPAdapter -from python3_capsolver.core.enum import ResponseStatusEnm, EndpointPostfixEnm -from python3_capsolver.core.config import RETRIES, REQUEST_URL, VALID_STATUS_CODES, attempts_generator -from python3_capsolver.core.serializer import ( - CaptchaOptionsSer, - CaptchaResponseSer, - RequestCreateTaskSer, - RequestGetTaskResultSer, -) +from .enum import ResponseStatusEnm, EndpointPostfixEnm +from .const import REQUEST_URL, VALID_STATUS_CODES +from .utils import attempts_generator +from .serializer import CaptchaResponseSer, RequestCreateTaskSer, RequestGetTaskResultSer +from .context_instr import AIOContextManager, SIOContextManager +from .captcha_instrument import CaptchaInstrument +__all__ = ("CaptchaParams",) -class BaseCaptcha: + +class CaptchaParams(SIOContextManager, AIOContextManager): """ Basic Captcha solving class @@ -38,136 +33,26 @@ def __init__( **kwargs, ): # assign args to validator - self.__params = CaptchaOptionsSer(**locals()) - self.__request_url = request_url - - # prepare session - self.__session = requests.Session() - self.__session.mount("http://", HTTPAdapter(max_retries=RETRIES)) - self.__session.mount("https://", HTTPAdapter(max_retries=RETRIES)) - - def _prepare_create_task_payload(self, serializer: Type[BaseModel], create_params: Dict[str, Any] = None) -> None: - """ - Method prepare `createTask` payload - - Args: - serializer: Serializer for task creation - create_params: Parameters for task creation payload - - Examples: - - >>> self._prepare_create_task_payload(serializer=PostRequestSer, create_params={}) - - """ - self.task_payload = serializer(clientKey=self.__params.api_key) - # added task params to payload - self.task_payload.task = {**create_params} if create_params else {} - - def __enter__(self): - return self - - def __exit__(self, exc_type, exc_value, traceback): - if exc_type: - return False - return True - - async def __aenter__(self): - return self - - async def __aexit__(self, exc_type, exc_value, traceback): - if exc_type: - return False - return True - - """ - Sync part - """ - - def _processing_captcha( - self, create_params: dict, serializer: Type[BaseModel] = RequestCreateTaskSer - ) -> CaptchaResponseSer: - self._prepare_create_task_payload(serializer=serializer, create_params=create_params) - self.created_task_data = CaptchaResponseSer(**self._create_task()) - - # if task created and ready - return result - if self.created_task_data.status == ResponseStatusEnm.Ready.value: - return self.created_task_data - # if captcha is not ready but task success created - waiting captcha result - elif self.created_task_data.errorId == 0: - return self._get_result() - return self.created_task_data - - def _create_task(self, url_postfix: str = EndpointPostfixEnm.CREATE_TASK.value) -> dict: - """ - Function send SYNC request to service and wait for result - """ - try: - resp = self.__session.post( - parse.urljoin(self.__request_url, url_postfix), json=self.task_payload.dict(exclude_none=True) - ) - if resp.status_code in VALID_STATUS_CODES: - return resp.json() - else: - raise ValueError(resp.raise_for_status()) - except Exception as error: - logging.exception(error) - raise - - def _get_result(self, url_postfix: str = EndpointPostfixEnm.GET_TASK_RESULT.value) -> CaptchaResponseSer: - """ - Method send SYNC request to service and wait for result - """ - # initial waiting - time.sleep(self.__params.sleep_time) - - get_result_payload = RequestGetTaskResultSer( - clientKey=self.__params.api_key, taskId=self.created_task_data.taskId - ) - attempts = attempts_generator() - for _ in attempts: - try: - resp = self.__session.post( - parse.urljoin(self.__request_url, url_postfix), json=get_result_payload.dict(exclude_none=True) - ) - if resp.status_code in VALID_STATUS_CODES: - result_data = CaptchaResponseSer(**resp.json()) - if result_data.status in (ResponseStatusEnm.Ready, ResponseStatusEnm.Failed): - # if captcha ready\failed or have unknown status - return exist data - return result_data - else: - raise ValueError(resp.raise_for_status()) - except Exception as error: - logging.exception(error) - raise - - # if captcha just created or in processing now - wait - time.sleep(self.__params.sleep_time) - # default response if server is silent - return CaptchaResponseSer( - errorId=1, - errorCode="ERROR_CAPTCHA_UNSOLVABLE", - errorDescription="Captcha not recognized", - taskId=self.created_task_data.taskId, - status=ResponseStatusEnm.Failed, - ) + self.create_task_payload = RequestCreateTaskSer(clientKey=api_key) + # `task` body for task creation payload + self.task_params = {} + # prepare `get task result` payload + self.get_result_params = RequestGetTaskResultSer(clientKey=api_key) + self.request_url = request_url + self._captcha_handling_instrument = CaptchaInstrument() """ Async part """ - async def _aio_processing_captcha( - self, create_params: dict, serializer: Type[BaseModel] = RequestCreateTaskSer - ) -> CaptchaResponseSer: - self._prepare_create_task_payload(serializer=serializer, create_params=create_params) + async def _aio_processing_captcha(self) -> dict: + self._prepare_task_payload() self.created_task_data = CaptchaResponseSer(**await self._aio_create_task()) # if task created and already ready - return result - if self.created_task_data.status == ResponseStatusEnm.Ready.value: - return self.created_task_data - # if captcha is not ready but task success created - waiting captcha result - elif self.created_task_data.errorId == 0: - return await self._aio_get_result() - return self.created_task_data + if self.created_task_data.errorId == 0 and self.created_task_data.status == ResponseStatusEnm.Processing.value: + return (await self._aio_get_result()).to_dict() + return self.created_task_data.to_dict() async def _aio_create_task(self, url_postfix: str = EndpointPostfixEnm.CREATE_TASK.value) -> dict: """ @@ -176,7 +61,7 @@ async def _aio_create_task(self, url_postfix: str = EndpointPostfixEnm.CREATE_TA async with aiohttp.ClientSession() as session: try: async with session.post( - parse.urljoin(self.__request_url, url_postfix), json=self.task_payload.dict(exclude_none=True) + parse.urljoin(self.__request_url, url_postfix), json=self.create_task_payload.to_dict() ) as resp: if resp.status in VALID_STATUS_CODES: return await resp.json() @@ -193,15 +78,13 @@ async def _aio_get_result(self, url_postfix: str = EndpointPostfixEnm.GET_TASK_R # initial waiting await asyncio.sleep(self.__params.sleep_time) - get_result_payload = RequestGetTaskResultSer( - clientKey=self.__params.api_key, taskId=self.created_task_data.taskId - ) + self.get_result_params.taskId = self.created_task_data.taskId attempts = attempts_generator() async with aiohttp.ClientSession() as session: for _ in attempts: try: async with session.post( - parse.urljoin(self.__request_url, url_postfix), json=get_result_payload.dict(exclude_none=True) + parse.urljoin(self.__request_url, url_postfix), json=self.get_result_params.to_dict() ) as resp: if resp.status in VALID_STATUS_CODES: result_data = CaptchaResponseSer(**await resp.json()) diff --git a/src/python3_capsolver/core/captcha_instrument.py b/src/python3_capsolver/core/captcha_instrument.py new file mode 100644 index 00000000..9bd43faf --- /dev/null +++ b/src/python3_capsolver/core/captcha_instrument.py @@ -0,0 +1,55 @@ +import os +import uuid +import shutil +from pathlib import Path + +from .serializer import CaptchaResponseSer + +__all__ = ("CaptchaInstrument",) + + +class FileInstrument: + @staticmethod + def _local_file_captcha(captcha_file: str): + """ + Method get local file, read it and prepare for sending to Captcha solving service + """ + with open(captcha_file, "rb") as file: + return file.read() + + @staticmethod + def _file_const_saver(content: bytes, file_path: str, file_extension: str = "png") -> str: + """ + Method create and save file in folder + """ + Path(file_path).mkdir(parents=True, exist_ok=True) + + # generate image name + file_name = f"file-{uuid.uuid4()}.{file_extension}" + + full_file_path = os.path.join(file_path, file_name) + + # save image to folder + with open(full_file_path, "wb") as out_image: + out_image.write(content) + return full_file_path + + @staticmethod + def _file_clean(full_file_path: str): + shutil.rmtree(full_file_path, ignore_errors=True) + + +class CaptchaInstrument(FileInstrument): + CAPTCHA_UNSOLVABLE = "ERROR_CAPTCHA_UNSOLVABLE" + """ + Basic Captcha solving class + + Args: + api_key: Capsolver API key + captcha_type: Captcha type name, like `ReCaptchaV2Task` and etc. + sleep_time: The waiting time between requests to get the result of the Captcha + request_url: API address for sending requests + """ + + def __init__(self): + self.result = CaptchaResponseSer() diff --git a/src/python3_capsolver/core/const.py b/src/python3_capsolver/core/const.py new file mode 100644 index 00000000..e4253e58 --- /dev/null +++ b/src/python3_capsolver/core/const.py @@ -0,0 +1,13 @@ +import urllib3 +from tenacity import AsyncRetrying, wait_fixed, stop_after_attempt +from requests.adapters import Retry + +urllib3.disable_warnings(urllib3.exceptions.InsecureRequestWarning) + +RETRIES = Retry(total=5, backoff_factor=0.9, status_forcelist=[500, 502, 503, 504]) +ASYNC_RETRIES = AsyncRetrying(wait=wait_fixed(5), stop=stop_after_attempt(5), reraise=True) + +REQUEST_URL = "https://api.capsolver.com" +VALID_STATUS_CODES = (200, 202, 400, 401, 405) + +APP_ID = "3E36E3CD-7EB5-4CAF-AA15-91011E652321" diff --git a/src/python3_capsolver/core/context_instr.py b/src/python3_capsolver/core/context_instr.py new file mode 100644 index 00000000..d128f1b3 --- /dev/null +++ b/src/python3_capsolver/core/context_instr.py @@ -0,0 +1,25 @@ +__all__ = ("SIOContextManager", "AIOContextManager") + + +class SIOContextManager: + + # Context methods + def __enter__(self): + return self + + def __exit__(self, exc_type, exc_value, traceback): + if exc_type: + return False + return True + + +class AIOContextManager: + + # Context methods + async def __aenter__(self): + return self + + async def __aexit__(self, exc_type, exc_value, traceback): + if exc_type: + return False + return True diff --git a/src/python3_capsolver/core/enum.py b/src/python3_capsolver/core/enum.py index 4466cec9..3ec02ef4 100644 --- a/src/python3_capsolver/core/enum.py +++ b/src/python3_capsolver/core/enum.py @@ -137,3 +137,8 @@ class ResponseStatusEnm(str, MyEnum): Processing = "processing" # Task is not ready yet Ready = "ready" # Task completed, solution object can be found in solution property Failed = "failed" # Task failed, check the errorDescription to know why failed. + + +class SaveFormatsEnm(str, MyEnum): + TEMP = "temp" + CONST = "const" diff --git a/src/python3_capsolver/core/serializer.py b/src/python3_capsolver/core/serializer.py index 5af2f8d2..c266c14e 100644 --- a/src/python3_capsolver/core/serializer.py +++ b/src/python3_capsolver/core/serializer.py @@ -1,30 +1,38 @@ from typing import Any, Dict, List, Literal, Optional -from pydantic import Field, BaseModel, conint +from msgspec import Struct + +from .enum import ResponseStatusEnm +from .const import APP_ID + + +class MyBaseModel(Struct): + def to_dict(self): + return {f: getattr(self, f) for f in self.__struct_fields__} -from python3_capsolver.core.enum import ResponseStatusEnm -from python3_capsolver.core.config import APP_ID """ HTTP API Request ser """ -class PostRequestSer(BaseModel): - clientKey: str = Field(..., description="Client account key, can be found in user account") - task: dict = Field(None, description="Task object") +class PostRequestSer(MyBaseModel): + clientKey: str + task: Dict = {} + callbackUrl: Optional[str] = None -class TaskSer(BaseModel): - type: str = Field(..., description="Task type name", alias="captcha_type") +class TaskSer(MyBaseModel): + type: str class RequestCreateTaskSer(PostRequestSer): appId: Literal[APP_ID] = APP_ID -class RequestGetTaskResultSer(PostRequestSer): - taskId: Optional[str] = Field(None, description="ID created by the createTask method") +class RequestGetTaskResultSer(MyBaseModel): + clientKey: str + taskId: Optional[str] = None """ @@ -32,24 +40,21 @@ class RequestGetTaskResultSer(PostRequestSer): """ -class ResponseSer(BaseModel): - errorId: int = Field(..., description="Error message: `False` - no error, `True` - with error") +class ResponseSer(MyBaseModel): + errorId: int = 0 # error info - errorCode: Optional[str] = Field(None, description="Error code") - errorDescription: Optional[str] = Field(None, description="Error description") + errorCode: Optional[str] = None + errorDescription: Optional[str] = None class CaptchaResponseSer(ResponseSer): - taskId: Optional[str] = Field(None, description="Task ID for future use in getTaskResult method.") - status: ResponseStatusEnm = Field(ResponseStatusEnm.Processing, description="Task current status") - solution: Dict[str, Any] = Field(None, description="Task result data. Different for each type of task.") - - class Config: - populate_by_name = True + taskId: Optional[str] = None + status: ResponseStatusEnm = ResponseStatusEnm.Processing + solution: Optional[Dict[str, Any]] = None class ControlResponseSer(ResponseSer): - balance: Optional[float] = Field(0, description="Account balance value in USD") + balance: float = 0 """ @@ -57,9 +62,9 @@ class ControlResponseSer(ResponseSer): """ -class CaptchaOptionsSer(BaseModel): - api_key: str - sleep_time: conint(ge=5) = 5 +class CaptchaOptionsSer(MyBaseModel): + api_key: str = None + sleep_time: int = 5 """ @@ -68,88 +73,67 @@ class CaptchaOptionsSer(BaseModel): class WebsiteDataOptionsSer(TaskSer): - websiteURL: str = Field(..., description="Address of a webpage with Captcha") - websiteKey: Optional[str] = Field(None, description="Website key") + websiteURL: str + websiteKey: Optional[str] class ReCaptchaV3Ser(WebsiteDataOptionsSer): - pageAction: str = Field( - "verify", - description="Widget action value." - "Website owner defines what user is doing on the page through this parameter", - ) + pageAction: str = "verify" class HCaptchaClassificationOptionsSer(TaskSer): - queries: List[str] = Field(..., description="Base64-encoded images, do not include 'data:image/***;base64,'") - question: str = Field( - ..., description="Question ID. Support English and Chinese, other languages please convert yourself" - ) + queries: List[str] + question: str class FunCaptchaClassificationOptionsSer(TaskSer): - images: List[str] = Field(..., description="Base64-encoded images, do not include 'data:image/***;base64,'") - question: str = Field( - ..., - description="Question name. this param value from API response game_variant field. Exmaple: maze,maze2,flockCompass,3d_rollball_animals", - ) + images: List[str] + question: str class GeeTestSer(TaskSer): - websiteURL: str = Field(..., description="Address of a webpage with Geetest") - gt: str = Field(..., description="The domain public key, rarely updated") - challenge: Optional[str] = Field( - "", - description="If you need to solve Geetest V3 you must use this parameter, don't need if you need to solve GeetestV4", - ) + websiteURL: str + gt: str + challenge: Optional[str] = "" class FunCaptchaSer(TaskSer): - websiteURL: str = Field(..., description="Address of a webpage with Funcaptcha") - websitePublicKey: str = Field(..., description="Funcaptcha website key.") - funcaptchaApiJSSubdomain: Optional[str] = Field( - None, - description="A special subdomain of funcaptcha.com, from which the JS captcha widget should be loaded." - "Most FunCaptcha installations work from shared domains.", - ) + websiteURL: str + websitePublicKey: str + funcaptchaApiJSSubdomain: Optional[str] = None class DatadomeSliderSer(TaskSer): - websiteURL: str = Field(..., description="Address of a webpage with DatadomeSlider") - captchaUrl: str = Field(..., description="Captcha Url where is the captcha") - userAgent: str = Field(..., description="Browser's User-Agent which is used in emulation") + websiteURL: str + captchaUrl: str + userAgent: str class CloudflareTurnstileSer(WebsiteDataOptionsSer): ... class CyberSiAraSer(WebsiteDataOptionsSer): - SlideMasterUrlId: str = Field( - ..., description="You can get MasterUrlId param form `api/CyberSiara/GetCyberSiara` endpoint request" - ) - UserAgent: str = Field(..., description="Browser userAgent, you need submit your userAgent") + SlideMasterUrlId: str class AntiAkamaiBMPTaskSer(TaskSer): - packageName: str = Field("de.zalando.iphone", description="Package name of AkamaiBMP mobile APP") - version: str = Field("3.2.6", description="AKAMAI BMP Version number") - country: str = Field("US", description="AKAMAI BMP country") + packageName: str = "de.zalando.iphone" + version: str = "3.2.6" + country: str = "US" class AntiAkamaiWebTaskSer(TaskSer): - url: str = Field(..., description="Browser url address") + url: str class AntiImpervaTaskSer(TaskSer): - websiteUrl: str = Field(..., description="The website url") - userAgent: str = Field(..., description="Browser userAgent") - utmvc: bool = Field( - True, description="If cookie contains `incap_see_xxx`, `nlbi_xxx`, `visid_inap_xxx`, mean is true" - ) - reese84: bool = Field(True, description="if cookie conains `reese84`, set it true") + websiteUrl: str + userAgent: str + utmvc: bool = True + reese84: bool = True class BinanceCaptchaTaskSer(TaskSer): - websiteURL: str = Field(..., description="Address of a webpage with Binance captcha") - websiteKey: str = Field("login", description="`bizId` always be `login`") - validateId: str = Field(..., description="`validateId` bncaptcha validateId field") + websiteURL: str + validateId: str + websiteKey: str = "login" diff --git a/src/python3_capsolver/core/sio_captcha_instrument.py b/src/python3_capsolver/core/sio_captcha_instrument.py new file mode 100644 index 00000000..e7c47c3e --- /dev/null +++ b/src/python3_capsolver/core/sio_captcha_instrument.py @@ -0,0 +1,164 @@ +import time +import base64 +import logging +from typing import Union, Optional +from urllib import parse + +import requests +from requests.adapters import HTTPAdapter + +from .enum import SaveFormatsEnm, ResponseStatusEnm, EndpointPostfixEnm +from .const import RETRIES, VALID_STATUS_CODES +from .utils import attempts_generator +from .serializer import CaptchaResponseSer +from .captcha_instrument import CaptchaInstrument + +__all__ = ("SIOCaptchaInstrument",) + + +class SIOCaptchaInstrument(CaptchaInstrument): + """ + Instrument for working with sync captcha + """ + + def __init__(self, captcha_params: "CaptchaParams"): + super().__init__() + self.captcha_params = captcha_params + self.created_task_data = CaptchaResponseSer + # prepare session + self.session = requests.Session() + self.session.mount("http://", HTTPAdapter(max_retries=RETRIES)) + self.session.mount("https://", HTTPAdapter(max_retries=RETRIES)) + self.session.verify = False + + """ + Sync part + """ + + def processing_captcha(self) -> dict: + self.created_task_data = CaptchaResponseSer(**self.__create_task()) + + # if task created and ready - return result + if self.created_task_data.errorId == 0 and self.created_task_data.status == ResponseStatusEnm.Processing: + return self.__get_result().to_dict() + return self.created_task_data.to_dict() + + def processing_image_captcha( + self, + save_format: Union[str, SaveFormatsEnm], + img_clearing: bool, + captcha_link: str, + captcha_file: str, + captcha_base64: bytes, + img_path: str, + ) -> dict: + self.__body_file_processing( + save_format=save_format, + img_clearing=img_clearing, + file_path=img_path, + captcha_link=captcha_link, + captcha_file=captcha_file, + captcha_base64=captcha_base64, + ) + if not self.result.errorId: + return self.processing_captcha() + return self.result.to_dict() + + def __create_task(self, url_postfix: str = EndpointPostfixEnm.CREATE_TASK.value) -> dict: + """ + Function send SYNC request to service and wait for result + """ + try: + resp = self.session.post( + parse.urljoin(self.captcha_params.request_url, url_postfix), + json=self.captcha_params.create_task_payload.to_dict(), + ) + if resp.status_code in VALID_STATUS_CODES: + return resp.json() + else: + raise ValueError(resp.raise_for_status()) + except Exception as error: + logging.exception(error) + raise + + def __get_result(self, url_postfix: str = EndpointPostfixEnm.GET_TASK_RESULT.value) -> CaptchaResponseSer: + """ + Method send SYNC request to service and wait for result + """ + # initial waiting + time.sleep(self.captcha_params.sleep_time) + + self.captcha_params.get_result_params.taskId = self.created_task_data.taskId + + attempts = attempts_generator() + for _ in attempts: + try: + resp = self.session.post( + parse.urljoin(self.captcha_params.request_url, url_postfix), + json=self.captcha_params.get_result_params.to_dict(), + ) + if resp.status_code in VALID_STATUS_CODES: + result_data = CaptchaResponseSer(**resp.json()) + if result_data.status in (ResponseStatusEnm.Ready, ResponseStatusEnm.Failed): + # if captcha ready\failed or have unknown status - return exist data + return result_data + else: + raise ValueError(resp.raise_for_status()) + except Exception as error: + logging.exception(error) + raise + + # if captcha just created or in processing now - wait + time.sleep(self.captcha_params.sleep_time) + # default response if server is silent + self.result.errorId = 1 + self.result.errorCode = self.CAPTCHA_UNSOLVABLE + self.result.errorDescription = "Captcha not recognized" + self.result.taskId = self.created_task_data.taskId + self.result.status = ResponseStatusEnm.Failed + + def __body_file_processing( + self, + save_format: SaveFormatsEnm, + img_clearing: bool, + file_path: str, + file_extension: str = "png", + captcha_link: Optional[str] = None, + captcha_file: Optional[str] = None, + captcha_base64: Optional[bytes] = None, + **kwargs, + ): + # if a local file link is passed + if captcha_file: + self.captcha_params.create_task_payload.task.update( + {"body": base64.b64encode(self._local_file_captcha(captcha_file=captcha_file)).decode("utf-8")} + ) + # if the file is transferred in base64 encoding + elif captcha_base64: + self.captcha_params.create_task_payload.task.update( + {"body": base64.b64encode(captcha_base64).decode("utf-8")} + ) + # if a URL is passed + elif captcha_link: + try: + content = self._url_read(url=captcha_link, **kwargs).content + # according to the value of the passed parameter, select the function to save the image + if save_format == SaveFormatsEnm.CONST.value: + full_file_path = self._file_const_saver(content, file_path, file_extension=file_extension) + if img_clearing: + self._file_clean(full_file_path=full_file_path) + self.captcha_params.create_task_payload.task.update({"body": base64.b64encode(content).decode("utf-8")}) + except Exception as error: + self.result.errorId = 1 + self.result.errorCode = self.CAPTCHA_UNSOLVABLE + self.result.errorDescription = str(error) + + else: + self.result.errorId = 1 + self.result.errorCode = self.CAPTCHA_UNSOLVABLE + + def _url_read(self, url: str, **kwargs): + """ + Method open links + """ + return self.session.get(url=url, **kwargs) diff --git a/src/python3_capsolver/core/config.py b/src/python3_capsolver/core/utils.py similarity index 58% rename from src/python3_capsolver/core/config.py rename to src/python3_capsolver/core/utils.py index 9fb0349d..d864790c 100644 --- a/src/python3_capsolver/core/config.py +++ b/src/python3_capsolver/core/utils.py @@ -1,16 +1,5 @@ from typing import Generator -from tenacity import AsyncRetrying, wait_fixed, stop_after_attempt -from requests.adapters import Retry - -RETRIES = Retry(total=5, backoff_factor=0.9, status_forcelist=[500, 502, 503, 504]) -ASYNC_RETRIES = AsyncRetrying(wait=wait_fixed(5), stop=stop_after_attempt(5), reraise=True) - -REQUEST_URL = "https://api.capsolver.com" -VALID_STATUS_CODES = (200, 202, 400, 401, 405) - -APP_ID = "3E36E3CD-7EB5-4CAF-AA15-91011E652321" - # Connection retry generator def attempts_generator(amount: int = 16) -> Generator: diff --git a/src/python3_capsolver/image_to_text.py b/src/python3_capsolver/image_to_text.py index 7af5f562..efbedfbc 100644 --- a/src/python3_capsolver/image_to_text.py +++ b/src/python3_capsolver/image_to_text.py @@ -1,11 +1,11 @@ from typing import Union -from python3_capsolver.core.base import BaseCaptcha +from python3_capsolver.core.base import CaptchaParams from python3_capsolver.core.enum import ImageToTextTaskTypeEnm from python3_capsolver.core.serializer import TaskSer, CaptchaResponseSer -class ImageToText(BaseCaptcha): +class ImageToText(CaptchaParams): """ The class is used to work with Capsolver Image captcha solving methods. From 36268ea6fcda0547efc45b07f0bffffa1b8033f1 Mon Sep 17 00:00:00 2001 From: Andrei Date: Thu, 19 Dec 2024 20:25:44 +0300 Subject: [PATCH 02/15] updated structure --- src/python3_capsolver/control.py | 60 ++++---- .../core/aio_captcha_instrument.py | 136 ++++++++++-------- src/python3_capsolver/core/base.py | 76 +--------- src/python3_capsolver/core/const.py | 3 + 4 files changed, 109 insertions(+), 166 deletions(-) diff --git a/src/python3_capsolver/control.py b/src/python3_capsolver/control.py index ee93b983..1fa406bf 100644 --- a/src/python3_capsolver/control.py +++ b/src/python3_capsolver/control.py @@ -1,29 +1,33 @@ -from python3_capsolver.core.base import BaseCaptcha -from python3_capsolver.core.enum import EndpointPostfixEnm -from python3_capsolver.core.serializer import PostRequestSer, ControlResponseSer +from python3_capsolver.core.serializer import PostRequestSer +from .core.base import CaptchaParams +from .core.const import GET_BALANCE_POSTFIX +from .core.aio_captcha_instrument import AIOCaptchaInstrument +from .core.sio_captcha_instrument import SIOCaptchaInstrument -class Control(BaseCaptcha): - """ - The class is used to work with Capsolver control methods. - Args: - api_key: Capsolver API key - - Notes: - https://docs.capsolver.com/guide/api-getbalance.html - """ +class Control(CaptchaParams): serializer = PostRequestSer def __init__( self, + api_key: str, *args, **kwargs, ): - super().__init__(*args, **kwargs) + """ + The class is used to work with Capsolver control methods. + + Args: + api_key: Capsolver API key - def get_balance(self) -> ControlResponseSer: + Notes: + https://docs.capsolver.com/guide/api-getbalance.html + """ + super().__init__(api_key=api_key, *args, **kwargs) + + def get_balance(self) -> dict: """ Synchronous method to view the balance @@ -32,19 +36,19 @@ def get_balance(self) -> ControlResponseSer: ControlResponseSer(errorId=0 errorCode=None errorDescription=None balance=150.9085) Returns: - ResponseSer model with full server response + Dict with full server response Notes: - https://docs.capsolver.com/guide/api-getbalance.html + Check class docstring for more info """ - self._prepare_create_task_payload(serializer=self.serializer) - return ControlResponseSer( - **self._create_task( - url_postfix=EndpointPostfixEnm.GET_BALANCE.value, - ) + self._captcha_handling_instrument = SIOCaptchaInstrument(captcha_params=self) + return self._captcha_handling_instrument.send_post_request( + session=self._captcha_handling_instrument.session, + url_postfix=GET_BALANCE_POSTFIX, + payload={"clientKey": self.create_task_payload.clientKey}, ) - async def aio_get_balance(self) -> ControlResponseSer: + async def aio_get_balance(self) -> dict: """ Asynchronous method to view the balance @@ -53,14 +57,12 @@ async def aio_get_balance(self) -> ControlResponseSer: ControlResponseSer(errorId=0 errorCode=None errorDescription=None balance=150.9085) Returns: - ResponseSer model with full server response + Dict with full server response Notes: - https://docs.capsolver.com/guide/api-getbalance.html + Check class docstring for more info """ - self._prepare_create_task_payload(serializer=self.serializer) - return ControlResponseSer( - **await self._aio_create_task( - url_postfix=EndpointPostfixEnm.GET_BALANCE.value, - ) + return await AIOCaptchaInstrument.send_post_request( + url_postfix=GET_BALANCE_POSTFIX, + payload={"clientKey": self.create_task_payload.clientKey}, ) diff --git a/src/python3_capsolver/core/aio_captcha_instrument.py b/src/python3_capsolver/core/aio_captcha_instrument.py index 82046083..30edb343 100644 --- a/src/python3_capsolver/core/aio_captcha_instrument.py +++ b/src/python3_capsolver/core/aio_captcha_instrument.py @@ -3,14 +3,13 @@ import logging from typing import Union, Optional from urllib import parse -from urllib.parse import urljoin import aiohttp -from .enum import SaveFormatsEnm -from .const import ASYNC_RETRIES, BASE_REQUEST_URL, GET_RESULT_POSTFIX, CREATE_TASK_POSTFIX +from .enum import SaveFormatsEnm, ResponseStatusEnm, EndpointPostfixEnm +from .const import REQUEST_URL, ASYNC_RETRIES, VALID_STATUS_CODES, GET_BALANCE_POSTFIX from .utils import attempts_generator -from .serializer import CreateTaskResponseSer +from .serializer import CaptchaResponseSer from .captcha_instrument import CaptchaInstrument __all__ = ("AIOCaptchaInstrument",) @@ -24,21 +23,70 @@ class AIOCaptchaInstrument(CaptchaInstrument): def __init__(self, captcha_params: "CaptchaParams"): super().__init__() self.captcha_params = captcha_params + self.created_task_data = CaptchaResponseSer async def processing_captcha(self) -> dict: - # added task params to payload - self.captcha_params.create_task_payload.task.update(self.captcha_params.task_params) + self.created_task_data = CaptchaResponseSer(**await self.__create_task()) - created_task = await self._create_task() + # if task created and already ready - return result + if self.created_task_data.errorId == 0 and self.created_task_data.status == ResponseStatusEnm.Processing.value: + return (await self.__get_result()).to_dict() + return self.created_task_data.to_dict() - if created_task.errorId == 0: - self.captcha_params.get_result_params.taskId = created_task.taskId - else: - return created_task.to_dict() + async def __create_task(self, url_postfix: str = EndpointPostfixEnm.CREATE_TASK.value) -> dict: + """ + Function send the ASYNC request to service and wait for result + """ + async with aiohttp.ClientSession() as session: + try: + async with session.post( + parse.urljoin(self.captcha_params.request_url, url_postfix), + json=self.captcha_params.create_task_payload.to_dict(), + ) as resp: + if resp.status in VALID_STATUS_CODES: + return await resp.json() + else: + raise ValueError(resp.reason) + except Exception as error: + logging.exception(error) + raise + async def __get_result(self, url_postfix: str = EndpointPostfixEnm.GET_TASK_RESULT.value) -> CaptchaResponseSer: + """ + Function send the ASYNC request to service and wait for result + """ + # initial waiting await asyncio.sleep(self.captcha_params.sleep_time) - return await self._get_result() + self.captcha_params.get_result_params.taskId = self.created_task_data.taskId + attempts = attempts_generator() + async with aiohttp.ClientSession() as session: + for _ in attempts: + try: + async with session.post( + parse.urljoin(self.captcha_params.request_url, url_postfix), + json=self.captcha_params.get_result_params.to_dict(), + ) as resp: + if resp.status in VALID_STATUS_CODES: + result_data = CaptchaResponseSer(**await resp.json()) + if result_data.status in (ResponseStatusEnm.Ready, ResponseStatusEnm.Failed): + # if captcha ready\failed or have unknown status - return exist data + return result_data + else: + raise ValueError(resp.reason) + except Exception as error: + logging.exception(error) + raise + + # if captcha just created or in processing now - wait + await asyncio.sleep(self.captcha_params.sleep_time) + + # default response if server is silent + self.result.errorId = 1 + self.result.errorCode = self.CAPTCHA_UNSOLVABLE + self.result.errorDescription = "Captcha not recognized" + self.result.taskId = self.created_task_data.taskId + self.result.status = ResponseStatusEnm.Failed async def processing_image_captcha( self, @@ -94,49 +142,22 @@ async def __body_file_processing( self.captcha_params.create_task_payload.task.update({"body": base64.b64encode(content).decode("utf-8")}) except Exception as error: self.result.errorId = 12 - self.result.errorCode = self.NO_CAPTCHA_ERR + self.result.errorCode = self.CAPTCHA_UNSOLVABLE self.result.errorDescription = str(error) else: self.result.errorId = 12 - self.result.errorCode = self.NO_CAPTCHA_ERR - - async def _url_read(self, url: str, **kwargs) -> bytes: - """ - Async method read bytes from link - """ - async with aiohttp.ClientSession() as session: - async for attempt in ASYNC_RETRIES: - with attempt: - async with session.get(url=url, **kwargs) as resp: - return await resp.content.read() - - async def _create_task(self, url_postfix: str = CREATE_TASK_POSTFIX) -> CreateTaskResponseSer: - """ - Function send SYNC request to service and wait for result - """ - async with aiohttp.ClientSession() as session: - try: - async with session.post( - parse.urljoin(BASE_REQUEST_URL, url_postfix), json=self.captcha_params.create_task_payload.to_dict() - ) as resp: - if resp.status == 200: - return CreateTaskResponseSer(**await resp.json()) - else: - raise ValueError(resp.reason) - except Exception as error: - logging.exception(error) - raise + self.result.errorCode = self.CAPTCHA_UNSOLVABLE @staticmethod - async def send_post_request(payload: Optional[dict] = None, url_postfix: str = CREATE_TASK_POSTFIX) -> dict: + async def send_post_request(payload: Optional[dict] = None, url_postfix: str = GET_BALANCE_POSTFIX) -> dict: """ Function send ASYNC request to service and wait for result """ async with aiohttp.ClientSession() as session: try: - async with session.post(parse.urljoin(BASE_REQUEST_URL, url_postfix), json=payload) as resp: + async with session.post(parse.urljoin(REQUEST_URL, url_postfix), json=payload) as resp: if resp.status == 200: return await resp.json() else: @@ -145,24 +166,13 @@ async def send_post_request(payload: Optional[dict] = None, url_postfix: str = C logging.exception(error) raise - async def _get_result(self, url_response: str = GET_RESULT_POSTFIX) -> dict: - attempts = attempts_generator() - # Send request for status of captcha solution. + @staticmethod + async def _url_read(url: str, **kwargs) -> bytes: + """ + Async method read bytes from link + """ async with aiohttp.ClientSession() as session: - for _ in attempts: - async with session.post( - url=urljoin(BASE_REQUEST_URL, url_response), json=self.captcha_params.get_result_params.to_dict() - ) as resp: - json_result = await resp.json() - # if there is no error, check CAPTCHA status - if json_result["errorId"] == 0: - # If not yet resolved, wait - if json_result["status"] == "processing": - await asyncio.sleep(self.captcha_params.sleep_time) - # otherwise return response - else: - json_result.update({"taskId": self.captcha_params.get_result_params.taskId}) - return json_result - else: - json_result.update({"taskId": self.captcha_params.get_result_params.taskId}) - return json_result + async for attempt in ASYNC_RETRIES: + with attempt: + async with session.get(url=url, **kwargs) as resp: + return await resp.content.read() diff --git a/src/python3_capsolver/core/base.py b/src/python3_capsolver/core/base.py index 6de60275..240de224 100644 --- a/src/python3_capsolver/core/base.py +++ b/src/python3_capsolver/core/base.py @@ -1,13 +1,5 @@ -import asyncio -import logging -from urllib import parse - -import aiohttp - -from .enum import ResponseStatusEnm, EndpointPostfixEnm -from .const import REQUEST_URL, VALID_STATUS_CODES -from .utils import attempts_generator -from .serializer import CaptchaResponseSer, RequestCreateTaskSer, RequestGetTaskResultSer +from .const import REQUEST_URL +from .serializer import RequestCreateTaskSer, RequestGetTaskResultSer from .context_instr import AIOContextManager, SIOContextManager from .captcha_instrument import CaptchaInstrument @@ -44,67 +36,3 @@ def __init__( """ Async part """ - - async def _aio_processing_captcha(self) -> dict: - self._prepare_task_payload() - self.created_task_data = CaptchaResponseSer(**await self._aio_create_task()) - - # if task created and already ready - return result - if self.created_task_data.errorId == 0 and self.created_task_data.status == ResponseStatusEnm.Processing.value: - return (await self._aio_get_result()).to_dict() - return self.created_task_data.to_dict() - - async def _aio_create_task(self, url_postfix: str = EndpointPostfixEnm.CREATE_TASK.value) -> dict: - """ - Function send the ASYNC request to service and wait for result - """ - async with aiohttp.ClientSession() as session: - try: - async with session.post( - parse.urljoin(self.__request_url, url_postfix), json=self.create_task_payload.to_dict() - ) as resp: - if resp.status in VALID_STATUS_CODES: - return await resp.json() - else: - raise ValueError(resp.reason) - except Exception as error: - logging.exception(error) - raise - - async def _aio_get_result(self, url_postfix: str = EndpointPostfixEnm.GET_TASK_RESULT.value) -> CaptchaResponseSer: - """ - Function send the ASYNC request to service and wait for result - """ - # initial waiting - await asyncio.sleep(self.__params.sleep_time) - - self.get_result_params.taskId = self.created_task_data.taskId - attempts = attempts_generator() - async with aiohttp.ClientSession() as session: - for _ in attempts: - try: - async with session.post( - parse.urljoin(self.__request_url, url_postfix), json=self.get_result_params.to_dict() - ) as resp: - if resp.status in VALID_STATUS_CODES: - result_data = CaptchaResponseSer(**await resp.json()) - if result_data.status in (ResponseStatusEnm.Ready, ResponseStatusEnm.Failed): - # if captcha ready\failed or have unknown status - return exist data - return result_data - else: - raise ValueError(resp.reason) - except Exception as error: - logging.exception(error) - raise - - # if captcha just created or in processing now - wait - await asyncio.sleep(self.__params.sleep_time) - - # default response if server is silent - return CaptchaResponseSer( - errorId=1, - errorCode="ERROR_CAPTCHA_UNSOLVABLE", - errorDescription="Captcha not recognized", - taskId=self.created_task_data.taskId, - status=ResponseStatusEnm.Failed, - ) diff --git a/src/python3_capsolver/core/const.py b/src/python3_capsolver/core/const.py index e4253e58..c4721818 100644 --- a/src/python3_capsolver/core/const.py +++ b/src/python3_capsolver/core/const.py @@ -8,6 +8,9 @@ ASYNC_RETRIES = AsyncRetrying(wait=wait_fixed(5), stop=stop_after_attempt(5), reraise=True) REQUEST_URL = "https://api.capsolver.com" +CREATE_TASK_POSTFIX = "/createTask" +GET_RESULT_POSTFIX = "/getTaskResult" +GET_BALANCE_POSTFIX = "/getBalance" VALID_STATUS_CODES = (200, 202, 400, 401, 405) APP_ID = "3E36E3CD-7EB5-4CAF-AA15-91011E652321" From 363106f2c52c1ebbefe19c0bd79b91433640c94f Mon Sep 17 00:00:00 2001 From: Andrei Date: Thu, 19 Dec 2024 20:25:49 +0300 Subject: [PATCH 03/15] Update aws_waf.py --- src/python3_capsolver/aws_waf.py | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/src/python3_capsolver/aws_waf.py b/src/python3_capsolver/aws_waf.py index 02c53487..a6e87c56 100644 --- a/src/python3_capsolver/aws_waf.py +++ b/src/python3_capsolver/aws_waf.py @@ -2,6 +2,7 @@ from .core.base import CaptchaParams from .core.enum import AntiAwsWafTaskTypeEnm +from .core.aio_captcha_instrument import AIOCaptchaInstrument from .core.sio_captcha_instrument import SIOCaptchaInstrument @@ -118,4 +119,6 @@ async def aio_captcha_handler(self) -> dict: Notes: Check class docstring for more info """ - return await self._aio_processing_captcha() + self.create_task_payload.task = {**self.task_params} + self._captcha_handling_instrument = AIOCaptchaInstrument(captcha_params=self) + return await self._captcha_handling_instrument._aio_processing_captcha() From 9e79d433859b26514106a1de7e686ac0d8c91d37 Mon Sep 17 00:00:00 2001 From: Andrei Date: Thu, 19 Dec 2024 20:25:52 +0300 Subject: [PATCH 04/15] Update sio_captcha_instrument.py --- .../core/sio_captcha_instrument.py | 25 +++++++++++++++---- 1 file changed, 20 insertions(+), 5 deletions(-) diff --git a/src/python3_capsolver/core/sio_captcha_instrument.py b/src/python3_capsolver/core/sio_captcha_instrument.py index e7c47c3e..539af364 100644 --- a/src/python3_capsolver/core/sio_captcha_instrument.py +++ b/src/python3_capsolver/core/sio_captcha_instrument.py @@ -8,7 +8,7 @@ from requests.adapters import HTTPAdapter from .enum import SaveFormatsEnm, ResponseStatusEnm, EndpointPostfixEnm -from .const import RETRIES, VALID_STATUS_CODES +from .const import RETRIES, REQUEST_URL, VALID_STATUS_CODES, GET_BALANCE_POSTFIX from .utils import attempts_generator from .serializer import CaptchaResponseSer from .captcha_instrument import CaptchaInstrument @@ -31,10 +31,6 @@ def __init__(self, captcha_params: "CaptchaParams"): self.session.mount("https://", HTTPAdapter(max_retries=RETRIES)) self.session.verify = False - """ - Sync part - """ - def processing_captcha(self) -> dict: self.created_task_data = CaptchaResponseSer(**self.__create_task()) @@ -157,6 +153,25 @@ def __body_file_processing( self.result.errorId = 1 self.result.errorCode = self.CAPTCHA_UNSOLVABLE + @staticmethod + def send_post_request( + payload: Optional[dict] = None, + session: requests.Session = requests.Session(), + url_postfix: str = GET_BALANCE_POSTFIX, + ) -> dict: + """ + Function send SYNC request to service and wait for result + """ + try: + resp = session.post(parse.urljoin(REQUEST_URL, url_postfix), json=payload) + if resp.status_code == 200: + return resp.json() + else: + raise ValueError(resp.raise_for_status()) + except Exception as error: + logging.exception(error) + raise + def _url_read(self, url: str, **kwargs): """ Method open links From 6b5302c8f7e7ea6e83ee46a0039ca241e7049b43 Mon Sep 17 00:00:00 2001 From: Andrei Date: Thu, 19 Dec 2024 20:26:59 +0300 Subject: [PATCH 05/15] Update base.py --- src/python3_capsolver/core/base.py | 42 +++++++++++++++++++++++++++--- 1 file changed, 39 insertions(+), 3 deletions(-) diff --git a/src/python3_capsolver/core/base.py b/src/python3_capsolver/core/base.py index 240de224..29f23fe6 100644 --- a/src/python3_capsolver/core/base.py +++ b/src/python3_capsolver/core/base.py @@ -2,6 +2,8 @@ from .serializer import RequestCreateTaskSer, RequestGetTaskResultSer from .context_instr import AIOContextManager, SIOContextManager from .captcha_instrument import CaptchaInstrument +from .aio_captcha_instrument import AIOCaptchaInstrument +from .sio_captcha_instrument import SIOCaptchaInstrument __all__ = ("CaptchaParams",) @@ -33,6 +35,40 @@ def __init__( self.request_url = request_url self._captcha_handling_instrument = CaptchaInstrument() - """ - Async part - """ + def captcha_handler(self, **additional_params) -> dict: + """ + Synchronous method for captcha solving + + Args: + additional_params: Some additional parameters that will be used in creating the task + and will be passed to the payload under ``task`` key. + Like ``proxyLogin``, ``proxyPassword`` and etc. - more info in service docs + + Returns: + Dict with full server response + + Notes: + Check class docstirng for more info + """ + self.task_params.update({**additional_params}) + self._captcha_handling_instrument = SIOCaptchaInstrument(captcha_params=self) + return self._captcha_handling_instrument.processing_captcha() + + async def aio_captcha_handler(self, **additional_params) -> dict: + """ + Asynchronous method for captcha solving + + Args: + additional_params: Some additional parameters that will be used in creating the task + and will be passed to the payload under ``task`` key. + Like ``proxyLogin``, ``proxyPassword`` and etc. - more info in service docs + + Returns: + Dict with full server response + + Notes: + Check class docstirng for more info + """ + self.task_params.update({**additional_params}) + self._captcha_handling_instrument = AIOCaptchaInstrument(captcha_params=self) + return await self._captcha_handling_instrument.processing_captcha() From 2110674cebdbeaae18ad7e4ac1b6219e7aef67f0 Mon Sep 17 00:00:00 2001 From: Andrei Date: Thu, 19 Dec 2024 20:27:02 +0300 Subject: [PATCH 06/15] Update aws_waf.py --- src/python3_capsolver/aws_waf.py | 30 ------------------------------ 1 file changed, 30 deletions(-) diff --git a/src/python3_capsolver/aws_waf.py b/src/python3_capsolver/aws_waf.py index a6e87c56..e370e41b 100644 --- a/src/python3_capsolver/aws_waf.py +++ b/src/python3_capsolver/aws_waf.py @@ -2,8 +2,6 @@ from .core.base import CaptchaParams from .core.enum import AntiAwsWafTaskTypeEnm -from .core.aio_captcha_instrument import AIOCaptchaInstrument -from .core.sio_captcha_instrument import SIOCaptchaInstrument class AwsWaf(CaptchaParams): @@ -94,31 +92,3 @@ def __init__( f"""Invalid `captcha_type` parameter set for `{self.__class__.__name__}`, available - {AntiAwsWafTaskTypeEnm.list_values()}""" ) - - def captcha_handler(self) -> dict: - """ - Sync solving method - - Returns: - Dict with full service response - - Notes: - Check class docstring for more info - """ - self.create_task_payload.task = {**self.task_params} - self._captcha_handling_instrument = SIOCaptchaInstrument(captcha_params=self) - return self._captcha_handling_instrument.processing_captcha() - - async def aio_captcha_handler(self) -> dict: - """ - Async method for captcha solving - - Returns: - Dict with full service response - - Notes: - Check class docstring for more info - """ - self.create_task_payload.task = {**self.task_params} - self._captcha_handling_instrument = AIOCaptchaInstrument(captcha_params=self) - return await self._captcha_handling_instrument._aio_processing_captcha() From a3b759f5fb96a0dd1ac99b5b7601c3c48d614718 Mon Sep 17 00:00:00 2001 From: Andrei Date: Thu, 19 Dec 2024 20:28:41 +0300 Subject: [PATCH 07/15] Update aws_waf.py --- src/python3_capsolver/aws_waf.py | 2 ++ 1 file changed, 2 insertions(+) diff --git a/src/python3_capsolver/aws_waf.py b/src/python3_capsolver/aws_waf.py index e370e41b..be8985e8 100644 --- a/src/python3_capsolver/aws_waf.py +++ b/src/python3_capsolver/aws_waf.py @@ -3,6 +3,8 @@ from .core.base import CaptchaParams from .core.enum import AntiAwsWafTaskTypeEnm +__all__ = ("AwsWaf",) + class AwsWaf(CaptchaParams): def __init__( From d49b931b0286161438ef3122d29347ff4ed80afd Mon Sep 17 00:00:00 2001 From: Andrei Date: Thu, 19 Dec 2024 20:28:43 +0300 Subject: [PATCH 08/15] Update control.py --- src/python3_capsolver/control.py | 2 ++ 1 file changed, 2 insertions(+) diff --git a/src/python3_capsolver/control.py b/src/python3_capsolver/control.py index 1fa406bf..9b1ae385 100644 --- a/src/python3_capsolver/control.py +++ b/src/python3_capsolver/control.py @@ -5,6 +5,8 @@ from .core.aio_captcha_instrument import AIOCaptchaInstrument from .core.sio_captcha_instrument import SIOCaptchaInstrument +__all__ = ("Control",) + class Control(CaptchaParams): From 0a202ae87e790b0415ffd9bf056f067bcc4f6791 Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Mon, 23 Dec 2024 21:58:45 +0000 Subject: [PATCH 09/15] Bump pydantic from 2.10.3 to 2.10.4 Bumps [pydantic](https://github.com/pydantic/pydantic) from 2.10.3 to 2.10.4. - [Release notes](https://github.com/pydantic/pydantic/releases) - [Changelog](https://github.com/pydantic/pydantic/blob/main/HISTORY.md) - [Commits](https://github.com/pydantic/pydantic/compare/v2.10.3...v2.10.4) --- updated-dependencies: - dependency-name: pydantic dependency-type: direct:production update-type: version-update:semver-patch ... Signed-off-by: dependabot[bot] --- docs/requirements.txt | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/docs/requirements.txt b/docs/requirements.txt index b8dd6893..c2c9cac2 100644 --- a/docs/requirements.txt +++ b/docs/requirements.txt @@ -2,5 +2,5 @@ sphinx==8.1.3 pallets_sphinx_themes==2.3.0 myst-parser==4.0.0 autodoc_pydantic==2.2.0 -pydantic==2.10.3 +pydantic==2.10.4 pydantic-settings==2.7.0 From 06eefa3212fc32cf08e92ff785426877f3356d64 Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Mon, 30 Dec 2024 21:15:39 +0000 Subject: [PATCH 10/15] Update msgspec requirement from ==0.18.* to >=0.18,<0.20 Updates the requirements on [msgspec](https://github.com/jcrist/msgspec) to permit the latest version. - [Release notes](https://github.com/jcrist/msgspec/releases) - [Commits](https://github.com/jcrist/msgspec/compare/0.18.0...0.19.0) --- updated-dependencies: - dependency-name: msgspec dependency-type: direct:production ... Signed-off-by: dependabot[bot] --- pyproject.toml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/pyproject.toml b/pyproject.toml index 5d4ac64c..472d6e56 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -85,7 +85,7 @@ classifiers = [ dependencies = [ "requests>=2.21.0", "aiohttp>=3.9.2", - "msgspec==0.18.*", + "msgspec>=0.18,<0.20", "tenacity>=8,<10" ] From 694788839a7c312815b87f7cc3a124656cebe02e Mon Sep 17 00:00:00 2001 From: Andrei Date: Sun, 5 Jan 2025 20:27:40 +0300 Subject: [PATCH 11/15] Update info.md --- docs/modules/other-libs/info.md | 8 +++++++- 1 file changed, 7 insertions(+), 1 deletion(-) diff --git a/docs/modules/other-libs/info.md b/docs/modules/other-libs/info.md index 75385a89..41a1798a 100644 --- a/docs/modules/other-libs/info.md +++ b/docs/modules/other-libs/info.md @@ -4,4 +4,10 @@ 1. [RuCaptcha / 2Captcha](https://github.com/AndreiDrang/python-rucaptcha) 2. [AntiCaptcha](https://github.com/AndreiDrang/python3-anticaptcha) 3. [Capsolver](https://github.com/AndreiDrang/python3-capsolver) -4. [RedPandaDev group](https://red-panda-dev.xyz/) + +## Rust +1. [Rust-AntiCaptcha crate](https://crates.io/crates/rust-anticaptcha) + +Our other projects: +- [RedPandaDev group](https://red-panda-dev.xyz/blog/) + From 65af8c8bf3b63e71954366a0e6d55b8b03942662 Mon Sep 17 00:00:00 2001 From: Andrei Date: Sun, 5 Jan 2025 20:38:04 +0300 Subject: [PATCH 12/15] Update info.md --- docs/modules/main/info.md | 8 ++++++-- 1 file changed, 6 insertions(+), 2 deletions(-) diff --git a/docs/modules/main/info.md b/docs/modules/main/info.md index 3d16b569..d4bfda49 100644 --- a/docs/modules/main/info.md +++ b/docs/modules/main/info.md @@ -27,8 +27,6 @@ Tested on UNIX based OS. The library is intended for software developers and is used to work with the [Capsolver](https://dashboard.capsolver.com/passport/register?inviteCode=kQTn-tG07Jb1) service API. -*** - You can check our other projects here - [RedPandaDev group](https://red-panda-dev.xyz/blog/). *** @@ -59,3 +57,9 @@ python setup.py install ### Changelog Check [releases page](https://github.com/AndreiDrang/python3-capsolver/releases). + +### Contacts + +If you have any questions, please send a message to the [Telegram](https://t.me/pythoncaptcha) chat room. + +Or email python-captcha@pm.me From dbc3563c366464c7143f4e667ab07898b9652ae6 Mon Sep 17 00:00:00 2001 From: Andrei Date: Sun, 5 Jan 2025 20:38:06 +0300 Subject: [PATCH 13/15] Update README.md --- README.md | 12 ++++++------ 1 file changed, 6 insertions(+), 6 deletions(-) diff --git a/README.md b/README.md index 733ccb2d..9bd021f1 100644 --- a/README.md +++ b/README.md @@ -25,12 +25,6 @@ Tested on UNIX based OS. The library is intended for software developers and is used to work with the [Capsolver](https://dashboard.capsolver.com/passport/register?inviteCode=kQTn-tG07Jb1) service API. -*** - -If you have any questions, please send a message to the [Telegram](https://t.me/pythoncaptcha) chat room. - -Or email python-captcha@pm.me - ## How to install? We recommend using the latest version of Python. `python3-capsolver` supports Python 3.7+. @@ -59,3 +53,9 @@ Check [releases page](https://github.com/AndreiDrang/python3-capsolver/releases) ### How to get API Key to work with the library 1. On the page - https://dashboard.capsolver.com/overview/user-center 2. Find it: [![img.png](https://s.vyjava.xyz/files/2024/12-December/17/ae8d4fbf/img.png)](https://vyjava.xyz/dashboard/image/ae8d4fbf-7451-441d-8984-79b1a7adbe27) + +### Contacts + +If you have any questions, please send a message to the [Telegram](https://t.me/pythoncaptcha) chat room. + +Or email python-captcha@pm.me From 2643ec97b77ebb660028cec716f329fbab7f8178 Mon Sep 17 00:00:00 2001 From: Andrei Date: Sun, 5 Jan 2025 20:47:05 +0300 Subject: [PATCH 14/15] Update info.md --- docs/modules/main/info.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/docs/modules/main/info.md b/docs/modules/main/info.md index d4bfda49..260e1056 100644 --- a/docs/modules/main/info.md +++ b/docs/modules/main/info.md @@ -9,8 +9,8 @@ [![PyPI version](https://badge.fury.io/py/python3-capsolver.svg)](https://badge.fury.io/py/python3-capsolver) [![Python versions](https://img.shields.io/pypi/pyversions/python3-capsolver.svg?logo=python&logoColor=FBE072)](https://badge.fury.io/py/python3-capsolver) [![Downloads](https://pepy.tech/badge/python3-capsolver/month)](https://pepy.tech/project/python3-capsolver) +[![Static Badge](https://img.shields.io/badge/docs-Sphinx-green?label=Documentation&labelColor=gray)](https://andreidrang.github.io/python3-capsolver/) -[![Maintainability](https://api.codeclimate.com/v1/badges/3c30167b5fb37a0775ea/maintainability)](https://codeclimate.com/github/AndreiDrang/python3-capsolver/maintainability) [![Codacy Badge](https://app.codacy.com/project/badge/Grade/323d4eda0fe1477bbea8fe8902b9e97e)](https://www.codacy.com/gh/AndreiDrang/python3-capsolver/dashboard?utm_source=github.com&utm_medium=referral&utm_content=AndreiDrang/python3-capsolver&utm_campaign=Badge_Grade) [![codecov](https://codecov.io/gh/AndreiDrang/python3-capsolver/branch/main/graph/badge.svg?token=2L4VVIF4G8)](https://codecov.io/gh/AndreiDrang/python3-capsolver) From cc9f30d1e5a7e47c6bda822f1ca64ca9ca734461 Mon Sep 17 00:00:00 2001 From: Andrei Date: Sun, 5 Jan 2025 20:47:07 +0300 Subject: [PATCH 15/15] Update README.md --- README.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/README.md b/README.md index 9bd021f1..0b26dde7 100644 --- a/README.md +++ b/README.md @@ -7,8 +7,8 @@ [![PyPI version](https://badge.fury.io/py/python3-capsolver.svg)](https://badge.fury.io/py/python3-capsolver) [![Python versions](https://img.shields.io/pypi/pyversions/python3-capsolver.svg?logo=python&logoColor=FBE072)](https://badge.fury.io/py/python3-capsolver) [![Downloads](https://static.pepy.tech/badge/python3-capsolver/month)](https://pepy.tech/project/python3-capsolver) +[![Static Badge](https://img.shields.io/badge/docs-Sphinx-green?label=Documentation&labelColor=gray)](https://andreidrang.github.io/python3-capsolver/) -[![Maintainability](https://api.codeclimate.com/v1/badges/3c30167b5fb37a0775ea/maintainability)](https://codeclimate.com/github/AndreiDrang/python3-capsolver/maintainability) [![Codacy Badge](https://app.codacy.com/project/badge/Grade/323d4eda0fe1477bbea8fe8902b9e97e)](https://www.codacy.com/gh/AndreiDrang/python3-capsolver/dashboard?utm_source=github.com&utm_medium=referral&utm_content=AndreiDrang/python3-capsolver&utm_campaign=Badge_Grade) [![codecov](https://codecov.io/gh/AndreiDrang/python3-capsolver/branch/main/graph/badge.svg?token=2L4VVIF4G8)](https://codecov.io/gh/AndreiDrang/python3-capsolver)