From 0a066d9eaa5606ed46f8d7c739c91e19d4d586d9 Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Mon, 10 Nov 2025 18:25:04 +0000 Subject: [PATCH 01/29] Bump black from 25.9.0 to 25.11.0 Bumps [black](https://github.com/psf/black) from 25.9.0 to 25.11.0. - [Release notes](https://github.com/psf/black/releases) - [Changelog](https://github.com/psf/black/blob/main/CHANGES.md) - [Commits](https://github.com/psf/black/compare/25.9.0...25.11.0) --- updated-dependencies: - dependency-name: black dependency-version: 25.11.0 dependency-type: direct:production update-type: version-update:semver-minor ... Signed-off-by: dependabot[bot] --- requirements.style.txt | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) 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.* From 26cceb6f1e403fe2984caa7dd29891a524840236 Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Thu, 20 Nov 2025 17:07:35 +0000 Subject: [PATCH 02/29] [github-actions] Bump actions/checkout from 5 to 6 Bumps [actions/checkout](https://github.com/actions/checkout) from 5 to 6. - [Release notes](https://github.com/actions/checkout/releases) - [Changelog](https://github.com/actions/checkout/blob/main/CHANGELOG.md) - [Commits](https://github.com/actions/checkout/compare/v5...v6) --- updated-dependencies: - dependency-name: actions/checkout dependency-version: '6' dependency-type: direct:production update-type: version-update:semver-major ... Signed-off-by: dependabot[bot] --- .github/workflows/build.yml | 2 +- .github/workflows/install.yml | 2 +- .github/workflows/lint.yml | 2 +- .github/workflows/sphinx.yml | 2 +- .github/workflows/test.yml | 2 +- 5 files changed, 5 insertions(+), 5 deletions(-) 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..30c4478a 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: From fd5a696fa671cfa0a7489873db1f03e7bd6dd895 Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Mon, 24 Nov 2025 18:17:19 +0000 Subject: [PATCH 03/29] Update msgspec requirement from <0.20,>=0.18 to >=0.18,<0.21 Updates the requirements on [msgspec](https://github.com/jcrist/msgspec) to permit the latest version. - [Release notes](https://github.com/jcrist/msgspec/releases) - [Changelog](https://github.com/jcrist/msgspec/blob/main/docs/changelog.md) - [Commits](https://github.com/jcrist/msgspec/compare/0.18.0...0.20.0) --- updated-dependencies: - dependency-name: msgspec dependency-version: 0.20.0 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 8175c19c..4ae1e060 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -90,7 +90,7 @@ classifiers = [ dependencies = [ "requests>=2.21.0", "aiohttp>=3.9.2", - "msgspec>=0.18,<0.20", + "msgspec>=0.18,<0.21", "tenacity>=8,<10" ] From 158c68273633dfed1d5022482c6b3ef4c579f124 Mon Sep 17 00:00:00 2001 From: pyup-bot Date: Mon, 1 Dec 2025 20:38:15 +0300 Subject: [PATCH 04/29] Update sphinx from 8.3.0 to 9.0.1 --- docs/requirements.txt | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/docs/requirements.txt b/docs/requirements.txt index dccd4a60..6190ffba 100644 --- a/docs/requirements.txt +++ b/docs/requirements.txt @@ -1,4 +1,4 @@ -sphinx==8.3.0 +sphinx==9.0.1 pallets_sphinx_themes==2.3.0 myst-parser==4.0.1 enum-tools[sphinx]==0.13.0 From 3931ba96e7bb7e5988156f85400aa5ad2dc4b925 Mon Sep 17 00:00:00 2001 From: pyup-bot Date: Thu, 4 Dec 2025 06:55:40 +0300 Subject: [PATCH 05/29] Update sphinx from 9.0.1 to 9.0.3 --- docs/requirements.txt | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/docs/requirements.txt b/docs/requirements.txt index 6190ffba..bb536be1 100644 --- a/docs/requirements.txt +++ b/docs/requirements.txt @@ -1,4 +1,4 @@ -sphinx==9.0.1 +sphinx==9.0.3 pallets_sphinx_themes==2.3.0 myst-parser==4.0.1 enum-tools[sphinx]==0.13.0 From c604a0f2bfc3d39600ae76e8e9572724bf216e15 Mon Sep 17 00:00:00 2001 From: pyup-bot Date: Wed, 31 Dec 2025 18:55:28 +0300 Subject: [PATCH 06/29] Update sphinx from 9.0.3 to 9.1.0 --- docs/requirements.txt | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/docs/requirements.txt b/docs/requirements.txt index bb536be1..3aa73ed7 100644 --- a/docs/requirements.txt +++ b/docs/requirements.txt @@ -1,4 +1,4 @@ -sphinx==9.0.3 +sphinx==9.1.0 pallets_sphinx_themes==2.3.0 myst-parser==4.0.1 enum-tools[sphinx]==0.13.0 From 9fdfa8a7ad3b62d9e4d2b1fff275d8163d35c0c9 Mon Sep 17 00:00:00 2001 From: pyup-bot Date: Thu, 15 Jan 2026 12:17:15 +0300 Subject: [PATCH 07/29] Update myst-parser from 4.0.1 to 5.0.0 --- docs/requirements.txt | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/docs/requirements.txt b/docs/requirements.txt index bb536be1..faf947b1 100644 --- a/docs/requirements.txt +++ b/docs/requirements.txt @@ -1,6 +1,6 @@ sphinx==9.0.3 pallets_sphinx_themes==2.3.0 -myst-parser==4.0.1 +myst-parser==5.0.0 enum-tools[sphinx]==0.13.0 requests>=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 From cb9b6f2f919abc49f39bc9684dfc00f8d46fc7af Mon Sep 17 00:00:00 2001 From: Andrei Date: Sat, 21 Feb 2026 00:04:14 +0300 Subject: [PATCH 08/29] Create AGENTS.md --- AGENTS.md | 68 +++++++++++++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 68 insertions(+) create mode 100644 AGENTS.md 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) From 870b230eba0a2d39afe379ea98afce457c3f3fdd Mon Sep 17 00:00:00 2001 From: Andrei Date: Sat, 21 Feb 2026 00:04:18 +0300 Subject: [PATCH 09/29] Create AGENTS.md --- src/python_rucaptcha/AGENTS.md | 46 ++++++++++++++++++++++++++++++++++ 1 file changed, 46 insertions(+) create mode 100644 src/python_rucaptcha/AGENTS.md 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 From 31ccf93dccf8f2e7ad709244e11e1f2f80796bae Mon Sep 17 00:00:00 2001 From: Andrei Date: Sat, 21 Feb 2026 00:04:20 +0300 Subject: [PATCH 10/29] Create AGENTS.md --- src/python_rucaptcha/core/AGENTS.md | 25 +++++++++++++++++++++++++ 1 file changed, 25 insertions(+) create mode 100644 src/python_rucaptcha/core/AGENTS.md 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 From f8f3803eaf5131c5198a3c5fd6c5884faae9f263 Mon Sep 17 00:00:00 2001 From: Andrei Date: Sat, 21 Feb 2026 00:04:23 +0300 Subject: [PATCH 11/29] Create AGENTS.md --- tests/AGENTS.md | 41 +++++++++++++++++++++++++++++++++++++++++ 1 file changed, 41 insertions(+) create mode 100644 tests/AGENTS.md 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 From 815106002bb572e5c8708114ebdc02b87368c57e Mon Sep 17 00:00:00 2001 From: OpenCode Date: Sun, 22 Feb 2026 03:02:37 +0300 Subject: [PATCH 12/29] docs: rewrite README with improved structure and examples Restructure documentation to be more approachable for new users: - Clean up badge section, remove broken images - Add 'What is this?' section explaining the library purpose - Add Quick Start guide with install, API key, and usage examples - Add supported CAPTCHA types table - Add service switching examples - Simplify testing section - Update changelog with recent versions Stats: - 1 file changed - +104/-41 lines --- README.md | 157 ++++++++++++++++++++++++++++++++++++++++-------------- 1 file changed, 116 insertions(+), 41 deletions(-) diff --git a/README.md b/README.md index f898305c..1142b28e 100644 --- a/README.md +++ b/README.md @@ -1,70 +1,145 @@ # 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 -## How to use? +result = ImageCaptcha(api_key).captcha_handler( + image_link="https://example.com/captcha.jpg" +) +``` + +**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 + +| 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 | +| Grid | `GridCaptcha` | Select grid cells | +| Coordinates | `CoordinatesCaptcha` | Click on coordinates | +| And 20+ more | ... | See [full docs](https://andreidrang.github.io/python-rucaptcha/) | + +## Switching Services -Is described in the [documentation-website](https://andreidrang.github.io/python-rucaptcha/). +Use the same code with different services: -## How to test? +```python +from python_rucaptcha import HCaptcha +from python_rucaptcha.core.enums import ServiceEnm -1. You need set ``RUCAPTCHA_KEY`` in your environment(get this value from you account). -2. Run command ``make tests``, from root directory. +# 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 From fcc5be5820361996c24c56fcf66d3bb79f3a2496 Mon Sep 17 00:00:00 2001 From: OpenCode Date: Sun, 22 Feb 2026 05:12:39 +0300 Subject: [PATCH 13/29] feat(altcha): add ALTCHA captcha support Add ALTCHA captcha solver implementation following 2Captcha API specification. Includes XOR validation for challengeURL/challengeJSON parameters and support for both proxyless and proxy modes. Changes: - src/python_rucaptcha/core/enums.py: Add AltchaEnm enum with AltchaTaskProxyless and AltchaTask values - src/python_rucaptcha/altcha_captcha.py: Add AltchaCaptcha class with sync/async handlers and XOR parameter validation - tests/test_altcha.py: Add unit tests with XOR validation coverage - README.md: Document new ALTCHA captcha type Stats: - 4 files changed - +173/-0 lines --- README.md | 14 ++ src/python_rucaptcha/altcha_captcha.py | 152 ++++++++++++++++++++ src/python_rucaptcha/core/enums.py | 5 + tests/test_altcha.py | 185 +++++++++++++++++++++++++ 4 files changed, 356 insertions(+) create mode 100644 src/python_rucaptcha/altcha_captcha.py create mode 100644 tests/test_altcha.py diff --git a/README.md b/README.md index 1142b28e..3c0831a2 100644 --- a/README.md +++ b/README.md @@ -67,6 +67,19 @@ result = ImageCaptcha(api_key).captcha_handler( ) ``` +**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 @@ -94,6 +107,7 @@ token = asyncio.run(solve()) | GeeTest | `GeeTest` | Chinese geetest puzzles | | KeyCaptcha | `KeyCaptcha` | KeyCAPTCHA service | | Amazon WAF | `AmazonWaf` | AWS WAF challenge | +| ALTCHA | `AltchaCaptcha` | ALTCHA challenge | | Grid | `GridCaptcha` | Select grid cells | | Coordinates | `CoordinatesCaptcha` | Click on coordinates | | And 20+ more | ... | See [full docs](https://andreidrang.github.io/python-rucaptcha/) | 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/core/enums.py b/src/python_rucaptcha/core/enums.py index 544be6e6..ba19ba23 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" 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 + """ From 972b7665214949f36d4e04bdd7acc332a6c5f7b3 Mon Sep 17 00:00:00 2001 From: pyup-bot Date: Sat, 28 Feb 2026 14:24:04 +0300 Subject: [PATCH 14/29] Update pallets_sphinx_themes from 2.3.0 to 2.5.0 --- docs/requirements.txt | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/docs/requirements.txt b/docs/requirements.txt index 4a5a57d8..b2cbd620 100644 --- a/docs/requirements.txt +++ b/docs/requirements.txt @@ -1,5 +1,5 @@ sphinx==9.1.0 -pallets_sphinx_themes==2.3.0 +pallets_sphinx_themes==2.5.0 myst-parser==5.0.0 enum-tools[sphinx]==0.13.0 requests>=2.32.4 # not directly required, pinned by Snyk to avoid a vulnerability From 9103e4c04004f7c52f5f62c50354fa11e7fc70bb Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Thu, 26 Mar 2026 17:53:15 +0000 Subject: [PATCH 15/29] [github-actions] Bump codecov/codecov-action from 5 to 6 Bumps [codecov/codecov-action](https://github.com/codecov/codecov-action) from 5 to 6. - [Release notes](https://github.com/codecov/codecov-action/releases) - [Changelog](https://github.com/codecov/codecov-action/blob/main/CHANGELOG.md) - [Commits](https://github.com/codecov/codecov-action/compare/v5...v6) --- updated-dependencies: - dependency-name: codecov/codecov-action dependency-version: '6' dependency-type: direct:production update-type: version-update:semver-major ... Signed-off-by: dependabot[bot] --- .github/workflows/test.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml index 30c4478a..9dbc905e 100644 --- a/.github/workflows/test.yml +++ b/.github/workflows/test.yml @@ -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 From c749c5e2f5a31081e39d4803e195dae2a0fa6bf5 Mon Sep 17 00:00:00 2001 From: Andrei Date: Fri, 3 Apr 2026 13:38:54 +0300 Subject: [PATCH 16/29] Add Z.ai Code Bot workflow configuration --- .github/workflows/zai-code-bot.yml | 45 ++++++++++++++++++++++++++++++ 1 file changed, 45 insertions(+) create mode 100644 .github/workflows/zai-code-bot.yml 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 }} From 0361945bbb5ffbcd92068f6deb5299c2706418f6 Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Mon, 27 Apr 2026 21:48:09 +0000 Subject: [PATCH 17/29] Update urllib3 requirement from >=2.2.2 to >=2.6.3 Updates the requirements on [urllib3](https://github.com/urllib3/urllib3) to permit the latest version. - [Release notes](https://github.com/urllib3/urllib3/releases) - [Changelog](https://github.com/urllib3/urllib3/blob/main/CHANGES.rst) - [Commits](https://github.com/urllib3/urllib3/compare/2.2.2...2.6.3) --- updated-dependencies: - dependency-name: urllib3 dependency-version: 2.6.3 dependency-type: direct:production ... 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 b2cbd620..2d430de7 100644 --- a/docs/requirements.txt +++ b/docs/requirements.txt @@ -3,5 +3,5 @@ pallets_sphinx_themes==2.5.0 myst-parser==5.0.0 enum-tools[sphinx]==0.13.0 requests>=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 +urllib3>=2.6.3 # not directly required, pinned by Snyk to avoid a vulnerability zipp>=3.19.1 # not directly required, pinned by Snyk to avoid a vulnerability From e68f33cb0865ae070439036cee2df207605967d4 Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Mon, 27 Apr 2026 21:48:13 +0000 Subject: [PATCH 18/29] Update zipp requirement from >=3.19.1 to >=3.23.1 Updates the requirements on [zipp](https://github.com/jaraco/zipp) to permit the latest version. - [Release notes](https://github.com/jaraco/zipp/releases) - [Changelog](https://github.com/jaraco/zipp/blob/main/NEWS.rst) - [Commits](https://github.com/jaraco/zipp/compare/v3.19.1...v3.23.1) --- updated-dependencies: - dependency-name: zipp dependency-version: 3.23.1 dependency-type: direct:production ... 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 b2cbd620..0df188a3 100644 --- a/docs/requirements.txt +++ b/docs/requirements.txt @@ -4,4 +4,4 @@ myst-parser==5.0.0 enum-tools[sphinx]==0.13.0 requests>=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 From a88bc490ffbbb54a50b8577705ff789eec2b65bf Mon Sep 17 00:00:00 2001 From: pyup-bot Date: Wed, 13 May 2026 14:17:46 +0300 Subject: [PATCH 19/29] Update myst-parser from 5.0.0 to 5.1.0 --- docs/requirements.txt | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/docs/requirements.txt b/docs/requirements.txt index b2cbd620..5d503e8e 100644 --- a/docs/requirements.txt +++ b/docs/requirements.txt @@ -1,6 +1,6 @@ sphinx==9.1.0 pallets_sphinx_themes==2.5.0 -myst-parser==5.0.0 +myst-parser==5.1.0 enum-tools[sphinx]==0.13.0 requests>=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 From 1809d8321b7022d701c175934e908453869a758b Mon Sep 17 00:00:00 2001 From: snyk-bot Date: Fri, 22 May 2026 13:21:31 +0000 Subject: [PATCH 20/29] fix: docs/requirements.txt to reduce vulnerabilities The following vulnerabilities are fixed by pinning transitive dependencies: - https://snyk.io/vuln/SNYK-PYTHON-IDNA-16769942 --- docs/requirements.txt | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/docs/requirements.txt b/docs/requirements.txt index efe75490..6551e7fe 100644 --- a/docs/requirements.txt +++ b/docs/requirements.txt @@ -3,5 +3,6 @@ pallets_sphinx_themes==2.5.0 myst-parser==5.1.0 enum-tools[sphinx]==0.13.0 requests>=2.32.4 # not directly required, pinned by Snyk to avoid a vulnerability -urllib3>=2.6.3 # 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.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 From 5cb9fcd494f745f43584e1c0a9fb84b8d131079a Mon Sep 17 00:00:00 2001 From: snyk-bot Date: Fri, 22 May 2026 13:21:33 +0000 Subject: [PATCH 21/29] fix: docs/requirements.txt to reduce vulnerabilities The following vulnerabilities are fixed by pinning transitive dependencies: - https://snyk.io/vuln/SNYK-PYTHON-IDNA-16769942 From 1cc4ac11f5b6609e911318e371c900b74403acb7 Mon Sep 17 00:00:00 2001 From: AndreiDrang Date: Mon, 1 Jun 2026 03:26:53 +0300 Subject: [PATCH 22/29] chore: add .osgrep to gitignore Why: * IDE/tool artifact should not be tracked What: * Add .osgrep to .gitignore Changes: * .gitignore: add .osgrep entry Stats: * 1 file changed * +1 additions/-0 deletions Co-authored-by: opencode --- .gitignore | 1 + 1 file changed, 1 insertion(+) 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 From 4ce50100a3f6807ffaa068d67a45c6eb126bb52a Mon Sep 17 00:00:00 2001 From: AndreiDrang Date: Mon, 1 Jun 2026 03:27:15 +0300 Subject: [PATCH 23/29] feat(vk-captcha): add VKCaptchaImageTask support and fix VKCaptchaTask bugs Why: * 2captcha API now supports VKCaptchaImageTask (image-based, no proxy) * VKCaptchaTask had incorrect payload key (websiteURL instead of redirectUri) * proxyPort was typed as str instead of int * Missing proxyLogin/proxyPassword optional params * No method validation against enum What: * Add VKCaptchaImageTask enum value and image-based solving path * Fix payload key websiteURL -> redirectUri * Fix proxyPort type from str to int * Add proxyLogin/proxyPassword optional params * Add method validation against VKCaptchaEnm * Add image file processing in handlers for VKCaptchaImageTask * Add comprehensive test suite BREAKING CHANGE: VKCaptcha constructor signature changed from required positional params to optional keyword params with method selection. proxyPort changed from str to int. Payload key changed from websiteURL to redirectUri. Changes: * src/python_rucaptcha/core/enums.py: add VKCaptchaImageTask to VKCaptchaEnm * src/python_rucaptcha/vk_captcha.py: full rewrite with dual task type support * tests/test_vk_captcha.py: new test file Stats: * 3 files changed * +306 additions/-25 deletions Co-authored-by: opencode --- src/python_rucaptcha/core/enums.py | 1 + src/python_rucaptcha/vk_captcha.py | 155 ++++++++++++++++++++----- tests/test_vk_captcha.py | 175 +++++++++++++++++++++++++++++ 3 files changed, 306 insertions(+), 25 deletions(-) create mode 100644 tests/test_vk_captcha.py diff --git a/src/python_rucaptcha/core/enums.py b/src/python_rucaptcha/core/enums.py index ba19ba23..fba324a6 100644 --- a/src/python_rucaptcha/core/enums.py +++ b/src/python_rucaptcha/core/enums.py @@ -177,6 +177,7 @@ class CaptchaFoxEnm(str, MyEnum): class VKCaptchaEnm(str, MyEnum): VKCaptchaTask = "VKCaptchaTask" + VKCaptchaImageTask = "VKCaptchaImageTask" class TemuCaptchaEnm(str, MyEnum): 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/tests/test_vk_captcha.py b/tests/test_vk_captcha.py new file mode 100644 index 00000000..c92cbef8 --- /dev/null +++ b/tests/test_vk_captcha.py @@ -0,0 +1,175 @@ +import pytest + +from tests.conftest import BaseTest +from python_rucaptcha.vk_captcha import VKCaptcha +from python_rucaptcha.core.enums import VKCaptchaEnm +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 From 79c8c91251ca500e89a207a35febcdd7912c4db1 Mon Sep 17 00:00:00 2001 From: AndreiDrang Date: Mon, 1 Jun 2026 03:28:13 +0300 Subject: [PATCH 24/29] style(test): fix import order in test_vk_captcha Sort imports alphabetically within local import group. Co-authored-by: opencode --- tests/test_vk_captcha.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/tests/test_vk_captcha.py b/tests/test_vk_captcha.py index c92cbef8..e3022e18 100644 --- a/tests/test_vk_captcha.py +++ b/tests/test_vk_captcha.py @@ -1,8 +1,8 @@ import pytest from tests.conftest import BaseTest -from python_rucaptcha.vk_captcha import VKCaptcha from python_rucaptcha.core.enums import VKCaptchaEnm +from python_rucaptcha.vk_captcha import VKCaptcha from python_rucaptcha.core.serializer import GetTaskResultResponseSer From a33bb200af11c222a26a33bcd3cb0f95b036ae32 Mon Sep 17 00:00:00 2001 From: AndreiDrang Date: Mon, 1 Jun 2026 03:42:11 +0300 Subject: [PATCH 25/29] feat: add Binance CAPTCHA support Why: * 2Captcha/RuCaptcha now exposes BinanceTask/BinanceTaskProxyless token-based CAPTCHA. Provide first-class Python client to match the existing Altcha/Friendly/Atb patterns (token solver with optional proxy mode). What: * Add BinanceCaptchaEnm with BinanceTaskProxyless and BinanceTask values. * Add BinanceCaptcha solver accepting websiteURL, websiteKey, validateId and optional userAgent. Enforces proxyType/Address/Port when method == BinanceTask. Forwards to _processing_response and _aio_processing_response for sync/async flows. * Add tests covering payload construction, validation (missing required args, bad method, missing proxy for BinanceTask), both enum values, userAgent handling, and live API context managers. * Add Binance row to README supported CAPTCHA types table. * Add 'binance-captcha' keyword to pyproject.toml. Changes: * src/python_rucaptcha/core/enums.py: add BinanceCaptchaEnm * src/python_rucaptcha/binance_captcha.py: new BinanceCaptcha class * tests/test_binance.py: 15 tests (13 unit + 2 live API) * README.md: document Binance in supported types table * pyproject.toml: add binance-captcha keyword Stats: * 5 files changed * ~340 insertions Co-authored-by: opencode --- README.md | 1 + pyproject.toml | 3 +- src/python_rucaptcha/binance_captcha.py | 155 +++++++++++++++++++ src/python_rucaptcha/core/enums.py | 5 + tests/test_binance.py | 197 ++++++++++++++++++++++++ 5 files changed, 360 insertions(+), 1 deletion(-) create mode 100644 src/python_rucaptcha/binance_captcha.py create mode 100644 tests/test_binance.py diff --git a/README.md b/README.md index 3c0831a2..6edea8e2 100644 --- a/README.md +++ b/README.md @@ -108,6 +108,7 @@ token = asyncio.run(solve()) | 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/) | diff --git a/pyproject.toml b/pyproject.toml index 4ae1e060..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 = [ 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/enums.py b/src/python_rucaptcha/core/enums.py index fba324a6..c6bd556f 100644 --- a/src/python_rucaptcha/core/enums.py +++ b/src/python_rucaptcha/core/enums.py @@ -182,3 +182,8 @@ class VKCaptchaEnm(str, MyEnum): class TemuCaptchaEnm(str, MyEnum): TemuCaptchaTask = "TemuCaptchaTask" + + +class BinanceCaptchaEnm(str, MyEnum): + BinanceTaskProxyless = "BinanceTaskProxyless" + BinanceTask = "BinanceTask" 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, + ) From caed3190747c75de592840ac8471adf68f6c645c Mon Sep 17 00:00:00 2001 From: AndreiDrang Date: Mon, 1 Jun 2026 03:42:29 +0300 Subject: [PATCH 26/29] docs: add Binance CAPTCHA documentation Why: * Sphinx docs must list every public captcha solver and its enum so the API reference and toctree stay in sync with the new module added in the preceding feat commit. What: * Add captcha-binance example.rst mirroring the friendly-captcha/ captcha-temu templates (import line + autoclass directive). * Register BinanceCaptchaEnm in docs/modules/enum/info.rst. * Add captcha-binance to the captcha examples toctree in index.rst (alphabetical placement). * Import binance_captcha in docs/conf.py so the module is discoverable by autodoc. Changes: * docs/modules/captcha-binance/example.rst: new example page * docs/modules/enum/info.rst: register BinanceCaptchaEnm * docs/index.rst: add captcha-binance to toctree * docs/conf.py: import binance_captcha module Stats: * 4 files changed * ~9 insertions Co-authored-by: opencode --- docs/conf.py | 1 + docs/index.rst | 1 + docs/modules/captcha-binance/example.rst | 12 ++++++++++++ docs/modules/enum/info.rst | 3 +++ 4 files changed, 17 insertions(+) create mode 100644 docs/modules/captcha-binance/example.rst diff --git a/docs/conf.py b/docs/conf.py index 8d3f987a..cb276aa4 100644 --- a/docs/conf.py +++ b/docs/conf.py @@ -26,6 +26,7 @@ image_captcha, lemin_captcha, rotate_captcha, + binance_captcha, datadome_captcha, friendly_captcha, cyber_siara_captcha, diff --git a/docs/index.rst b/docs/index.rst index 586851dd..fd9e801c 100644 --- a/docs/index.rst +++ b/docs/index.rst @@ -52,6 +52,7 @@ Check our other projects here - `RedPandaDev group Date: Mon, 1 Jun 2026 19:37:17 +0300 Subject: [PATCH 27/29] feat(yidun): add Yidun NECaptcha support Why: * Support NetEase Yidun CAPTCHA solving via RuCaptcha API What: * Add YidunEnm enum with YidunTaskProxyless and YidunTask * Create YidunCaptcha class with sync/async handlers * Add tests and documentation Changes: * src/python_rucaptcha/core/enums.py: add YidunEnm enum * src/python_rucaptcha/yidun_captcha.py: new captcha module * tests/test_yidun_captcha.py: add test suite * docs/conf.py: register yidun_captcha module * docs/index.rst: add toctree entry * docs/modules/enum/info.rst: add YidunEnm autoenum * docs/modules/yidun-necaptcha/example.rst: new example doc Stats: * 7 files changed, 386 insertions Co-authored-by: Andrei Drang --- docs/conf.py | 1 + docs/index.rst | 1 + docs/modules/enum/info.rst | 3 + docs/modules/yidun-necaptcha/example.rst | 12 ++ src/python_rucaptcha/core/enums.py | 6 + src/python_rucaptcha/yidun_captcha.py | 171 ++++++++++++++++++++ tests/test_yidun_captcha.py | 192 +++++++++++++++++++++++ 7 files changed, 386 insertions(+) create mode 100644 docs/modules/yidun-necaptcha/example.rst create mode 100644 src/python_rucaptcha/yidun_captcha.py create mode 100644 tests/test_yidun_captcha.py diff --git a/docs/conf.py b/docs/conf.py index cb276aa4..38108420 100644 --- a/docs/conf.py +++ b/docs/conf.py @@ -25,6 +25,7 @@ text_captcha, image_captcha, lemin_captcha, + yidun_captcha, rotate_captcha, binance_captcha, datadome_captcha, diff --git a/docs/index.rst b/docs/index.rst index fd9e801c..e9dd6685 100644 --- a/docs/index.rst +++ b/docs/index.rst @@ -56,6 +56,7 @@ Check our other projects here - `RedPandaDev group >> 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/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, + ) From 8f884f9c4b104c3630104e9e3fa65f4bc19b7786 Mon Sep 17 00:00:00 2001 From: AndreiDrang Date: Mon, 1 Jun 2026 19:37:45 +0300 Subject: [PATCH 28/29] feat(yandex): add Yandex SmartCaptcha support Why: * Support Yandex SmartCaptcha solving via RuCaptcha API * Covers token proxyless, token proxy, and image (CoordinatesTask) methods What: * Add YandexSmartCaptchaEnm enum * Create YandexSmartCaptcha class with dual sync/async handlers * Add tests and documentation Changes: * src/python_rucaptcha/core/enums.py: add YandexSmartCaptchaEnm enum * src/python_rucaptcha/yandex_smart_captcha.py: new captcha module (246 lines) * tests/test_yandex_smart_captcha.py: add test suite (18 tests) * docs/index.rst: add toctree entry * docs/modules/yandex-smart-captcha/example.rst: new example doc Stats: * 5 files changed, 499 insertions Co-authored-by: Andrei Drang --- docs/index.rst | 1 + docs/modules/yandex-smart-captcha/example.rst | 12 + src/python_rucaptcha/core/enums.py | 4 + src/python_rucaptcha/yandex_smart_captcha.py | 246 ++++++++++++++++++ tests/test_yandex_smart_captcha.py | 236 +++++++++++++++++ 5 files changed, 499 insertions(+) create mode 100644 docs/modules/yandex-smart-captcha/example.rst create mode 100644 src/python_rucaptcha/yandex_smart_captcha.py create mode 100644 tests/test_yandex_smart_captcha.py diff --git a/docs/index.rst b/docs/index.rst index e9dd6685..a26664d5 100644 --- a/docs/index.rst +++ b/docs/index.rst @@ -57,6 +57,7 @@ Check our other projects here - `RedPandaDev group >> 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/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() From d9b8c98e130beee0b30217e57d20b140be9d73ea Mon Sep 17 00:00:00 2001 From: AndreiDrang Date: Mon, 1 Jun 2026 19:37:58 +0300 Subject: [PATCH 29/29] chore: bump version to 6.6.0 Why: * Prepare for release with new captcha support Changes: * src/python_rucaptcha/__version__.py: 6.5.0 -> 6.6.0 Stats: * 1 file changed, 1 insertion(+), 1 deletion(-) Co-authored-by: Andrei Drang --- src/python_rucaptcha/__version__.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) 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"