stealth-cli 0.5.0
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/LICENSE +21 -0
- package/README.md +295 -0
- package/bin/stealth.js +50 -0
- package/package.json +65 -0
- package/skills/SKILL.md +244 -0
- package/src/browser.js +341 -0
- package/src/client.js +115 -0
- package/src/commands/batch.js +180 -0
- package/src/commands/browse.js +101 -0
- package/src/commands/config.js +85 -0
- package/src/commands/crawl.js +169 -0
- package/src/commands/daemon.js +143 -0
- package/src/commands/extract.js +153 -0
- package/src/commands/fingerprint.js +306 -0
- package/src/commands/interactive.js +284 -0
- package/src/commands/mcp.js +68 -0
- package/src/commands/monitor.js +160 -0
- package/src/commands/pdf.js +109 -0
- package/src/commands/profile.js +112 -0
- package/src/commands/proxy.js +116 -0
- package/src/commands/screenshot.js +96 -0
- package/src/commands/search.js +162 -0
- package/src/commands/serve.js +240 -0
- package/src/config.js +123 -0
- package/src/cookies.js +67 -0
- package/src/daemon-entry.js +19 -0
- package/src/daemon.js +294 -0
- package/src/errors.js +136 -0
- package/src/extractors/base.js +59 -0
- package/src/extractors/bing.js +47 -0
- package/src/extractors/duckduckgo.js +91 -0
- package/src/extractors/github.js +103 -0
- package/src/extractors/google.js +173 -0
- package/src/extractors/index.js +55 -0
- package/src/extractors/youtube.js +87 -0
- package/src/humanize.js +210 -0
- package/src/index.js +32 -0
- package/src/macros.js +36 -0
- package/src/mcp-server.js +341 -0
- package/src/output.js +65 -0
- package/src/profiles.js +308 -0
- package/src/proxy-pool.js +256 -0
- package/src/retry.js +112 -0
- package/src/session.js +159 -0
package/src/profiles.js
ADDED
|
@@ -0,0 +1,308 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Profile management — persistent browser identity profiles
|
|
3
|
+
*
|
|
4
|
+
* Each profile stores:
|
|
5
|
+
* - Fingerprint config (locale, timezone, viewport, os)
|
|
6
|
+
* - Proxy settings
|
|
7
|
+
* - Cookie data (auto-saved between sessions)
|
|
8
|
+
* - Usage stats
|
|
9
|
+
*
|
|
10
|
+
* Storage: ~/.stealth/profiles/<name>.json
|
|
11
|
+
*/
|
|
12
|
+
|
|
13
|
+
import fs from 'fs';
|
|
14
|
+
import path from 'path';
|
|
15
|
+
import os from 'os';
|
|
16
|
+
import crypto from 'crypto';
|
|
17
|
+
|
|
18
|
+
const PROFILES_DIR = path.join(os.homedir(), '.stealth', 'profiles');
|
|
19
|
+
|
|
20
|
+
// Realistic fingerprint presets
|
|
21
|
+
const FINGERPRINT_PRESETS = {
|
|
22
|
+
'us-desktop': {
|
|
23
|
+
locale: 'en-US',
|
|
24
|
+
timezone: 'America/New_York',
|
|
25
|
+
viewport: { width: 1920, height: 1080 },
|
|
26
|
+
os: 'windows',
|
|
27
|
+
geo: { latitude: 40.7128, longitude: -74.006 },
|
|
28
|
+
},
|
|
29
|
+
'us-laptop': {
|
|
30
|
+
locale: 'en-US',
|
|
31
|
+
timezone: 'America/Los_Angeles',
|
|
32
|
+
viewport: { width: 1440, height: 900 },
|
|
33
|
+
os: 'macos',
|
|
34
|
+
geo: { latitude: 37.7749, longitude: -122.4194 },
|
|
35
|
+
},
|
|
36
|
+
'uk-desktop': {
|
|
37
|
+
locale: 'en-GB',
|
|
38
|
+
timezone: 'Europe/London',
|
|
39
|
+
viewport: { width: 1920, height: 1080 },
|
|
40
|
+
os: 'windows',
|
|
41
|
+
geo: { latitude: 51.5074, longitude: -0.1278 },
|
|
42
|
+
},
|
|
43
|
+
'de-desktop': {
|
|
44
|
+
locale: 'de-DE',
|
|
45
|
+
timezone: 'Europe/Berlin',
|
|
46
|
+
viewport: { width: 1920, height: 1080 },
|
|
47
|
+
os: 'windows',
|
|
48
|
+
geo: { latitude: 52.52, longitude: 13.405 },
|
|
49
|
+
},
|
|
50
|
+
'jp-desktop': {
|
|
51
|
+
locale: 'ja-JP',
|
|
52
|
+
timezone: 'Asia/Tokyo',
|
|
53
|
+
viewport: { width: 1920, height: 1080 },
|
|
54
|
+
os: 'windows',
|
|
55
|
+
geo: { latitude: 35.6762, longitude: 139.6503 },
|
|
56
|
+
},
|
|
57
|
+
'cn-desktop': {
|
|
58
|
+
locale: 'zh-CN',
|
|
59
|
+
timezone: 'Asia/Shanghai',
|
|
60
|
+
viewport: { width: 1920, height: 1080 },
|
|
61
|
+
os: 'windows',
|
|
62
|
+
geo: { latitude: 31.2304, longitude: 121.4737 },
|
|
63
|
+
},
|
|
64
|
+
'mobile-ios': {
|
|
65
|
+
locale: 'en-US',
|
|
66
|
+
timezone: 'America/Chicago',
|
|
67
|
+
viewport: { width: 390, height: 844 },
|
|
68
|
+
os: 'macos',
|
|
69
|
+
geo: { latitude: 41.8781, longitude: -87.6298 },
|
|
70
|
+
},
|
|
71
|
+
'mobile-android': {
|
|
72
|
+
locale: 'en-US',
|
|
73
|
+
timezone: 'America/Denver',
|
|
74
|
+
viewport: { width: 412, height: 915 },
|
|
75
|
+
os: 'linux',
|
|
76
|
+
geo: { latitude: 39.7392, longitude: -104.9903 },
|
|
77
|
+
},
|
|
78
|
+
};
|
|
79
|
+
|
|
80
|
+
// Random viewport sizes for generating unique profiles
|
|
81
|
+
const VIEWPORTS = [
|
|
82
|
+
{ width: 1920, height: 1080 },
|
|
83
|
+
{ width: 1440, height: 900 },
|
|
84
|
+
{ width: 1536, height: 864 },
|
|
85
|
+
{ width: 1366, height: 768 },
|
|
86
|
+
{ width: 1280, height: 720 },
|
|
87
|
+
{ width: 1600, height: 900 },
|
|
88
|
+
{ width: 2560, height: 1440 },
|
|
89
|
+
{ width: 1280, height: 800 },
|
|
90
|
+
];
|
|
91
|
+
|
|
92
|
+
const LOCALES = [
|
|
93
|
+
{ locale: 'en-US', tz: 'America/New_York', geo: { latitude: 40.7128, longitude: -74.006 } },
|
|
94
|
+
{ locale: 'en-US', tz: 'America/Los_Angeles', geo: { latitude: 34.0522, longitude: -118.2437 } },
|
|
95
|
+
{ locale: 'en-US', tz: 'America/Chicago', geo: { latitude: 41.8781, longitude: -87.6298 } },
|
|
96
|
+
{ locale: 'en-GB', tz: 'Europe/London', geo: { latitude: 51.5074, longitude: -0.1278 } },
|
|
97
|
+
{ locale: 'de-DE', tz: 'Europe/Berlin', geo: { latitude: 52.52, longitude: 13.405 } },
|
|
98
|
+
{ locale: 'fr-FR', tz: 'Europe/Paris', geo: { latitude: 48.8566, longitude: 2.3522 } },
|
|
99
|
+
{ locale: 'ja-JP', tz: 'Asia/Tokyo', geo: { latitude: 35.6762, longitude: 139.6503 } },
|
|
100
|
+
{ locale: 'zh-CN', tz: 'Asia/Shanghai', geo: { latitude: 31.2304, longitude: 121.4737 } },
|
|
101
|
+
];
|
|
102
|
+
|
|
103
|
+
/**
|
|
104
|
+
* Ensure profiles directory exists
|
|
105
|
+
*/
|
|
106
|
+
function ensureDir() {
|
|
107
|
+
fs.mkdirSync(PROFILES_DIR, { recursive: true });
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
/**
|
|
111
|
+
* Get path to a profile file
|
|
112
|
+
*/
|
|
113
|
+
function profilePath(name) {
|
|
114
|
+
// Sanitize name
|
|
115
|
+
const safeName = name.replace(/[^a-zA-Z0-9_-]/g, '_');
|
|
116
|
+
return path.join(PROFILES_DIR, `${safeName}.json`);
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
/**
|
|
120
|
+
* Generate a random fingerprint
|
|
121
|
+
*/
|
|
122
|
+
function randomFingerprint() {
|
|
123
|
+
const localeInfo = LOCALES[Math.floor(Math.random() * LOCALES.length)];
|
|
124
|
+
const viewport = VIEWPORTS[Math.floor(Math.random() * VIEWPORTS.length)];
|
|
125
|
+
const osOptions = ['windows', 'macos', 'linux'];
|
|
126
|
+
const selectedOs = osOptions[Math.floor(Math.random() * osOptions.length)];
|
|
127
|
+
|
|
128
|
+
return {
|
|
129
|
+
locale: localeInfo.locale,
|
|
130
|
+
timezone: localeInfo.tz,
|
|
131
|
+
viewport,
|
|
132
|
+
os: selectedOs,
|
|
133
|
+
geo: localeInfo.geo,
|
|
134
|
+
};
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
/**
|
|
138
|
+
* Create a new profile
|
|
139
|
+
*
|
|
140
|
+
* @param {string} name - Profile name
|
|
141
|
+
* @param {object} opts
|
|
142
|
+
* @param {string} [opts.preset] - Use a preset (us-desktop, uk-desktop, etc.)
|
|
143
|
+
* @param {string} [opts.proxy] - Proxy server
|
|
144
|
+
* @param {boolean} [opts.random] - Generate random fingerprint
|
|
145
|
+
*/
|
|
146
|
+
export function createProfile(name, opts = {}) {
|
|
147
|
+
ensureDir();
|
|
148
|
+
const filePath = profilePath(name);
|
|
149
|
+
|
|
150
|
+
if (fs.existsSync(filePath)) {
|
|
151
|
+
throw new Error(`Profile "${name}" already exists. Use --force to overwrite.`);
|
|
152
|
+
}
|
|
153
|
+
|
|
154
|
+
let fingerprint;
|
|
155
|
+
|
|
156
|
+
if (opts.preset) {
|
|
157
|
+
fingerprint = FINGERPRINT_PRESETS[opts.preset];
|
|
158
|
+
if (!fingerprint) {
|
|
159
|
+
throw new Error(`Unknown preset "${opts.preset}". Available: ${Object.keys(FINGERPRINT_PRESETS).join(', ')}`);
|
|
160
|
+
}
|
|
161
|
+
fingerprint = { ...fingerprint }; // Clone
|
|
162
|
+
} else if (opts.random || !opts.locale) {
|
|
163
|
+
fingerprint = randomFingerprint();
|
|
164
|
+
} else {
|
|
165
|
+
fingerprint = {
|
|
166
|
+
locale: opts.locale || 'en-US',
|
|
167
|
+
timezone: opts.timezone || 'America/New_York',
|
|
168
|
+
viewport: opts.viewport || { width: 1920, height: 1080 },
|
|
169
|
+
os: opts.os || 'windows',
|
|
170
|
+
geo: opts.geo || { latitude: 40.7128, longitude: -74.006 },
|
|
171
|
+
};
|
|
172
|
+
}
|
|
173
|
+
|
|
174
|
+
const profile = {
|
|
175
|
+
id: crypto.randomUUID(),
|
|
176
|
+
name,
|
|
177
|
+
fingerprint,
|
|
178
|
+
proxy: opts.proxy || null,
|
|
179
|
+
cookies: [],
|
|
180
|
+
createdAt: new Date().toISOString(),
|
|
181
|
+
lastUsed: null,
|
|
182
|
+
useCount: 0,
|
|
183
|
+
};
|
|
184
|
+
|
|
185
|
+
fs.writeFileSync(filePath, JSON.stringify(profile, null, 2));
|
|
186
|
+
return profile;
|
|
187
|
+
}
|
|
188
|
+
|
|
189
|
+
/**
|
|
190
|
+
* Load a profile by name
|
|
191
|
+
*/
|
|
192
|
+
export function loadProfile(name) {
|
|
193
|
+
const filePath = profilePath(name);
|
|
194
|
+
|
|
195
|
+
if (!fs.existsSync(filePath)) {
|
|
196
|
+
throw new Error(`Profile "${name}" not found. Create with: stealth profile create ${name}`);
|
|
197
|
+
}
|
|
198
|
+
|
|
199
|
+
return JSON.parse(fs.readFileSync(filePath, 'utf-8'));
|
|
200
|
+
}
|
|
201
|
+
|
|
202
|
+
/**
|
|
203
|
+
* Save profile (update cookies, stats, etc.)
|
|
204
|
+
*/
|
|
205
|
+
export function saveProfile(name, profile) {
|
|
206
|
+
ensureDir();
|
|
207
|
+
const filePath = profilePath(name);
|
|
208
|
+
fs.writeFileSync(filePath, JSON.stringify(profile, null, 2));
|
|
209
|
+
}
|
|
210
|
+
|
|
211
|
+
/**
|
|
212
|
+
* Update profile usage stats
|
|
213
|
+
*/
|
|
214
|
+
export function touchProfile(name) {
|
|
215
|
+
const profile = loadProfile(name);
|
|
216
|
+
profile.lastUsed = new Date().toISOString();
|
|
217
|
+
profile.useCount += 1;
|
|
218
|
+
saveProfile(name, profile);
|
|
219
|
+
return profile;
|
|
220
|
+
}
|
|
221
|
+
|
|
222
|
+
/**
|
|
223
|
+
* Save cookies to profile (auto-called when browser closes)
|
|
224
|
+
*/
|
|
225
|
+
export async function saveCookiesToProfile(name, context) {
|
|
226
|
+
try {
|
|
227
|
+
const profile = loadProfile(name);
|
|
228
|
+
const cookies = await context.cookies();
|
|
229
|
+
profile.cookies = cookies;
|
|
230
|
+
profile.lastUsed = new Date().toISOString();
|
|
231
|
+
saveProfile(name, profile);
|
|
232
|
+
return cookies.length;
|
|
233
|
+
} catch {
|
|
234
|
+
return 0;
|
|
235
|
+
}
|
|
236
|
+
}
|
|
237
|
+
|
|
238
|
+
/**
|
|
239
|
+
* Load cookies from profile into browser context
|
|
240
|
+
*/
|
|
241
|
+
export async function loadCookiesFromProfile(name, context) {
|
|
242
|
+
try {
|
|
243
|
+
const profile = loadProfile(name);
|
|
244
|
+
if (profile.cookies && profile.cookies.length > 0) {
|
|
245
|
+
await context.addCookies(profile.cookies);
|
|
246
|
+
return profile.cookies.length;
|
|
247
|
+
}
|
|
248
|
+
return 0;
|
|
249
|
+
} catch {
|
|
250
|
+
return 0;
|
|
251
|
+
}
|
|
252
|
+
}
|
|
253
|
+
|
|
254
|
+
/**
|
|
255
|
+
* List all profiles
|
|
256
|
+
*/
|
|
257
|
+
export function listProfiles() {
|
|
258
|
+
ensureDir();
|
|
259
|
+
const files = fs.readdirSync(PROFILES_DIR).filter((f) => f.endsWith('.json'));
|
|
260
|
+
|
|
261
|
+
return files.map((f) => {
|
|
262
|
+
try {
|
|
263
|
+
const profile = JSON.parse(fs.readFileSync(path.join(PROFILES_DIR, f), 'utf-8'));
|
|
264
|
+
return {
|
|
265
|
+
name: profile.name,
|
|
266
|
+
locale: profile.fingerprint?.locale || '?',
|
|
267
|
+
timezone: profile.fingerprint?.timezone || '?',
|
|
268
|
+
os: profile.fingerprint?.os || '?',
|
|
269
|
+
viewport: profile.fingerprint?.viewport
|
|
270
|
+
? `${profile.fingerprint.viewport.width}x${profile.fingerprint.viewport.height}`
|
|
271
|
+
: '?',
|
|
272
|
+
proxy: profile.proxy ? '✓' : '-',
|
|
273
|
+
cookies: profile.cookies?.length || 0,
|
|
274
|
+
lastUsed: profile.lastUsed || 'never',
|
|
275
|
+
useCount: profile.useCount || 0,
|
|
276
|
+
};
|
|
277
|
+
} catch {
|
|
278
|
+
return { name: f.replace('.json', ''), error: 'corrupted' };
|
|
279
|
+
}
|
|
280
|
+
});
|
|
281
|
+
}
|
|
282
|
+
|
|
283
|
+
/**
|
|
284
|
+
* Delete a profile
|
|
285
|
+
*/
|
|
286
|
+
export function deleteProfile(name) {
|
|
287
|
+
const filePath = profilePath(name);
|
|
288
|
+
if (!fs.existsSync(filePath)) {
|
|
289
|
+
throw new Error(`Profile "${name}" not found`);
|
|
290
|
+
}
|
|
291
|
+
fs.unlinkSync(filePath);
|
|
292
|
+
}
|
|
293
|
+
|
|
294
|
+
/**
|
|
295
|
+
* Get available presets
|
|
296
|
+
*/
|
|
297
|
+
export function getPresets() {
|
|
298
|
+
return Object.keys(FINGERPRINT_PRESETS);
|
|
299
|
+
}
|
|
300
|
+
|
|
301
|
+
/**
|
|
302
|
+
* Pick a random profile from existing ones
|
|
303
|
+
*/
|
|
304
|
+
export function randomProfile() {
|
|
305
|
+
const profiles = listProfiles();
|
|
306
|
+
if (profiles.length === 0) return null;
|
|
307
|
+
return profiles[Math.floor(Math.random() * profiles.length)].name;
|
|
308
|
+
}
|
|
@@ -0,0 +1,256 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Proxy pool management — store, test, and rotate proxies
|
|
3
|
+
*
|
|
4
|
+
* Storage: ~/.stealth/proxies.json
|
|
5
|
+
*/
|
|
6
|
+
|
|
7
|
+
import fs from 'fs';
|
|
8
|
+
import path from 'path';
|
|
9
|
+
import os from 'os';
|
|
10
|
+
|
|
11
|
+
const STEALTH_DIR = path.join(os.homedir(), '.stealth');
|
|
12
|
+
const PROXIES_FILE = path.join(STEALTH_DIR, 'proxies.json');
|
|
13
|
+
|
|
14
|
+
function ensureDir() {
|
|
15
|
+
fs.mkdirSync(STEALTH_DIR, { recursive: true });
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
function loadData() {
|
|
19
|
+
ensureDir();
|
|
20
|
+
if (!fs.existsSync(PROXIES_FILE)) {
|
|
21
|
+
return { proxies: [], lastRotateIndex: 0 };
|
|
22
|
+
}
|
|
23
|
+
return JSON.parse(fs.readFileSync(PROXIES_FILE, 'utf-8'));
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
function saveData(data) {
|
|
27
|
+
ensureDir();
|
|
28
|
+
fs.writeFileSync(PROXIES_FILE, JSON.stringify(data, null, 2));
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
/**
|
|
32
|
+
* Add a proxy to the pool
|
|
33
|
+
*
|
|
34
|
+
* @param {string} proxyUrl - Proxy URL (http://user:pass@host:port)
|
|
35
|
+
* @param {object} opts
|
|
36
|
+
* @param {string} [opts.label] - Label/name for this proxy
|
|
37
|
+
* @param {string} [opts.region] - Geographic region
|
|
38
|
+
*/
|
|
39
|
+
export function addProxy(proxyUrl, opts = {}) {
|
|
40
|
+
const data = loadData();
|
|
41
|
+
|
|
42
|
+
// Check for duplicates
|
|
43
|
+
if (data.proxies.some((p) => p.url === proxyUrl)) {
|
|
44
|
+
throw new Error('Proxy already exists in pool');
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
data.proxies.push({
|
|
48
|
+
url: proxyUrl,
|
|
49
|
+
label: opts.label || null,
|
|
50
|
+
region: opts.region || null,
|
|
51
|
+
addedAt: new Date().toISOString(),
|
|
52
|
+
lastUsed: null,
|
|
53
|
+
useCount: 0,
|
|
54
|
+
lastStatus: null, // 'ok' | 'fail' | null
|
|
55
|
+
lastLatency: null, // ms
|
|
56
|
+
failCount: 0,
|
|
57
|
+
});
|
|
58
|
+
|
|
59
|
+
saveData(data);
|
|
60
|
+
return data.proxies.length;
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
/**
|
|
64
|
+
* Remove a proxy from the pool
|
|
65
|
+
*/
|
|
66
|
+
export function removeProxy(proxyUrl) {
|
|
67
|
+
const data = loadData();
|
|
68
|
+
const idx = data.proxies.findIndex((p) => p.url === proxyUrl || p.label === proxyUrl);
|
|
69
|
+
if (idx === -1) throw new Error('Proxy not found');
|
|
70
|
+
data.proxies.splice(idx, 1);
|
|
71
|
+
saveData(data);
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
/**
|
|
75
|
+
* List all proxies
|
|
76
|
+
*/
|
|
77
|
+
export function listProxies() {
|
|
78
|
+
const data = loadData();
|
|
79
|
+
return data.proxies.map((p) => ({
|
|
80
|
+
url: maskPassword(p.url),
|
|
81
|
+
label: p.label || '-',
|
|
82
|
+
region: p.region || '-',
|
|
83
|
+
status: p.lastStatus || 'unknown',
|
|
84
|
+
latency: p.lastLatency ? `${p.lastLatency}ms` : '-',
|
|
85
|
+
useCount: p.useCount,
|
|
86
|
+
failCount: p.failCount,
|
|
87
|
+
lastUsed: p.lastUsed || 'never',
|
|
88
|
+
}));
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
/**
|
|
92
|
+
* Get the next proxy (round-robin rotation)
|
|
93
|
+
*/
|
|
94
|
+
export function getNextProxy() {
|
|
95
|
+
const data = loadData();
|
|
96
|
+
|
|
97
|
+
if (data.proxies.length === 0) return null;
|
|
98
|
+
|
|
99
|
+
// Filter out proxies with too many consecutive failures
|
|
100
|
+
const available = data.proxies.filter((p) => p.failCount < 5);
|
|
101
|
+
if (available.length === 0) {
|
|
102
|
+
// Reset all fail counts and try again
|
|
103
|
+
data.proxies.forEach((p) => { p.failCount = 0; });
|
|
104
|
+
saveData(data);
|
|
105
|
+
return data.proxies[0]?.url || null;
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
// Round-robin
|
|
109
|
+
const idx = data.lastRotateIndex % available.length;
|
|
110
|
+
const proxy = available[idx];
|
|
111
|
+
|
|
112
|
+
// Update stats
|
|
113
|
+
proxy.lastUsed = new Date().toISOString();
|
|
114
|
+
proxy.useCount += 1;
|
|
115
|
+
data.lastRotateIndex = idx + 1;
|
|
116
|
+
|
|
117
|
+
saveData(data);
|
|
118
|
+
return proxy.url;
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
/**
|
|
122
|
+
* Get a random proxy from the pool
|
|
123
|
+
*/
|
|
124
|
+
export function getRandomProxy() {
|
|
125
|
+
const data = loadData();
|
|
126
|
+
const available = data.proxies.filter((p) => p.failCount < 5);
|
|
127
|
+
if (available.length === 0) return null;
|
|
128
|
+
|
|
129
|
+
const proxy = available[Math.floor(Math.random() * available.length)];
|
|
130
|
+
proxy.lastUsed = new Date().toISOString();
|
|
131
|
+
proxy.useCount += 1;
|
|
132
|
+
saveData(data);
|
|
133
|
+
|
|
134
|
+
return proxy.url;
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
/**
|
|
138
|
+
* Report proxy success/failure (updates stats)
|
|
139
|
+
*/
|
|
140
|
+
export function reportProxy(proxyUrl, success, latencyMs = null) {
|
|
141
|
+
const data = loadData();
|
|
142
|
+
const proxy = data.proxies.find((p) => p.url === proxyUrl);
|
|
143
|
+
if (!proxy) return;
|
|
144
|
+
|
|
145
|
+
if (success) {
|
|
146
|
+
proxy.lastStatus = 'ok';
|
|
147
|
+
proxy.failCount = 0;
|
|
148
|
+
proxy.lastLatency = latencyMs;
|
|
149
|
+
} else {
|
|
150
|
+
proxy.lastStatus = 'fail';
|
|
151
|
+
proxy.failCount += 1;
|
|
152
|
+
}
|
|
153
|
+
|
|
154
|
+
saveData(data);
|
|
155
|
+
}
|
|
156
|
+
|
|
157
|
+
/**
|
|
158
|
+
* Test a proxy by making a request
|
|
159
|
+
*/
|
|
160
|
+
export async function testProxy(proxyUrl) {
|
|
161
|
+
const { launchOptions } = await import('camoufox-js');
|
|
162
|
+
const { firefox } = await import('playwright-core');
|
|
163
|
+
|
|
164
|
+
const start = Date.now();
|
|
165
|
+
let browser;
|
|
166
|
+
|
|
167
|
+
try {
|
|
168
|
+
// Parse proxy URL
|
|
169
|
+
let proxyConfig;
|
|
170
|
+
try {
|
|
171
|
+
const url = new URL(proxyUrl.startsWith('http') ? proxyUrl : `http://${proxyUrl}`);
|
|
172
|
+
proxyConfig = {
|
|
173
|
+
server: `${url.protocol}//${url.hostname}:${url.port}`,
|
|
174
|
+
username: url.username || undefined,
|
|
175
|
+
password: url.password || undefined,
|
|
176
|
+
};
|
|
177
|
+
} catch {
|
|
178
|
+
throw new Error(`Invalid proxy URL: ${proxyUrl}`);
|
|
179
|
+
}
|
|
180
|
+
|
|
181
|
+
const options = await launchOptions({
|
|
182
|
+
headless: true,
|
|
183
|
+
proxy: proxyConfig,
|
|
184
|
+
});
|
|
185
|
+
|
|
186
|
+
browser = await firefox.launch(options);
|
|
187
|
+
const context = await browser.newContext();
|
|
188
|
+
const page = await context.newPage();
|
|
189
|
+
|
|
190
|
+
// Test with a fast, reliable site
|
|
191
|
+
await page.goto('https://httpbin.org/ip', { timeout: 15000 });
|
|
192
|
+
const body = await page.textContent('body');
|
|
193
|
+
const ip = JSON.parse(body)?.origin || 'unknown';
|
|
194
|
+
|
|
195
|
+
const latency = Date.now() - start;
|
|
196
|
+
|
|
197
|
+
await context.close();
|
|
198
|
+
await browser.close();
|
|
199
|
+
|
|
200
|
+
reportProxy(proxyUrl, true, latency);
|
|
201
|
+
|
|
202
|
+
return {
|
|
203
|
+
ok: true,
|
|
204
|
+
ip,
|
|
205
|
+
latency,
|
|
206
|
+
proxy: maskPassword(proxyUrl),
|
|
207
|
+
};
|
|
208
|
+
} catch (err) {
|
|
209
|
+
if (browser) await browser.close().catch(() => {});
|
|
210
|
+
reportProxy(proxyUrl, false);
|
|
211
|
+
|
|
212
|
+
return {
|
|
213
|
+
ok: false,
|
|
214
|
+
error: err.message,
|
|
215
|
+
latency: Date.now() - start,
|
|
216
|
+
proxy: maskPassword(proxyUrl),
|
|
217
|
+
};
|
|
218
|
+
}
|
|
219
|
+
}
|
|
220
|
+
|
|
221
|
+
/**
|
|
222
|
+
* Test all proxies in pool
|
|
223
|
+
*/
|
|
224
|
+
export async function testAllProxies() {
|
|
225
|
+
const data = loadData();
|
|
226
|
+
const results = [];
|
|
227
|
+
|
|
228
|
+
for (const proxy of data.proxies) {
|
|
229
|
+
const result = await testProxy(proxy.url);
|
|
230
|
+
results.push(result);
|
|
231
|
+
}
|
|
232
|
+
|
|
233
|
+
return results;
|
|
234
|
+
}
|
|
235
|
+
|
|
236
|
+
/**
|
|
237
|
+
* Get pool size
|
|
238
|
+
*/
|
|
239
|
+
export function poolSize() {
|
|
240
|
+
return loadData().proxies.length;
|
|
241
|
+
}
|
|
242
|
+
|
|
243
|
+
/**
|
|
244
|
+
* Mask password in proxy URL for display
|
|
245
|
+
*/
|
|
246
|
+
function maskPassword(url) {
|
|
247
|
+
try {
|
|
248
|
+
const parsed = new URL(url.startsWith('http') ? url : `http://${url}`);
|
|
249
|
+
if (parsed.password) {
|
|
250
|
+
parsed.password = '****';
|
|
251
|
+
}
|
|
252
|
+
return parsed.toString().replace(/\/$/, '');
|
|
253
|
+
} catch {
|
|
254
|
+
return url;
|
|
255
|
+
}
|
|
256
|
+
}
|
package/src/retry.js
ADDED
|
@@ -0,0 +1,112 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Retry mechanism with exponential backoff
|
|
3
|
+
*/
|
|
4
|
+
|
|
5
|
+
import { log } from './output.js';
|
|
6
|
+
|
|
7
|
+
/**
|
|
8
|
+
* Retryable error types
|
|
9
|
+
*/
|
|
10
|
+
const RETRYABLE_PATTERNS = [
|
|
11
|
+
'timeout',
|
|
12
|
+
'net::ERR_',
|
|
13
|
+
'Navigation failed',
|
|
14
|
+
'Target closed',
|
|
15
|
+
'Session closed',
|
|
16
|
+
'Connection refused',
|
|
17
|
+
'ECONNRESET',
|
|
18
|
+
'ECONNREFUSED',
|
|
19
|
+
'ETIMEDOUT',
|
|
20
|
+
'page.goto: Timeout',
|
|
21
|
+
'frame was detached',
|
|
22
|
+
'browser has disconnected',
|
|
23
|
+
];
|
|
24
|
+
|
|
25
|
+
/**
|
|
26
|
+
* Check if an error is retryable
|
|
27
|
+
*/
|
|
28
|
+
function isRetryable(err) {
|
|
29
|
+
const msg = err.message || String(err);
|
|
30
|
+
return RETRYABLE_PATTERNS.some((p) => msg.toLowerCase().includes(p.toLowerCase()));
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
/**
|
|
34
|
+
* Execute a function with retry logic
|
|
35
|
+
*
|
|
36
|
+
* @param {Function} fn - Async function to execute
|
|
37
|
+
* @param {object} opts
|
|
38
|
+
* @param {number} [opts.maxRetries=3] - Maximum retry attempts
|
|
39
|
+
* @param {number} [opts.baseDelay=1000] - Base delay in ms (doubles each retry)
|
|
40
|
+
* @param {number} [opts.maxDelay=10000] - Maximum delay between retries
|
|
41
|
+
* @param {string} [opts.label='operation'] - Label for log messages
|
|
42
|
+
* @param {Function} [opts.shouldRetry] - Custom retry check (receives error)
|
|
43
|
+
* @param {Function} [opts.onRetry] - Callback before each retry (receives { error, attempt, delay })
|
|
44
|
+
* @returns {Promise<*>} Result of fn
|
|
45
|
+
*/
|
|
46
|
+
export async function withRetry(fn, opts = {}) {
|
|
47
|
+
const {
|
|
48
|
+
maxRetries = 3,
|
|
49
|
+
baseDelay = 1000,
|
|
50
|
+
maxDelay = 10000,
|
|
51
|
+
label = 'operation',
|
|
52
|
+
shouldRetry = isRetryable,
|
|
53
|
+
onRetry,
|
|
54
|
+
} = opts;
|
|
55
|
+
|
|
56
|
+
let lastError;
|
|
57
|
+
|
|
58
|
+
for (let attempt = 0; attempt <= maxRetries; attempt++) {
|
|
59
|
+
try {
|
|
60
|
+
return await fn(attempt);
|
|
61
|
+
} catch (err) {
|
|
62
|
+
lastError = err;
|
|
63
|
+
|
|
64
|
+
// Don't retry if max attempts reached
|
|
65
|
+
if (attempt >= maxRetries) break;
|
|
66
|
+
|
|
67
|
+
// Don't retry if error is not retryable
|
|
68
|
+
if (!shouldRetry(err)) {
|
|
69
|
+
break;
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
// Calculate delay with exponential backoff + jitter
|
|
73
|
+
const exponentialDelay = baseDelay * Math.pow(2, attempt);
|
|
74
|
+
const jitter = Math.random() * baseDelay * 0.5;
|
|
75
|
+
const delay = Math.min(exponentialDelay + jitter, maxDelay);
|
|
76
|
+
|
|
77
|
+
log.warn(`${label} failed (attempt ${attempt + 1}/${maxRetries + 1}): ${err.message}`);
|
|
78
|
+
log.dim(` Retrying in ${Math.round(delay)}ms...`);
|
|
79
|
+
|
|
80
|
+
if (onRetry) {
|
|
81
|
+
await onRetry({ error: err, attempt: attempt + 1, delay });
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
await sleep(delay);
|
|
85
|
+
}
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
throw lastError;
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
/**
|
|
92
|
+
* Navigate to URL with automatic retry
|
|
93
|
+
*/
|
|
94
|
+
export async function navigateWithRetry(page, url, opts = {}) {
|
|
95
|
+
const { timeout = 30000, waitUntil = 'domcontentloaded', maxRetries = 2, ...retryOpts } = opts;
|
|
96
|
+
|
|
97
|
+
return withRetry(
|
|
98
|
+
async () => {
|
|
99
|
+
await page.goto(url, { waitUntil, timeout });
|
|
100
|
+
return page.url();
|
|
101
|
+
},
|
|
102
|
+
{
|
|
103
|
+
maxRetries,
|
|
104
|
+
label: `navigate to ${url.slice(0, 60)}`,
|
|
105
|
+
...retryOpts,
|
|
106
|
+
},
|
|
107
|
+
);
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
function sleep(ms) {
|
|
111
|
+
return new Promise((resolve) => setTimeout(resolve, ms));
|
|
112
|
+
}
|