initial commit

This commit is contained in:
sati.ac 2023-07-19 11:56:01 +03:00
commit fb7610845e
17 changed files with 3291 additions and 0 deletions

2
.gitignore vendored Normal file

@ -0,0 +1,2 @@
node_modules/**
dist/**

7
LICENSE.txt 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.

25
README.md Normal file

@ -0,0 +1,25 @@
# sati-js - javascript client for sati.ac
[![NPM version](https://badge.fury.io/js/sati.svg)](https://www.npmjs.com/package/sati)
![Downloads](https://img.shields.io/npm/dm/sati.svg?style=flat)
## usage example
```ts
const sati = new Sati({
token: 'your token here'
})
await sati.init() // you must call the init method after construction
const balance = await sati.getBalance()
console.log(balance)
// first argument - task type, second - task parameters
// supported tasks: https://sati.ac/docs/tasks
const task = await sati.solve('Turnstile', {
siteKey: '0x4AAAAAAAHMEd1rGJs9qy-0',
pageUrl: 'https://polygon.sati.ac/Turnstile'
})
console.log(task, task.result.token)
sati.close() // you must call close method after you've done
```

2680
package-lock.json generated Normal file

File diff suppressed because it is too large Load Diff

45
package.json Normal file

@ -0,0 +1,45 @@
{
"name": "sati",
"version": "0.1.0",
"description": "next generation anti-captcha",
"license": "MIT",
"repository": {
"type": "git",
"url": "git://git.sati.ac/sati.ac/sati-js"
},
"keywords": [
"sati.ac",
"anti-captcha",
"anticaptcha",
"captcha"
],
"files": [
"dist/",
"!**/*.tsbuildinfo"
],
"main": "dist/sati.node.cjs.js",
"unpkg": "dist/sati.web.umd.js",
"scripts": {
"build": "rm -rf dist && webpack"
},
"author": "",
"dependencies": {
"@types/node": "^20.1.1",
"@types/ws": "^8.5.4",
"ws": "^8.13.0"
},
"devDependencies": {
"ts-loader": "^9.4.2",
"typescript": "^5.0.4",
"webpack": "^5.82.0",
"webpack-cli": "^5.1.1"
},
"types": "./dist/src/index.d.ts",
"exports": {
".": {
"import": "dist/sati.web.esm.mjs",
"require": "dist/sati.web.umd.js",
"types": "dist/src/index.d.ts"
}
}
}

109
src/Sati.ts Normal file

