@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.
- package/.claude/settings.local.json +14 -0
- package/.dockerignore +34 -0
- package/README.md +256 -0
- package/api-1 (3).json +831 -0
- package/dist/browser-cash.js +128 -0
- package/dist/claude-runner.js +285 -0
- package/dist/cli-install.js +104 -0
- package/dist/cli.js +503 -0
- package/dist/codegen/bash-generator.js +104 -0
- package/dist/config.js +112 -0
- package/dist/errors/error-classifier.js +351 -0
- package/dist/hooks/capture-hook.js +57 -0
- package/dist/index.js +180 -0
- package/dist/iterative-tester.js +407 -0
- package/dist/logger/command-log.js +38 -0
- package/dist/prompts/agentic-prompt.js +78 -0
- package/dist/prompts/fix-prompt.js +477 -0
- package/dist/prompts/helpers.js +214 -0
- package/dist/prompts/system-prompt.js +282 -0
- package/dist/script-runner.js +429 -0
- package/dist/server.js +1934 -0
- package/dist/types/iteration-history.js +139 -0
- package/openapi.json +1131 -0
- package/package.json +44 -0
|
@@ -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
|
+
}
|