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.
Files changed (44) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +295 -0
  3. package/bin/stealth.js +50 -0
  4. package/package.json +65 -0
  5. package/skills/SKILL.md +244 -0
  6. package/src/browser.js +341 -0
  7. package/src/client.js +115 -0
  8. package/src/commands/batch.js +180 -0
  9. package/src/commands/browse.js +101 -0
  10. package/src/commands/config.js +85 -0
  11. package/src/commands/crawl.js +169 -0
  12. package/src/commands/daemon.js +143 -0
  13. package/src/commands/extract.js +153 -0
  14. package/src/commands/fingerprint.js +306 -0
  15. package/src/commands/interactive.js +284 -0
  16. package/src/commands/mcp.js +68 -0
  17. package/src/commands/monitor.js +160 -0
  18. package/src/commands/pdf.js +109 -0
  19. package/src/commands/profile.js +112 -0
  20. package/src/commands/proxy.js +116 -0
  21. package/src/commands/screenshot.js +96 -0
  22. package/src/commands/search.js +162 -0
  23. package/src/commands/serve.js +240 -0
  24. package/src/config.js +123 -0
  25. package/src/cookies.js +67 -0
  26. package/src/daemon-entry.js +19 -0
  27. package/src/daemon.js +294 -0
  28. package/src/errors.js +136 -0
  29. package/src/extractors/base.js +59 -0
  30. package/src/extractors/bing.js +47 -0
  31. package/src/extractors/duckduckgo.js +91 -0
  32. package/src/extractors/github.js +103 -0
  33. package/src/extractors/google.js +173 -0
  34. package/src/extractors/index.js +55 -0
  35. package/src/extractors/youtube.js +87 -0
  36. package/src/humanize.js +210 -0
  37. package/src/index.js +32 -0
  38. package/src/macros.js +36 -0
  39. package/src/mcp-server.js +341 -0
  40. package/src/output.js +65 -0
  41. package/src/profiles.js +308 -0
  42. package/src/proxy-pool.js +256 -0
  43. package/src/retry.js +112 -0
  44. package/src/session.js +159 -0
@@ -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
+ }