@ -0,0 +1,109 @@
import { EventEmitter } from './helpers/EventEmitter'
import { SatiError } from './SatiError'
import { SatiSocket, SocketState } from './SatiSocket'
import { events, methods, tasks, Task } from './types'
/**
* high level api wrapper
* @example
* const sati = new Sati({
* token: 'your token here'
* })
* await sati.init() // you must call the init method after construction
*
* const task = await sati.solve('Turnstile', {
* siteKey: '0x4AAAAAAAHMEd1rGJs9qy-0',
* pageUrl: 'https://polygon.sati.ac/Turnstile'
* })
*
* console.log(task, task.result.token)
*
* sati.close() // you must call close method after you've done
*/
export class Sati extends EventEmitter<events> {
private socket: SatiSocket
private awaitedTasks: {
[ index: number ]: {
resolve(data: any): void,
reject(reason: any): void
}
} = Object.create(null)
/** @param token your api token. get it at https://sati.ac/dashboard */
constructor({ token }: { token: string }) {
super()
this.socket = new SatiSocket(token)
this.socket.on('event', ({ type, data }) => {
this.emit(type as keyof events, data)
})
this.socket.on('stateChange', ({ state, error }) => {
switch(state) {
case SocketState.connected:
// as of socket was disconnected, there might be missed events
// so we need to recheck the state of each awaited task
for(const [ id, awaited ] of Object.entries(this.awaitedTasks)) {
this.call('getTask', { id: +id }).then(this.handleTaskResult.bind(this), err => {
awaited.reject(err)
delete this.awaitedTasks[+id]
})
}
case SocketState.unrecoverable:
// socket got an unrecoverable error, so it will never be connected again
for(const awaited of Object.values(this.awaitedTasks)) {
awaited.reject(error)
}
}
})
this.on('taskUpdate', this.handleTaskResult.bind(this))
}
private handleTaskResult(task: Task) {
if(!(task.id in this.awaitedTasks)) return
if(task.state !== 'error' && task.state !== 'success') return
const awaited = this.awaitedTasks[task.id]
if(task.state === 'error') {
awaited.reject(new SatiError(`unable to solve ${task.type} task #${task.id}`))
} else if(task.state === 'success') {
awaited.resolve(task)
}
delete this.awaitedTasks[task.id]
}
/** connects to the socket, must be called before any other methods */
public async init() {
await this.socket.connect()
}
/** closes the socket */
public close() {
this.socket.close()
}
/** calls the api method, use top-level wrappers for them instead */
public async call<M extends keyof methods>(method: M, data: methods[M]['request']): Promise<methods[M]['response']> {
const resp = await this.socket.send('call', { method, data })
if(!resp.success) {
throw new SatiError(resp.data.description)
}
return resp.data
}
/**
* creates task, then waits for the result
* @throws {SatiError} if unable to solve
*/
public async solve<T extends keyof tasks>(type: T, data: tasks[T]['params']): Promise<Task<T, 'success'>> {
const task = await this.call('createTask', { type, data })
return new Promise((resolve, reject) => {
this.awaitedTasks[task.id] = { resolve, reject }
})
}
public async getBalance() {
return (await this.call('getBalance', {})).balance
}
}

5
src/SatiError.ts Normal file

@ -0,0 +1,5 @@
export class SatiError extends Error {
constructor(msg: string) {
super(`sati: ${msg}`)
}
}

147
src/SatiSocket.ts Normal file

@ -0,0 +1,147 @@
import { EventEmitter } from './helpers/EventEmitter'
import { SocketAdapter } from '%env%'
import { SatiError } from './SatiError'
import { endpoint } from './config'
interface WebSocketMessage {
id: number
type: string
data: any
to?: number
}
export enum SocketState {
connected,
disconnected,
unrecoverable
}
/** low-level api wrapper */
export class SatiSocket extends EventEmitter<{
event: {
type: string,
data: any
},
stateChange: {
state: SocketState,
error: Error | null
}
}> {
private token: string
private socket: SocketAdapter | null = null
private error: Error | null = null
private state: SocketState = SocketState.disconnected
private idCounter = 0
private awaitedMessages: Record<number, {
resolve: (data: any) => void
reject: (error: any) => void
}> = Object.create(null)
private queue: [(data: any) => void, string, any][] = []
constructor(token: string) {
super()
this.token = token
}
private setState(state: SocketState, error: Error | null = null) {
this.state = state
this.error = error
this.emit('stateChange', { state, error })
}
public async connect() {
if(this.socket || this.state !== SocketState.disconnected) return;
this.idCounter = 0
this.socket = new SocketAdapter(endpoint)
this.socket.once('close', this.onSocketClose.bind(this))
this.socket.on('message', this.onMessage.bind(this))
await new Promise((res, rej) => {
this.socket?.once('open', res)
this.socket?.once('error', rej)
})
const resp = await this.unqueuedSend('auth', { token: this.token })
if(!resp.success) {
const error = new SatiError('invalid auth token')
this.setState(SocketState.unrecoverable, error)
this.socket?.close()
throw error
}
this.setState(SocketState.connected)
await this.flushQueue()
}
public close() {
this.setState(SocketState.unrecoverable, new SatiError('socket was closed'))
this.socket?.close()
}
private flushQueue() {
const promises = []
for(const [ cb, type, data ] of this.queue) {
promises.push(this.unqueuedSend(type, data).then(cb))
}
return Promise.all(promises)
}
private onSocketClose() {
this.socket = null
for(const awaited of Object.values(this.awaitedMessages)) {
// we don't want to resend them, because it may cause task double-creation
awaited.reject(new SatiError('socket gone'))
}
this.awaitedMessages = Object.create(null)
if(this.state !== SocketState.unrecoverable) {
this.setState(SocketState.disconnected)
setTimeout(() => {
this.connect().catch(err => {
console?.error?.('sati: error while reconnecting:', err)
})
}, 1000)
}
}
private async unqueuedSend(type: string, data: any) {
if(this.state === SocketState.unrecoverable) throw this.error
if(!this.socket) return;
const id = ++this.idCounter
this.socket.send(JSON.stringify({ id, type, data }))
return new Promise<any>((resolve, reject) => {
this.awaitedMessages[id] = { resolve, reject }
})
}
public send(type: string, data: any) {
switch(this.state) {
case SocketState.connected:
return this.unqueuedSend(type, data)
case SocketState.disconnected:
return new Promise(resolve => {
this.queue.push([ resolve, type, data ])
})
case SocketState.unrecoverable:
throw this.error
}
}
private onMessage(message: string) {
const parsed: WebSocketMessage = JSON.parse(message)
if(parsed.type === 'event') {
if(parsed.data.type === 'tokenReissue') {
this.setState(SocketState.unrecoverable, new SatiError('token was reissued'))
this.socket?.close()
}
this.emit('event', parsed.data)
return
}
if(parsed.to && parsed.to in this.awaitedMessages) {
this.awaitedMessages[parsed.to].resolve(parsed.data)
}
}
}

