initial commit
This commit is contained in:
commit
f7dea1ac6a
1
.gitignore
vendored
Normal file
1
.gitignore
vendored
Normal file
@ -0,0 +1 @@
|
|||||||
|
dist
|
7
LICENSE
Normal file
7
LICENSE
Normal file
@ -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.
|
19
README.md
Normal file
19
README.md
Normal file
@ -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)
|
||||||
|
```
|
22
pyproject.toml
Normal file
22
pyproject.toml
Normal file
@ -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"
|
3
sati/__init__.py
Normal file
3
sati/__init__.py
Normal file
@ -0,0 +1,3 @@
|
|||||||
|
from .sati import Sati
|
||||||
|
from .util import SatiDict
|
||||||
|
from .socket import SatiSocket
|
70
sati/sati.py
Normal file
70
sati/sati.py
Normal file
@ -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
|
164
sati/socket.py
Normal file
164
sati/socket.py
Normal file
@ -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)
|
9
sati/util.py
Normal file
9
sati/util.py
Normal file
@ -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__
|
Loading…
x
Reference in New Issue
Block a user