├── .gitignore ├── LICENSE ├── README.md ├── pyproject.toml └── src └── nopecha ├── __init__.py ├── api ├── __init__.py ├── _base.py ├── _throttle.py ├── _validate.py ├── aiohttp.py ├── httpx.py ├── requests.py ├── types.py └── urllib.py └── extension ├── __init__.py ├── _adapter.py └── extension.py /.gitignore: -------------------------------------------------------------------------------- 1 | # Byte-compiled / optimized / DLL files 2 | __pycache__/ 3 | *.py[cod] 4 | *$py.class 5 | 6 | # C extensions 7 | *.so 8 | 9 | # Distribution / packaging 10 | .Python 11 | env/ 12 | build/ 13 | develop-eggs/ 14 | dist/ 15 | downloads/ 16 | eggs/ 17 | .eggs/ 18 | lib/ 19 | lib64/ 20 | parts/ 21 | sdist/ 22 | var/ 23 | wheels/ 24 | *.egg-info/ 25 | .installed.cfg 26 | *.egg 27 | 28 | # PyInstaller 29 | # Usually these files are written by a python script from a template 30 | # before PyInstaller builds the exe, so as to inject date/other infos into it. 31 | *.manifest 32 | *.spec 33 | 34 | # Installer logs 35 | pip-log.txt 36 | pip-delete-this-directory.txt 37 | 38 | # Unit test / coverage reports 39 | htmlcov/ 40 | .tox/ 41 | .coverage 42 | .coverage.* 43 | .cache 44 | nosetests.xml 45 | coverage.xml 46 | *.cover 47 | .hypothesis/ 48 | .pytest_cache/ 49 | 50 | # Translations 51 | *.mo 52 | *.pot 53 | 54 | # Django stuff: 55 | *.log 56 | local_settings.py 57 | 58 | # Flask stuff: 59 | instance/ 60 | .webassets-cache 61 | 62 | # Scrapy stuff: 63 | .scrapy 64 | 65 | # Sphinx documentation 66 | docs/_build/ 67 | 68 | # PyBuilder 69 | target/ 70 | 71 | # Jupyter Notebook 72 | .ipynb_checkpoints 73 | 74 | # pyenv 75 | .python-version 76 | 77 | # celery beat schedule file 78 | celerybeat-schedule 79 | 80 | # SageMath parsed files 81 | *.sage.py 82 | 83 | # dotenv 84 | .env 85 | 86 | # virtualenv 87 | .venv 88 | venv/ 89 | ENV/ 90 | 91 | # Spyder project settings 92 | .spyderproject 93 | .spyproject 94 | 95 | # Rope project settings 96 | .ropeproject 97 | 98 | # mkdocs documentation 99 | /site 100 | 101 | # mypy 102 | .mypy_cache/ 103 | 104 | # IDE settings 105 | .vscode/ 106 | 107 | # Mac OS artifacts 108 | .DS_Store 109 | ._* 110 | 111 | test.py 112 | upload.sh 113 | archive/ 114 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2022-present NopeCHA 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | SOFTWARE. 22 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # NopeCHA 2 | 3 | ![PyPI - Version](https://img.shields.io/pypi/v/nopecha?label=PyPI&link=https%3A%2F%2Fnopecha.com&link=https%3A%2F%2Fnopecha.com%2Fpypi) 4 | ![NPM Version](https://img.shields.io/npm/v/nopecha?label=NPM&link=https%3A%2F%2Fnopecha.com&link=https%3A%2F%2Fnopecha.com%2Fnpm) 5 | ![GitHub Release](https://img.shields.io/github/v/release/NopeCHALLC/nopecha-extension?label=Extension%20Release&color=4a4&link=https%3A%2F%2Fnopecha.com&link=https%3A%2F%2Fnopecha.com%2Fgithub) 6 | ![Chrome Web Store Version](https://img.shields.io/chrome-web-store/v/dknlfmjaanfblgfdfebhijalfmhmjjjo?label=Chrome%20Web%20Store&color=4a4&link=https%3A%2F%2Fnopecha.com&link=https%3A%2F%2Fnopecha.com%2Fchrome) 7 | ![Mozilla Add-on Version](https://img.shields.io/amo/v/noptcha?label=Mozilla%20Add-on&color=4a4&link=https%3A%2F%2Fnopecha.com&link=https%3A%2F%2Fnopecha.com%2Ffirefox) 8 | 9 | API bindings for the [NopeCHA](https://nopecha.com) CAPTCHA service. 10 | 11 | ## Installation 12 | 13 | To install from PyPI, run `python3 -m pip install nopecha`. 14 | 15 | ## API Usage 16 | 17 | This package provides API wrappers for the following http packages: 18 | 19 | - [`requests`](https://pypi.org/project/requests/) (sync) 20 | - [`aiohttp`](https://pypi.org/project/aiohttp/) (async) 21 | - [`httpx`](https://pypi.org/project/httpx/) (sync & async) 22 | - [`urllib`](https://docs.python.org/3/library/urllib.html) (sync, built-in) 23 | 24 | Note: You will need to install the http package you want to use separately 25 | (except for `urllib`, as it's built-in but not recommended). 26 | 27 | ### Requests example 28 | 29 | ```python 30 | from nopecha.api.requests import RequestsAPIClient 31 | 32 | api = RequestsAPIClient("YOUR_API_KEY") 33 | solution = api.solve_hcaptcha("b4c45857-0e23-48e6-9017-e28fff99ffb2", "https://nopecha.com/demo/hcaptcha#easy") 34 | 35 | print("token is", solution["data"]) 36 | ``` 37 | 38 | ### Async HTTPX example 39 | 40 | ```python 41 | from nopecha.api.httpx import AsyncHTTPXAPIClient 42 | 43 | async def main(): 44 | api = AsyncHTTPXAPIClient("YOUR_API_KEY") 45 | solution = await api.solve_hcaptcha("b4c45857-0e23-48e6-9017-e28fff99ffb2", "https://nopecha.com/demo/hcaptcha#easy") 46 | print("token is", solution["data"]) 47 | 48 | asyncio.run(main()) 49 | ``` 50 | 51 | ## Extension builder 52 | 53 | This package also provides a extension builder for 54 | [Automation builds](https://developers.nopecha.com/guides/extension_advanced/#automation-build) 55 | which includes: 56 | 57 | 1. downloading the extension 58 | 2. updating the extension 59 | 3. updating the extension's manifest to include your settings 60 | 61 | ### Example 62 | 63 | ```python 64 | from nopecha.extension import build_chromium 65 | 66 | # will download the extension to the current working directory 67 | output = build_chromium({ 68 | "key": "YOUR_API_KEY", 69 | }) 70 | 71 | # custom output directory 72 | from pathlib import Path 73 | output = build_chromium({ 74 | "key": "YOUR_API_KEY", 75 | }, Path("extension")) 76 | ``` 77 | 78 | You can plug the output path directly into your browser's extension manager to 79 | load the extension: 80 | 81 | ```python 82 | import undetected_chromedriver as uc 83 | from nopecha.extension import build_chromium 84 | 85 | output = build_chromium({ 86 | "key": "YOUR_API_KEY", 87 | }) 88 | 89 | options = uc.ChromeOptions() 90 | options.add_argument(f"load-extension={output}") 91 | ``` 92 | 93 | ## Building 94 | 95 | To build from source, you will need to install 96 | [`build`](https://packaging.python.org/en/latest/key_projects/#build) 97 | (`python3 -m pip install --upgrade build `). 98 | 99 | Then simply run `python3 -m build` to build the package. 100 | 101 | #### Uploading to PyPI 102 | 103 | To upload to PyPI, you will need to install 104 | [`twine`](https://packaging.python.org/en/latest/key_projects/#twine) 105 | (`python3 -m pip install --upgrade twine`). 106 | 107 | Then simply run `python3 -m twine upload dist/*` to upload the package. 108 | 109 | ## Migrate from v1 110 | 111 | If you are migrating from v1, you will need to update your code to use the new 112 | client classes. 113 | 114 | V1 was synchronous only, using the requests HTTP library. V2 supports both 115 | synchronous and asynchronous code, and multiple HTTP libraries. 116 | 117 | To migrate, you will need to: 118 | 119 | 1. Install the http library you want to use (requests, aiohttp, httpx) or use 120 | the built-in urllib. 121 | 2. Replace `nopecha.api_key` with creating a client instance. 122 | 123 | ```py 124 | # Before 125 | import nopecha 126 | 127 | nopecha.api_key = "YOUR_API_KEY" 128 | 129 | # Now 130 | from nopecha.api.requests import RequestsAPIClient 131 | 132 | client = RequestsAPIClient("YOUR_API_KEY") 133 | ``` 134 | 135 | 3. Replace 136 | `nopecha.Token.solve()`/`nopecha.Recognition.solve()`/`nopecha.Balance.get()` 137 | with the appropriate method on the client instance. 138 | 139 | ```py 140 | # Before 141 | import nopecha 142 | nopecha.api_key = "..." 143 | 144 | clicks = nopecha.Recognition.solve( 145 | type='hcaptcha', 146 | task='Please click each image containing a cat-shaped cookie.', 147 | image_urls=[f"https://nopecha.com/image/demo/hcaptcha/{i}.png" for i in range(9)], 148 | ) 149 | print(clicks) 150 | 151 | token = nopecha.Token.solve( 152 | type='hcaptcha', 153 | sitekey='ab803303-ac41-41aa-9be1-7b4e01b91e2c', 154 | url='https://nopecha.com/demo/hcaptcha', 155 | ) 156 | print(token) 157 | 158 | balance = nopecha.Balance.get() 159 | print(balance) 160 | 161 | # Now 162 | from nopecha.api.requests import RequestsAPIClient 163 | 164 | client = RequestsAPIClient("YOUR_API_KEY") 165 | 166 | clicks = client.recognize_hcaptcha( 167 | 'Please click each image containing a cat-shaped cookie.', 168 | [f"https://nopecha.com/image/demo/hcaptcha/{i}.png" for i in range(9)], 169 | ) 170 | print(clicks) 171 | 172 | token = client.solve_hcaptcha( 173 | 'ab803303-ac41-41aa-9be1-7b4e01b91e2c', 174 | 'https://nopecha.com/demo/hcaptcha', 175 | ) 176 | print(token) 177 | 178 | balance = client.status() 179 | print(balance) 180 | ``` 181 | -------------------------------------------------------------------------------- /pyproject.toml: -------------------------------------------------------------------------------- 1 | [build-system] 2 | requires = ["hatchling"] 3 | build-backend = "hatchling.build" 4 | 5 | [project] 6 | name = "nopecha" 7 | version = "2.0.1" 8 | authors = [ 9 | { name="NopeCHA", email="contact@nopecha.com" }, 10 | ] 11 | description = "API bindings for the NopeCHA CAPTCHA-solving service." 12 | readme = "README.md" 13 | require_python = ">=3.7.1" 14 | classifiers = [ 15 | "Programming Language :: Python :: 3", 16 | "License :: OSI Approved :: MIT License", 17 | "Operating System :: OS Independent", 18 | ] 19 | 20 | [project.urls] 21 | Homepage = "https://nopecha.com" 22 | Documentation = "https://developers.nopecha.com" 23 | "GitHub Repository" = "https://github.com/NopeCHALLC/nopecha-python" 24 | 25 | -------------------------------------------------------------------------------- /src/nopecha/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/NopeCHALLC/nopecha-python/17da40ca00184f81c977d89f5dc9d0abc56a720f/src/nopecha/__init__.py -------------------------------------------------------------------------------- /src/nopecha/api/__init__.py: -------------------------------------------------------------------------------- 1 | __all__ = [] 2 | -------------------------------------------------------------------------------- /src/nopecha/api/_base.py: -------------------------------------------------------------------------------- 1 | import typing 2 | from abc import ABC, abstractmethod 3 | from logging import getLogger 4 | from urllib.parse import urlencode 5 | 6 | from .types import ( 7 | AudioRecognitionRequest, 8 | ErrorCode, 9 | GeneralTokenRequest, 10 | HCaptchaAreaSelectRequest, 11 | HCaptchaAreaSelectResponse, 12 | HCaptchaMultipleChoiceRequest, 13 | ImageRecognitionRequest, 14 | Proxy, 15 | RecognitionRequest, 16 | RecognitionResponse, 17 | StatusResponse, 18 | TextCaptchaRecognitionRequest, 19 | TokenRequest, 20 | TokenResponse, 21 | TurnstileTokenRequest, 22 | ) 23 | from ._throttle import exp_throttle, linear_throttle, sleeper, async_sleeper 24 | from ._validate import validate_image 25 | 26 | logger = getLogger(__name__) 27 | _error_message = ( 28 | "Server did not {} after {} attempts. " 29 | "Server may be overloaded (https://nopecha.com/discord). " 30 | "Alternatively try increasing the {}_max_attempts parameter (or set to 0 for unlimited retries)." 31 | ) 32 | 33 | 34 | class UniformResponse(typing.NamedTuple): 35 | status_code: int 36 | body: typing.Optional[dict] 37 | 38 | 39 | class APIClientMixin: 40 | key: str | None = None 41 | post_max_attempts: int = 10 42 | get_max_attempts: int = 120 43 | host = "https://api.nopecha.com" 44 | 45 | def __init__( 46 | self, 47 | key: str | None = None, 48 | *, 49 | post_max_attempts: int = 10, 50 | get_max_attempts: int = 120, 51 | ): 52 | self.key = key 53 | self.post_max_attempts = post_max_attempts 54 | self.get_max_attempts = get_max_attempts 55 | 56 | def _should_retry(self, response: UniformResponse) -> bool: 57 | # automatically retry on 5xx errors and 429 58 | if response.status_code >= 500: 59 | logger.debug(f"Server returned {response.status_code}, retrying") 60 | return True 61 | elif response.status_code == 429: 62 | logger.debug("Server is ratelimiting us, retrying") 63 | return True 64 | elif response.body is None: 65 | logger.debug("Server returned no data, retrying") 66 | return True 67 | return False 68 | 69 | def _get_headers(self) -> dict: 70 | headers = { 71 | "user-agent": self._get_useragent(), 72 | } 73 | if self.key: 74 | headers["authorization"] = f"Bearer {self.key}" 75 | return headers 76 | 77 | def _get_useragent(self) -> str: 78 | try: 79 | import pkg_resources 80 | 81 | package_version = pkg_resources.get_distribution("nopecha").version 82 | except: 83 | package_version = "unknown" 84 | 85 | try: 86 | import platform 87 | 88 | system = platform.platform() 89 | python_version = ( 90 | platform.python_implementation() + "/" + platform.python_version() 91 | ) 92 | except: 93 | system = "unknown" 94 | python_version = "unknown" 95 | 96 | signficant_dependencies = ["requests", "httpx", "aiohttp"] 97 | versions = [] 98 | for dependency in signficant_dependencies: 99 | try: 100 | import pkg_resources 101 | 102 | version = pkg_resources.get_distribution(dependency).version 103 | versions.append(f"{dependency}/{version}") 104 | except: 105 | pass 106 | 107 | return f"NopeCHA-Python/{package_version} ({python_version}; {type(self).__name__}; {system}) {' '.join(versions)}".strip() 108 | 109 | 110 | class APIClient(ABC, APIClientMixin): 111 | @abstractmethod 112 | def _request_raw( 113 | self, method: str, url: str, body: typing.Optional[dict] = None 114 | ) -> UniformResponse: 115 | raise NotImplementedError 116 | 117 | def _request(self, endpoint: str, body: typing.Any) -> typing.Any: 118 | if self.key: 119 | body["key"] = self.key 120 | job_id = self._request_post(endpoint, body) 121 | 122 | get_endpoint = endpoint + "?" + urlencode({"key": self.key, "id": job_id}) 123 | return self._request_get(get_endpoint) 124 | 125 | def _request_post(self, endpoint: str, body: typing.Any) -> str: 126 | for _ in sleeper(exp_throttle(max_attempts=self.post_max_attempts)): 127 | job_request = self._request_raw("POST", endpoint, body) 128 | if self._should_retry(job_request): 129 | continue 130 | assert job_request.body is not None 131 | if "data" in job_request.body: 132 | return job_request.body["data"] 133 | elif "error" in job_request.body: 134 | raise RuntimeError(f"Server returned error: {job_request.body}") 135 | 136 | raise RuntimeError( 137 | _error_message.format("accept job", self.post_max_attempts, "post") 138 | ) 139 | 140 | def _request_get(self, endpoint: str) -> typing.Any: 141 | for _ in sleeper(linear_throttle(max_attempts=self.get_max_attempts)): 142 | job_request = self._request_raw("GET", endpoint) 143 | if self._should_retry(job_request): 144 | continue 145 | assert job_request.body is not None 146 | if "data" in job_request.body: 147 | return job_request.body 148 | elif "error" in job_request.body: 149 | if job_request.body["error"] == ErrorCode.IncompleteJob: 150 | continue 151 | raise RuntimeError(f"Server returned error: {job_request.body}") 152 | 153 | raise RuntimeError( 154 | _error_message.format("solve job", self.get_max_attempts, "get") 155 | ) 156 | 157 | def recognize_raw(self, body: RecognitionRequest) -> RecognitionResponse: 158 | return self._request(f"{self.host}/", body) 159 | 160 | def solve_raw(self, body: TokenRequest) -> TokenResponse: 161 | return self._request(f"{self.host}/token/", body) 162 | 163 | def status(self) -> StatusResponse: 164 | url = f"{self.host}/status/?" + urlencode({"key": self.key}) 165 | for _ in sleeper(linear_throttle(max_attempts=self.get_max_attempts)): 166 | status_request = self._request_raw("GET", url) 167 | if self._should_retry(status_request): 168 | continue 169 | assert status_request.body is not None 170 | return typing.cast(StatusResponse, status_request.body) 171 | 172 | raise RuntimeError( 173 | _error_message.format("get status", self.get_max_attempts, "get") 174 | ) 175 | 176 | def recognize_hcaptcha( 177 | self, task: str, images: typing.List[str] 178 | ) -> RecognitionResponse: 179 | for image in images: 180 | validate_image(image) 181 | 182 | body: ImageRecognitionRequest = { 183 | "type": "hcaptcha", 184 | "task": task, 185 | "image_data": images, 186 | } 187 | return typing.cast(RecognitionResponse, self.recognize_raw(body)) 188 | 189 | def recognize_hcaptcha_area_select( 190 | self, 191 | task: str, 192 | image: str, 193 | image_examples: typing.Optional[typing.List[str]] = None, 194 | ) -> HCaptchaAreaSelectResponse: 195 | validate_image(image) 196 | if image_examples is not None: 197 | for image_example in image_examples: 198 | validate_image(image_example) 199 | 200 | body: HCaptchaAreaSelectRequest = { 201 | "type": "hcaptcha_area_select", 202 | "task": task, 203 | "image_data": (image,), 204 | "image_examples": image_examples, 205 | } 206 | return typing.cast(HCaptchaAreaSelectResponse, self.recognize_raw(body)) 207 | 208 | def recognize_hcaptcha_multiple_choice( 209 | self, 210 | task: str, 211 | image: str, 212 | choices: typing.List[str], 213 | image_choices: typing.Optional[typing.List[str]] = None, 214 | ) -> HCaptchaMultipleChoiceRequest: 215 | validate_image(image) 216 | if image_choices is not None: 217 | for image_choice in image_choices: 218 | validate_image(image_choice) 219 | 220 | body: HCaptchaMultipleChoiceRequest = { 221 | "type": "hcaptcha_multiple_choice", 222 | "task": task, 223 | "image_data": (image,), 224 | "choices": choices, 225 | "image_choices": image_choices, 226 | } 227 | return typing.cast(HCaptchaMultipleChoiceRequest, self.recognize_raw(body)) 228 | 229 | def recognize_recaptcha( 230 | self, task: str, images: typing.List[str] 231 | ) -> RecognitionResponse: 232 | for image in images: 233 | validate_image(image) 234 | 235 | if not 9 >= len(images) >= 1: 236 | raise ValueError("recaptcha requires 1-9 images") 237 | 238 | body: ImageRecognitionRequest = { 239 | "type": "recaptcha", 240 | "task": task, 241 | "image_data": images, 242 | } 243 | return typing.cast(RecognitionResponse, self.recognize_raw(body)) 244 | 245 | def recognize_funcaptcha(self, task: str, image: str) -> RecognitionResponse: 246 | validate_image(image) 247 | 248 | body: ImageRecognitionRequest = { 249 | "type": "funcaptcha", 250 | "task": task, 251 | "image_data": [image], 252 | } 253 | return typing.cast(RecognitionResponse, self.recognize_raw(body)) 254 | 255 | def recognize_textcaptcha(self, image: str) -> RecognitionResponse: 256 | validate_image(image) 257 | 258 | body: TextCaptchaRecognitionRequest = { 259 | "type": "textcaptcha", 260 | "image_data": (image,), 261 | } 262 | return typing.cast(RecognitionResponse, self.recognize_raw(body)) 263 | 264 | def recognize_awscaptcha(self, audio: str) -> RecognitionResponse: 265 | validate_image(audio) 266 | 267 | body: AudioRecognitionRequest = { 268 | "type": "awscaptcha", 269 | "audio_data": (audio,), 270 | } 271 | return typing.cast(RecognitionResponse, self.recognize_raw(body)) 272 | 273 | def solve_hcaptcha( 274 | self, 275 | sitekey: str, 276 | url: str, 277 | *, 278 | enterprise: bool = False, 279 | proxy: typing.Optional[Proxy] = None, 280 | useragent: typing.Optional[str] = None, 281 | rqdata: typing.Optional[str] = None, 282 | ) -> TokenResponse: 283 | if not enterprise and rqdata is not None: 284 | logger.warning( 285 | "you are setting rqdata for non-enterprise hcaptcha, this makes no sense" 286 | ) 287 | elif enterprise and proxy is None: 288 | logger.warning( 289 | "you are using enterprise hcaptcha without a proxy, probably won't work" 290 | ) 291 | 292 | body: GeneralTokenRequest = { 293 | "type": "hcaptcha", 294 | "sitekey": sitekey, 295 | "url": url, 296 | "enterprise": enterprise, 297 | "proxy": proxy, 298 | "data": {"rqdata": rqdata} if rqdata is not None else None, 299 | "useragent": useragent, 300 | } 301 | return typing.cast(TokenResponse, self.solve_raw(body)) 302 | 303 | def solve_recaptcha_v2( 304 | self, 305 | sitekey: str, 306 | url: str, 307 | *, 308 | enterprise: bool = False, 309 | proxy: typing.Optional[Proxy] = None, 310 | useragent: typing.Optional[str] = None, 311 | sdata: typing.Optional[str] = None, 312 | ) -> TokenResponse: 313 | if not enterprise and sdata is not None: 314 | logger.warning( 315 | "you are setting sdata for non-enterprise recaptcha, this makes no sense" 316 | ) 317 | elif enterprise and proxy is None: 318 | logger.warning( 319 | "you are using enterprise recaptcha without a proxy, probably won't work" 320 | ) 321 | 322 | body: GeneralTokenRequest = { 323 | "type": "recaptcha2", 324 | "sitekey": sitekey, 325 | "url": url, 326 | "proxy": proxy, 327 | "useragent": useragent, 328 | "data": {"s": sdata} if sdata is not None else None, 329 | "enterprise": enterprise, 330 | } 331 | return typing.cast(TokenResponse, self.solve_raw(body)) 332 | 333 | def solve_recaptcha_v3( 334 | self, 335 | sitekey: str, 336 | url: str, 337 | *, 338 | enterprise: bool = False, 339 | proxy: typing.Optional[Proxy] = None, 340 | useragent: typing.Optional[str] = None, 341 | action: typing.Optional[str] = None, 342 | ) -> TokenResponse: 343 | if enterprise and proxy is None: 344 | logger.warning( 345 | "you are using enterprise recaptcha without a proxy, probably won't work" 346 | ) 347 | 348 | body: GeneralTokenRequest = { 349 | "type": "recaptcha3", 350 | "sitekey": sitekey, 351 | "url": url, 352 | "proxy": proxy, 353 | "useragent": useragent, 354 | "data": {"action": action} if action is not None else None, 355 | "enterprise": enterprise, 356 | } 357 | return typing.cast(TokenResponse, self.solve_raw(body)) 358 | 359 | def solve_cloudflare_turnstile( 360 | self, 361 | sitekey: str, 362 | url: str, 363 | *, 364 | proxy: typing.Optional[Proxy] = None, 365 | useragent: typing.Optional[str] = None, 366 | action: typing.Optional[str] = None, 367 | cdata: typing.Optional[str] = None, 368 | challenge_page_data: typing.Optional[str] = None, 369 | ) -> TokenResponse: 370 | body: TurnstileTokenRequest = { 371 | "type": "turnstile", 372 | "sitekey": sitekey, 373 | "url": url, 374 | "proxy": proxy, 375 | "useragent": useragent, 376 | "data": { 377 | "action": action, 378 | "cdata": cdata, 379 | "chlPageData": challenge_page_data, 380 | }, 381 | } 382 | return typing.cast(TokenResponse, self.solve_raw(body)) 383 | 384 | 385 | class AsyncAPIClient(APIClientMixin): 386 | async def _request_raw( 387 | self, method: str, url: str, body: typing.Optional[dict] = None 388 | ) -> UniformResponse: 389 | raise NotImplementedError 390 | 391 | async def _request(self, endpoint: str, body: typing.Any) -> typing.Any: 392 | if self.key: 393 | body["key"] = self.key 394 | job_id = await self._request_post(endpoint, body) 395 | 396 | get_endpoint = endpoint + "?" + urlencode({"key": self.key, "id": job_id}) 397 | return await self._request_get(get_endpoint) 398 | 399 | async def _request_post(self, endpoint: str, body: typing.Any) -> str: 400 | async for _ in async_sleeper(exp_throttle(max_attempts=self.post_max_attempts)): 401 | job_request = await self._request_raw("POST", endpoint, body) 402 | if self._should_retry(job_request): 403 | continue 404 | assert job_request.body is not None 405 | if "data" in job_request.body: 406 | return job_request.body["data"] 407 | elif "error" in job_request.body: 408 | raise RuntimeError(f"Server returned error: {job_request.body}") 409 | 410 | raise RuntimeError( 411 | _error_message.format("accept job", self.post_max_attempts, "post") 412 | ) 413 | 414 | async def _request_get(self, endpoint: str) -> typing.Any: 415 | async for _ in async_sleeper( 416 | linear_throttle(max_attempts=self.get_max_attempts) 417 | ): 418 | job_request = await self._request_raw("GET", endpoint) 419 | if self._should_retry(job_request): 420 | continue 421 | assert job_request.body is not None 422 | if "data" in job_request.body: 423 | return job_request.body 424 | elif "error" in job_request.body: 425 | if job_request.body["error"] == ErrorCode.IncompleteJob: 426 | continue 427 | raise RuntimeError(f"Server returned error: {job_request.body}") 428 | 429 | raise RuntimeError( 430 | _error_message.format("solve job", self.get_max_attempts, "get") 431 | ) 432 | 433 | async def recognize_raw(self, body: RecognitionRequest) -> RecognitionResponse: 434 | return await self._request("/", body) 435 | 436 | async def solve_raw(self, body: TokenRequest) -> TokenResponse: 437 | return await self._request("/token/", body) 438 | 439 | async def status(self) -> StatusResponse: 440 | url = f"{self.host}/status/?" + urlencode({"key": self.key}) 441 | async for _ in async_sleeper( 442 | linear_throttle(max_attempts=self.get_max_attempts) 443 | ): 444 | status_request = await self._request_raw("GET", url) 445 | if self._should_retry(status_request): 446 | continue 447 | assert status_request.body is not None 448 | return typing.cast(StatusResponse, status_request.body) 449 | 450 | raise RuntimeError( 451 | _error_message.format("get status", self.get_max_attempts, "get") 452 | ) 453 | 454 | async def recognize_hcaptcha( 455 | self, task: str, images: typing.List[str] 456 | ) -> RecognitionResponse: 457 | for image in images: 458 | validate_image(image) 459 | 460 | body: ImageRecognitionRequest = { 461 | "type": "hcaptcha", 462 | "task": task, 463 | "image_data": images, 464 | } 465 | return typing.cast(RecognitionResponse, await self.recognize_raw(body)) 466 | 467 | async def recognize_hcaptcha_area_select( 468 | self, 469 | task: str, 470 | image: str, 471 | image_examples: typing.Optional[typing.List[str]] = None, 472 | ) -> HCaptchaAreaSelectResponse: 473 | validate_image(image) 474 | if image_examples is not None: 475 | for image_example in image_examples: 476 | validate_image(image_example) 477 | 478 | body: HCaptchaAreaSelectRequest = { 479 | "type": "hcaptcha_area_select", 480 | "task": task, 481 | "image_data": (image,), 482 | "image_examples": image_examples, 483 | } 484 | return typing.cast(HCaptchaAreaSelectResponse, await self.recognize_raw(body)) 485 | 486 | async def recognize_hcaptcha_multiple_choice( 487 | self, 488 | task: str, 489 | image: str, 490 | choices: typing.List[str], 491 | image_choices: typing.Optional[typing.List[str]] = None, 492 | ) -> HCaptchaMultipleChoiceRequest: 493 | validate_image(image) 494 | if image_choices is not None: 495 | for image_choice in image_choices: 496 | validate_image(image_choice) 497 | 498 | body: HCaptchaMultipleChoiceRequest = { 499 | "type": "hcaptcha_multiple_choice", 500 | "task": task, 501 | "image_data": (image,), 502 | "choices": choices, 503 | "image_choices": image_choices, 504 | } 505 | return typing.cast( 506 | HCaptchaMultipleChoiceRequest, await self.recognize_raw(body) 507 | ) 508 | 509 | async def recognize_recaptcha( 510 | self, task: str, images: typing.List[str] 511 | ) -> RecognitionResponse: 512 | for image in images: 513 | validate_image(image) 514 | 515 | if not 9 >= len(images) >= 1: 516 | raise ValueError("recaptcha requires 1-9 images") 517 | 518 | body: ImageRecognitionRequest = { 519 | "type": "recaptcha", 520 | "task": task, 521 | "image_data": images, 522 | } 523 | return typing.cast(RecognitionResponse, await self.recognize_raw(body)) 524 | 525 | async def recognize_funcaptcha(self, task: str, image: str) -> RecognitionResponse: 526 | validate_image(image) 527 | 528 | body: ImageRecognitionRequest = { 529 | "type": "funcaptcha", 530 | "task": task, 531 | "image_data": [image], 532 | } 533 | return typing.cast(RecognitionResponse, await self.recognize_raw(body)) 534 | 535 | async def recognize_textcaptcha(self, image: str) -> RecognitionResponse: 536 | validate_image(image) 537 | 538 | body: TextCaptchaRecognitionRequest = { 539 | "type": "textcaptcha", 540 | "image_data": (image,), 541 | } 542 | return typing.cast(RecognitionResponse, await self.recognize_raw(body)) 543 | 544 | async def recognize_awscaptcha(self, audio: str) -> RecognitionResponse: 545 | validate_image(audio) 546 | 547 | body: AudioRecognitionRequest = { 548 | "type": "awscaptcha", 549 | "audio_data": (audio,), 550 | } 551 | return typing.cast(RecognitionResponse, await self.recognize_raw(body)) 552 | 553 | async def solve_hcaptcha( 554 | self, 555 | sitekey: str, 556 | url: str, 557 | *, 558 | enterprise: bool = False, 559 | proxy: typing.Optional[Proxy] = None, 560 | useragent: typing.Optional[str] = None, 561 | rqdata: typing.Optional[str] = None, 562 | ) -> TokenResponse: 563 | if not enterprise and rqdata is not None: 564 | logger.warning( 565 | "you are setting rqdata for non-enterprise hcaptcha, this makes no sense" 566 | ) 567 | elif enterprise and proxy is None: 568 | logger.warning( 569 | "you are using enterprise hcaptcha without a proxy, probably won't work" 570 | ) 571 | 572 | body: GeneralTokenRequest = { 573 | "type": "hcaptcha", 574 | "sitekey": sitekey, 575 | "url": url, 576 | "enterprise": enterprise, 577 | "proxy": proxy, 578 | "data": {"rqdata": rqdata} if rqdata is not None else None, 579 | "useragent": useragent, 580 | } 581 | return typing.cast(TokenResponse, await self.solve_raw(body)) 582 | 583 | async def solve_recaptcha_v2( 584 | self, 585 | sitekey: str, 586 | url: str, 587 | *, 588 | enterprise: bool = False, 589 | proxy: typing.Optional[Proxy] = None, 590 | useragent: typing.Optional[str] = None, 591 | sdata: typing.Optional[str] = None, 592 | ) -> TokenResponse: 593 | if not enterprise and sdata is not None: 594 | logger.warning( 595 | "you are setting sdata for non-enterprise recaptcha, this makes no sense" 596 | ) 597 | elif enterprise and proxy is None: 598 | logger.warning( 599 | "you are using enterprise recaptcha without a proxy, probably won't work" 600 | ) 601 | 602 | body: GeneralTokenRequest = { 603 | "type": "recaptcha2", 604 | "sitekey": sitekey, 605 | "url": url, 606 | "proxy": proxy, 607 | "useragent": useragent, 608 | "data": {"s": sdata} if sdata is not None else None, 609 | "enterprise": enterprise, 610 | } 611 | return typing.cast(TokenResponse, await self.solve_raw(body)) 612 | 613 | async def solve_recaptcha_v3( 614 | self, 615 | sitekey: str, 616 | url: str, 617 | *, 618 | enterprise: bool = False, 619 | proxy: typing.Optional[Proxy] = None, 620 | useragent: typing.Optional[str] = None, 621 | action: typing.Optional[str] = None, 622 | ) -> TokenResponse: 623 | if not enterprise and action is not None: 624 | logger.warning( 625 | "you are setting action for non-enterprise recaptcha, this makes no sense" 626 | ) 627 | elif enterprise and proxy is None: 628 | logger.warning( 629 | "you are using enterprise recaptcha without a proxy, probably won't work" 630 | ) 631 | 632 | body: GeneralTokenRequest = { 633 | "type": "recaptcha3", 634 | "sitekey": sitekey, 635 | "url": url, 636 | "proxy": proxy, 637 | "useragent": useragent, 638 | "data": {"action": action} if action is not None else None, 639 | "enterprise": enterprise, 640 | } 641 | return typing.cast(TokenResponse, await self.solve_raw(body)) 642 | 643 | async def solve_cloudflare_turnstile( 644 | self, 645 | sitekey: str, 646 | url: str, 647 | *, 648 | proxy: typing.Optional[Proxy] = None, 649 | useragent: typing.Optional[str] = None, 650 | action: typing.Optional[str] = None, 651 | cdata: typing.Optional[str] = None, 652 | challenge_page_data: typing.Optional[str] = None, 653 | ) -> TokenResponse: 654 | body: TurnstileTokenRequest = { 655 | "type": "turnstile", 656 | "sitekey": sitekey, 657 | "url": url, 658 | "proxy": proxy, 659 | "useragent": useragent, 660 | "data": { 661 | "action": action, 662 | "cdata": cdata, 663 | "chlPageData": challenge_page_data, 664 | }, 665 | } 666 | return typing.cast(TokenResponse, await self.solve_raw(body)) 667 | -------------------------------------------------------------------------------- /src/nopecha/api/_throttle.py: -------------------------------------------------------------------------------- 1 | """ 2 | Throttler for API calls with exponential backoff. 3 | Will automatically sleep for you. 4 | 5 | Usage: 6 | 7 | for _ in sleeper(throttle()): 8 | pass # call the API 9 | 10 | 11 | async for _ in async_sleeper(throttle()): 12 | pass # call the API 13 | 14 | """ 15 | 16 | import asyncio 17 | import time 18 | import typing 19 | 20 | 21 | def linear_throttle(*, factor: float = 1, max_sleep: float = 60, max_attempts: int = 0): 22 | yield 0 # first one is free 23 | attempt = 0 24 | while max_attempts > attempt or max_attempts <= 0: 25 | attempt += 1 26 | yield min(factor * attempt, max_sleep) 27 | 28 | 29 | def exp_throttle(*, base: float = 1.54, max_sleep: float = 60, max_attempts: int = 0): 30 | yield 0 # first one is free 31 | attempt = 0 32 | while max_attempts > attempt or max_attempts < 0: 33 | attempt += 1 34 | yield min(base**attempt, max_sleep) 35 | 36 | 37 | def sleeper(gen: typing.Generator[float, None, None]): 38 | for delay in gen: 39 | yield time.sleep(delay) 40 | 41 | 42 | async def async_sleeper(gen: typing.Generator[float, None, None]): 43 | for delay in gen: 44 | yield await asyncio.sleep(delay) 45 | -------------------------------------------------------------------------------- /src/nopecha/api/_validate.py: -------------------------------------------------------------------------------- 1 | def validate_not_url(url): 2 | return isinstance(url, str) and not url.startswith(("http:", "https:")) 3 | 4 | 5 | def validate_image(image): 6 | if not validate_not_url(image): 7 | raise ValueError("image must be a base64 encoded image") 8 | 9 | 10 | def validate_audio(audio): 11 | if not validate_not_url(audio): 12 | raise ValueError("audio must be a base64 encoded audio") 13 | -------------------------------------------------------------------------------- /src/nopecha/api/aiohttp.py: -------------------------------------------------------------------------------- 1 | import typing 2 | from logging import getLogger 3 | 4 | from aiohttp import ClientSession 5 | 6 | try: 7 | import aiohttp 8 | except ImportError: 9 | raise ImportError("You must install 'aiohttp' to use `nopecha.api.aiohttp`") 10 | 11 | from ._base import AsyncAPIClient, UniformResponse 12 | 13 | logger = getLogger(__name__) 14 | __all__ = ["AsyncHTTPXAPIClient"] 15 | 16 | 17 | class AsyncHTTPXAPIClient(AsyncAPIClient): 18 | client: aiohttp.ClientSession 19 | 20 | def __init__(self, *args, client: ClientSession, **kwargs): 21 | super().__init__(*args, **kwargs) 22 | 23 | self.client = client 24 | 25 | async def _request_raw( 26 | self, method: str, url: str, body: typing.Optional[dict] = None 27 | ) -> UniformResponse: 28 | status = 999 29 | try: 30 | async with self.client.request( 31 | method, url, json=body, headers=self._get_headers() 32 | ) as response: 33 | status = response.status 34 | return UniformResponse(status, await response.json()) 35 | except Exception as e: 36 | logger.warning("Request failed: %s", e) 37 | return UniformResponse(status, None) 38 | -------------------------------------------------------------------------------- /src/nopecha/api/httpx.py: -------------------------------------------------------------------------------- 1 | import typing 2 | from logging import getLogger 3 | 4 | try: 5 | import httpx 6 | except ImportError: 7 | raise ImportError("You must install 'httpx' to use `nopecha.api.httpx`") 8 | 9 | from ._base import APIClient, AsyncAPIClient, UniformResponse 10 | 11 | logger = getLogger(__name__) 12 | __all__ = ["HTTPXAPIClient", "AsyncHTTPXAPIClient"] 13 | 14 | 15 | class HTTPXAPIClient(APIClient): 16 | client: httpx.Client 17 | 18 | def __init__(self, *args, **kwargs): 19 | client = kwargs.pop("client", None) 20 | super().__init__(*args, **kwargs) 21 | 22 | if client is None: 23 | client = httpx.Client() 24 | elif isinstance(client, httpx.AsyncClient): 25 | raise TypeError( 26 | "Expected httpx.Client, got httpx.AsyncClient. Use `nopecha.api.httpx.AsyncHTTPXAPIClient` for async usage instead." 27 | ) 28 | self.client = client 29 | 30 | def _request_raw( 31 | self, method: str, url: str, body: typing.Optional[dict] = None 32 | ) -> UniformResponse: 33 | status = 999 34 | try: 35 | response = self.client.request( 36 | method, url, json=body, headers=self._get_headers() 37 | ) 38 | status = response.status_code 39 | return UniformResponse(status, response.json()) 40 | except Exception as e: 41 | logger.warning("Request failed: %s", e) 42 | return UniformResponse(status, None) 43 | 44 | 45 | class AsyncHTTPXAPIClient(AsyncAPIClient): 46 | client: httpx.AsyncClient 47 | 48 | def __init__(self, *args, **kwargs): 49 | client = kwargs.pop("client", None) 50 | super().__init__(*args, **kwargs) 51 | 52 | if client is None: 53 | client = httpx.AsyncClient() 54 | elif isinstance(client, httpx.Client): 55 | raise TypeError( 56 | "Expected httpx.AsyncClient, got httpx.Client. Use `nopecha.api.httpx.HTTPXAPIClient` for sync usage instead." 57 | ) 58 | self.client = client 59 | 60 | async def _request_raw( 61 | self, method: str, url: str, body: typing.Optional[dict] = None 62 | ) -> UniformResponse: 63 | status = 999 64 | try: 65 | response = await self.client.request( 66 | method, url, json=body, headers=self._get_headers() 67 | ) 68 | status = response.status_code 69 | return UniformResponse(status, response.json()) 70 | except Exception as e: 71 | logger.warning("Request failed: %s", e) 72 | return UniformResponse(status, None) 73 | -------------------------------------------------------------------------------- /src/nopecha/api/requests.py: -------------------------------------------------------------------------------- 1 | import typing 2 | from logging import getLogger 3 | 4 | try: 5 | import requests 6 | except ImportError: 7 | raise ImportError("You must install 'requests' to use `nopecha.api.requests`") 8 | 9 | from ._base import APIClient, UniformResponse 10 | 11 | logger = getLogger(__name__) 12 | __all__ = ["RequestsAPIClient"] 13 | 14 | 15 | class RequestsAPIClient(APIClient): 16 | session: requests.Session 17 | 18 | def __init__(self, *args, **kwargs): 19 | session = kwargs.pop("session", None) 20 | super().__init__(*args, **kwargs) 21 | 22 | if session is None: 23 | session = requests.Session() 24 | self.session = session 25 | 26 | def _request_raw( 27 | self, method: str, url: str, body: typing.Optional[dict] = None 28 | ) -> UniformResponse: 29 | status = 999 30 | try: 31 | response = self.session.request( 32 | method, url, json=body, headers=self._get_headers() 33 | ) 34 | status = response.status_code 35 | return UniformResponse(status, response.json()) 36 | except Exception as e: 37 | logger.warning("Request failed: %s", e) 38 | return UniformResponse(status, None) 39 | -------------------------------------------------------------------------------- /src/nopecha/api/types.py: -------------------------------------------------------------------------------- 1 | import typing 2 | from enum import IntEnum 3 | 4 | 5 | class ErrorCode(IntEnum): 6 | Unknown = 9 7 | InvalidRequest = 10 8 | Ratelimited = 11 9 | BannedIp = 12 10 | NoJob = 13 11 | IncompleteJob = 14 12 | InvalidKey = 15 13 | NoCredit = 16 14 | UpdateRequired = 17 15 | UnavailableFeature = 18 16 | 17 | 18 | class JobQueuedResponse(typing.TypedDict): 19 | data: str 20 | 21 | 22 | class ErrorResponse(typing.TypedDict): 23 | error: typing.Union[ErrorCode, int] 24 | message: str 25 | 26 | 27 | class Proxy(typing.TypedDict): 28 | type: typing.Literal["http", "https", "socks4", "socks5"] 29 | host: str 30 | port: int 31 | login: typing.Optional[str] 32 | password: typing.Optional[str] 33 | 34 | 35 | class ImageRecognitionRequest(typing.TypedDict): 36 | type: typing.Literal["funcaptcha", "hcaptcha", "recaptcha"] 37 | task: str 38 | image_data: typing.List[str] 39 | 40 | 41 | class TextCaptchaRecognitionRequest(typing.TypedDict): 42 | type: typing.Literal["textcaptcha"] 43 | image_data: typing.Tuple[str] 44 | 45 | 46 | class HCaptchaAreaSelectRequest(typing.TypedDict): 47 | type: typing.Literal["hcaptcha_area_select"] 48 | task: str 49 | image_data: typing.Tuple[str] 50 | image_examples: typing.Optional[typing.List[str]] 51 | 52 | 53 | class HCaptchaMultipleChoiceRequest(typing.TypedDict): 54 | type: typing.Literal["hcaptcha_multiple_choice"] 55 | task: str 56 | image_data: typing.Tuple[str] 57 | choices: typing.List[str] 58 | image_choices: typing.Optional[typing.List[str]] 59 | 60 | 61 | class AudioRecognitionRequest(typing.TypedDict): 62 | type: typing.Literal["awscaptcha"] 63 | audio_data: typing.Tuple[str] 64 | 65 | 66 | RecognitionRequest = typing.Union[ 67 | ImageRecognitionRequest, 68 | TextCaptchaRecognitionRequest, 69 | HCaptchaMultipleChoiceRequest, 70 | HCaptchaAreaSelectRequest, 71 | AudioRecognitionRequest, 72 | ] 73 | 74 | 75 | class ImageRecognitionResponse(typing.TypedDict): 76 | data: typing.List[str] 77 | 78 | 79 | class HCaptchaAreaSelectResponseData(typing.TypedDict): 80 | x: int 81 | y: int 82 | w: int # in % (0-100) 83 | h: int # in % (0-100) 84 | 85 | 86 | class HCaptchaAreaSelectResponse(typing.TypedDict): 87 | data: HCaptchaAreaSelectResponseData 88 | 89 | 90 | class HCaptchaMultipleChoiceResponse(typing.TypedDict): 91 | data: str 92 | 93 | 94 | RecognitionResponse = typing.Union[ 95 | ImageRecognitionResponse, HCaptchaMultipleChoiceResponse, HCaptchaAreaSelectResponse 96 | ] 97 | 98 | 99 | class GeneralTokenRequest(typing.TypedDict): 100 | type: typing.Literal["hcaptcha", "recaptcha2", "recaptcha3"] 101 | sitekey: str 102 | url: str 103 | enterprise: typing.Optional[bool] # only for recaptcha and hcaptcha 104 | data: typing.Optional[typing.Dict[str, typing.Any]] 105 | proxy: typing.Optional[Proxy] 106 | useragent: typing.Optional[str] 107 | 108 | 109 | class TurnstileTokenRequest(typing.TypedDict): 110 | type: typing.Literal["turnstile"] 111 | sitekey: str 112 | url: str 113 | data: typing.Optional[typing.Dict[str, typing.Any]] 114 | proxy: typing.Optional[Proxy] 115 | useragent: typing.Optional[str] 116 | 117 | 118 | TokenRequest = typing.Union[GeneralTokenRequest, TurnstileTokenRequest] 119 | 120 | 121 | class TokenResponse(typing.TypedDict): 122 | data: str 123 | 124 | 125 | class StatusResponse(typing.TypedDict): 126 | plan: str 127 | status: str 128 | credit: int 129 | quota: int 130 | duration: int 131 | lastreset: int 132 | ttl: int 133 | subscribed: bool 134 | current_period_start: int 135 | current_period_end: int 136 | -------------------------------------------------------------------------------- /src/nopecha/api/urllib.py: -------------------------------------------------------------------------------- 1 | import typing 2 | from json import dumps, loads 3 | from logging import getLogger 4 | from urllib.request import urlopen, Request 5 | from urllib.error import HTTPError, URLError 6 | 7 | from ._base import APIClient, UniformResponse 8 | 9 | logger = getLogger(__name__) 10 | __all__ = ["UrllibAPIClient"] 11 | 12 | 13 | class UrllibAPIClient(APIClient): 14 | def _get_headers(self) -> dict: 15 | headers = super()._get_headers() 16 | headers.update({"content-type": "application/json"}) 17 | return headers 18 | 19 | def _request_raw( 20 | self, method: str, url: str, body: typing.Optional[dict] = None 21 | ) -> UniformResponse: 22 | status = 999 23 | try: 24 | request = Request( 25 | url, 26 | dumps(body).encode("utf-8") if body is not None else None, 27 | headers=self._get_headers(), 28 | method=method, 29 | ) 30 | 31 | try: 32 | response = urlopen(request) 33 | except HTTPError as e: 34 | # Here, e is an HTTPError object that acts like a response object 35 | response = e 36 | 37 | if response.status is not None: 38 | # according to the types HTTPError.status can be None, but no idea when it would be 39 | status = response.status 40 | 41 | response_body = response.read().decode("utf-8") # Decode to string 42 | 43 | return UniformResponse( 44 | status, loads(response_body) if response_body else None 45 | ) 46 | 47 | except URLError as e: 48 | # Handle URL errors that occur from unreachable URLs or network issues 49 | logger.warning("URL Error: %s", e) 50 | return UniformResponse(status, None) 51 | 52 | except Exception as e: 53 | logger.warning("Request failed: %s", e) 54 | return UniformResponse(status, None) 55 | -------------------------------------------------------------------------------- /src/nopecha/extension/__init__.py: -------------------------------------------------------------------------------- 1 | from .extension import build_chromium, build_firefox 2 | 3 | __all__ = ["build_chromium", "build_firefox"] 4 | -------------------------------------------------------------------------------- /src/nopecha/extension/_adapter.py: -------------------------------------------------------------------------------- 1 | try: 2 | from requests import get 3 | except ImportError: 4 | from urllib.request import Request, urlopen 5 | from http.client import HTTPResponse 6 | 7 | def request(url: str) -> bytes: 8 | request = Request(url) 9 | response = urlopen(request) 10 | assert isinstance(response, HTTPResponse) 11 | return response.read() 12 | 13 | else: 14 | 15 | def request(url: str) -> bytes: 16 | return get(url).content 17 | 18 | 19 | __all__ = ["request"] 20 | -------------------------------------------------------------------------------- /src/nopecha/extension/extension.py: -------------------------------------------------------------------------------- 1 | import typing 2 | from shutil import rmtree 3 | from json import dumps, loads 4 | from pathlib import Path 5 | from io import BytesIO 6 | from zipfile import ZipFile 7 | 8 | from ._adapter import request 9 | 10 | REPO = "NopeCHALLC/nopecha-extension" 11 | 12 | __all__ = [ 13 | "build", 14 | "build_chromium", 15 | "build_firefox", 16 | ] 17 | 18 | 19 | def get_latest_release() -> typing.Any: 20 | releases_url = f"https://api.github.com/repos/{REPO}/releases?per_page=1" 21 | return loads(request(releases_url))[0] 22 | 23 | 24 | def download_release(download_url: str, outpath: Path) -> None: 25 | print(f"[NopeCHA] Downloading {download_url} to {outpath}") 26 | 27 | if outpath.exists(): 28 | rmtree(outpath) 29 | outpath.mkdir(parents=True, exist_ok=True) 30 | 31 | content = request(download_url) 32 | with ZipFile(BytesIO(content)) as zip: 33 | zip.extractall(outpath) 34 | 35 | print(f"[NopeCHA] Downloaded {download_url} to {outpath}") 36 | 37 | 38 | def build( 39 | branch: typing.Literal["chromium", "firefox"], 40 | manifest: typing.Dict[str, typing.Any], 41 | outpath: typing.Optional[Path] = None, 42 | ) -> str: 43 | if outpath is None: 44 | outpath = Path(f"nopecha-{branch}") 45 | 46 | latest_release = get_latest_release() 47 | 48 | for asset in latest_release["assets"]: 49 | if asset["name"] == f"{branch}_automation.zip": 50 | download_url = asset["browser_download_url"] 51 | break 52 | else: 53 | raise RuntimeError(f"Could not find download link for {branch}") 54 | 55 | if outpath.exists(): 56 | manifest = loads((outpath / "manifest.json").read_text()) 57 | if manifest["version_name"] != latest_release["tag_name"]: 58 | print( 59 | f"[NopeCHA] {latest_release['tag_name']} is available, you got {manifest['version_name']}" 60 | ) 61 | download_release(download_url, outpath) 62 | 63 | else: 64 | print(f"[NopeCHA] Downloading {latest_release['tag_name']}") 65 | download_release(download_url, outpath) 66 | 67 | manifest = loads((outpath / "manifest.json").read_text()) 68 | manifest["nopecha"].update(manifest) 69 | (outpath / "manifest.json").write_text(dumps(manifest, indent=2)) 70 | 71 | print(f"[NopeCHA] Built {branch} extension to {outpath}") 72 | 73 | return str(outpath) 74 | 75 | 76 | def build_chromium( 77 | manifest: typing.Dict[str, typing.Any], outpath: typing.Optional[Path] = None 78 | ) -> str: 79 | return build("chromium", manifest, outpath) 80 | 81 | 82 | def build_firefox( 83 | manifest: typing.Dict[str, typing.Any], outpath: typing.Optional[Path] = None 84 | ) -> str: 85 | return build("firefox", manifest, outpath) 86 | --------------------------------------------------------------------------------