1
src/config.ts Normal file

@ -0,0 +1 @@
export const endpoint = 'wss://api.sati.ac/ws'

36
src/env/node/index.ts vendored Normal file

@ -0,0 +1,36 @@
import { WebSocket, RawData } from 'ws'
import { SocketAdapter } from '../../helpers/SocketAdapter'
class NodeSocketAdapter extends SocketAdapter {
private socket: WebSocket
constructor(url: string) {
super()
this.socket = new WebSocket(url)
this.socket.on('message', this.emitMessage.bind(this))
this.socket.on('error', err => this.emit('error', err))
this.socket.on('open', () => this.emit('open', null))
this.socket.on('close', () => this.emit('close', null))
}
public send(message: string) {
this.socket.send(message)
}
public close() {
this.socket.close()
}
private emitMessage(message: RawData) {
if(Array.isArray(message)) {
message = Buffer.concat(message)
}
if(message instanceof ArrayBuffer) {
message = Buffer.from(message)
}
this.emit('message', message.toString())
}
}
export { NodeSocketAdapter as SocketAdapter }

23
src/env/web/index.ts vendored Normal file

@ -0,0 +1,23 @@
import { SocketAdapter } from '../../helpers/SocketAdapter'
class BrowserSocketAdapter extends SocketAdapter {
private socket: WebSocket
constructor(url: string) {
super()
this.socket = new WebSocket(url)
this.socket.addEventListener('message', e => this.emit('message', e.data))
this.socket.addEventListener('error', e => this.emit('error', e))
this.socket.addEventListener('open', () => this.emit('open', null))
this.socket.addEventListener('close', () => this.emit('close', null))
}
public send(message: string) {
this.socket.send(message)
}
public close() {
this.socket.close()
}
}
export { BrowserSocketAdapter as SocketAdapter }

