initial commit

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

2
.gitignore vendored 100644
View File

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

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

File diff suppressed because it is too large Load Diff

45
package.json 100644
View 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 100644
View 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 100644
View File

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

147
src/SatiSocket.ts 100644
View 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 100644
View File

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

36
src/env/node/index.ts vendored 100644
View 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 100644
View 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 }

View File

@ -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)
}
}
}

View File

@ -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 100644
View File

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

72
src/types.ts 100644
View 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 100644
View 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 100644
View 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
}
}]