commit f7dea1ac6adab40fe47e1e79de6fe78f176bfc8f Author: sati.ac Date: Sat Sep 2 08:23:44 2023 +0300 initial commit diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..1521c8b --- /dev/null +++ b/.gitignore @@ -0,0 +1 @@ +dist diff --git a/LICENSE b/LICENSE new file mode 100644 index 0000000..3b84c20 --- /dev/null +++ b/LICENSE @@ -0,0 +1,7 @@ +Copyright 2023 sati.ac + +Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated documentation files (the “Software”), to deal in the Software without restriction, including without limitation the rights to use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the Software, and to permit persons to whom the Software is furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED “AS IS”, WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. \ No newline at end of file diff --git a/README.md b/README.md new file mode 100644 index 0000000..e98af82 --- /dev/null +++ b/README.md @@ -0,0 +1,19 @@ +# sati-ac - python client for sati.ac +[![NPM version](https://badge.fury.io/py/sati-ac.svg)](https://pypi.org/project/sati-ac) + +## usage example +```py +import asyncio +from sati import Sati + +async def main(): + sati = Sati(token) + print(await sati.get_balance()) + task = await sati.solve('Turnstile', + siteKey = '0x4AAAAAAAHMEd1rGJs9qy-0', + pageUrl = 'https://polygon.sati.ac/Turnstile' + ) + print(task.result.token) + +asyncio.run(main) +``` \ No newline at end of file diff --git a/pyproject.toml b/pyproject.toml new file mode 100644 index 0000000..191e0c2 --- /dev/null +++ b/pyproject.toml @@ -0,0 +1,22 @@ +[build-system] +requires = ["hatchling"] +build-backend = "hatchling.build" + +[project] +name = "sati-ac" +version = "0.1.0" +authors = [ + { name="sati.ac", email="sati.ac@proton.me" } +] +description = "Next generation anti-captcha" +readme = "README.md" +requires-python = ">=3.8" +classifiers = [ + "Programming Language :: Python :: 3", + "License :: OSI Approved :: MIT License", + "Operating System :: OS Independent", +] +dependencies = ["websockets"] + +[project.urls] +"Homepage" = "https://git.sati.ac/sati.ac/sati-py" \ No newline at end of file diff --git a/sati/__init__.py b/sati/__init__.py new file mode 100644 index 0000000..59b8fe3 --- /dev/null +++ b/sati/__init__.py @@ -0,0 +1,3 @@ +from .sati import Sati +from .util import SatiDict +from .socket import SatiSocket diff --git a/sati/sati.py b/sati/sati.py new file mode 100644 index 0000000..aa38b31 --- /dev/null +++ b/sati/sati.py @@ -0,0 +1,70 @@ +import asyncio +import base64 + +from .util import SatiDict +from .socket import SatiSocket + +class UnableToSolveTask(Exception): + task: SatiDict + def __init__(self, task: SatiDict): + super().__init__(f"sati: unable to solve {task.type} task #{task.id}") + self.task = task + +class Sati: + ''' + usage example: + >>> from sati import Sati + >>> + >>> sati = Sati(token) + >>> task = await sati.solve('Turnstile', + >>> siteKey='0x4AAAAAAAHMEd1rGJs9qy-0', + >>> pageUrl='https://polygon.sati.ac/Turnstile') + >>> + >>> print(task.result.token) + ''' + + _socket: SatiSocket + _project_id: int + _awaited_tasks: dict = {} + + def __init__( + self, + token: str, + url: str = 'wss://api.sati.ac/ws', + reconnection_interval: float = 1, + project_id: int = 0, + debug = False + ): + self._socket = SatiSocket(token, reconnection_interval, url, debug) + self._project_id = project_id + self._socket.on('taskUpdate', self._process_task) + + async def solve(self, task_type: str, **data): + # special case for images + if task_type == 'ImageToText' and 'image' in data and \ + isinstance(data['image'], (bytearray, bytes)): + data['image'] = base64.b64encode(data['image']).decode('ascii') + + task = await self._socket.call('createTask', { + 'type': task_type, + 'data': data, + 'projectId': self._project_id + }) + fut = asyncio.Future() + self._awaited_tasks[task.id] = fut + return await fut + + def _process_task(self, task: SatiDict): + if task.id not in self._awaited_tasks or task.state not in ('success', 'error'): + return + fut = self._awaited_tasks[task.id] + if task.state == 'success': + fut.set_result(task) + else: + fut.set_exception(UnableToSolveTask(task)) + + def destroy(self): + self._socket.close() + + async def get_balance(self) -> float: + return (await self._socket.call('getBalance')).balance diff --git a/sati/socket.py b/sati/socket.py new file mode 100644 index 0000000..9a5ff00 --- /dev/null +++ b/sati/socket.py @@ -0,0 +1,164 @@ +import json +import typing +from dataclasses import dataclass +import asyncio +import websockets.client as wsc +from .util import SatiDict + +STATE_CONNECTED = 0 +STATE_RECONNECTING = 1 +STATE_UNRECOVERABLE = 2 + +class SatiUnrecoverableException(Exception): + def __init__(self, message: str): + super().__init__(f"sati: {message}") + +class SatiException(Exception): + ''' api error ''' + + code: int + + def __init__(self, message: str, code: int = 0): + super().__init__(f"sati: #{code}: {message}") + self.code = code + +@dataclass +class QueueEntry: + fut: asyncio.Future + method: str + data: dict + +class SatiSocket: + ''' low-level api wrapper ''' + __token: str + __state: int = STATE_RECONNECTING + __socket: typing.Any + __reconnection_interval: float + __connector_ref: asyncio.Task + __id_counter: int = 0 + __awaited_replies: dict = {} + __error = None + __queue = [] + __url: str + __event_handlers = {} + __debug: bool + + def __init__( + self, + token: str, + reconnection_interval: float = 1, + url = 'wss://api.sati.ac/ws', + debug = False + ): + self.__token = token + self.__reconnection_interval = reconnection_interval + self.__url = url + self.__connector_ref = asyncio.create_task(self.__connector()) + self.__debug = debug + + async def __connector(self): + while self.__state != STATE_UNRECOVERABLE: + try: + try: + await self.__connect() + except asyncio.CancelledError as ex: + raise SatiUnrecoverableException('socket closed') from ex + except SatiUnrecoverableException as ex: + self.__state = STATE_UNRECOVERABLE + self.__error = ex + break + except Exception as ex: + print(ex) + await asyncio.sleep(self.__reconnection_interval) + + async def __connect(self): + self.__socket = await wsc.connect(self.__url) + self.__state = STATE_RECONNECTING + + reader = asyncio.create_task(self.__reader()) + auth_resp = await self.__send('auth', { 'token': self.__token }) + + if not auth_resp.success: + ex = SatiUnrecoverableException('invalid token') + for entry in self.__queue: + entry.fut.set_exception(ex) + self.__queue = [] + raise ex + + self.__state = STATE_CONNECTED + for entry in self.__queue: + asyncio.create_task(self.__resend_call(entry)) + self.__queue = [] + + await reader + + async def __resend_call(self, call: QueueEntry): + try: + result = await self.call(call.method, call.data) + call.fut.set_result(result) + except Exception as ex: + call.fut.set_exception(ex) + + async def __send(self, msg_type: str, data: dict) -> dict: + self.__id_counter += 1 + msg_id = self.__id_counter + + if self.__debug: + print(f'sending message {msg_type} with id {msg_id}', data) + + if msg_type in ( 'auth', 'call' ): + fut = self.__awaited_replies[msg_id] = asyncio.Future() + await self.__socket.send(json.dumps({ + 'id': self.__id_counter, + 'type': msg_type, + 'data': data + })) + + if msg_type in ( 'auth', 'call' ): + return await fut + + async def call(self, method: str, data: dict = {}) -> SatiDict: + if self.__state == STATE_CONNECTED: + resp = await self.__send('call', { + 'method': method, + 'data': data + }) + if not resp.success: + raise SatiException(resp.data.description, code=resp.data.code) + return resp.data + if self.__state == STATE_RECONNECTING: + fut = asyncio.Future() + self.__queue.append(QueueEntry(fut, method, data)) + return await fut + if self.__state == STATE_UNRECOVERABLE: + raise self.__error + + async def __reader(self): + try: + async for msg in self.__socket: + msg = SatiDict(json.loads(msg)) + + if self.__debug: + print('recieved message', msg) + + if msg.type in [ 'auth', 'call' ] and msg.to in self.__awaited_replies: + self.__awaited_replies[msg.to].set_result(msg.data) + elif msg.type == 'event': + if msg.data.type not in self.__event_handlers: + continue + for handler in self.__event_handlers[msg.data.type]: + handler(msg.data.data) + except Exception as ex: + for key, reply in self.__awaited_replies.items(): + reply.set_exception(ex) + del self.__awaited_replies[key] + raise ex + + def close(self): + self.__connector_ref.cancel() + + def on(self, event: str, handler: typing.Callable[[SatiDict], None]): + if event not in self.__event_handlers: + self.__event_handlers[event] = [] + + self.__event_handlers[event].append(handler) diff --git a/sati/util.py b/sati/util.py new file mode 100644 index 0000000..e29dbc1 --- /dev/null +++ b/sati/util.py @@ -0,0 +1,9 @@ +class SatiDict(dict): + ''' dict wrapper for using convenient dot-notation ''' + def __getattr__(self, key: str): + if isinstance(self[key], dict): + return SatiDict(self[key]) + return self[key] + + __setattr__ = dict.__setitem__ + __delattr__ = dict.__delitem__ \ No newline at end of file