spectrawl 0.1.0 → 0.1.2
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/package.json +1 -1
- package/src/act/index.js +15 -14
- package/src/cli.js +37 -0
- package/src/proxy/index.js +234 -0
package/package.json
CHANGED
package/src/act/index.js
CHANGED
|
@@ -53,22 +53,9 @@ class ActEngine {
|
|
|
53
53
|
}
|
|
54
54
|
}
|
|
55
55
|
|
|
56
|
-
// Get auth for this platform/account
|
|
57
56
|
const account = params.account
|
|
58
|
-
if (account) {
|
|
59
|
-
const cookies = await this.auth.getCookies(platform, account)
|
|
60
|
-
if (!cookies) {
|
|
61
|
-
return {
|
|
62
|
-
success: false,
|
|
63
|
-
error: 'auth_missing',
|
|
64
|
-
detail: `No auth found for ${platform}/${account}.`,
|
|
65
|
-
suggestion: `Run: spectrawl login ${platform} --account ${account}`
|
|
66
|
-
}
|
|
67
|
-
}
|
|
68
|
-
params._cookies = cookies
|
|
69
|
-
}
|
|
70
57
|
|
|
71
|
-
// Check rate limits
|
|
58
|
+
// Check rate limits FIRST (no point checking auth if rate limited)
|
|
72
59
|
const rateCheck = this.rateLimiter.check(platform, action, params)
|
|
73
60
|
if (!rateCheck.allowed) {
|
|
74
61
|
return {
|
|
@@ -94,6 +81,20 @@ class ActEngine {
|
|
|
94
81
|
}
|
|
95
82
|
}
|
|
96
83
|
|
|
84
|
+
// Get auth for this platform/account
|
|
85
|
+
if (account) {
|
|
86
|
+
const cookies = await this.auth.getCookies(platform, account)
|
|
87
|
+
if (!cookies) {
|
|
88
|
+
return {
|
|
89
|
+
success: false,
|
|
90
|
+
error: 'auth_missing',
|
|
91
|
+
detail: `No auth found for ${platform}/${account}.`,
|
|
92
|
+
suggestion: `Run: spectrawl login ${platform} --account ${account}`
|
|
93
|
+
}
|
|
94
|
+
}
|
|
95
|
+
params._cookies = cookies
|
|
96
|
+
}
|
|
97
|
+
|
|
97
98
|
try {
|
|
98
99
|
const result = await adapter.execute(action, params, {
|
|
99
100
|
auth: this.auth,
|
package/src/cli.js
CHANGED
|
@@ -22,6 +22,8 @@ async function main() {
|
|
|
22
22
|
return mcp()
|
|
23
23
|
case 'install-stealth':
|
|
24
24
|
return installStealth()
|
|
25
|
+
case 'proxy':
|
|
26
|
+
return proxy()
|
|
25
27
|
case 'version':
|
|
26
28
|
console.log('spectrawl v0.1.0')
|
|
27
29
|
return
|
|
@@ -116,6 +118,40 @@ async function mcp() {
|
|
|
116
118
|
server.start()
|
|
117
119
|
}
|
|
118
120
|
|
|
121
|
+
async function proxy() {
|
|
122
|
+
const { ProxyServer } = require('./proxy')
|
|
123
|
+
const { loadConfig } = require('./config')
|
|
124
|
+
const config = loadConfig()
|
|
125
|
+
|
|
126
|
+
const port = getFlag('--port') || config.proxy?.localPort || 8080
|
|
127
|
+
const upstreams = config.proxy?.upstreams || []
|
|
128
|
+
|
|
129
|
+
if (upstreams.length === 0) {
|
|
130
|
+
console.log('No upstream proxies configured.')
|
|
131
|
+
console.log('Add them to spectrawl.json:')
|
|
132
|
+
console.log(JSON.stringify({
|
|
133
|
+
proxy: {
|
|
134
|
+
localPort: 8080,
|
|
135
|
+
strategy: 'round-robin',
|
|
136
|
+
upstreams: [
|
|
137
|
+
{ url: 'http://user:pass@proxy1.example.com:8080' },
|
|
138
|
+
{ url: 'http://user:pass@proxy2.example.com:8080' }
|
|
139
|
+
]
|
|
140
|
+
}
|
|
141
|
+
}, null, 2))
|
|
142
|
+
return
|
|
143
|
+
}
|
|
144
|
+
|
|
145
|
+
const server = new ProxyServer({
|
|
146
|
+
port: parseInt(port),
|
|
147
|
+
upstreams,
|
|
148
|
+
strategy: config.proxy?.strategy || 'round-robin',
|
|
149
|
+
maxFailures: config.proxy?.maxFailures || 5
|
|
150
|
+
})
|
|
151
|
+
|
|
152
|
+
server.start()
|
|
153
|
+
}
|
|
154
|
+
|
|
119
155
|
async function installStealth() {
|
|
120
156
|
const { install, isInstalled } = require('./browse/install-stealth')
|
|
121
157
|
if (isInstalled()) {
|
|
@@ -138,6 +174,7 @@ Commands:
|
|
|
138
174
|
serve [--port N] Start HTTP server
|
|
139
175
|
mcp Start MCP server (stdio)
|
|
140
176
|
install-stealth Download Camoufox anti-detect browser
|
|
177
|
+
proxy [--port N] Start rotating proxy server
|
|
141
178
|
version Show version
|
|
142
179
|
|
|
143
180
|
Examples:
|
|
@@ -0,0 +1,234 @@
|
|
|
1
|
+
const http = require('http')
|
|
2
|
+
const net = require('net')
|
|
3
|
+
const { URL } = require('url')
|
|
4
|
+
|
|
5
|
+
/**
|
|
6
|
+
* Spectrawl Proxy Server — rotating residential proxy gateway.
|
|
7
|
+
*
|
|
8
|
+
* One local endpoint (localhost:8080) that rotates through upstream
|
|
9
|
+
* residential proxies. Any tool on the server points here instead
|
|
10
|
+
* of configuring ProxyCheap/BrightData individually.
|
|
11
|
+
*
|
|
12
|
+
* Supports HTTP and HTTPS (CONNECT tunnel).
|
|
13
|
+
*/
|
|
14
|
+
class ProxyServer {
|
|
15
|
+
constructor(config = {}) {
|
|
16
|
+
this.port = config.port || 8080
|
|
17
|
+
this.upstreams = (config.upstreams || []).map(u => ({
|
|
18
|
+
...u,
|
|
19
|
+
failures: 0,
|
|
20
|
+
lastFailure: 0,
|
|
21
|
+
requests: 0
|
|
22
|
+
}))
|
|
23
|
+
this.strategy = config.strategy || 'round-robin' // round-robin | random | least-used
|
|
24
|
+
this.maxFailures = config.maxFailures || 5
|
|
25
|
+
this.failureCooldown = config.failureCooldown || 60000 // 1 min
|
|
26
|
+
this.server = null
|
|
27
|
+
this._index = 0
|
|
28
|
+
this._stats = { total: 0, success: 0, failed: 0, started: null }
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
/**
|
|
32
|
+
* Pick next upstream proxy.
|
|
33
|
+
*/
|
|
34
|
+
_nextUpstream() {
|
|
35
|
+
const now = Date.now()
|
|
36
|
+
const healthy = this.upstreams.filter(u =>
|
|
37
|
+
u.failures < this.maxFailures || (now - u.lastFailure) > this.failureCooldown
|
|
38
|
+
)
|
|
39
|
+
|
|
40
|
+
if (healthy.length === 0) {
|
|
41
|
+
// Reset all if everything is dead
|
|
42
|
+
this.upstreams.forEach(u => { u.failures = 0 })
|
|
43
|
+
return this.upstreams[0] || null
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
switch (this.strategy) {
|
|
47
|
+
case 'random':
|
|
48
|
+
return healthy[Math.floor(Math.random() * healthy.length)]
|
|
49
|
+
|
|
50
|
+
case 'least-used':
|
|
51
|
+
return healthy.sort((a, b) => a.requests - b.requests)[0]
|
|
52
|
+
|
|
53
|
+
case 'round-robin':
|
|
54
|
+
default:
|
|
55
|
+
this._index = (this._index + 1) % healthy.length
|
|
56
|
+
return healthy[this._index]
|
|
57
|
+
}
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
/**
|
|
61
|
+
* Parse upstream proxy URL into components.
|
|
62
|
+
*/
|
|
63
|
+
_parseUpstream(upstream) {
|
|
64
|
+
if (!upstream) return null
|
|
65
|
+
const url = upstream.url || `http://${upstream.host}:${upstream.port}`
|
|
66
|
+
const parsed = new URL(url)
|
|
67
|
+
return {
|
|
68
|
+
host: parsed.hostname,
|
|
69
|
+
port: parseInt(parsed.port) || 80,
|
|
70
|
+
auth: upstream.username && upstream.password
|
|
71
|
+
? Buffer.from(`${upstream.username}:${upstream.password}`).toString('base64')
|
|
72
|
+
: parsed.username && parsed.password
|
|
73
|
+
? Buffer.from(`${parsed.username}:${parsed.password}`).toString('base64')
|
|
74
|
+
: null
|
|
75
|
+
}
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
/**
|
|
79
|
+
* Handle HTTP requests (non-CONNECT).
|
|
80
|
+
*/
|
|
81
|
+
_handleRequest(clientReq, clientRes) {
|
|
82
|
+
this._stats.total++
|
|
83
|
+
const upstream = this._nextUpstream()
|
|
84
|
+
|
|
85
|
+
if (!upstream) {
|
|
86
|
+
clientRes.writeHead(502, { 'Content-Type': 'application/json' })
|
|
87
|
+
clientRes.end(JSON.stringify({ error: 'No upstream proxies configured' }))
|
|
88
|
+
return
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
upstream.requests++
|
|
92
|
+
const proxy = this._parseUpstream(upstream)
|
|
93
|
+
|
|
94
|
+
const opts = {
|
|
95
|
+
hostname: proxy.host,
|
|
96
|
+
port: proxy.port,
|
|
97
|
+
path: clientReq.url,
|
|
98
|
+
method: clientReq.method,
|
|
99
|
+
headers: { ...clientReq.headers }
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
if (proxy.auth) {
|
|
103
|
+
opts.headers['Proxy-Authorization'] = `Basic ${proxy.auth}`
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
const proxyReq = http.request(opts, (proxyRes) => {
|
|
107
|
+
this._stats.success++
|
|
108
|
+
clientRes.writeHead(proxyRes.statusCode, proxyRes.headers)
|
|
109
|
+
proxyRes.pipe(clientRes)
|
|
110
|
+
})
|
|
111
|
+
|
|
112
|
+
proxyReq.on('error', (err) => {
|
|
113
|
+
this._stats.failed++
|
|
114
|
+
upstream.failures++
|
|
115
|
+
upstream.lastFailure = Date.now()
|
|
116
|
+
console.log(`Proxy upstream ${upstream.url || upstream.host} failed: ${err.message}`)
|
|
117
|
+
clientRes.writeHead(502, { 'Content-Type': 'application/json' })
|
|
118
|
+
clientRes.end(JSON.stringify({ error: 'Upstream proxy failed', detail: err.message }))
|
|
119
|
+
})
|
|
120
|
+
|
|
121
|
+
clientReq.pipe(proxyReq)
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
/**
|
|
125
|
+
* Handle HTTPS CONNECT tunnels.
|
|
126
|
+
*/
|
|
127
|
+
_handleConnect(clientReq, clientSocket, head) {
|
|
128
|
+
this._stats.total++
|
|
129
|
+
const upstream = this._nextUpstream()
|
|
130
|
+
|
|
131
|
+
if (!upstream) {
|
|
132
|
+
clientSocket.write('HTTP/1.1 502 No upstream proxy\r\n\r\n')
|
|
133
|
+
clientSocket.destroy()
|
|
134
|
+
return
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
upstream.requests++
|
|
138
|
+
const proxy = this._parseUpstream(upstream)
|
|
139
|
+
const [targetHost, targetPort] = clientReq.url.split(':')
|
|
140
|
+
|
|
141
|
+
// Connect to upstream proxy
|
|
142
|
+
const proxySocket = net.connect(proxy.port, proxy.host, () => {
|
|
143
|
+
// Send CONNECT to upstream
|
|
144
|
+
let connectReq = `CONNECT ${clientReq.url} HTTP/1.1\r\nHost: ${clientReq.url}\r\n`
|
|
145
|
+
if (proxy.auth) {
|
|
146
|
+
connectReq += `Proxy-Authorization: Basic ${proxy.auth}\r\n`
|
|
147
|
+
}
|
|
148
|
+
connectReq += '\r\n'
|
|
149
|
+
|
|
150
|
+
proxySocket.write(connectReq)
|
|
151
|
+
})
|
|
152
|
+
|
|
153
|
+
proxySocket.once('data', (chunk) => {
|
|
154
|
+
const response = chunk.toString()
|
|
155
|
+
if (response.includes('200')) {
|
|
156
|
+
this._stats.success++
|
|
157
|
+
clientSocket.write('HTTP/1.1 200 Connection Established\r\n\r\n')
|
|
158
|
+
proxySocket.write(head)
|
|
159
|
+
proxySocket.pipe(clientSocket)
|
|
160
|
+
clientSocket.pipe(proxySocket)
|
|
161
|
+
} else {
|
|
162
|
+
this._stats.failed++
|
|
163
|
+
upstream.failures++
|
|
164
|
+
upstream.lastFailure = Date.now()
|
|
165
|
+
clientSocket.write('HTTP/1.1 502 Upstream Rejected\r\n\r\n')
|
|
166
|
+
clientSocket.destroy()
|
|
167
|
+
proxySocket.destroy()
|
|
168
|
+
}
|
|
169
|
+
})
|
|
170
|
+
|
|
171
|
+
proxySocket.on('error', (err) => {
|
|
172
|
+
this._stats.failed++
|
|
173
|
+
upstream.failures++
|
|
174
|
+
upstream.lastFailure = Date.now()
|
|
175
|
+
clientSocket.write('HTTP/1.1 502 Upstream Error\r\n\r\n')
|
|
176
|
+
clientSocket.destroy()
|
|
177
|
+
})
|
|
178
|
+
|
|
179
|
+
clientSocket.on('error', () => proxySocket.destroy())
|
|
180
|
+
}
|
|
181
|
+
|
|
182
|
+
/**
|
|
183
|
+
* Start the proxy server.
|
|
184
|
+
*/
|
|
185
|
+
start() {
|
|
186
|
+
this.server = http.createServer((req, res) => {
|
|
187
|
+
// Health endpoint
|
|
188
|
+
if (req.url === '/__health') {
|
|
189
|
+
res.writeHead(200, { 'Content-Type': 'application/json' })
|
|
190
|
+
res.end(JSON.stringify(this.stats()))
|
|
191
|
+
return
|
|
192
|
+
}
|
|
193
|
+
this._handleRequest(req, res)
|
|
194
|
+
})
|
|
195
|
+
|
|
196
|
+
this.server.on('connect', (req, socket, head) => {
|
|
197
|
+
this._handleConnect(req, socket, head)
|
|
198
|
+
})
|
|
199
|
+
|
|
200
|
+
this.server.listen(this.port, () => {
|
|
201
|
+
this._stats.started = new Date().toISOString()
|
|
202
|
+
console.log(`🔀 Spectrawl proxy running on http://localhost:${this.port}`)
|
|
203
|
+
console.log(` Upstreams: ${this.upstreams.length}`)
|
|
204
|
+
console.log(` Strategy: ${this.strategy}`)
|
|
205
|
+
console.log(` Health: http://localhost:${this.port}/__health`)
|
|
206
|
+
})
|
|
207
|
+
|
|
208
|
+
return this.server
|
|
209
|
+
}
|
|
210
|
+
|
|
211
|
+
/**
|
|
212
|
+
* Get proxy stats.
|
|
213
|
+
*/
|
|
214
|
+
stats() {
|
|
215
|
+
return {
|
|
216
|
+
...this._stats,
|
|
217
|
+
upstreams: this.upstreams.map(u => ({
|
|
218
|
+
url: u.url || `${u.host}:${u.port}`,
|
|
219
|
+
requests: u.requests,
|
|
220
|
+
failures: u.failures,
|
|
221
|
+
healthy: u.failures < this.maxFailures
|
|
222
|
+
}))
|
|
223
|
+
}
|
|
224
|
+
}
|
|
225
|
+
|
|
226
|
+
stop() {
|
|
227
|
+
if (this.server) {
|
|
228
|
+
this.server.close()
|
|
229
|
+
this.server = null
|
|
230
|
+
}
|
|
231
|
+
}
|
|
232
|
+
}
|
|
233
|
+
|
|
234
|
+
module.exports = { ProxyServer }
|