@ -0,0 +1,35 @@
/**
* fully typed node-like event emitter
* usage: `EventEmitter<{ event: eventDataType }>`
*/
export class EventEmitter<T extends {} = {}> {
private listeners: { [E in keyof T]: Set<((data: T[E]) => void)> } = Object.create(null)
public on<E extends keyof T>(event: E, listener: ((data: T[E]) => void)) {
this.listeners[event] ||= new Set()
this.listeners[event].add(listener)
}
public off<E extends keyof T>(event: E, listener: ((data: T[E]) => void)) {
this.listeners[event]?.delete(listener)
if(this.listeners[event]?.size === 0) {
delete this.listeners[event]
}
}
public once<E extends keyof T>(event: E, listener: ((data: T[E]) => void)) {
const wrapped = (data: T[E]) => {
this.off(event, wrapped)
listener(data)
}
this.on(event, wrapped)
}
protected emit<E extends keyof T>(event: E, data: T[E]) {
if(!this.listeners[event]) return
for(const listener of this.listeners[event]) {
listener(data)
}
}
}

@ -0,0 +1,16 @@
import { EventEmitter } from '../helpers/EventEmitter'
export interface SocketAdapterConstructor {
new(url: string): SocketAdapter
}
/** cross-platform websocket interface */
export abstract class SocketAdapter extends EventEmitter<{
message: string,
open: null,
close: null,
error: any
}> {
public abstract send(message: string): void
public abstract close(): void
}

3
src/index.ts Normal file

@ -0,0 +1,3 @@
export * from './Sati'
export * from './SatiError'
export type { Task } from './types'

72
src/types.ts Normal file

@ -0,0 +1,72 @@
export type tasks = {
Turnstile: {
params: {
siteKey: string,
pageUrl: string,
action?: string,
cData?: string
},
result: {
token: string
}
},
ReCaptcha2: {
params: {
siteKey: string,
pageUrl: string
},
result: {
token: string
}
},
FunCaptcha: {
params: {
siteKey: string,
pageUrl: string,
data: Record<string, string>
serviceUrl?: string
},
result: {
token: string
}
}
}
export type methods = {
getBalance: {
request: {},
response: {
balance: string
}
},
createTask: {
request: {
type: keyof tasks,
data: tasks[keyof tasks]['params']
},
response: Task
},
getTask: {
request: {
id: number
},
response: Task
}
}
export type events = {
taskUpdate: Task,
tokenReissue: {}
}
export type TaskState = 'queued' | 'processing' | 'error' | 'success'
export type Task<T extends keyof tasks = keyof tasks, S extends TaskState = TaskState> = {
id: number,
type: T,
creationDate: string,
cost: string
state: S,
} & (S extends 'success'
? { result: tasks[T]['result'] }
: { result: null })

18
tsconfig.json Normal file

@ -0,0 +1,18 @@
{
"compilerOptions": {
"outDir": "./dist",
"module": "commonjs",
"target": "esnext",
"allowJs": false,
"declaration": true,
"moduleResolution": "node",
"strict": true,
"types": [ "node" ],
"rootDir": ".",
"incremental": true,
"allowSyntheticDefaultImports": true,
"paths": {
"%env%": ["./src/env/node"]
}
}
}

67
webpack.config.js Normal file

@ -0,0 +1,67 @@
const path = require('path')
const build = env => ({
mode: 'production',
entry: './src/index.ts',
module: {
rules: [{
test: /\.ts$/,
use: {
loader: 'ts-loader',
options: {
compilerOptions: {
module: 'esnext',
paths: {
'%env%': [`./src/env/${env}`]
}
}
}
},
exclude: /node_modules/
}],
},
resolve: {
alias: {
'%env%': path.resolve(__dirname, `src/env/${env}`)
},
extensions: [ '.ts', '.js' ]
},
})
module.exports = [{
// prebuilt binary, for browser usage
...build('web'),
output: {
library: {
name: 'Sati',
type: 'umd'
},
filename: `sati.web.umd.js`
}
}, {
// prebuilt binary with external dependencies, for browser usage with bundlers
...build('web'),
output: {
library: {
type: 'module'
},
filename: `sati.web.esm.js`
},
externals: /^[^\.%]/,
experiments: {
outputModule: true
}
}, {
// prebuilt node version
...build('node'),
output: {
library: {
type: 'commonjs2'
},
filename: `sati.node.cjs.js`
},
externals: /^[^\.%]/,
optimization: {
minimize: false
}
}]