@browsercash/chase 1.0.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.
@@ -0,0 +1,429 @@
1
+ import { spawn } from 'child_process';
2
+ import * as fs from 'fs';
3
+ import { classifyErrors, ErrorCategory, } from './errors/error-classifier.js';
4
+ /**
5
+ * Check if a CDP URL is still valid/responsive
6
+ * Returns true if the connection works, false if stale/unavailable
7
+ *
8
+ * Supports retry with exponential backoff for transient failures.
9
+ */
10
+ export async function checkCdpConnectivity(cdpUrl, options = {}) {
11
+ const { maxRetries = 3, initialDelayMs = 1000, maxDelayMs = 4000, } = options;
12
+ let lastError;
13
+ let attempts = 0;
14
+ for (let retry = 0; retry < maxRetries; retry++) {
15
+ attempts++;
16
+ const result = await checkCdpConnectivityOnce(cdpUrl);
17
+ if (result.connected) {
18
+ return { connected: true, attempts };
19
+ }
20
+ lastError = result.error;
21
+ // Don't retry on definitive errors (server not running)
22
+ if (result.error?.includes('ECONNREFUSED')) {
23
+ return { connected: false, error: result.error, attempts };
24
+ }
25
+ // Exponential backoff for retryable errors
26
+ if (retry < maxRetries - 1) {
27
+ const delay = Math.min(initialDelayMs * Math.pow(2, retry), maxDelayMs);
28
+ await sleep(delay);
29
+ }
30
+ }
31
+ return { connected: false, error: lastError, attempts };
32
+ }
33
+ /**
34
+ * Single CDP connectivity check without retry.
35
+ */
36
+ async function checkCdpConnectivityOnce(cdpUrl) {
37
+ return new Promise((resolve) => {
38
+ const proc = spawn('agent-browser', ['--cdp', cdpUrl, 'eval', 'true'], {
39
+ stdio: ['ignore', 'pipe', 'pipe'],
40
+ });
41
+ let stderr = '';
42
+ let stdout = '';
43
+ proc.stdout?.on('data', (data) => {
44
+ stdout += data.toString();
45
+ });
46
+ proc.stderr?.on('data', (data) => {
47
+ stderr += data.toString();
48
+ });
49
+ const timeoutId = setTimeout(() => {
50
+ proc.kill();
51
+ resolve({ connected: false, error: 'CDP connectivity check timed out' });
52
+ }, 10000);
53
+ proc.on('close', (code) => {
54
+ clearTimeout(timeoutId);
55
+ const combined = stdout + stderr;
56
+ // Check for common stale connection errors
57
+ if (combined.includes('Resource temporarily unavailable') ||
58
+ combined.includes('os error 35') ||
59
+ combined.includes('WebSocket') ||
60
+ combined.includes('ECONNREFUSED') ||
61
+ combined.includes('connection closed') ||
62
+ code !== 0) {
63
+ resolve({ connected: false, error: combined.trim() || `Exit code: ${code}` });
64
+ }
65
+ else {
66
+ resolve({ connected: true });
67
+ }
68
+ });
69
+ proc.on('error', (err) => {
70
+ clearTimeout(timeoutId);
71
+ resolve({ connected: false, error: err.message });
72
+ });
73
+ });
74
+ }
75
+ /**
76
+ * Sleep for a specified number of milliseconds.
77
+ */
78
+ function sleep(ms) {
79
+ return new Promise(resolve => setTimeout(resolve, ms));
80
+ }
81
+ /**
82
+ * Run a bash script and capture the result
83
+ */
84
+ export async function runScript(scriptPath, cdpUrl, timeout, taskDescription) {
85
+ return new Promise((resolve) => {
86
+ let stdout = '';
87
+ let stderr = '';
88
+ let timedOut = false;
89
+ const proc = spawn('bash', [scriptPath], {
90
+ env: { ...process.env, CDP_URL: cdpUrl },
91
+ stdio: ['ignore', 'pipe', 'pipe'],
92
+ });
93
+ proc.stdout?.on('data', (data) => {
94
+ stdout += data.toString();
95
+ });
96
+ proc.stderr?.on('data', (data) => {
97
+ stderr += data.toString();
98
+ });
99
+ const timeoutId = setTimeout(() => {
100
+ timedOut = true;
101
+ proc.kill('SIGTERM');
102
+ // Give it a moment to terminate gracefully, then force kill
103
+ setTimeout(() => {
104
+ proc.kill('SIGKILL');
105
+ }, 2000);
106
+ }, timeout);
107
+ proc.on('close', (code) => {
108
+ clearTimeout(timeoutId);
109
+ const failedLineNumber = parseFailedLineNumber(stderr, stdout);
110
+ const semanticError = detectSemanticError(stdout, stderr, taskDescription);
111
+ // Classify errors for structured handling
112
+ const classifiedErrors = classifyErrors(stdout, stderr, code, timedOut);
113
+ // Check for CDP stale error
114
+ const cdpStale = classifiedErrors.some(e => e.category === ErrorCategory.CDP_CONNECTION);
115
+ // Consider semantic errors as failures even if exit code is 0
116
+ const hasError = code !== 0 || timedOut || semanticError !== null;
117
+ resolve({
118
+ success: !hasError,
119
+ stdout,
120
+ stderr,
121
+ exitCode: code,
122
+ timedOut,
123
+ failedLineNumber,
124
+ semanticError: semanticError || undefined,
125
+ cdpStale,
126
+ classifiedErrors,
127
+ });
128
+ });
129
+ proc.on('error', (err) => {
130
+ clearTimeout(timeoutId);
131
+ resolve({
132
+ success: false,
133
+ stdout,
134
+ stderr: stderr + '\n' + err.message,
135
+ exitCode: null,
136
+ timedOut: false,
137
+ });
138
+ });
139
+ });
140
+ }
141
+ /**
142
+ * Run a script from content (writes to temp file first)
143
+ */
144
+ export async function runScriptContent(scriptContent, cdpUrl, timeout) {
145
+ // Write to temp file
146
+ const tempPath = `/tmp/claude-gen-test-${Date.now()}.sh`;
147
+ fs.writeFileSync(tempPath, scriptContent);
148
+ fs.chmodSync(tempPath, '755');
149
+ try {
150
+ return await runScript(tempPath, cdpUrl, timeout);
151
+ }
152
+ finally {
153
+ // Clean up temp file
154
+ try {
155
+ fs.unlinkSync(tempPath);
156
+ }
157
+ catch {
158
+ // Ignore cleanup errors
159
+ }
160
+ }
161
+ }
162
+ /**
163
+ * Detect semantic errors in script output
164
+ * These are cases where the script "succeeds" but didn't accomplish its goal
165
+ * @param stdout - Script stdout
166
+ * @param stderr - Script stderr
167
+ * @param taskDescription - Optional task description to validate expected counts
168
+ */
169
+ function detectSemanticError(stdout, stderr, taskDescription) {
170
+ const combined = stdout + '\n' + stderr;
171
+ // Check for incomplete extraction - totalExtracted vs expected
172
+ // Pattern: "totalExtracted": 8 or "totalExtracted":8
173
+ const extractedMatch = stdout.match(/"totalExtracted":\s*(\d+)/);
174
+ if (extractedMatch) {
175
+ const extractedCount = parseInt(extractedMatch[1], 10);
176
+ // If task mentions "all" items, be strict about completeness
177
+ if (taskDescription && /\ball\b/i.test(taskDescription)) {
178
+ // Look for indicators of how many items should exist on the page
179
+ // e.g., "24 products", "showing 50 results"
180
+ const pageCountMatch = stdout.match(/(\d+)\s*(?:products?|items?|results?|parkas?)/i);
181
+ if (pageCountMatch) {
182
+ const pageCount = parseInt(pageCountMatch[1], 10);
183
+ // If page shows more items than we extracted, it's incomplete
184
+ if (pageCount > extractedCount * 1.2) { // Allow 20% margin
185
+ return `INCOMPLETE: Page shows ${pageCount} items but only extracted ${extractedCount}. Need to navigate to the full product listing page (not a landing page), then scroll more or handle pagination.`;
186
+ }
187
+ }
188
+ // For "all items" tasks, require at least 20 items for product listings
189
+ // Most e-commerce category pages have 40+ products per page
190
+ if (extractedCount < 20) {
191
+ return `INCOMPLETE: Only extracted ${extractedCount} items. For "all items" tasks, this is likely incomplete. Your selector may be targeting a carousel/ads section instead of the main product grid. Look for a selector that returns 20-50 items per page.`;
192
+ }
193
+ }
194
+ }
195
+ // Check for pagination that didn't actually change items (wrong selector targeting sticky element)
196
+ // Pattern: "Found X items on page 1" ... "Found X items on page 2" with same sample item
197
+ const pageExtractionPattern = /Found (\d+) items on page (\d+)/g;
198
+ const pageExtractions = [];
199
+ let pageMatch;
200
+ while ((pageMatch = pageExtractionPattern.exec(stdout)) !== null) {
201
+ pageExtractions.push({ page: parseInt(pageMatch[2], 10), count: parseInt(pageMatch[1], 10) });
202
+ }
203
+ // If we have multiple pages with very low counts (< 10), likely wrong selector
204
+ if (pageExtractions.length > 3) {
205
+ const lowCountPages = pageExtractions.filter(p => p.count < 10).length;
206
+ if (lowCountPages > pageExtractions.length * 0.5) {
207
+ return `WRONG_SELECTOR: Pages are returning very few items (${pageExtractions.map(p => p.count).join(', ')}). Your selector is likely targeting a carousel or ads section instead of the main product grid. Find a different selector that returns 20-50 items per page.`;
208
+ }
209
+ }
210
+ // Check for same sample item appearing on multiple pages (strong signal of wrong selector)
211
+ const sampleItemPattern = /Sample item:[\s\S]*?"name":\s*"([^"]+)"/g;
212
+ const sampleItems = [];
213
+ let sampleMatch;
214
+ while ((sampleMatch = sampleItemPattern.exec(stdout)) !== null) {
215
+ sampleItems.push(sampleMatch[1]);
216
+ }
217
+ if (sampleItems.length > 3) {
218
+ // Check if same item appears on > 50% of pages
219
+ const firstItem = sampleItems[0];
220
+ const sameItemCount = sampleItems.filter(s => s === firstItem).length;
221
+ if (sameItemCount > sampleItems.length * 0.5) {
222
+ return `WRONG_SELECTOR: Same item "${firstItem.substring(0, 50)}..." appears on ${sameItemCount}/${sampleItems.length} pages. This means your selector is targeting a sticky element (ads/carousel) that doesn't change between pages. Find a different selector for the main product grid.`;
223
+ }
224
+ }
225
+ // Note: Low item count check is already handled by totalExtracted check above.
226
+ // The previous regex-based check was removed because it was faulty
227
+ // (matched only once at array start, not each item).
228
+ // Check if we have substantial JSON data extraction (indicates success, not 404)
229
+ // Count occurrences of common data field patterns in JSON output
230
+ // Handles both escaped (\"name\":) and unescaped ("name":) JSON
231
+ const dataFieldPattern = /\\?"(?:name|title|price|url|id|rank)\\?":\s*\\?["\d\[{]/g;
232
+ const dataFieldCount = (stdout.match(dataFieldPattern) || []).length;
233
+ const hasSubstantialData = dataFieldCount >= 5;
234
+ // Check for 404/error pages - be more specific to avoid false positives
235
+ // Only check stderr for navigation errors, not stdout (which may contain page content)
236
+ // Match patterns like "Page not found", "Error 404", "404 error", "404 Not Found"
237
+ const pageNotFoundPattern = /(?:page[\s-]?not[\s-]?found|error[\s:_-]*404|404[\s:_-]*(?:not[\s-]?found|error)|Page\s+Not\s+Found)/i;
238
+ // Flag 404 if it's in stderr, OR in stdout AND (no substantial data OR low item count for "all" tasks)
239
+ const extractedCount = extractedMatch ? parseInt(extractedMatch[1], 10) : 0;
240
+ const lowItemCountFor404 = extractedCount > 0 && extractedCount < 15;
241
+ const isAllItemsTask = taskDescription && /\ball\b/i.test(taskDescription);
242
+ if (pageNotFoundPattern.test(stderr) ||
243
+ (pageNotFoundPattern.test(stdout) && (!hasSubstantialData || (isAllItemsTask && lowItemCountFor404)))) {
244
+ return 'Navigation landed on 404/error page - URL may have changed or redirected';
245
+ }
246
+ // Check for empty results when we expected data
247
+ // Pattern 1: "[]" or '[]' as the only output on a line
248
+ const emptyArrayPattern = /^(?:\s*"\[\]"\s*|\s*\[\]\s*)$/m;
249
+ if (emptyArrayPattern.test(stdout)) {
250
+ return 'Script returned empty results []';
251
+ }
252
+ // Pattern 2: JSON with zero counts or empty arrays in final output
253
+ // Only trigger if we see intermediate successful extractions followed by empty final result
254
+ const zeroCountPattern = /"(?:total|count|length)":\s*0/i;
255
+ const emptyArrayInJsonPattern = /"(?:tokens|results|items|data|entries|records)":\s*\[\s*\]/;
256
+ const hasIntermediateSuccess = /"count":\s*(?:[1-9]|[1-9]\d+)/.test(stdout) ||
257
+ /extraction complete/i.test(stdout) ||
258
+ /Page \d+/i.test(stdout);
259
+ if (hasIntermediateSuccess && (zeroCountPattern.test(stdout) || emptyArrayInJsonPattern.test(stdout))) {
260
+ // Check if the empty result appears in the final lines (after intermediate extractions)
261
+ const finalLines = stdout.trim().split('\n').slice(-20).join('\n');
262
+ if (emptyArrayInJsonPattern.test(finalLines) || zeroCountPattern.test(finalLines)) {
263
+ return 'Script extracted data but final result is empty (likely variable scoping issue between evals)';
264
+ }
265
+ }
266
+ // Pattern 3: Standalone empty arrays/objects at the end
267
+ const finalLinesMatch = stdout.trim().split('\n').slice(-5).join('\n');
268
+ if (/^\s*\[\s*\]\s*$/m.test(finalLinesMatch) || /^\s*\{\s*\}\s*$/m.test(finalLinesMatch)) {
269
+ return 'Script returned empty result';
270
+ }
271
+ // Pattern 4: Garbled/concatenated data values
272
+ // Note: Some sites legitimately show both short and long format (e.g., "$5.72B$5,722,189,974")
273
+ // This is common on financial sites like CoinMarketCap - not actually an error
274
+ // Only flag truly garbled data where values are nonsensically merged
275
+ // DISABLED: This check was too aggressive and flagged valid data from financial sites
276
+ // const garbledValuePattern = /\$[\d,.]+[TBMK]?\$[\d,.]+|\d{3,},\d{3,}[TBMK]?\d/;
277
+ // if (garbledValuePattern.test(stdout)) {
278
+ // return 'Data quality issue: Concatenated/garbled values detected';
279
+ // }
280
+ // Pattern 5: Script extracts data but no clear final output section
281
+ // Check if there are multiple JSON arrays printed but no "FINAL" or "RESULT" section
282
+ // Skip this check if we have substantial data - the script may output data directly
283
+ const jsonArrayMatches = stdout.match(/\[\s*\{[^}]+\}/g) || [];
284
+ const hasFinalSection = /(?:FINAL|RESULT|COMBINED|OUTPUT|TOTAL)/i.test(stdout);
285
+ if (jsonArrayMatches.length >= 2 && !hasFinalSection && !hasSubstantialData) {
286
+ // Multiple data extractions but no final combined output
287
+ return 'Script has multiple extraction outputs but no final combined result section';
288
+ }
289
+ // Pattern 6: Extraction count mismatch - if we see "Total rows/items: X" but extracted much less
290
+ const totalCountMatch = stdout.match(/Total (?:rows|items|results|entries)[:\s]+(\d+)/i);
291
+ if (totalCountMatch) {
292
+ const totalCount = parseInt(totalCountMatch[1], 10);
293
+ // Count objects in JSON arrays (rough estimate by counting opening braces after commas/brackets)
294
+ const jsonObjectMatches = stdout.match(/[\[,]\s*\{/g) || [];
295
+ // If we report 50+ items but only extracted 25% or less, flag it
296
+ if (totalCount >= 50 && jsonObjectMatches.length < totalCount * 0.25) {
297
+ return `Incomplete extraction: Found ${totalCount} items but only extracted ~${jsonObjectMatches.length}`;
298
+ }
299
+ }
300
+ // Check for common error indicators in output
301
+ // Only flag if in stderr or if we don't have substantial data
302
+ if ((stderr.includes('Access Denied') || stderr.includes('403 Forbidden')) ||
303
+ ((combined.includes('Access Denied') || combined.includes('403 Forbidden')) && !hasSubstantialData)) {
304
+ return 'Access denied to page';
305
+ }
306
+ // Check for JavaScript errors in eval - these are real errors in stderr
307
+ if (stderr.includes('SyntaxError:') || stderr.includes('TypeError:') || stderr.includes('ReferenceError:')) {
308
+ return 'JavaScript error in eval command';
309
+ }
310
+ // Check for jq JSON parsing errors (double-encoded JSON from agent-browser eval)
311
+ if (stderr.includes('jq: error') || combined.includes('cannot be added') ||
312
+ combined.includes('cannot be subtracted') || combined.includes('Cannot iterate over string')) {
313
+ return 'JSON_DOUBLE_ENCODING: agent-browser eval returns string-encoded JSON. Add unwrap_json() helper and use: DATA=$(unwrap_json "$RAW_OUTPUT")';
314
+ }
315
+ // Check for navigation errors - only flag if in stderr or no substantial data
316
+ // Scripts may encounter transient nav errors but still succeed
317
+ if (stderr.includes('net::ERR_') || stderr.includes('Navigation failed') ||
318
+ ((combined.includes('net::ERR_') || combined.includes('Navigation failed')) && !hasSubstantialData)) {
319
+ return 'Navigation error';
320
+ }
321
+ // Check for undefined/null results that suggest variable scoping issues
322
+ if (/^undefined$/m.test(stdout) || /^null$/m.test(stdout)) {
323
+ return 'Script returned undefined/null (likely variable not accessible between evals)';
324
+ }
325
+ // Check for bash runtime errors (arithmetic, syntax in runtime)
326
+ // These appear even when exit code is 0 if using set +e or error in subshell
327
+ const bashRuntimeErrors = [
328
+ /integer expression expected/i,
329
+ /syntax error: operand expected/i,
330
+ /unbound variable/i,
331
+ /bad substitution/i,
332
+ ];
333
+ for (const pattern of bashRuntimeErrors) {
334
+ if (pattern.test(combined)) {
335
+ return `Bash runtime error: ${combined.match(pattern)?.[0] || 'unknown'}`;
336
+ }
337
+ }
338
+ // Check for CDP/WebSocket connection errors (stale session)
339
+ if (combined.includes('Resource temporarily unavailable') ||
340
+ combined.includes('os error 35') ||
341
+ combined.includes('WebSocket') ||
342
+ combined.includes('ECONNREFUSED') ||
343
+ combined.includes('connection closed')) {
344
+ return 'CDP_STALE: Browser session is no longer available';
345
+ }
346
+ // Pattern 7: Task-based extraction count validation
347
+ // If the task mentions a specific count (e.g., "top 200 tokens"), verify we got close
348
+ if (taskDescription) {
349
+ const expectedCountMatch = taskDescription.match(/(?:top|first|get|extract|scrape)\s+(\d+)\s+(?:items?|tokens?|products?|results?|rows?|entries?|records?)/i);
350
+ if (expectedCountMatch) {
351
+ const expectedCount = parseInt(expectedCountMatch[1], 10);
352
+ // First, check if totalExtracted is in the output
353
+ const totalExtractedMatch = stdout.match(/"totalExtracted":\s*(\d+)/);
354
+ if (totalExtractedMatch) {
355
+ const totalExtracted = parseInt(totalExtractedMatch[1], 10);
356
+ // If we got less than 70% of expected, flag as incomplete
357
+ if (totalExtracted < expectedCount * 0.7) {
358
+ return `Incomplete extraction: Task requested ${expectedCount} items but only extracted ${totalExtracted} (${Math.round(totalExtracted / expectedCount * 100)}%). Need more scrolling or pagination.`;
359
+ }
360
+ // If we got a good count, don't flag as error
361
+ return null;
362
+ }
363
+ // Fallback: Count actual items in JSON output - handle various escaping scenarios
364
+ // Patterns: {"rank": or {\"rank\": or {\\"rank\\": or { "rank":
365
+ const jsonObjectPatterns = [
366
+ /\{\s*\\?"(?:rank|id|name|title)\\?":/g, // {"rank": or {\"rank\":
367
+ /\{\s*\\"(?:rank|id|name|title)\\":/g, // {\\"rank\\": (double escaped)
368
+ /\\n\s*\{\s*\\n\s*\\"rank\\":/g, // Escaped JSON with newlines
369
+ ];
370
+ let jsonObjectCount = 0;
371
+ for (const pattern of jsonObjectPatterns) {
372
+ const matches = stdout.match(pattern) || [];
373
+ jsonObjectCount = Math.max(jsonObjectCount, matches.length);
374
+ }
375
+ // If we got less than 70% of expected, flag as incomplete
376
+ if (jsonObjectCount < expectedCount * 0.7) {
377
+ return `Incomplete extraction: Task requested ${expectedCount} items but only extracted ~${jsonObjectCount} (${Math.round(jsonObjectCount / expectedCount * 100)}%). This often indicates a virtualized/lazy-loaded table that requires scroll-and-accumulate pattern.`;
378
+ }
379
+ }
380
+ }
381
+ return null;
382
+ }
383
+ /**
384
+ * Parse error output to find the line number that failed
385
+ */
386
+ function parseFailedLineNumber(stderr, stdout) {
387
+ const combined = stderr + '\n' + stdout;
388
+ // Look for patterns like "line 42:" or "at line 42"
389
+ const lineMatch = combined.match(/(?:line\s+|:)(\d+)(?:\s*:|\s*$)/i);
390
+ if (lineMatch) {
391
+ return parseInt(lineMatch[1], 10);
392
+ }
393
+ // Look for bash error format: "script.sh: line 42: command not found"
394
+ const bashMatch = combined.match(/\.sh:\s*line\s+(\d+):/i);
395
+ if (bashMatch) {
396
+ return parseInt(bashMatch[1], 10);
397
+ }
398
+ return undefined;
399
+ }
400
+ /**
401
+ * Format error output for display
402
+ */
403
+ export function formatErrorOutput(result) {
404
+ let output = '';
405
+ if (result.semanticError) {
406
+ output += `[SEMANTIC ERROR] ${result.semanticError}\n`;
407
+ }
408
+ if (result.timedOut) {
409
+ output += '[TIMEOUT] Script execution timed out\n';
410
+ }
411
+ if (result.stderr) {
412
+ output += `[STDERR]\n${result.stderr}\n`;
413
+ }
414
+ if (result.stdout) {
415
+ // Truncate stdout if too long
416
+ const maxLen = 2000;
417
+ const truncated = result.stdout.length > maxLen
418
+ ? result.stdout.substring(result.stdout.length - maxLen) + '\n... (truncated)'
419
+ : result.stdout;
420
+ output += `[STDOUT]\n${truncated}\n`;
421
+ }
422
+ if (result.exitCode !== null && result.exitCode !== 0) {
423
+ output += `[EXIT CODE] ${result.exitCode}\n`;
424
+ }
425
+ if (result.failedLineNumber) {
426
+ output += `[FAILED AT] Line ${result.failedLineNumber}\n`;
427
+ }
428
+ return output.trim();
429
+ }