diff --git a/.github/workflows/build.yml b/.github/workflows/build.yml index 88caabd0..6a42cd31 100644 --- a/.github/workflows/build.yml +++ b/.github/workflows/build.yml @@ -23,7 +23,7 @@ jobs: python-version: ["3.9", "3.10", "3.11", "3.12"] steps: - - uses: actions/checkout@v5 + - uses: actions/checkout@v6 - name: Set up Python ${{ matrix.python-version }} uses: actions/setup-python@v6 with: diff --git a/.github/workflows/install.yml b/.github/workflows/install.yml index 15d7d821..2886378a 100644 --- a/.github/workflows/install.yml +++ b/.github/workflows/install.yml @@ -23,7 +23,7 @@ jobs: python-version: ["3.9", "3.10", "3.11", "3.12"] steps: - - uses: actions/checkout@v5 + - uses: actions/checkout@v6 - name: Set up Python ${{ matrix.python-version }} uses: actions/setup-python@v6 with: diff --git a/.github/workflows/lint.yml b/.github/workflows/lint.yml index 6b5199ad..a862597a 100644 --- a/.github/workflows/lint.yml +++ b/.github/workflows/lint.yml @@ -27,7 +27,7 @@ jobs: python-version: ["3.12"] steps: - - uses: actions/checkout@v5 + - uses: actions/checkout@v6 - name: Set up Python ${{ matrix.python-version }} uses: actions/setup-python@v6 with: diff --git a/.github/workflows/sphinx.yml b/.github/workflows/sphinx.yml index 03089cfe..8896470d 100644 --- a/.github/workflows/sphinx.yml +++ b/.github/workflows/sphinx.yml @@ -14,7 +14,7 @@ jobs: runs-on: ubuntu-latest steps: - - uses: actions/checkout@v5 + - uses: actions/checkout@v6 - uses: actions/setup-python@v6 - name: Build docs requirements diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml index 993d26e2..9dbc905e 100644 --- a/.github/workflows/test.yml +++ b/.github/workflows/test.yml @@ -33,7 +33,7 @@ jobs: python-version: ["3.11"] steps: - - uses: actions/checkout@v5 + - uses: actions/checkout@v6 - name: Set up Python ${{ matrix.python-version }} uses: actions/setup-python@v6 with: @@ -48,7 +48,7 @@ jobs: run: make tests - name: Upload coverage to Codecov - uses: codecov/codecov-action@v5 + uses: codecov/codecov-action@v6 with: token: ${{ secrets.CODECOV_TOKEN }} files: ${{github.workspace}}/coverage/coverage.xml diff --git a/.github/workflows/zai-code-bot.yml b/.github/workflows/zai-code-bot.yml new file mode 100644 index 00000000..6d6abb10 --- /dev/null +++ b/.github/workflows/zai-code-bot.yml @@ -0,0 +1,45 @@ +name: Z.ai Code Bot + +concurrency: + group: zai-bot-${{ github.event.pull_request.number || github.event.issue.number }} + cancel-in-progress: false + +on: + pull_request: + types: + - opened + - synchronize + - reopened + - ready_for_review + branches: + - main + - prod + issue_comment: + types: + - created + pull_request_review_comment: + types: + - created + +permissions: + contents: read + pull-requests: write + issues: write + +jobs: + zai-bot: + if: | + (github.event_name == 'pull_request' && github.event.pull_request.draft == false) || + (github.event_name == 'issue_comment' && github.event.issue.pull_request) || + (github.event_name == 'pull_request_review_comment' && github.event.pull_request.draft == false) + runs-on: ubuntu-latest + steps: + - name: Checkout repository + uses: actions/checkout@v6 + + - name: Run Z.ai Bot + uses: AndreiDrang/zai-code-bot@main + with: + ZAI_API_KEY: ${{ secrets.ZAI_API_KEY }} + ZAI_MODEL: ${{ vars.ZAI_MODEL || 'glm-5.1' }} + GITHUB_TOKEN: ${{ github.token }} diff --git a/.gitignore b/.gitignore index bad8eb9a..cec94e62 100644 --- a/.gitignore +++ b/.gitignore @@ -23,3 +23,4 @@ env/ /src/coverage/lcov.info /docs/_build/ /coverage/ +.osgrep diff --git a/AGENTS.md b/AGENTS.md new file mode 100644 index 00000000..9f1b394d --- /dev/null +++ b/AGENTS.md @@ -0,0 +1,68 @@ +# PROJECT KNOWLEDGE BASE + +**Generated:** 2026-02-20 +**Commit:** 86ee3e6 +**Branch:** master + +## OVERVIEW +Python 3.9+ library for RuCaptcha/2Captcha/DeathByCaptcha service APIs. Supports 30+ CAPTCHA types with dual sync/async interfaces. + +## STRUCTURE +``` +./ +├── src/python_rucaptcha/ # Main package (30+ captcha modules) +│ └── core/ # Base classes, serializers, enums +├── tests/ # Pytest test suite (23+ files) +├── docs/ # Sphinx documentation +├── pyproject.toml # Build config (black 110, isort, pytest) +└── Makefile # Build automation +``` + +## WHERE TO LOOK +| Task | Location | Notes | +|------|----------|-------| +| Add new CAPTCHA | `src/python_rucaptcha/` | Create module inheriting `BaseCaptcha` | +| Modify API flow | `src/python_rucaptcha/core/base.py` | `_processing_response()` methods | +| Change serialization | `src/python_rucaptcha/core/serializer.py` | `MyBaseModel` class | +| Add CAPTCHA enum | `src/python_rucaptcha/core/enums.py` | 25+ enum classes | +| Run tests | `tests/` | Requires `RUCAPTCHA_KEY` env var | + +## CODE MAP +| Symbol | Type | Location | Role | +|--------|------|----------|------| +| BaseCaptcha | Class | core/base.py | Parent for all captcha solvers | +| MyBaseModel | Class | core/serializer.py | msgspec Struct wrapper | +| CaptchaOptionsSer | Class | core/config.py | Service URL abstraction | +| MyEnum | Class | core/enums.py | Custom enum with utils | + +## CONVENTIONS +- **Line length**: 110 chars (pyproject.toml) +- **Async mode**: `asyncio_mode = auto` in pytest +- **No tox**: Uses Makefile directly for test/lint +- **Import order**: isort with black profile + +## ANTI-PATTERNS (THIS PROJECT) +- No TODO/FIXME/DEPRECATED comments in code +- No explicit "DO NOT" directives +- Logging warnings output full result objects (potential sensitive data) + +## UNIQUE STYLES +- **25+ custom enums**: Each CAPTCHA type has dedicated enum (e.g., `HCaptchaEnm`) +- **Service abstraction**: Unified API for 2Captcha/RuCaptcha/DeathByCaptcha +- **msgspec**: Fast serialization (replaced pydantic v6.0) +- **Dual sync/async**: Every captcha class has both handlers + +## COMMANDS +```bash +make install # pip install -e . +make tests # Run pytest with coverage +make lint # autoflake + black + isort check +make build # Build package +make doc # Build Sphinx docs +``` + +## NOTES +- Tests require live API keys (`RUCAPTCHA_KEY`, `DEATHBYCAPTCHA_KEY`) +- CI runs on Python 3.11 (tests) / 3.12 (lint) — version mismatch +- No cross-platform testing (only ubuntu-latest) +- x.py in root is debug script (non-standard) diff --git a/README.md b/README.md index f898305c..6edea8e2 100644 --- a/README.md +++ b/README.md @@ -1,70 +1,160 @@ # python-rucaptcha -[![RuCaptchaHigh.png](https://s.vyjava.xyz/files/2024/12-December/17/45247a56/RuCaptchaHigh.png)](https://vyjava.xyz/dashboard/image/45247a56-3332-48ee-8df8-fc95bcfc52f0) - -
- [![PyPI version](https://badge.fury.io/py/python-rucaptcha.svg)](https://badge.fury.io/py/python-rucaptcha) [![Python versions](https://img.shields.io/pypi/pyversions/python-rucaptcha.svg?logo=python&logoColor=FBE072)](https://badge.fury.io/py/python-rucaptcha) [![Downloads](https://static.pepy.tech/badge/python-rucaptcha/month)](https://pepy.tech/project/python-rucaptcha) -[![Static Badge](https://img.shields.io/badge/docs-Sphinx-green?label=Documentation&labelColor=gray)](https://andreidrang.github.io/python-rucaptcha/) +[![Documentation](https://img.shields.io/badge/docs-Sphinx-green)](https://andreidrang.github.io/python-rucaptcha/) -[![Maintainability](https://api.codeclimate.com/v1/badges/aec93bb04a277cf0dde9/maintainability)](https://codeclimate.com/github/AndreiDrang/python-rucaptcha/maintainability) -[![Codacy Badge](https://app.codacy.com/project/badge/Grade/b4087362bd024b088b358b3e10e7a62f)](https://www.codacy.com/gh/AndreiDrang/python-rucaptcha/dashboard?utm_source=github.com&utm_medium=referral&utm_content=AndreiDrang/python-rucaptcha&utm_campaign=Badge_Grade) -[![codecov](https://codecov.io/gh/AndreiDrang/python-rucaptcha/branch/master/graph/badge.svg?token=doybTUCfbD)](https://codecov.io/gh/AndreiDrang/python-rucaptcha) +**Python 3.9+ library to solve CAPTCHAs automatically using RuCaptcha, 2Captcha, or DeathByCaptcha services.** -[![Sphinx docs](https://github.com/AndreiDrang/python-rucaptcha/actions/workflows/sphinx.yml/badge.svg?branch=release)](https://github.com/AndreiDrang/python-rucaptcha/actions/workflows/sphinx.yml) -[![Build](https://github.com/AndreiDrang/python-rucaptcha/actions/workflows/build.yml/badge.svg?branch=master)](https://github.com/AndreiDrang/python-rucaptcha/actions/workflows/build.yml) -[![Installation](https://github.com/AndreiDrang/python-rucaptcha/actions/workflows/install.yml/badge.svg?branch=master)](https://github.com/AndreiDrang/python-rucaptcha/actions/workflows/install.yml) -[![Tests](https://github.com/AndreiDrang/python-rucaptcha/actions/workflows/test.yml/badge.svg?branch=master)](https://github.com/AndreiDrang/python-rucaptcha/actions/workflows/test.yml) -[![Lint](https://github.com/AndreiDrang/python-rucaptcha/actions/workflows/lint.yml/badge.svg?branch=master)](https://github.com/AndreiDrang/python-rucaptcha/actions/workflows/lint.yml) +## What is this? -Python3 library for [RuCaptcha](https://rucaptcha.com/?from=4170435) and [2Captcha](https://2captcha.com/?from=4170435) service API. +This library automates CAPTCHA solving by connecting to third-party services. When your code encounters a CAPTCHA, python-rucaptcha sends it to the service, waits for a human to solve it, and returns the solution to your application. -Tested on UNIX based OS. +**Supports 30+ CAPTCHA types:** +- reCAPTCHA v2/v3, hCaptcha, Cloudflare Turnstile +- Image captchas, Audio captchas +- GeeTest, KeyCaptcha, Amazon WAF, Tencent +- And many more... -The library is intended for software developers and is used to work with the [RuCaptcha](https://rucaptcha.com/?from=4170435) and [2Captcha](https://2captcha.com/?from=4170435) service API. +## Quick Start -Support of the service [Death By Captcha](https://deathbycaptcha.com?refid=1237267242) is integrated into this library, more information in the library documentation or in the [service docs](https://deathbycaptcha.com/api/2captcha?refid=1237267242). +### 1. Install + +```bash +pip install python-rucaptcha +``` -Application in [RuCaptcha software](https://rucaptcha.com/software/python-rucaptcha) and [2Captcha software](https://2captcha.com/software/python-rucaptcha). +### 2. Get an API Key -## How to install? +Sign up at [RuCaptcha](https://rucaptcha.com) or [2Captcha](https://2captcha.com), then copy your API key from the dashboard. -### pip +### 3. Solve a CAPTCHA -```bash -pip install python-rucaptcha +```python +from python_rucaptcha import HCaptcha + +# Your API key +key = "your_api_key_here" + +# Solve hCaptcha +result = HCaptcha(aptcha_key=key).captcha_handler(site_url="https://example.com", site_key="abc123") + +if result['code'] == 0: + print(f"Solved! Token: {result['token']}") +else: + print(f"Error: {result['message']}") +``` + +### Solving Different CAPTCHA Types + +**reCAPTCHA v2:** +```python +from python_rucaptcha import ReCaptcha + +result = ReCaptcha(api_key).captcha_handler( + site_url="https://example.com", + site_key="your_site_key" +) +``` + +**Image CAPTCHA:** +```python +from python_rucaptcha import ImageCaptcha + +result = ImageCaptcha(api_key).captcha_handler( + image_link="https://example.com/captcha.jpg" +) +``` + +**ALTCHA:** +```python +from python_rucaptcha import AltchaCaptcha +from python_rucaptcha.core.enums import AltchaEnm + +result = AltchaCaptcha( + rucaptcha_key=api_key, + websiteURL="https://example.com", + challengeURL="https://example.com/altcha/challenge", + method=AltchaEnm.AltchaTaskProxyless, +).captcha_handler() +``` + +**Using async:** +```python +import asyncio +from python_rucaptcha import HCaptcha + +async def solve(): + result = await HCaptcha(api_key).aio_captcha_handler( + site_url="https://example.com", + site_key="abc123" + ) + return result + +token = asyncio.run(solve()) ``` +## Supported CAPTCHA Types -## How to use? +| CAPTCHA | Module | Description | +|---------|--------|-------------| +| reCAPTCHA v2/v3 | `ReCaptcha` | Google reCAPTCHA | +| hCaptcha | `HCaptcha` | hCaptcha challenge | +| Cloudflare Turnstile | `Turnstile` | Cloudflare protection | +| Image | `ImageCaptcha` | Type the text from image | +| Audio | `AudioCaptcha` | Listen and type audio | +| GeeTest | `GeeTest` | Chinese geetest puzzles | +| KeyCaptcha | `KeyCaptcha` | KeyCAPTCHA service | +| Amazon WAF | `AmazonWaf` | AWS WAF challenge | +| ALTCHA | `AltchaCaptcha` | ALTCHA challenge | +| Binance | `BinanceCaptcha` | Token-based Binance challenge | +| Grid | `GridCaptcha` | Select grid cells | +| Coordinates | `CoordinatesCaptcha` | Click on coordinates | +| And 20+ more | ... | See [full docs](https://andreidrang.github.io/python-rucaptcha/) | -Is described in the [documentation-website](https://andreidrang.github.io/python-rucaptcha/). +## Switching Services -## How to test? +Use the same code with different services: -1. You need set ``RUCAPTCHA_KEY`` in your environment(get this value from you account). -2. Run command ``make tests``, from root directory. +```python +from python_rucaptcha import HCaptcha +from python_rucaptcha.core.enums import ServiceEnm +# Use 2Captcha (default) +result = HCaptcha("2captcha_key").captcha_handler(...) + +# Use RuCaptcha +result = HCaptcha("rucaptcha_key", service_type=ServiceEnm.RuCaptcha).captcha_handler(...) + +# Use DeathByCaptcha +result = HCaptcha("dbc_user:dbc_pass", service_type=ServiceEnm.DeathByCaptcha).captcha_handler(...) +``` + +## Testing + +```bash +# Set your API key +export RUCAPTCHA_KEY="your_key_here" + +# Run tests +make tests +``` -### Changelog +## Documentation -For full changelog info check - [Releases page](https://github.com/AndreiDrang/python-rucaptcha/releases). +For advanced usage, configuration options, and all CAPTCHA types, see the [full documentation](https://andreidrang.github.io/python-rucaptcha/). -- v.6.0 - Library refactoring. Stop using `pydantic`, start using `msgspec`. Move to API v2. Drop Python 3.8 support. More details at [Releases page](https://github.com/AndreiDrang/python-rucaptcha/releases). -- v.5.3 - Added support for [Death By Captcha](https://www.deathbycaptcha.com?refid=1237267242) and other services by changing `service_type` and `url_request` \ `url_response` parameters. -- v.5.2 - Added Audio captcha method. -- v.5.1 - Check [releases page](https://github.com/AndreiDrang/python-rucaptcha/releases). -- v.5.0 - Added AmazonWAF captcha method. -- v.4.2 - Added [Yandex Smart Captcha](https://rucaptcha.com/api-rucaptcha#yandex). +## Support -### Get API Key to work with the library -1. On the page - https://rucaptcha.com/enterpage -2. Find it: [![img.png](https://s.vyjava.xyz/files/2024/12-December/17/ac679557/img.png)](https://vyjava.xyz/dashboard/image/ac679557-f3cc-402f-bf95-6c45d252a2ef) +- **Telegram:** [pythoncaptcha](https://t.me/pythoncaptcha) +- **Email:** python-captcha@pm.me +- **Issues:** [GitHub Issues](https://github.com/AndreiDrang/python-rucaptcha/issues) -### Contacts +## Changelog -If you have any questions, please send a message to the [Telegram](https://t.me/pythoncaptcha) chat room. +See [Releases](https://github.com/AndreiDrang/python-rucaptcha/releases) for full changelog. -Or email python-captcha@pm.me +- **v6.0** - Refactored to use msgspec (faster), API v2, dropped Python 3.8 +- **v5.3** - Added DeathByCaptcha support +- **v5.2** - Added audio CAPTCHA solving diff --git a/docs/conf.py b/docs/conf.py index 8d3f987a..38108420 100644 --- a/docs/conf.py +++ b/docs/conf.py @@ -25,7 +25,9 @@ text_captcha, image_captcha, lemin_captcha, + yidun_captcha, rotate_captcha, + binance_captcha, datadome_captcha, friendly_captcha, cyber_siara_captcha, diff --git a/docs/index.rst b/docs/index.rst index 586851dd..a26664d5 100644 --- a/docs/index.rst +++ b/docs/index.rst @@ -52,9 +52,12 @@ Check our other projects here - `RedPandaDev group =2.32.4 # not directly required, pinned by Snyk to avoid a vulnerability urllib3>=2.2.2 # not directly required, pinned by Snyk to avoid a vulnerability -zipp>=3.19.1 # not directly required, pinned by Snyk to avoid a vulnerability +zipp>=3.23.1 # not directly required, pinned by Snyk to avoid a vulnerability +idna>=3.15 # not directly required, pinned by Snyk to avoid a vulnerability diff --git a/pyproject.toml b/pyproject.toml index 8175c19c..24ff62cc 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -69,7 +69,8 @@ keywords = [ "captcha", "vk-captcha", "fox-captcha", "temu-captcha", - "friendly-captcha" + "friendly-captcha", + "binance-captcha" ] license = "MIT" classifiers = [ @@ -90,7 +91,7 @@ classifiers = [ dependencies = [ "requests>=2.21.0", "aiohttp>=3.9.2", - "msgspec>=0.18,<0.20", + "msgspec>=0.18,<0.21", "tenacity>=8,<10" ] diff --git a/requirements.style.txt b/requirements.style.txt index ad634e98..b4100bfa 100644 --- a/requirements.style.txt +++ b/requirements.style.txt @@ -1,4 +1,4 @@ # codestyle isort==6.* -black==25.9.0 +black==25.11.0 autoflake==2.* diff --git a/src/python_rucaptcha/AGENTS.md b/src/python_rucaptcha/AGENTS.md new file mode 100644 index 00000000..77569d04 --- /dev/null +++ b/src/python_rucaptcha/AGENTS.md @@ -0,0 +1,46 @@ +# python_rucaptcha Package + +**Parent:** ./AGENTS.md + +## OVERVIEW +Main package containing 30+ CAPTCHA solver modules. Each module implements a specific CAPTCHA type. + +## STRUCTURE +``` +src/python_rucaptcha/ +├── __init__.py # Exports __version__, package info +├── core/ # Base classes (base.py, config.py, enums.py, serializer.py) +├── _captcha.py # Internal helpers +├── hcaptcha.py # hCaptcha solver +├── re_captcha.py # reCaptcha v2/v3 solver +├── turnstile.py # Cloudflare Turnstile solver +├── image_captcha.py # Image captcha solver +├── audio_captcha.py # Audio captcha solver +├── gee_test.py # GeeTest solver +├── key_captcha.py # KeyCaptcha solver +├── ... # 20+ more captcha types +└── control.py # Balance/status checker +``` + +## WHERE TO LOOK +| Task | File | +|------|------| +| Add new CAPTCHA type | Create new `*_captcha.py` module | +| Modify BaseCaptcha | `core/base.py` | +| Add new enum | `core/enums.py` | + +## CONVENTIONS +- **Module naming**: `*_captcha.py` pattern +- **Class naming**: `{CaptchaType}Captcha` (e.g., `HCaptcha`, `ReCaptcha`) +- **Enum naming**: `{CaptchaType}Enm` (e.g., `HCaptchaEnm`) +- **Inheritance**: All captcha classes extend `BaseCaptcha` + +## ADDING NEW CAPTCHA +1. Create `src/python_rucaptcha/new_captcha.py` +2. Define enum `NewCaptchaEnm` in `core/enums.py` +3. Create class `NewCaptcha(BaseCaptcha)` implementing required methods +4. Add tests in `tests/test_new_captcha.py` + +## ANTI-PATTERNS +- DO NOT modify core files without understanding the inheritance chain +- DO NOT add captcha-specific logic directly in BaseCaptcha diff --git a/src/python_rucaptcha/__version__.py b/src/python_rucaptcha/__version__.py index b61ecefd..e205487d 100644 --- a/src/python_rucaptcha/__version__.py +++ b/src/python_rucaptcha/__version__.py @@ -1 +1 @@ -__version__ = "6.5.0" +__version__ = "6.6.0" diff --git a/src/python_rucaptcha/altcha_captcha.py b/src/python_rucaptcha/altcha_captcha.py new file mode 100644 index 00000000..174409f4 --- /dev/null +++ b/src/python_rucaptcha/altcha_captcha.py @@ -0,0 +1,152 @@ +from typing import Union, Optional + +from .core.base import BaseCaptcha +from .core.enums import AltchaEnm + + +class AltchaCaptcha(BaseCaptcha): + def __init__( + self, + websiteURL: str, + method: Union[str, AltchaEnm] = AltchaEnm.AltchaTaskProxyless, + challengeURL: Optional[str] = None, + challengeJSON: Optional[str] = None, + proxyType: Optional[str] = None, + proxyAddress: Optional[str] = None, + proxyPort: Optional[int] = None, + proxyLogin: Optional[str] = None, + proxyPassword: Optional[str] = None, + *args, + **kwargs, + ): + """ + The class is used to work with ALTCHA captcha. + + Args: + rucaptcha_key: User API key + websiteURL: Full URL of the captcha page + method: Captcha type + challengeURL: Full URL of the page that contains ALTCHA challenge + challengeJSON: JSON-encoded ALTCHA challenge data + proxyType: Proxy type (http, https, socks4, socks5) + proxyAddress: Proxy IP address or hostname + proxyPort: Proxy port + proxyLogin: Proxy login + proxyPassword: Proxy password + kwargs: Not required params for task creation request + + Examples: + >>> AltchaCaptcha(rucaptcha_key="aa9011f31111181111168611f1151122", + ... websiteURL="https://example.com", + ... challengeURL="https://example.com/altcha/challenge.js", + ... method=AltchaEnm.AltchaTaskProxyless.value, + ... ).captcha_handler() + { + "errorId":0, + "status":"ready", + "solution":{ + "token":"..." + }, + "cost":"0.00145", + "ip":"1.2.3.4", + "createTime":1692863536, + "endTime":1692863556, + "solveCount":1, + "taskId": 73243152973, + } + + >>> await AltchaCaptcha(rucaptcha_key="aa9011f31111181111168611f1151122", + ... websiteURL="https://example.com", + ... challengeJSON='{"挑战数据"}', + ... method=AltchaEnm.AltchaTask.value, + ... proxyType="http", + ... proxyAddress="1.2.3.4", + ... proxyPort=8080, + ... ).aio_captcha_handler() + { + "errorId":0, + "status":"ready", + "solution":{ + "token":"..." + }, + "cost":"0.00145", + "ip":"1.2.3.4", + "createTime":1692863536, + "endTime":1692863556, + "solveCount":1, + "taskId": 73243152973, + } + + Returns: + Dict with full server response + + Notes: + https://rucaptcha.com/api-docs/altcha + """ + + super().__init__(method=method, *args, **kwargs) + + # XOR validation: exactly one of challengeURL or challengeJSON must be provided + if not (bool(challengeURL) ^ bool(challengeJSON)): + raise ValueError( + "Exactly one of 'challengeURL' or 'challengeJSON' must be provided, not both or neither" + ) + + # Validate method + if method not in AltchaEnm.list_values(): + raise ValueError(f"Invalid method parameter set, available - {AltchaEnm.list_values()}") + + # Build task payload + task_data = {"websiteURL": websiteURL} + + if challengeURL: + task_data["challengeURL"] = challengeURL + + if challengeJSON: + task_data["challengeJSON"] = challengeJSON + + # Add proxy params only for non-proxyless methods + if method == AltchaEnm.AltchaTask.value: + if not all([proxyType, proxyAddress, proxyPort]): + raise ValueError( + "Proxy parameters (proxyType, proxyAddress, proxyPort) are required for AltchaTask" + ) + task_data.update( + { + "proxyType": proxyType, + "proxyAddress": proxyAddress, + "proxyPort": proxyPort, + } + ) + if proxyLogin and proxyPassword: + task_data["proxyLogin"] = proxyLogin + task_data["proxyPassword"] = proxyPassword + + self.create_task_payload["task"].update(task_data) + + def captcha_handler(self, **kwargs) -> dict: + """ + Sync solving method + + Args: + kwargs: Parameters for the `requests` library + + Returns: + Dict with full server response + + Notes: + Check class docstirng for more info + """ + return self._processing_response(**kwargs) + + async def aio_captcha_handler(self) -> dict: + """ + Async solving method + + Returns: + Dict with full server response + + Notes: + Check class docstirng for more info + """ + return await self._aio_processing_response() diff --git a/src/python_rucaptcha/binance_captcha.py b/src/python_rucaptcha/binance_captcha.py new file mode 100644 index 00000000..d925b11b --- /dev/null +++ b/src/python_rucaptcha/binance_captcha.py @@ -0,0 +1,155 @@ +from typing import Union, Optional + +from .core.base import BaseCaptcha +from .core.enums import BinanceCaptchaEnm + + +class BinanceCaptcha(BaseCaptcha): + def __init__( + self, + websiteURL: str, + websiteKey: str, + validateId: str, + method: Union[str, BinanceCaptchaEnm] = BinanceCaptchaEnm.BinanceTaskProxyless, + userAgent: Optional[str] = None, + proxyType: Optional[str] = None, + proxyAddress: Optional[str] = None, + proxyPort: Optional[int] = None, + proxyLogin: Optional[str] = None, + proxyPassword: Optional[str] = None, + *args, + **kwargs, + ): + """ + The class is used to work with Binance CAPTCHA. + + Args: + rucaptcha_key: User API key + websiteURL: Full URL of the page where the captcha is loaded + websiteKey: Value of bizId, bizType, or bizCode from page requests + validateId: Dynamic value of validateId, securityId, or securityCheckResponseValidateId + method: Captcha type + userAgent: User-Agent string to be used when solving the captcha + proxyType: Proxy type (http, https, socks4, socks5) + proxyAddress: Proxy IP address or hostname + proxyPort: Proxy port + proxyLogin: Proxy login + proxyPassword: Proxy password + + Examples: + >>> BinanceCaptcha(rucaptcha_key="aa9011f31111181111168611f1151122", + ... websiteURL="https://example.com/page-with-binance", + ... websiteKey="login", + ... validateId="cb0bfefa598...e54ecd57b", + ... userAgent="Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 " + ... "(KHTML, like Gecko) Chrome/138.0.0.0 Safari/537.36", + ... method=BinanceCaptchaEnm.BinanceTaskProxyless.value, + ... ).captcha_handler() + { + "errorId":0, + "status":"ready", + "solution":{ + "token":"captcha#09ba4905a79f44f...kc99maS943qIsquNP9D77", + "userAgent":"Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/138.0.0.0 Safari/537.36" + }, + "cost":"0.00299", + "ip":"1.2.3.4", + "createTime":1692863536, + "endTime":1692863556, + "solveCount":1, + "taskId": 73243152973, + } + + >>> await BinanceCaptcha(rucaptcha_key="aa9011f31111181111168611f1151122", + ... websiteURL="https://example.com/page-with-binance", + ... websiteKey="login", + ... validateId="cb0bfefa598...e54ecd57b", + ... method=BinanceCaptchaEnm.BinanceTask.value, + ... proxyType="http", + ... proxyAddress="1.2.3.4", + ... proxyPort=8080, + ... ).aio_captcha_handler() + { + "errorId":0, + "status":"ready", + "solution":{ + "token":"captcha#09ba4905a79f44f...kc99maS943qIsquNP9D77", + "userAgent":"Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/138.0.0.0 Safari/537.36" + }, + "cost":"0.00299", + "ip":"1.2.3.4", + "createTime":1692863536, + "endTime":1692863556, + "solveCount":1, + "taskId": 73243152973, + } + + Returns: + Dict with full server response + + Notes: + https://2captcha.com/api-docs/binance-captcha + + https://rucaptcha.com/api-docs/binance-captcha + """ + super().__init__(method=method, *args, **kwargs) + + # Validate method + if method not in BinanceCaptchaEnm.list_values(): + raise ValueError(f"Invalid method parameter set, available - {BinanceCaptchaEnm.list_values()}") + + # Build task payload + task_data = { + "websiteURL": websiteURL, + "websiteKey": websiteKey, + "validateId": validateId, + } + + if userAgent: + task_data["userAgent"] = userAgent + + # Add proxy params only for non-proxyless methods + if method == BinanceCaptchaEnm.BinanceTask.value: + if not all([proxyType, proxyAddress, proxyPort]): + raise ValueError( + "Proxy parameters (proxyType, proxyAddress, proxyPort) are required for BinanceTask" + ) + task_data.update( + { + "proxyType": proxyType, + "proxyAddress": proxyAddress, + "proxyPort": proxyPort, + } + ) + if proxyLogin and proxyPassword: + task_data["proxyLogin"] = proxyLogin + task_data["proxyPassword"] = proxyPassword + + self.create_task_payload["task"].update(task_data) + + def captcha_handler(self, **kwargs) -> dict: + """ + Sync solving method + + Args: + kwargs: Parameters for the `requests` library + + Returns: + Dict with full server response + + Notes: + Check class docstirng for more info + """ + return self._processing_response(**kwargs) + + async def aio_captcha_handler(self) -> dict: + """ + Async solving method + + Returns: + Dict with full server response + + Notes: + Check class docstirng for more info + """ + return await self._aio_processing_response() diff --git a/src/python_rucaptcha/core/AGENTS.md b/src/python_rucaptcha/core/AGENTS.md new file mode 100644 index 00000000..580836be --- /dev/null +++ b/src/python_rucaptcha/core/AGENTS.md @@ -0,0 +1,25 @@ +# core Module + +**Parent:** ../AGENTS.md, ../../src/python_rucaptcha/AGENTS.md + +## OVERVIEW +Foundation module with base classes, configuration, serialization, and enums. All captcha solvers depend on these. + +## WHERE TO LOOK +| File | Role | +|------|------| +| `base.py` | BaseCaptcha class - parent for all solvers | +| `config.py` | CaptchaOptionsSer - service URL abstraction | +| `serializer.py` | MyBaseModel - msgspec wrapper | +| `enums.py` | MyEnum + 25+ CAPTCHA enums | + +## KEY SYMBOLS +- `BaseCaptcha`: Parent class for all captcha solvers (sync + async) +- `MyBaseModel`: msgspec Struct wrapper with `.to_dict()` +- `CaptchaOptionsSer`: Dynamic service URL selection +- `MyEnum`: Custom enum with `.list()`, `.list_values()`, `.list_names()` + +## CONVENTIONS +- **BaseCaptcha provides**: `captcha_handler()`, `aio_captcha_handler()`, session management, file handling +- **Serialization**: Uses msgspec (not pydantic) +- **Enums**: Each CAPTCHA type has dedicated enum in enums.py diff --git a/src/python_rucaptcha/core/enums.py b/src/python_rucaptcha/core/enums.py index 544be6e6..26315bff 100644 --- a/src/python_rucaptcha/core/enums.py +++ b/src/python_rucaptcha/core/enums.py @@ -98,6 +98,11 @@ class TurnstileCaptchaEnm(str, MyEnum): TurnstileTask = "TurnstileTask" +class AltchaEnm(str, MyEnum): + AltchaTaskProxyless = "AltchaTaskProxyless" + AltchaTask = "AltchaTask" + + class AmazonWAFCaptchaEnm(str, MyEnum): AmazonTask = "AmazonTask" AmazonTaskProxyless = "AmazonTaskProxyless" @@ -172,7 +177,23 @@ class CaptchaFoxEnm(str, MyEnum): class VKCaptchaEnm(str, MyEnum): VKCaptchaTask = "VKCaptchaTask" + VKCaptchaImageTask = "VKCaptchaImageTask" class TemuCaptchaEnm(str, MyEnum): TemuCaptchaTask = "TemuCaptchaTask" + + +class BinanceCaptchaEnm(str, MyEnum): + BinanceTaskProxyless = "BinanceTaskProxyless" + BinanceTask = "BinanceTask" + + +class YidunEnm(str, MyEnum): + YidunTaskProxyless = "YidunTaskProxyless" + YidunTask = "YidunTask" + + +class YandexSmartCaptchaEnm(str, MyEnum): + YandexSmartCaptchaTaskProxyless = "YandexSmartCaptchaTaskProxyless" + YandexSmartCaptchaTask = "YandexSmartCaptchaTask" diff --git a/src/python_rucaptcha/vk_captcha.py b/src/python_rucaptcha/vk_captcha.py index 23d9f6a8..ff76f763 100644 --- a/src/python_rucaptcha/vk_captcha.py +++ b/src/python_rucaptcha/vk_captcha.py @@ -1,32 +1,43 @@ -from typing import Any +from typing import Any, Union, Optional from .core.base import BaseCaptcha -from .core.enums import VKCaptchaEnm +from .core.enums import VKCaptchaEnm, SaveFormatsEnm class VKCaptcha(BaseCaptcha): def __init__( self, - redirectUri: str, - userAgent: str, - proxyType: str, - proxyAddress: str, - proxyPort: str, + method: Union[str, VKCaptchaEnm] = VKCaptchaEnm.VKCaptchaTask, + redirectUri: Optional[str] = None, + userAgent: Optional[str] = None, + proxyType: Optional[str] = None, + proxyAddress: Optional[str] = None, + proxyPort: Optional[int] = None, + proxyLogin: Optional[str] = None, + proxyPassword: Optional[str] = None, + save_format: Union[str, SaveFormatsEnm] = SaveFormatsEnm.TEMP, + img_path: str = "PythonRuCaptchaImages", *args, - **kwargs: dict[str, Any], + **kwargs, ): """ - The class is used to work with VKCaptchaTask. + The class is used to work with VKCaptchaTask and VKCaptchaImageTask. + + VKCaptchaTask requires proxy and returns a token. + VKCaptchaImageTask takes a captcha image and steps, returns image solution. Args: rucaptcha_key: User API key - redirectUri: The URL that is returned on requests to the captcha API. - userAgent: User-Agent of your browser will be used to load the captcha. - Use only modern browser's User-Agents - proxyType: Proxy type - `http`, `socks4`, `socks5` - proxyAddress: Proxy IP address or hostname - proxyPort: Proxy port - method: Captcha type + method: Captcha type - VKCaptchaTask or VKCaptchaImageTask + redirectUri: The URL that is returned on requests to the captcha API (VKCaptchaTask) + userAgent: User-Agent of your browser will be used to load the captcha (VKCaptchaTask) + proxyType: Proxy type - http, https, socks5 (VKCaptchaTask) + proxyAddress: Proxy IP address or hostname (VKCaptchaTask) + proxyPort: Proxy port (VKCaptchaTask) + proxyLogin: Proxy login (VKCaptchaTask) + proxyPassword: Proxy password (VKCaptchaTask) + save_format: Image save format for VKCaptchaImageTask - 'temp' or 'const' + img_path: Folder to save captcha images for VKCaptchaImageTask kwargs: Not required params for task creation request Examples: @@ -35,7 +46,7 @@ def __init__( ... userAgent="Mozilla/5.0 .....", ... proxyType="socks5", ... proxyAddress="1.2.3.4", - ... proxyPort="445", + ... proxyPort=445, ... ).captcha_handler() { "errorId":0, @@ -56,7 +67,7 @@ def __init__( ... userAgent="Mozilla/5.0 .....", ... proxyType="socks5", ... proxyAddress="1.2.3.4", - ... proxyPort="445", + ... proxyPort=445, ... ).aio_captcha_handler() { "errorId":0, @@ -72,6 +83,28 @@ def __init__( "taskId": 73243152973, } + >>> VKCaptcha(rucaptcha_key="aa9011f31111181111168611f1151122", + ... method=VKCaptchaEnm.VKCaptchaImageTask, + ... ).captcha_handler( + ... captcha_link="https://example.com/vk_captcha.png", steps=[3, 4, 5] + ... ) + { + "errorId":0, + "status":"ready", + "solution":{ + "best_step": 1, + "preview": "...", + "solution": "...", + "answer": "..." + }, + "cost":"0.002", + "ip":"1.2.3.4", + "createTime":1692863536, + "endTime":1692863556, + "solveCount":0, + "taskId": 73243152973, + } + Returns: Dict with full server response @@ -80,23 +113,51 @@ def __init__( https://rucaptcha.com/api-docs/vk-captcha """ - super().__init__(method=VKCaptchaEnm.VKCaptchaTask, *args, **kwargs) + super().__init__(method=method, *args, **kwargs) - self.create_task_payload["task"].update( - { - "websiteURL": redirectUri, + if method not in VKCaptchaEnm.list_values(): + raise ValueError(f"Invalid method parameter set, available - {VKCaptchaEnm.list_values()}") + + self.method = method + self.save_format = save_format + self.img_path = img_path + + if method == VKCaptchaEnm.VKCaptchaTask: + if not all([redirectUri, userAgent, proxyType, proxyAddress, proxyPort]): + raise ValueError( + "redirectUri, userAgent, proxyType, proxyAddress, " + "and proxyPort are required for VKCaptchaTask" + ) + + task_data = { + "redirectUri": redirectUri, "userAgent": userAgent, "proxyType": proxyType, "proxyAddress": proxyAddress, "proxyPort": proxyPort, } - ) + if proxyLogin and proxyPassword: + task_data["proxyLogin"] = proxyLogin + task_data["proxyPassword"] = proxyPassword + + self.create_task_payload["task"].update(task_data) - def captcha_handler(self, **kwargs: dict[str, Any]) -> dict[str, Any]: + def captcha_handler( + self, + captcha_link: Optional[str] = None, + captcha_file: Optional[str] = None, + captcha_base64: Optional[bytes] = None, + steps: Optional[list[int]] = None, + **kwargs: dict[str, Any], + ) -> dict[str, Any]: """ Sync solving method Args: + captcha_link: Captcha image URL (VKCaptchaImageTask) + captcha_file: Captcha image file path (VKCaptchaImageTask) + captcha_base64: Captcha image BASE64 info (VKCaptchaImageTask) + steps: List of step values for VKCaptchaImageTask kwargs: additional params for `requests` library Returns: @@ -105,16 +166,60 @@ def captcha_handler(self, **kwargs: dict[str, Any]) -> dict[str, Any]: Notes: Check class docstirng for more info """ + if self.method == VKCaptchaEnm.VKCaptchaImageTask: + self._body_file_processing( + save_format=self.save_format, + file_path=self.img_path, + image_key="image", + captcha_link=captcha_link, + captcha_file=captcha_file, + captcha_base64=captcha_base64, + ) + if steps: + self.create_task_payload["task"]["steps"] = steps + if not self.result.errorId: + return self._processing_response(**kwargs) + return self.result.to_dict() + return self._processing_response(**kwargs) - async def aio_captcha_handler(self) -> dict[str, Any]: + async def aio_captcha_handler( + self, + captcha_link: Optional[str] = None, + captcha_file: Optional[str] = None, + captcha_base64: Optional[bytes] = None, + steps: Optional[list[int]] = None, + **kwargs: dict[str, Any], + ) -> dict[str, Any]: """ Async solving method + Args: + captcha_link: Captcha image URL (VKCaptchaImageTask) + captcha_file: Captcha image file path (VKCaptchaImageTask) + captcha_base64: Captcha image BASE64 info (VKCaptchaImageTask) + steps: List of step values for VKCaptchaImageTask + kwargs: additional params for `aiohttp` library + Returns: Dict with full server response Notes: Check class docstirng for more info """ + if self.method == VKCaptchaEnm.VKCaptchaImageTask: + await self._aio_body_file_processing( + save_format=self.save_format, + file_path=self.img_path, + image_key="image", + captcha_link=captcha_link, + captcha_file=captcha_file, + captcha_base64=captcha_base64, + ) + if steps: + self.create_task_payload["task"]["steps"] = steps + if not self.result.errorId: + return await self._aio_processing_response() + return self.result.to_dict() + return await self._aio_processing_response() diff --git a/src/python_rucaptcha/yandex_smart_captcha.py b/src/python_rucaptcha/yandex_smart_captcha.py new file mode 100644 index 00000000..09522671 --- /dev/null +++ b/src/python_rucaptcha/yandex_smart_captcha.py @@ -0,0 +1,246 @@ +import shutil +from typing import Any, Union, Optional + +from .core.base import BaseCaptcha +from .core.enums import ( + SaveFormatsEnm, + CoordinatesCaptchaEnm, + YandexSmartCaptchaEnm, +) + + +class YandexSmartCaptcha(BaseCaptcha): + """ + The class is used to work with Yandex SmartCaptcha. + + Supports three 2Captcha task types: + - YandexSmartCaptchaTaskProxyless (token, no proxy) + - YandexSmartCaptchaTask (token, user proxy) + - CoordinatesTask (image, two imgType modes: smart_captcha, pazl_smart_captcha) + + Args: + rucaptcha_key: User API key + websiteURL: Full URL of the page where the captcha is loaded (token methods) + websiteKey: Sitekey from the page source (token methods) + method: Captcha type. Default YandexSmartCaptchaTaskProxyless. + userAgent: Browser User-Agent to use (token methods, optional) + cookies: Cookies to send in the request (token methods, optional, format "name1=value1;name2=value2") + proxyType: Proxy type (http, https, socks4, socks5) - required for YandexSmartCaptchaTask + proxyAddress: Proxy IP/hostname - required for YandexSmartCaptchaTask + proxyPort: Proxy port - required for YandexSmartCaptchaTask + proxyLogin: Proxy login (optional) + proxyPassword: Proxy password (optional) + imgType: Image variant type - "smart_captcha" or "pazl_smart_captcha" (CoordinatesTask only) + comment: Text hint for the worker - required for imgType="smart_captcha" + save_format: How to save the image - "temp" or "const" (CoordinatesTask only) + img_clearing: Whether to delete the image folder on instance destruction + img_path: Folder name for saved images + + Examples: + >>> YandexSmartCaptcha( + ... rucaptcha_key="aa9011f31111181111168611f1151122", + ... websiteURL="https://example.com/", + ... websiteKey="Y5Lh0ti...", + ... ).captcha_handler() + {"errorId": 0, "status": "ready", "solution": {"token": "..."}, "taskId": ...} + + >>> await YandexSmartCaptcha( + ... rucaptcha_key="aa9011f31111181111168611f1151122", + ... websiteURL="https://example.com/", + ... websiteKey="Y5Lh0ti...", + ... method=YandexSmartCaptchaEnm.YandexSmartCaptchaTask, + ... proxyType="http", + ... proxyAddress="1.2.3.4", + ... proxyPort=8080, + ... ).aio_captcha_handler() + {"errorId": 0, "status": "ready", "solution": {"token": "..."}, "taskId": ...} + + >>> YandexSmartCaptcha( + ... rucaptcha_key="aa9011f31111181111168611f1151122", + ... method=CoordinatesCaptchaEnm.CoordinatesTask, + ... imgType="smart_captcha", + ... comment="select objects in the order of the instruction", + ... ).captcha_handler( + ... captcha_file="src/examples/088636.png", + ... imgInstructions_file="src/examples/bounding_box_start.png", + ... ) + {"errorId": 0, "status": "ready", "solution": {"coordinates": [{"x": 57, "y": 82}, ...]}, "taskId": ...} + + Notes: + https://2captcha.com/api-docs/yandex-smart-captcha + + https://rucaptcha.com/api-docs/yandex-smart-captcha + """ + + _VALID_METHODS = YandexSmartCaptchaEnm.list_values() + [CoordinatesCaptchaEnm.CoordinatesTask.value] + + def __init__( + self, + websiteURL: Optional[str] = None, + websiteKey: Optional[str] = None, + method: Union[str, YandexSmartCaptchaEnm, CoordinatesCaptchaEnm] = ( + YandexSmartCaptchaEnm.YandexSmartCaptchaTaskProxyless + ), + userAgent: Optional[str] = None, + cookies: Optional[str] = None, + proxyType: Optional[str] = None, + proxyAddress: Optional[str] = None, + proxyPort: Optional[int] = None, + proxyLogin: Optional[str] = None, + proxyPassword: Optional[str] = None, + imgType: Optional[str] = None, + comment: Optional[str] = None, + save_format: Union[str, SaveFormatsEnm] = SaveFormatsEnm.TEMP, + img_clearing: bool = True, + img_path: str = "PythonRuCaptchaYandexSmart", + *args, + **kwargs, + ): + method_str = method.value if hasattr(method, "value") else method + + if method_str not in self._VALID_METHODS: + raise ValueError(f"Invalid method parameter set, available - {self._VALID_METHODS}") + + is_token = method_str in YandexSmartCaptchaEnm.list_values() + is_image = method_str == CoordinatesCaptchaEnm.CoordinatesTask.value + + # token-method-specific validation + if is_token: + if not (websiteURL and websiteKey): + raise ValueError( + "websiteURL and websiteKey are required for token methods " + f"({YandexSmartCaptchaEnm.list_values()})" + ) + + # proxy-method-specific validation + if method_str == YandexSmartCaptchaEnm.YandexSmartCaptchaTask.value: + if not all([proxyType, proxyAddress, proxyPort]): + raise ValueError( + "proxyType, proxyAddress, and proxyPort are required for YandexSmartCaptchaTask" + ) + + # image-method-specific validation + if is_image: + if not imgType: + raise ValueError("imgType is required for CoordinatesTask") + if imgType == "smart_captcha" and not comment: + raise ValueError('comment is required for CoordinatesTask with imgType="smart_captcha"') + + # Build task payload + task_data: dict[str, Any] = {} + if is_token: + task_data["websiteURL"] = websiteURL + task_data["websiteKey"] = websiteKey + if userAgent is not None: + task_data["userAgent"] = userAgent + if cookies is not None: + task_data["cookies"] = cookies + if method_str == YandexSmartCaptchaEnm.YandexSmartCaptchaTask.value: + task_data["proxyType"] = proxyType + task_data["proxyAddress"] = proxyAddress + task_data["proxyPort"] = proxyPort + if proxyLogin is not None: + task_data["proxyLogin"] = proxyLogin + if proxyPassword is not None: + task_data["proxyPassword"] = proxyPassword + elif is_image: + task_data["imgType"] = imgType + if comment is not None: + task_data["comment"] = comment + + super().__init__(method=method, *args, **kwargs) + self.method = method_str + self.save_format = save_format + self.img_clearing = img_clearing + self.img_path = img_path + self.create_task_payload["task"].update(task_data) + + def captcha_handler( + self, + captcha_link: Optional[str] = None, + captcha_file: Optional[str] = None, + captcha_base64: Optional[bytes] = None, + imgInstructions_link: Optional[str] = None, + imgInstructions_file: Optional[str] = None, + imgInstructions_base64: Optional[bytes] = None, + **kwargs: dict[str, Any], + ) -> dict[str, Any]: + """ + Sync solving method. + """ + if self.method == CoordinatesCaptchaEnm.CoordinatesTask.value: + self._body_file_processing( + save_format=self.save_format, + file_path=self.img_path, + image_key="body", + captcha_link=captcha_link, + captcha_file=captcha_file, + captcha_base64=captcha_base64, + **kwargs, + ) + if any([imgInstructions_link, imgInstructions_file, imgInstructions_base64]): + self._body_file_processing( + save_format=self.save_format, + file_path=self.img_path, + image_key="imgInstructions", + captcha_link=imgInstructions_link, + captcha_file=imgInstructions_file, + captcha_base64=imgInstructions_base64, + **kwargs, + ) + if not self.result.errorId: + return self._processing_response(**kwargs) + return self.result.to_dict() + + return self._processing_response(**kwargs) + + async def aio_captcha_handler( + self, + captcha_link: Optional[str] = None, + captcha_file: Optional[str] = None, + captcha_base64: Optional[bytes] = None, + imgInstructions_link: Optional[str] = None, + imgInstructions_file: Optional[str] = None, + imgInstructions_base64: Optional[bytes] = None, + **kwargs: dict[str, Any], + ) -> dict[str, Any]: + """ + Async solving method. + """ + if self.method == CoordinatesCaptchaEnm.CoordinatesTask.value: + await self._aio_body_file_processing( + save_format=self.save_format, + file_path=self.img_path, + image_key="body", + captcha_link=captcha_link, + captcha_file=captcha_file, + captcha_base64=captcha_base64, + **kwargs, + ) + if any([imgInstructions_link, imgInstructions_file, imgInstructions_base64]): + await self._aio_body_file_processing( + save_format=self.save_format, + file_path=self.img_path, + image_key="imgInstructions", + captcha_link=imgInstructions_link, + captcha_file=imgInstructions_file, + captcha_base64=imgInstructions_base64, + **kwargs, + ) + if not self.result.errorId: + return await self._aio_processing_response() + return self.result.to_dict() + + return await self._aio_processing_response() + + def __del__(self): + if ( + hasattr(self, "save_format") + and self.save_format == SaveFormatsEnm.CONST.value + and hasattr(self, "img_clearing") + and self.img_clearing + ): + try: + shutil.rmtree(self.img_path) + except OSError: + pass diff --git a/src/python_rucaptcha/yidun_captcha.py b/src/python_rucaptcha/yidun_captcha.py new file mode 100644 index 00000000..8e773c61 --- /dev/null +++ b/src/python_rucaptcha/yidun_captcha.py @@ -0,0 +1,171 @@ +from typing import Union, Optional + +from .core.base import BaseCaptcha +from .core.enums import YidunEnm + + +class YidunCaptcha(BaseCaptcha): + def __init__( + self, + websiteURL: str, + websiteKey: str, + method: Union[str, YidunEnm] = YidunEnm.YidunTaskProxyless, + userAgent: Optional[str] = None, + yidunGetLib: Optional[str] = None, + yidunApiServerSubdomain: Optional[str] = None, + challenge: Optional[str] = None, + hcg: Optional[str] = None, + hct: Optional[int] = None, + proxyType: Optional[str] = None, + proxyAddress: Optional[str] = None, + proxyPort: Optional[int] = None, + proxyLogin: Optional[str] = None, + proxyPassword: Optional[str] = None, + *args, + **kwargs, + ): + """ + The class is used to work with Yidun NECaptcha. + + Args: + rucaptcha_key: User API key + websiteURL: Full URL of the page where the captcha is loaded + websiteKey: Value of the `id` (or `sitekey`) parameter from the page source + or from the `get?referer=` / `check?referer=` network request + method: Captcha type (YidunTaskProxyless or YidunTask) + userAgent: Browser User-Agent used to open the page + yidunGetLib: Full URL of the JS file that loads the captcha. + Recommended for Enterprise version. + yidunApiServerSubdomain: Yidun API server subdomain without `https://`. + Specify if using a custom server. + challenge: Dynamic challenge parameter from network requests (Enterprise) + hcg: Captcha hash used when forming the request (Enterprise) + hct: Numeric timestamp identifier for Enterprise version validation (Unix milliseconds) + proxyType: Proxy type (http, socks4, socks5) - required for YidunTask + proxyAddress: Proxy IP or hostname - required for YidunTask + proxyPort: Proxy port - required for YidunTask + proxyLogin: Proxy login (optional) + proxyPassword: Proxy password (optional) + + Examples: + >>> YidunCaptcha(rucaptcha_key="aa9011f31111181111168611f1151122", + ... websiteURL="https://example.com/page-with-yidun", + ... websiteKey="0f743r3g1g...rz3grz0ym5", + ... method=YidunEnm.YidunTaskProxyless.value, + ... ).captcha_handler() + { + "errorId":0, + "status":"ready", + "solution":{ + "token":"D19scz7n4VCU7b_...fRyEY-tXQ0cmS6laRKp_tZEyei_EUzc5M1IW0oxUHnZ4fBMH2a0jMPjOReiHVWBgkrcRYaOkQRasHlFejEToe7HZJy2jaGkxiB9b" + }, + "cost":"0.003", + "ip":"1.2.3.4", + "createTime":1692863536, + "endTime":1692863556, + "solveCount":1, + "taskId": 73243152973, + } + + >>> await YidunCaptcha(rucaptcha_key="aa9011f31111181111168611f1151122", + ... websiteURL="https://example.com/page-with-yidun", + ... websiteKey="0f743r3g1g...rz3grz0ym5", + ... method=YidunEnm.YidunTask.value, + ... proxyType="http", + ... proxyAddress="1.2.3.4", + ... proxyPort=8080, + ... ).aio_captcha_handler() + { + "errorId":0, + "status":"ready", + "solution":{ + "token":"D19scz7n4VCU7b_...fRyEY-tXQ0cmS6laRKp_tZEyei_EUzc5M1IW0oxUHnZ4fBMH2a0jMPjOReiHVWBgkrcRYaOkQRasHlFejEToe7HZJy2jaGkxiB9b" + }, + "cost":"0.003", + "ip":"1.2.3.4", + "createTime":1692863536, + "endTime":1692863556, + "solveCount":1, + "taskId": 73243152973, + } + + Returns: + Dict with full server response + + Notes: + https://2captcha.com/api-docs/yidun-necaptcha + + https://rucaptcha.com/api-docs/yidun-necaptcha + """ + super().__init__(method=method, *args, **kwargs) + + # Validate method + if method not in YidunEnm.list_values(): + raise ValueError(f"Invalid method parameter set, available - {YidunEnm.list_values()}") + + # Build task payload + task_data = { + "websiteURL": websiteURL, + "websiteKey": websiteKey, + } + + # Optional Enterprise-version and userAgent fields (only include if non-None) + if userAgent is not None: + task_data["userAgent"] = userAgent + if yidunGetLib is not None: + task_data["yidunGetLib"] = yidunGetLib + if yidunApiServerSubdomain is not None: + task_data["yidunApiServerSubdomain"] = yidunApiServerSubdomain + if challenge is not None: + task_data["challenge"] = challenge + if hcg is not None: + task_data["hcg"] = hcg + if hct is not None: + task_data["hct"] = hct + + # Add proxy params only for non-proxyless methods + if method == YidunEnm.YidunTask.value: + if not all([proxyType, proxyAddress, proxyPort]): + raise ValueError( + "Proxy parameters (proxyType, proxyAddress, proxyPort) are required for YidunTask" + ) + task_data.update( + { + "proxyType": proxyType, + "proxyAddress": proxyAddress, + "proxyPort": proxyPort, + } + ) + if proxyLogin is not None: + task_data["proxyLogin"] = proxyLogin + if proxyPassword is not None: + task_data["proxyPassword"] = proxyPassword + + self.create_task_payload["task"].update(task_data) + + def captcha_handler(self, **kwargs) -> dict: + """ + Sync solving method + + Args: + kwargs: Parameters for the `requests` library + + Returns: + Dict with full server response + + Notes: + Check class docstring for more info + """ + return self._processing_response(**kwargs) + + async def aio_captcha_handler(self) -> dict: + """ + Async solving method + + Returns: + Dict with full server response + + Notes: + Check class docstring for more info + """ + return await self._aio_processing_response() diff --git a/tests/AGENTS.md b/tests/AGENTS.md new file mode 100644 index 00000000..83dd84dd --- /dev/null +++ b/tests/AGENTS.md @@ -0,0 +1,41 @@ +# tests Package + +**Parent:** ./AGENTS.md + +## OVERVIEW +Pytest test suite with 23+ test files covering all CAPTCHA types. + +## STRUCTURE +``` +tests/ +├── __init__.py +├── conftest.py # Fixtures + BaseTest class +├── test_core.py # BaseCaptcha tests +├── test_hcaptcha.py # hCaptcha tests +├── test_recaptcha.py # reCaptcha tests +├── test_turnstile.py # Turnstile tests +├── ... # 20+ more test files +└── test_image.py # Image captcha tests +``` + +## WHERE TO LOOK +| Task | File | +|------|------| +| Add new test | Create `test_{captcha}.py` | +| Test fixtures | `conftest.py` | + +## CONVENTIONS +- **File naming**: `test_*.py` pattern +- **Test class**: Extend `BaseTest` from `conftest.py` +- **Run**: `make tests` or `pytest tests/` + +## TEST REQUIREMENTS +- Requires `RUCAPTCHA_KEY` environment variable +- Optional: `DEATHBYCAPTCHA_KEY` for DeathByCaptcha tests +- Tests make real API calls to captcha services + +## FIXTURES (conftest.py) +- `delay_func`: 0.5s sleep (function scope) +- `delay_class`: 3s sleep (class scope) +- `BaseTest`: Base test class with required env var check +- `DeathByTest`: Subclass with optional DEATHBYCAPTCHA_KEY diff --git a/tests/test_altcha.py b/tests/test_altcha.py new file mode 100644 index 00000000..9fa6b94a --- /dev/null +++ b/tests/test_altcha.py @@ -0,0 +1,185 @@ +import pytest + +from tests.conftest import BaseTest +from python_rucaptcha.core.enums import AltchaEnm +from python_rucaptcha.altcha_captcha import AltchaCaptcha +from python_rucaptcha.core.serializer import GetTaskResultResponseSer + + +class TestAltcha(BaseTest): + pageurl = "https://example.com" + challenge_url = "https://example.com/altcha/challenge.js" + challenge_json = '{"challenge":"test_data"}' + 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" + kwargs_params = { + "proxyType": "socks5", + "proxyAddress": BaseTest.proxyAddress, + "proxyPort": BaseTest.proxyPort, + } + + def test_methods_exists(self): + assert "captcha_handler" in AltchaCaptcha.__dict__.keys() + assert "aio_captcha_handler" in AltchaCaptcha.__dict__.keys() + + @pytest.mark.parametrize("method", AltchaEnm.list_values()) + def test_args(self, method: str): + kwargs = {} + if method == AltchaEnm.AltchaTask.value: + kwargs = {"proxyType": "http", "proxyAddress": "1.2.3.4", "proxyPort": 8080} + instance = AltchaCaptcha( + rucaptcha_key=self.RUCAPTCHA_KEY, + websiteURL=self.pageurl, + challengeURL=self.challenge_url, + userAgent=self.useragent, + method=method, + **kwargs, + ) + assert instance.create_task_payload["clientKey"] == self.RUCAPTCHA_KEY + assert instance.create_task_payload["task"]["type"] == method + assert instance.create_task_payload["task"]["websiteURL"] == self.pageurl + assert instance.create_task_payload["task"]["challengeURL"] == self.challenge_url + + def test_kwargs(self): + instance = AltchaCaptcha( + rucaptcha_key=self.RUCAPTCHA_KEY, + websiteURL=self.pageurl, + challengeURL=self.challenge_url, + method=AltchaEnm.AltchaTask, + **self.kwargs_params, + ) + assert set(self.kwargs_params.keys()).issubset(set(instance.create_task_payload["task"].keys())) + assert set(self.kwargs_params.values()).issubset(set(instance.create_task_payload["task"].values())) + + def test_xor_validation_both_params(self): + """Test that providing both challengeURL and challengeJSON raises ValueError""" + with pytest.raises(ValueError, match="challengeURL|challengeJSON"): + AltchaCaptcha( + rucaptcha_key=self.RUCAPTCHA_KEY, + websiteURL=self.pageurl, + challengeURL=self.challenge_url, + challengeJSON=self.challenge_json, + method=AltchaEnm.AltchaTaskProxyless, + ) + + def test_xor_validation_neither_param(self): + """Test that providing neither challengeURL nor challengeJSON raises ValueError""" + with pytest.raises(ValueError, match="challengeURL|challengeJSON"): + AltchaCaptcha( + rucaptcha_key=self.RUCAPTCHA_KEY, + websiteURL=self.pageurl, + method=AltchaEnm.AltchaTaskProxyless, + ) + + def test_valid_challenge_url(self): + """Test that providing only challengeURL works correctly""" + instance = AltchaCaptcha( + rucaptcha_key=self.RUCAPTCHA_KEY, + websiteURL=self.pageurl, + challengeURL=self.challenge_url, + method=AltchaEnm.AltchaTaskProxyless, + ) + assert instance.create_task_payload["task"]["challengeURL"] == self.challenge_url + assert "challengeJSON" not in instance.create_task_payload["task"] + + def test_valid_challenge_json(self): + """Test that providing only challengeJSON works correctly""" + instance = AltchaCaptcha( + rucaptcha_key=self.RUCAPTCHA_KEY, + websiteURL=self.pageurl, + challengeJSON=self.challenge_json, + method=AltchaEnm.AltchaTaskProxyless, + ) + assert instance.create_task_payload["task"]["challengeJSON"] == self.challenge_json + assert "challengeURL" not in instance.create_task_payload["task"] + + def test_proxy_params_in_payload(self): + """Test that proxy params are included in payload for AltchaTask method""" + instance = AltchaCaptcha( + rucaptcha_key=self.RUCAPTCHA_KEY, + websiteURL=self.pageurl, + challengeURL=self.challenge_url, + method=AltchaEnm.AltchaTask, + proxyType="http", + proxyAddress="1.2.3.4", + proxyPort=8080, + ) + assert instance.create_task_payload["task"]["proxyType"] == "http" + assert instance.create_task_payload["task"]["proxyAddress"] == "1.2.3.4" + assert instance.create_task_payload["task"]["proxyPort"] == 8080 + + def test_wrong_method(self): + with pytest.raises(ValueError): + AltchaCaptcha( + rucaptcha_key=self.RUCAPTCHA_KEY, + websiteURL=self.pageurl, + challengeURL=self.challenge_url, + method=self.get_random_string(5), + ) + + """ + Success tests + """ + + def test_basic_data(self): + instance = AltchaCaptcha( + rucaptcha_key=self.RUCAPTCHA_KEY, + websiteURL=self.pageurl, + challengeURL=self.challenge_url, + method=AltchaEnm.AltchaTaskProxyless.value, + ) + + result = instance.captcha_handler() + + assert isinstance(result, dict) is True + if not result["errorId"]: + assert result["status"] == "ready" + assert isinstance(result["solution"], dict) is True + assert isinstance(result["taskId"], int) is True + else: + assert result["errorId"] in (1, 12) + assert result["errorCode"] == "ERROR_CAPTCHA_UNSOLVABLE" + + assert result.keys() == GetTaskResultResponseSer().to_dict().keys() + + async def test_aio_basic_data(self): + instance = AltchaCaptcha( + rucaptcha_key=self.RUCAPTCHA_KEY, + websiteURL=self.pageurl, + challengeURL=self.challenge_url, + method=AltchaEnm.AltchaTaskProxyless.value, + ) + + result = await instance.aio_captcha_handler() + + assert isinstance(result, dict) is True + if not result["errorId"]: + assert result["status"] == "ready" + assert isinstance(result["solution"], dict) is True + assert isinstance(result["taskId"], int) is True + else: + assert result["errorId"] in (1, 12) + assert result["errorCode"] == "ERROR_CAPTCHA_UNSOLVABLE" + + assert result.keys() == GetTaskResultResponseSer().to_dict().keys() + + def test_context_basic_data(self): + with AltchaCaptcha( + rucaptcha_key=self.RUCAPTCHA_KEY, + websiteURL=self.pageurl, + challengeURL=self.challenge_url, + method=AltchaEnm.AltchaTaskProxyless.value, + ) as instance: + assert instance + + async def test_context_aio_basic_data(self): + async with AltchaCaptcha( + rucaptcha_key=self.RUCAPTCHA_KEY, + websiteURL=self.pageurl, + challengeURL=self.challenge_url, + method=AltchaEnm.AltchaTaskProxyless.value, + ) as instance: + assert instance + + """ + Fail tests + """ diff --git a/tests/test_binance.py b/tests/test_binance.py new file mode 100644 index 00000000..20299276 --- /dev/null +++ b/tests/test_binance.py @@ -0,0 +1,197 @@ +import pytest + +from tests.conftest import BaseTest +from python_rucaptcha.core.enums import BinanceCaptchaEnm +from python_rucaptcha.binance_captcha import BinanceCaptcha +from python_rucaptcha.core.serializer import GetTaskResultResponseSer + + +class TestBinanceCaptcha(BaseTest): + websiteURL = "https://example.com/page-with-binance" + websiteKey = "login" + validateId = "cb0bfefa598c4d2a8b65f31fde54ecd57b" + userAgent = ( + "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 " + "(KHTML, like Gecko) Chrome/138.0.0.0 Safari/537.36" + ) + kwargs_params = { + "proxyType": "socks5", + "proxyAddress": BaseTest.proxyAddress, + "proxyPort": BaseTest.proxyPort, + } + + def test_methods_exists(self): + assert "captcha_handler" in BinanceCaptcha.__dict__.keys() + assert "aio_captcha_handler" in BinanceCaptcha.__dict__.keys() + + @pytest.mark.parametrize("method", BinanceCaptchaEnm.list_values()) + def test_args(self, method: str): + kwargs = {} + if method == BinanceCaptchaEnm.BinanceTask.value: + kwargs = {"proxyType": "http", "proxyAddress": "1.2.3.4", "proxyPort": 8080} + instance = BinanceCaptcha( + rucaptcha_key=self.RUCAPTCHA_KEY, + websiteURL=self.websiteURL, + websiteKey=self.websiteKey, + validateId=self.validateId, + userAgent=self.userAgent, + method=method, + **kwargs, + ) + assert instance.create_task_payload["clientKey"] == self.RUCAPTCHA_KEY + assert instance.create_task_payload["task"]["type"] == method + assert instance.create_task_payload["task"]["websiteURL"] == self.websiteURL + assert instance.create_task_payload["task"]["websiteKey"] == self.websiteKey + assert instance.create_task_payload["task"]["validateId"] == self.validateId + assert instance.create_task_payload["task"]["userAgent"] == self.userAgent + + def test_kwargs(self): + instance = BinanceCaptcha( + rucaptcha_key=self.RUCAPTCHA_KEY, + websiteURL=self.websiteURL, + websiteKey=self.websiteKey, + validateId=self.validateId, + method=BinanceCaptchaEnm.BinanceTask, + **self.kwargs_params, + ) + assert set(self.kwargs_params.keys()).issubset(set(instance.create_task_payload["task"].keys())) + assert set(self.kwargs_params.values()).issubset(set(instance.create_task_payload["task"].values())) + + def test_proxy_params_in_payload(self): + instance = BinanceCaptcha( + rucaptcha_key=self.RUCAPTCHA_KEY, + websiteURL=self.websiteURL, + websiteKey=self.websiteKey, + validateId=self.validateId, + method=BinanceCaptchaEnm.BinanceTask, + proxyType="http", + proxyAddress="1.2.3.4", + proxyPort=8080, + ) + assert instance.create_task_payload["task"]["proxyType"] == "http" + assert instance.create_task_payload["task"]["proxyAddress"] == "1.2.3.4" + assert instance.create_task_payload["task"]["proxyPort"] == 8080 + + def test_no_useragent(self): + instance = BinanceCaptcha( + rucaptcha_key=self.RUCAPTCHA_KEY, + websiteURL=self.websiteURL, + websiteKey=self.websiteKey, + validateId=self.validateId, + method=BinanceCaptchaEnm.BinanceTaskProxyless, + ) + assert "userAgent" not in instance.create_task_payload["task"] + + def test_missing_proxy_for_proxy_method(self): + with pytest.raises(ValueError, match="proxyType|proxyAddress|proxyPort"): + BinanceCaptcha( + rucaptcha_key=self.RUCAPTCHA_KEY, + websiteURL=self.websiteURL, + websiteKey=self.websiteKey, + validateId=self.validateId, + method=BinanceCaptchaEnm.BinanceTask, + ) + + def test_wrong_method(self): + with pytest.raises(ValueError): + BinanceCaptcha( + rucaptcha_key=self.RUCAPTCHA_KEY, + websiteURL=self.websiteURL, + websiteKey=self.websiteKey, + validateId=self.validateId, + method=self.get_random_string(5), + ) + + """ + Success tests + """ + + def test_basic_data(self): + instance = BinanceCaptcha( + rucaptcha_key=self.RUCAPTCHA_KEY, + websiteURL=self.websiteURL, + websiteKey=self.websiteKey, + validateId=self.validateId, + method=BinanceCaptchaEnm.BinanceTaskProxyless.value, + ) + + result = instance.captcha_handler() + + assert isinstance(result, dict) is True + if not result["errorId"]: + assert result["status"] in ("ready", "processing") + assert isinstance(result["taskId"], int) is True + else: + assert result["errorId"] in (1, 12) + assert result["errorCode"] == "ERROR_CAPTCHA_UNSOLVABLE" + + assert result.keys() == GetTaskResultResponseSer().to_dict().keys() + + async def test_aio_basic_data(self): + instance = BinanceCaptcha( + rucaptcha_key=self.RUCAPTCHA_KEY, + websiteURL=self.websiteURL, + websiteKey=self.websiteKey, + validateId=self.validateId, + method=BinanceCaptchaEnm.BinanceTaskProxyless.value, + ) + + result = await instance.aio_captcha_handler() + + assert isinstance(result, dict) is True + if not result["errorId"]: + assert result["status"] in ("ready", "processing") + assert isinstance(result["taskId"], int) is True + else: + assert result["errorId"] in (1, 12) + assert result["errorCode"] in ("ERROR_CAPTCHA_UNSOLVABLE", BinanceCaptcha.NO_CAPTCHA_ERR) + + assert result.keys() == GetTaskResultResponseSer().to_dict().keys() + + def test_context_basic_data(self): + with BinanceCaptcha( + rucaptcha_key=self.RUCAPTCHA_KEY, + websiteURL=self.websiteURL, + websiteKey=self.websiteKey, + validateId=self.validateId, + method=BinanceCaptchaEnm.BinanceTaskProxyless.value, + ) as instance: + assert instance + + async def test_context_aio_basic_data(self): + async with BinanceCaptcha( + rucaptcha_key=self.RUCAPTCHA_KEY, + websiteURL=self.websiteURL, + websiteKey=self.websiteKey, + validateId=self.validateId, + method=BinanceCaptchaEnm.BinanceTaskProxyless.value, + ) as instance: + assert instance + + """ + Fail tests + """ + + def test_no_websiteURL(self): + with pytest.raises(TypeError): + BinanceCaptcha( + rucaptcha_key=self.RUCAPTCHA_KEY, + websiteKey=self.websiteKey, + validateId=self.validateId, + ) + + def test_no_websiteKey(self): + with pytest.raises(TypeError): + BinanceCaptcha( + rucaptcha_key=self.RUCAPTCHA_KEY, + websiteURL=self.websiteURL, + validateId=self.validateId, + ) + + def test_no_validateId(self): + with pytest.raises(TypeError): + BinanceCaptcha( + rucaptcha_key=self.RUCAPTCHA_KEY, + websiteURL=self.websiteURL, + websiteKey=self.websiteKey, + ) diff --git a/tests/test_vk_captcha.py b/tests/test_vk_captcha.py new file mode 100644 index 00000000..e3022e18 --- /dev/null +++ b/tests/test_vk_captcha.py @@ -0,0 +1,175 @@ +import pytest + +from tests.conftest import BaseTest +from python_rucaptcha.core.enums import VKCaptchaEnm +from python_rucaptcha.vk_captcha import VKCaptcha +from python_rucaptcha.core.serializer import GetTaskResultResponseSer + + +class TestVKCaptcha(BaseTest): + redirectUri = "https://id.vk.com/not_robot_captcha?domain=vk.com" + userAgent = "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36" + captcha_link = "https://vk.com/captcha.php?sid=123456" + + def test_methods_exists(self): + assert "captcha_handler" in VKCaptcha.__dict__.keys() + assert "aio_captcha_handler" in VKCaptcha.__dict__.keys() + + @pytest.mark.parametrize("method", VKCaptchaEnm.list_values()) + def test_args(self, method: str): + instance = VKCaptcha( + rucaptcha_key=self.RUCAPTCHA_KEY, + method=method, + redirectUri=self.redirectUri, + userAgent=self.userAgent, + proxyType="socks5", + proxyAddress=self.proxyAddress, + proxyPort=self.proxyPort, + ) + assert instance.create_task_payload["clientKey"] == self.RUCAPTCHA_KEY + assert instance.create_task_payload["task"]["type"] == method + + def test_vkcaptcha_task_payload(self): + instance = VKCaptcha( + rucaptcha_key=self.RUCAPTCHA_KEY, + redirectUri=self.redirectUri, + userAgent=self.userAgent, + proxyType="socks5", + proxyAddress=self.proxyAddress, + proxyPort=self.proxyPort, + ) + task = instance.create_task_payload["task"] + assert task["type"] == "VKCaptchaTask" + assert task["redirectUri"] == self.redirectUri + assert task["userAgent"] == self.userAgent + assert task["proxyType"] == "socks5" + assert task["proxyAddress"] == self.proxyAddress + assert task["proxyPort"] == self.proxyPort + + def test_vkcaptcha_task_with_proxy_auth(self): + instance = VKCaptcha( + rucaptcha_key=self.RUCAPTCHA_KEY, + redirectUri=self.redirectUri, + userAgent=self.userAgent, + proxyType="socks5", + proxyAddress=self.proxyAddress, + proxyPort=self.proxyPort, + proxyLogin="user1", + proxyPassword="pass1", + ) + task = instance.create_task_payload["task"] + assert task["proxyLogin"] == "user1" + assert task["proxyPassword"] == "pass1" + + def test_vkcaptcha_image_task_no_proxy_required(self): + instance = VKCaptcha( + rucaptcha_key=self.RUCAPTCHA_KEY, + method=VKCaptchaEnm.VKCaptchaImageTask, + ) + assert instance.create_task_payload["task"]["type"] == "VKCaptchaImageTask" + assert "redirectUri" not in instance.create_task_payload["task"] + + def test_vkcaptcha_image_task_default_method(self): + instance = VKCaptcha( + rucaptcha_key=self.RUCAPTCHA_KEY, + ) + assert instance.create_task_payload["task"]["type"] == "VKCaptchaTask" + + """ + Fail tests + """ + + def test_wrong_method(self): + with pytest.raises(ValueError): + VKCaptcha( + rucaptcha_key=self.RUCAPTCHA_KEY, + method=self.get_random_string(length=5), + ) + + def test_vkcaptcha_task_missing_required_params(self): + with pytest.raises(ValueError): + VKCaptcha( + rucaptcha_key=self.RUCAPTCHA_KEY, + method=VKCaptchaEnm.VKCaptchaTask, + ) + + def test_vkcaptcha_task_missing_proxy(self): + with pytest.raises(ValueError): + VKCaptcha( + rucaptcha_key=self.RUCAPTCHA_KEY, + redirectUri=self.redirectUri, + userAgent=self.userAgent, + method=VKCaptchaEnm.VKCaptchaTask, + ) + + """ + Success tests + """ + + def test_basic_data(self): + instance = VKCaptcha( + rucaptcha_key=self.RUCAPTCHA_KEY, + redirectUri=self.redirectUri, + userAgent=self.userAgent, + proxyType="socks5", + proxyAddress=self.proxyAddress, + proxyPort=self.proxyPort, + ) + + result = instance.captcha_handler() + + assert isinstance(result, dict) is True + if not result["errorId"]: + assert result["status"] == "ready" + assert isinstance(result["solution"], dict) is True + assert isinstance(result["taskId"], int) is True + else: + assert result["errorId"] in (1, 12) + assert result["errorCode"] == "ERROR_CAPTCHA_UNSOLVABLE" + + assert result.keys() == GetTaskResultResponseSer().to_dict().keys() + + async def test_aio_basic_data(self): + instance = VKCaptcha( + rucaptcha_key=self.RUCAPTCHA_KEY, + redirectUri=self.redirectUri, + userAgent=self.userAgent, + proxyType="socks5", + proxyAddress=self.proxyAddress, + proxyPort=self.proxyPort, + ) + + result = await instance.aio_captcha_handler() + + assert isinstance(result, dict) is True + if not result["errorId"]: + assert result["status"] == "ready" + assert isinstance(result["solution"], dict) is True + assert isinstance(result["taskId"], int) is True + else: + assert result["errorId"] in (1, 12) + assert result["errorCode"] == "ERROR_CAPTCHA_UNSOLVABLE" + + assert result.keys() == GetTaskResultResponseSer().to_dict().keys() + + def test_context_basic_data(self): + with VKCaptcha( + rucaptcha_key=self.RUCAPTCHA_KEY, + redirectUri=self.redirectUri, + userAgent=self.userAgent, + proxyType="socks5", + proxyAddress=self.proxyAddress, + proxyPort=self.proxyPort, + ) as instance: + assert instance + + async def test_context_aio_basic_data(self): + async with VKCaptcha( + rucaptcha_key=self.RUCAPTCHA_KEY, + redirectUri=self.redirectUri, + userAgent=self.userAgent, + proxyType="socks5", + proxyAddress=self.proxyAddress, + proxyPort=self.proxyPort, + ) as instance: + assert instance diff --git a/tests/test_yandex_smart_captcha.py b/tests/test_yandex_smart_captcha.py new file mode 100644 index 00000000..412014c3 --- /dev/null +++ b/tests/test_yandex_smart_captcha.py @@ -0,0 +1,236 @@ +import pytest + +from tests.conftest import BaseTest +from python_rucaptcha.core.enums import YandexSmartCaptchaEnm, CoordinatesCaptchaEnm, SaveFormatsEnm +from python_rucaptcha.core.serializer import GetTaskResultResponseSer +from python_rucaptcha.yandex_smart_captcha import YandexSmartCaptcha + + +class TestYandexSmartCaptcha(BaseTest): + websiteURL = "https://example.com/" + websiteKey = "Y5Lh0ti..." + userAgent = ( + "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 " + "(KHTML, like Gecko) Chrome/138.0.0.0 Safari/537.36" + ) + comment = "select objects in the order of the instruction" + captcha_file = "src/examples/088636.png" + instruction_file = "src/examples/bounding_box_start.png" + kwargs_params = { + "proxyType": "http", + "proxyAddress": "1.2.3.4", + "proxyPort": 8080, + } + + def test_methods_exists(self): + assert "captcha_handler" in YandexSmartCaptcha.__dict__.keys() + assert "aio_captcha_handler" in YandexSmartCaptcha.__dict__.keys() + + @pytest.mark.parametrize("method", YandexSmartCaptchaEnm.list_values() + [CoordinatesCaptchaEnm.CoordinatesTask.value]) + def test_args(self, method: str): + kwargs = {} + if method == YandexSmartCaptchaEnm.YandexSmartCaptchaTask.value: + kwargs = {"proxyType": "http", "proxyAddress": "1.2.3.4", "proxyPort": 8080} + elif method == CoordinatesCaptchaEnm.CoordinatesTask.value: + kwargs = {"imgType": "smart_captcha", "comment": self.comment} + + instance = YandexSmartCaptcha( + rucaptcha_key=self.RUCAPTCHA_KEY, + websiteURL=self.websiteURL, + websiteKey=self.websiteKey, + method=method, + **kwargs, + ) + assert instance.create_task_payload["clientKey"] == self.RUCAPTCHA_KEY + assert instance.create_task_payload["task"]["type"] == method + + def test_kwargs(self): + instance = YandexSmartCaptcha( + rucaptcha_key=self.RUCAPTCHA_KEY, + websiteURL=self.websiteURL, + websiteKey=self.websiteKey, + method=YandexSmartCaptchaEnm.YandexSmartCaptchaTask, + **self.kwargs_params, + ) + assert set(self.kwargs_params.keys()).issubset(set(instance.create_task_payload["task"].keys())) + assert set(self.kwargs_params.values()).issubset(set(instance.create_task_payload["task"].values())) + + def test_no_useragent(self): + instance = YandexSmartCaptcha( + rucaptcha_key=self.RUCAPTCHA_KEY, + websiteURL=self.websiteURL, + websiteKey=self.websiteKey, + method=YandexSmartCaptchaEnm.YandexSmartCaptchaTaskProxyless, + ) + assert "userAgent" not in instance.create_task_payload["task"] + + def test_proxy_params_in_payload(self): + instance = YandexSmartCaptcha( + rucaptcha_key=self.RUCAPTCHA_KEY, + websiteURL=self.websiteURL, + websiteKey=self.websiteKey, + method=YandexSmartCaptchaEnm.YandexSmartCaptchaTask, + proxyType="http", + proxyAddress="1.2.3.4", + proxyPort=8080, + ) + assert instance.create_task_payload["task"]["proxyType"] == "http" + assert instance.create_task_payload["task"]["proxyAddress"] == "1.2.3.4" + assert instance.create_task_payload["task"]["proxyPort"] == 8080 + + def test_missing_proxy_for_proxy_method(self): + with pytest.raises(ValueError, match="proxyType|proxyAddress|proxyPort"): + YandexSmartCaptcha( + rucaptcha_key=self.RUCAPTCHA_KEY, + websiteURL=self.websiteURL, + websiteKey=self.websiteKey, + method=YandexSmartCaptchaEnm.YandexSmartCaptchaTask, + ) + + def test_missing_required_token_fields(self): + with pytest.raises(ValueError, match="websiteURL and websiteKey"): + YandexSmartCaptcha( + rucaptcha_key=self.RUCAPTCHA_KEY, + websiteURL=self.websiteURL, + method=YandexSmartCaptchaEnm.YandexSmartCaptchaTaskProxyless, + ) + + def test_wrong_method(self): + with pytest.raises(ValueError): + YandexSmartCaptcha( + rucaptcha_key=self.RUCAPTCHA_KEY, + websiteURL=self.websiteURL, + websiteKey=self.websiteKey, + method=self.get_random_string(5), + ) + + def test_smart_captcha_missing_comment(self): + with pytest.raises(ValueError, match="comment"): + YandexSmartCaptcha( + rucaptcha_key=self.RUCAPTCHA_KEY, + method=CoordinatesCaptchaEnm.CoordinatesTask, + imgType="smart_captcha", + ) + + def test_smart_captcha_with_instructions(self): + instance = YandexSmartCaptcha( + rucaptcha_key=self.RUCAPTCHA_KEY, + method=CoordinatesCaptchaEnm.CoordinatesTask, + imgType="smart_captcha", + comment=self.comment, + ) + result = instance.captcha_handler( + captcha_file=self.captcha_file, + imgInstructions_file=self.instruction_file, + ) + assert isinstance(result, dict) is True + # The handler will attempt to call the API + # We just verify it constructs without exception and returns a dict + + def test_pazl_smart_captcha_minimal(self): + instance = YandexSmartCaptcha( + rucaptcha_key=self.RUCAPTCHA_KEY, + method=CoordinatesCaptchaEnm.CoordinatesTask, + imgType="pazl_smart_captcha", + ) + assert instance.create_task_payload["task"]["imgType"] == "pazl_smart_captcha" + assert "comment" not in instance.create_task_payload["task"] + + """ + Success tests + """ + + def test_basic_data(self): + instance = YandexSmartCaptcha( + rucaptcha_key=self.RUCAPTCHA_KEY, + websiteURL=self.websiteURL, + websiteKey=self.websiteKey, + method=YandexSmartCaptchaEnm.YandexSmartCaptchaTaskProxyless.value, + ) + + result = instance.captcha_handler() + + assert isinstance(result, dict) is True + if not result["errorId"]: + assert result["status"] in ("ready", "processing") + assert isinstance(result["taskId"], int) is True + else: + assert result["errorId"] in (1, 12) + assert result["errorCode"] == "ERROR_CAPTCHA_UNSOLVABLE" + + assert result.keys() == GetTaskResultResponseSer().to_dict().keys() + + async def test_aio_basic_data(self): + instance = YandexSmartCaptcha( + rucaptcha_key=self.RUCAPTCHA_KEY, + websiteURL=self.websiteURL, + websiteKey=self.websiteKey, + method=YandexSmartCaptchaEnm.YandexSmartCaptchaTaskProxyless.value, + ) + + result = await instance.aio_captcha_handler() + + assert isinstance(result, dict) is True + if not result["errorId"]: + assert result["status"] in ("ready", "processing") + assert isinstance(result["taskId"], int) is True + else: + assert result["errorId"] in (1, 12) + assert result["errorCode"] in ("ERROR_CAPTCHA_UNSOLVABLE", YandexSmartCaptcha.NO_CAPTCHA_ERR) + + assert result.keys() == GetTaskResultResponseSer().to_dict().keys() + + def test_context_basic_data(self): + with YandexSmartCaptcha( + rucaptcha_key=self.RUCAPTCHA_KEY, + websiteURL=self.websiteURL, + websiteKey=self.websiteKey, + method=YandexSmartCaptchaEnm.YandexSmartCaptchaTaskProxyless.value, + ) as instance: + assert instance + + async def test_context_aio_basic_data(self): + async with YandexSmartCaptcha( + rucaptcha_key=self.RUCAPTCHA_KEY, + websiteURL=self.websiteURL, + websiteKey=self.websiteKey, + method=YandexSmartCaptchaEnm.YandexSmartCaptchaTaskProxyless.value, + ) as instance: + assert instance + + def test_image_with_url(self): + instance = YandexSmartCaptcha( + rucaptcha_key=self.RUCAPTCHA_KEY, + method=CoordinatesCaptchaEnm.CoordinatesTask, + imgType="smart_captcha", + comment=self.comment, + ) + result = instance.captcha_handler( + captcha_link=self.captcha_file, + imgInstructions_link=self.instruction_file, + ) + assert isinstance(result, dict) is True + + def test_no_captcha(self): + instance = YandexSmartCaptcha( + rucaptcha_key=self.RUCAPTCHA_KEY, + method=CoordinatesCaptchaEnm.CoordinatesTask, + imgType="pazl_smart_captcha", + ) + result = instance.captcha_handler() + assert isinstance(result, dict) is True + assert result["errorId"] == 12 + assert isinstance(result["errorCode"], str) is True + assert result.keys() == GetTaskResultResponseSer().to_dict().keys() + + async def test_aio_no_captcha(self): + instance = YandexSmartCaptcha( + rucaptcha_key=self.RUCAPTCHA_KEY, + method=CoordinatesCaptchaEnm.CoordinatesTask, + imgType="pazl_smart_captcha", + ) + result = await instance.aio_captcha_handler() + assert isinstance(result, dict) is True + assert result["errorId"] == 12 + assert isinstance(result["errorCode"], str) is True + assert result.keys() == GetTaskResultResponseSer().to_dict().keys() diff --git a/tests/test_yidun_captcha.py b/tests/test_yidun_captcha.py new file mode 100644 index 00000000..6b367cc2 --- /dev/null +++ b/tests/test_yidun_captcha.py @@ -0,0 +1,192 @@ +import pytest + +from tests.conftest import BaseTest +from python_rucaptcha.core.enums import YidunEnm +from python_rucaptcha.yidun_captcha import YidunCaptcha +from python_rucaptcha.core.serializer import GetTaskResultResponseSer + + +class TestYidunCaptcha(BaseTest): + websiteURL = "https://example.com/page-with-yidun" + websiteKey = "0f743r3g1grz3grz0ym5" + userAgent = ( + "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 " + "(KHTML, like Gecko) Chrome/138.0.0.0 Safari/537.36" + ) + kwargs_params = { + "proxyType": "socks5", + "proxyAddress": BaseTest.proxyAddress, + "proxyPort": BaseTest.proxyPort, + } + + def test_methods_exists(self): + assert "captcha_handler" in YidunCaptcha.__dict__.keys() + assert "aio_captcha_handler" in YidunCaptcha.__dict__.keys() + + @pytest.mark.parametrize("method", YidunEnm.list_values()) + def test_args(self, method: str): + kwargs = {} + if method == YidunEnm.YidunTask.value: + kwargs = {"proxyType": "http", "proxyAddress": "1.2.3.4", "proxyPort": 8080} + instance = YidunCaptcha( + rucaptcha_key=self.RUCAPTCHA_KEY, + websiteURL=self.websiteURL, + websiteKey=self.websiteKey, + userAgent=self.userAgent, + method=method, + **kwargs, + ) + assert instance.create_task_payload["clientKey"] == self.RUCAPTCHA_KEY + assert instance.create_task_payload["task"]["type"] == method + assert instance.create_task_payload["task"]["websiteURL"] == self.websiteURL + assert instance.create_task_payload["task"]["websiteKey"] == self.websiteKey + assert instance.create_task_payload["task"]["userAgent"] == self.userAgent + + def test_kwargs(self): + instance = YidunCaptcha( + rucaptcha_key=self.RUCAPTCHA_KEY, + websiteURL=self.websiteURL, + websiteKey=self.websiteKey, + method=YidunEnm.YidunTask, + **self.kwargs_params, + ) + assert set(self.kwargs_params.keys()).issubset(set(instance.create_task_payload["task"].keys())) + assert set(self.kwargs_params.values()).issubset(set(instance.create_task_payload["task"].values())) + + def test_enterprise_params(self): + enterprise_params = { + "yidunGetLib": "https://example.com/yidun/load.min.js", + "yidunApiServerSubdomain": "c.dun.163.com", + "challenge": "0c59ba0d1b2349f9b2c1a2b3c4d5e6f7", + "hcg": "2c78a7731e2345f6a7b8c9d0e1f2a3b4", + "hct": 1779358333191, + } + instance = YidunCaptcha( + rucaptcha_key=self.RUCAPTCHA_KEY, + websiteURL=self.websiteURL, + websiteKey=self.websiteKey, + **enterprise_params, + ) + for key, value in enterprise_params.items(): + assert instance.create_task_payload["task"][key] == value + + def test_no_useragent(self): + instance = YidunCaptcha( + rucaptcha_key=self.RUCAPTCHA_KEY, + websiteURL=self.websiteURL, + websiteKey=self.websiteKey, + method=YidunEnm.YidunTaskProxyless, + ) + assert "userAgent" not in instance.create_task_payload["task"] + + def test_proxy_params_in_payload(self): + instance = YidunCaptcha( + rucaptcha_key=self.RUCAPTCHA_KEY, + websiteURL=self.websiteURL, + websiteKey=self.websiteKey, + method=YidunEnm.YidunTask, + proxyType="http", + proxyAddress="1.2.3.4", + proxyPort=8080, + ) + assert instance.create_task_payload["task"]["proxyType"] == "http" + assert instance.create_task_payload["task"]["proxyAddress"] == "1.2.3.4" + assert instance.create_task_payload["task"]["proxyPort"] == 8080 + + def test_missing_proxy_for_proxy_method(self): + with pytest.raises(ValueError, match="proxyType|proxyAddress|proxyPort"): + YidunCaptcha( + rucaptcha_key=self.RUCAPTCHA_KEY, + websiteURL=self.websiteURL, + websiteKey=self.websiteKey, + method=YidunEnm.YidunTask, + ) + + def test_wrong_method(self): + with pytest.raises(ValueError): + YidunCaptcha( + rucaptcha_key=self.RUCAPTCHA_KEY, + websiteURL=self.websiteURL, + websiteKey=self.websiteKey, + method=self.get_random_string(5), + ) + + """ + Success tests + """ + + def test_basic_data(self): + instance = YidunCaptcha( + rucaptcha_key=self.RUCAPTCHA_KEY, + websiteURL=self.websiteURL, + websiteKey=self.websiteKey, + method=YidunEnm.YidunTaskProxyless.value, + ) + + result = instance.captcha_handler() + + assert isinstance(result, dict) is True + if not result["errorId"]: + assert result["status"] in ("ready", "processing") + assert isinstance(result["taskId"], int) is True + else: + assert result["errorId"] in (1, 12) + assert result["errorCode"] == "ERROR_CAPTCHA_UNSOLVABLE" + + assert result.keys() == GetTaskResultResponseSer().to_dict().keys() + + async def test_aio_basic_data(self): + instance = YidunCaptcha( + rucaptcha_key=self.RUCAPTCHA_KEY, + websiteURL=self.websiteURL, + websiteKey=self.websiteKey, + method=YidunEnm.YidunTaskProxyless.value, + ) + + result = await instance.aio_captcha_handler() + + assert isinstance(result, dict) is True + if not result["errorId"]: + assert result["status"] in ("ready", "processing") + assert isinstance(result["taskId"], int) is True + else: + assert result["errorId"] in (1, 12) + assert result["errorCode"] in ("ERROR_CAPTCHA_UNSOLVABLE", YidunCaptcha.NO_CAPTCHA_ERR) + + assert result.keys() == GetTaskResultResponseSer().to_dict().keys() + + def test_context_basic_data(self): + with YidunCaptcha( + rucaptcha_key=self.RUCAPTCHA_KEY, + websiteURL=self.websiteURL, + websiteKey=self.websiteKey, + method=YidunEnm.YidunTaskProxyless.value, + ) as instance: + assert instance + + async def test_context_aio_basic_data(self): + async with YidunCaptcha( + rucaptcha_key=self.RUCAPTCHA_KEY, + websiteURL=self.websiteURL, + websiteKey=self.websiteKey, + method=YidunEnm.YidunTaskProxyless.value, + ) as instance: + assert instance + + """ + Fail tests + """ + + def test_no_websiteURL(self): + with pytest.raises(TypeError): + YidunCaptcha( + rucaptcha_key=self.RUCAPTCHA_KEY, + websiteKey=self.websiteKey, + ) + + def test_no_websiteKey(self): + with pytest.raises(TypeError): + YidunCaptcha( + rucaptcha_key=self.RUCAPTCHA_KEY, + websiteURL=self.websiteURL, + )