@jackwener/opencli 1.7.5 → 1.7.7
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/README.md +22 -10
- package/README.zh-CN.md +18 -9
- package/cli-manifest.json +401 -11
- package/clis/51job/company.js +125 -0
- package/clis/51job/detail.js +108 -0
- package/clis/51job/hot.js +55 -0
- package/clis/51job/search.js +79 -0
- package/clis/51job/utils.js +302 -0
- package/clis/51job/utils.test.js +69 -0
- package/clis/bilibili/video.js +68 -0
- package/clis/bilibili/video.test.js +132 -0
- package/clis/chatgpt/image.js +1 -1
- package/clis/deepseek/ask.js +37 -11
- package/clis/deepseek/ask.test.js +165 -0
- package/clis/deepseek/utils.js +192 -24
- package/clis/deepseek/utils.test.js +145 -0
- package/clis/gemini/image.js +1 -1
- package/clis/instagram/download.js +1 -1
- package/clis/jianyu/search.js +139 -3
- package/clis/jianyu/search.test.js +25 -0
- package/clis/jianyu/shared/procurement-detail.js +15 -0
- package/clis/jianyu/shared/procurement-detail.test.js +12 -0
- package/clis/twitter/likes.js +3 -2
- package/clis/twitter/search.js +4 -2
- package/clis/twitter/search.test.js +4 -0
- package/clis/twitter/shared.js +35 -2
- package/clis/twitter/shared.test.js +96 -0
- package/clis/twitter/thread.js +3 -1
- package/clis/twitter/timeline.js +3 -2
- package/clis/twitter/tweets.js +219 -0
- package/clis/twitter/tweets.test.js +125 -0
- package/clis/web/read.js +25 -5
- package/clis/web/read.test.js +76 -0
- package/clis/weread/ai-outline.js +170 -0
- package/clis/weread/ai-outline.test.js +83 -0
- package/clis/weread/book.js +57 -44
- package/clis/weread/commands.test.js +24 -0
- package/clis/xiaoyuzhou/podcast-episodes.js +2 -2
- package/clis/xiaoyuzhou/podcast-episodes.test.js +78 -0
- package/clis/youtube/channel.js +35 -0
- package/dist/src/browser/analyze.d.ts +103 -0
- package/dist/src/browser/analyze.js +230 -0
- package/dist/src/browser/analyze.test.d.ts +1 -0
- package/dist/src/browser/analyze.test.js +164 -0
- package/dist/src/browser/article-extract.d.ts +57 -0
- package/dist/src/browser/article-extract.e2e.test.d.ts +1 -0
- package/dist/src/browser/article-extract.e2e.test.js +105 -0
- package/dist/src/browser/article-extract.js +169 -0
- package/dist/src/browser/article-extract.test.d.ts +1 -0
- package/dist/src/browser/article-extract.test.js +94 -0
- package/dist/src/browser/base-page.d.ts +13 -3
- package/dist/src/browser/base-page.js +35 -25
- package/dist/src/browser/cdp.d.ts +1 -0
- package/dist/src/browser/cdp.js +23 -5
- package/dist/src/browser/compound.d.ts +59 -0
- package/dist/src/browser/compound.js +112 -0
- package/dist/src/browser/compound.test.d.ts +1 -0
- package/dist/src/browser/compound.test.js +175 -0
- package/dist/src/browser/dom-snapshot.d.ts +7 -0
- package/dist/src/browser/dom-snapshot.js +76 -3
- package/dist/src/browser/dom-snapshot.test.js +65 -0
- package/dist/src/browser/extract.d.ts +69 -0
- package/dist/src/browser/extract.js +132 -0
- package/dist/src/browser/extract.test.d.ts +1 -0
- package/dist/src/browser/extract.test.js +129 -0
- package/dist/src/browser/find.d.ts +76 -0
- package/dist/src/browser/find.js +179 -0
- package/dist/src/browser/find.test.d.ts +1 -0
- package/dist/src/browser/find.test.js +120 -0
- package/dist/src/browser/html-tree.d.ts +75 -0
- package/dist/src/browser/html-tree.js +112 -0
- package/dist/src/browser/html-tree.test.d.ts +1 -0
- package/dist/src/browser/html-tree.test.js +181 -0
- package/dist/src/browser/network-cache.d.ts +48 -0
- package/dist/src/browser/network-cache.js +66 -0
- package/dist/src/browser/network-cache.test.d.ts +1 -0
- package/dist/src/browser/network-cache.test.js +58 -0
- package/dist/src/browser/network-key.d.ts +22 -0
- package/dist/src/browser/network-key.js +66 -0
- package/dist/src/browser/network-key.test.d.ts +1 -0
- package/dist/src/browser/network-key.test.js +49 -0
- package/dist/src/browser/shape-filter.d.ts +52 -0
- package/dist/src/browser/shape-filter.js +101 -0
- package/dist/src/browser/shape-filter.test.d.ts +1 -0
- package/dist/src/browser/shape-filter.test.js +101 -0
- package/dist/src/browser/shape.d.ts +23 -0
- package/dist/src/browser/shape.js +95 -0
- package/dist/src/browser/shape.test.d.ts +1 -0
- package/dist/src/browser/shape.test.js +82 -0
- package/dist/src/browser/target-errors.d.ts +14 -1
- package/dist/src/browser/target-errors.js +13 -0
- package/dist/src/browser/target-errors.test.js +39 -6
- package/dist/src/browser/target-resolver.d.ts +57 -10
- package/dist/src/browser/target-resolver.js +195 -75
- package/dist/src/browser/target-resolver.test.js +80 -5
- package/dist/src/browser/verify-fixture.d.ts +59 -0
- package/dist/src/browser/verify-fixture.js +213 -0
- package/dist/src/browser/verify-fixture.test.d.ts +1 -0
- package/dist/src/browser/verify-fixture.test.js +161 -0
- package/dist/src/cli.d.ts +32 -0
- package/dist/src/cli.js +936 -141
- package/dist/src/cli.test.js +1051 -1
- package/dist/src/daemon.d.ts +3 -2
- package/dist/src/daemon.js +16 -4
- package/dist/src/daemon.test.d.ts +1 -0
- package/dist/src/daemon.test.js +19 -0
- package/dist/src/download/article-download.d.ts +12 -0
- package/dist/src/download/article-download.js +141 -17
- package/dist/src/download/article-download.test.js +196 -0
- package/dist/src/download/index.js +73 -86
- package/dist/src/errors.js +4 -2
- package/dist/src/errors.test.js +13 -0
- package/dist/src/execution.js +7 -2
- package/dist/src/execution.test.js +54 -0
- package/dist/src/launcher.d.ts +1 -1
- package/dist/src/launcher.js +3 -3
- package/dist/src/main.js +16 -0
- package/dist/src/output.js +1 -1
- package/dist/src/output.test.js +6 -0
- package/dist/src/types.d.ts +18 -3
- package/package.json +5 -1
package/dist/src/cli.js
CHANGED
|
@@ -21,11 +21,177 @@ import { registerAllCommands } from './commanderAdapter.js';
|
|
|
21
21
|
import { EXIT_CODES, getErrorMessage, BrowserConnectError } from './errors.js';
|
|
22
22
|
import { TargetError } from './browser/target-errors.js';
|
|
23
23
|
import { resolveTargetJs, getTextResolvedJs, getValueResolvedJs, getAttributesResolvedJs, selectResolvedJs, isAutocompleteResolvedJs } from './browser/target-resolver.js';
|
|
24
|
+
import { buildFindJs, isFindError } from './browser/find.js';
|
|
25
|
+
import { inferShape } from './browser/shape.js';
|
|
26
|
+
import { assignKeys } from './browser/network-key.js';
|
|
27
|
+
import { DEFAULT_TTL_MS, findEntry, loadNetworkCache, saveNetworkCache } from './browser/network-cache.js';
|
|
28
|
+
import { parseFilter, shapeMatchesFilter } from './browser/shape-filter.js';
|
|
29
|
+
import { buildHtmlTreeJs } from './browser/html-tree.js';
|
|
30
|
+
import { buildExtractHtmlJs, runExtractFromHtml } from './browser/extract.js';
|
|
31
|
+
import { analyzeSite } from './browser/analyze.js';
|
|
24
32
|
import { daemonStatus, daemonStop } from './commands/daemon.js';
|
|
25
33
|
import { log } from './logger.js';
|
|
26
34
|
const CLI_FILE = fileURLToPath(import.meta.url);
|
|
27
35
|
const DEFAULT_BROWSER_WORKSPACE = 'browser:default';
|
|
28
36
|
const BROWSER_TAB_OPTION_DESCRIPTION = 'Target tab/page identity returned by "browser open", "browser tab new", or "browser tab list"';
|
|
37
|
+
/**
|
|
38
|
+
* Normalize raw capture entries (from daemon/CDP `readNetworkCapture` or
|
|
39
|
+
* the JS interceptor's `window.__opencli_net`) into a consistent shape.
|
|
40
|
+
* Response preview is parsed as JSON when possible, otherwise kept as string.
|
|
41
|
+
* `bodyFullSize` / `bodyTruncated` surface capture-layer truncation so the
|
|
42
|
+
* agent-facing envelope can warn when the body isn't whole.
|
|
43
|
+
*/
|
|
44
|
+
async function captureNetworkItems(page) {
|
|
45
|
+
if (page.readNetworkCapture) {
|
|
46
|
+
const raw = await page.readNetworkCapture();
|
|
47
|
+
if (Array.isArray(raw) && raw.length > 0) {
|
|
48
|
+
return raw.map((e) => {
|
|
49
|
+
const preview = e.responsePreview ?? null;
|
|
50
|
+
let body = null;
|
|
51
|
+
if (preview) {
|
|
52
|
+
try {
|
|
53
|
+
body = JSON.parse(preview);
|
|
54
|
+
}
|
|
55
|
+
catch {
|
|
56
|
+
body = preview;
|
|
57
|
+
}
|
|
58
|
+
}
|
|
59
|
+
const fullSize = typeof e.responseBodyFullSize === 'number'
|
|
60
|
+
? e.responseBodyFullSize
|
|
61
|
+
: (preview ? preview.length : 0);
|
|
62
|
+
const truncated = e.responseBodyTruncated === true;
|
|
63
|
+
return {
|
|
64
|
+
url: e.url || '',
|
|
65
|
+
method: e.method || 'GET',
|
|
66
|
+
status: e.responseStatus || 0,
|
|
67
|
+
size: fullSize,
|
|
68
|
+
ct: e.responseContentType || '',
|
|
69
|
+
body,
|
|
70
|
+
bodyFullSize: fullSize,
|
|
71
|
+
bodyTruncated: truncated,
|
|
72
|
+
};
|
|
73
|
+
});
|
|
74
|
+
}
|
|
75
|
+
}
|
|
76
|
+
const raw = await page.evaluate(`(function(){ var out = window.__opencli_net || []; window.__opencli_net = []; return JSON.stringify(out); })()`);
|
|
77
|
+
try {
|
|
78
|
+
return JSON.parse(raw);
|
|
79
|
+
}
|
|
80
|
+
catch {
|
|
81
|
+
if (process.env.OPENCLI_VERBOSE)
|
|
82
|
+
log.warn(`[network] Failed to parse interceptor buffer: ${typeof raw === 'string' ? raw.slice(0, 200) : String(raw)}`);
|
|
83
|
+
return [];
|
|
84
|
+
}
|
|
85
|
+
}
|
|
86
|
+
/** Drop static-resource / telemetry noise so agents see only API-shaped traffic. */
|
|
87
|
+
function filterNetworkItems(items) {
|
|
88
|
+
return items.filter((r) => (r.ct?.includes('json') || r.ct?.includes('xml') || r.ct?.includes('text/plain')) &&
|
|
89
|
+
!/\.(js|css|png|jpg|gif|svg|woff|ico|map)(\?|$)/i.test(r.url) &&
|
|
90
|
+
!/analytics|tracking|telemetry|beacon|pixel|gtag|fbevents/i.test(r.url));
|
|
91
|
+
}
|
|
92
|
+
/** Exit codes by network error code — usage errors vs runtime failures. */
|
|
93
|
+
const NETWORK_ERROR_EXIT = {
|
|
94
|
+
invalid_args: EXIT_CODES.USAGE_ERROR,
|
|
95
|
+
invalid_filter: EXIT_CODES.USAGE_ERROR,
|
|
96
|
+
invalid_max_body: EXIT_CODES.USAGE_ERROR,
|
|
97
|
+
};
|
|
98
|
+
/** Emit a structured error JSON so agents can branch on `error.code` without regex. */
|
|
99
|
+
function emitNetworkError(code, message, extra = {}) {
|
|
100
|
+
console.log(JSON.stringify({ error: { code, message, ...extra } }, null, 2));
|
|
101
|
+
process.exitCode = NETWORK_ERROR_EXIT[code] ?? EXIT_CODES.GENERIC_ERROR;
|
|
102
|
+
}
|
|
103
|
+
export function checkSiteMemory(site) {
|
|
104
|
+
const siteDir = path.join(os.homedir(), '.opencli', 'sites', site);
|
|
105
|
+
const endpointsPath = path.join(siteDir, 'endpoints.json');
|
|
106
|
+
const notesPath = path.join(siteDir, 'notes.md');
|
|
107
|
+
let endpointsCount = 0;
|
|
108
|
+
let endpointsPresent = fs.existsSync(endpointsPath);
|
|
109
|
+
if (endpointsPresent) {
|
|
110
|
+
try {
|
|
111
|
+
const parsed = JSON.parse(fs.readFileSync(endpointsPath, 'utf-8'));
|
|
112
|
+
if (parsed && typeof parsed === 'object' && !Array.isArray(parsed)) {
|
|
113
|
+
endpointsCount = Object.keys(parsed).length;
|
|
114
|
+
}
|
|
115
|
+
else if (Array.isArray(parsed)) {
|
|
116
|
+
endpointsCount = parsed.length;
|
|
117
|
+
}
|
|
118
|
+
}
|
|
119
|
+
catch {
|
|
120
|
+
endpointsPresent = false;
|
|
121
|
+
}
|
|
122
|
+
}
|
|
123
|
+
const notesPresent = fs.existsSync(notesPath);
|
|
124
|
+
return {
|
|
125
|
+
ok: endpointsPresent && endpointsCount > 0 && notesPresent,
|
|
126
|
+
siteDir,
|
|
127
|
+
endpoints: { present: endpointsPresent, count: endpointsCount, path: endpointsPath },
|
|
128
|
+
notes: { present: notesPresent, path: notesPath },
|
|
129
|
+
};
|
|
130
|
+
}
|
|
131
|
+
export function printSiteMemoryReport(report, strict) {
|
|
132
|
+
if (report.ok) {
|
|
133
|
+
console.log(` ✓ Memory: endpoints.json (${report.endpoints.count}), notes.md present at ${report.siteDir}`);
|
|
134
|
+
return;
|
|
135
|
+
}
|
|
136
|
+
const marker = strict ? '✗' : '⚠';
|
|
137
|
+
const missing = [];
|
|
138
|
+
if (!report.endpoints.present)
|
|
139
|
+
missing.push('endpoints.json');
|
|
140
|
+
else if (report.endpoints.count === 0)
|
|
141
|
+
missing.push('endpoints.json (empty)');
|
|
142
|
+
if (!report.notes.present)
|
|
143
|
+
missing.push('notes.md');
|
|
144
|
+
console.log(` ${marker} Memory: missing ${missing.join(', ')} under ${report.siteDir}`);
|
|
145
|
+
console.log(` Write the endpoint you just verified + a 1-line session note so the next agent starts from minute 0, not minute 95.`);
|
|
146
|
+
if (!strict) {
|
|
147
|
+
console.log(` (Re-run with --strict-memory to fail instead of warn.)`);
|
|
148
|
+
}
|
|
149
|
+
}
|
|
150
|
+
/** Coerce adapter JSON output into a row array. Accepts `[{...}]`, single `{}`, or `{items:[...]}`-style envelopes. */
|
|
151
|
+
export function normalizeVerifyRows(data) {
|
|
152
|
+
if (Array.isArray(data)) {
|
|
153
|
+
return data.map((r) => (r && typeof r === 'object' ? r : { value: r }));
|
|
154
|
+
}
|
|
155
|
+
if (data && typeof data === 'object') {
|
|
156
|
+
const obj = data;
|
|
157
|
+
for (const k of ['rows', 'items', 'data', 'results']) {
|
|
158
|
+
if (Array.isArray(obj[k])) {
|
|
159
|
+
return obj[k].map((r) => (r && typeof r === 'object' ? r : { value: r }));
|
|
160
|
+
}
|
|
161
|
+
}
|
|
162
|
+
return [obj];
|
|
163
|
+
}
|
|
164
|
+
return [];
|
|
165
|
+
}
|
|
166
|
+
/** Render up to 10 rows as a compact padded table for eyeball inspection during verify. */
|
|
167
|
+
export function renderVerifyPreview(rows, opts = {}) {
|
|
168
|
+
const maxRows = opts.maxRows ?? 10;
|
|
169
|
+
const maxCols = opts.maxCols ?? 6;
|
|
170
|
+
const cellMax = opts.cellMax ?? 40;
|
|
171
|
+
if (rows.length === 0)
|
|
172
|
+
return ' (no rows)';
|
|
173
|
+
const allCols = Array.from(new Set(rows.flatMap((r) => Object.keys(r))));
|
|
174
|
+
const cols = allCols.slice(0, maxCols);
|
|
175
|
+
const shown = rows.slice(0, maxRows);
|
|
176
|
+
const cellOf = (v) => {
|
|
177
|
+
if (v === null || v === undefined)
|
|
178
|
+
return '';
|
|
179
|
+
const s = typeof v === 'object' ? JSON.stringify(v) : String(v);
|
|
180
|
+
return s.replace(/\s+/g, ' ').slice(0, cellMax);
|
|
181
|
+
};
|
|
182
|
+
const widths = cols.map((c) => Math.max(c.length, ...shown.map((r) => cellOf(r[c]).length)));
|
|
183
|
+
const fmtRow = (vals) => vals.map((v, i) => v.padEnd(widths[i])).join(' ');
|
|
184
|
+
const out = [];
|
|
185
|
+
out.push(` ${fmtRow(cols)}`);
|
|
186
|
+
out.push(` ${widths.map((w) => '-'.repeat(w)).join(' ')}`);
|
|
187
|
+
for (const r of shown)
|
|
188
|
+
out.push(` ${fmtRow(cols.map((c) => cellOf(r[c])))}`);
|
|
189
|
+
if (rows.length > maxRows)
|
|
190
|
+
out.push(` ... and ${rows.length - maxRows} more row(s)`);
|
|
191
|
+
if (allCols.length > maxCols)
|
|
192
|
+
out.push(` (${allCols.length - maxCols} more column(s) hidden)`);
|
|
193
|
+
return out.join('\n');
|
|
194
|
+
}
|
|
29
195
|
function getBrowserCacheDir() {
|
|
30
196
|
return process.env.OPENCLI_CACHE_DIR || path.join(os.homedir(), '.opencli', 'cache');
|
|
31
197
|
}
|
|
@@ -249,12 +415,50 @@ export function createProgram(BUILTIN_CLIS, USER_CLIS) {
|
|
|
249
415
|
const browser = program
|
|
250
416
|
.command('browser')
|
|
251
417
|
.description('Browser control — navigate, click, type, extract, wait (no LLM needed)');
|
|
252
|
-
/**
|
|
253
|
-
|
|
254
|
-
|
|
418
|
+
/**
|
|
419
|
+
* Resolve a `<target>` (numeric ref or CSS selector) via the unified resolver.
|
|
420
|
+
* Returns the CSS match count so callers can propagate `matches_n` into the
|
|
421
|
+
* JSON envelope printed back to the agent.
|
|
422
|
+
*/
|
|
423
|
+
async function resolveRef(page, ref, opts = {}) {
|
|
424
|
+
const resolution = await page.evaluate(resolveTargetJs(ref, opts));
|
|
255
425
|
if (!resolution.ok) {
|
|
256
|
-
throw new TargetError(
|
|
426
|
+
throw new TargetError({
|
|
427
|
+
code: resolution.code,
|
|
428
|
+
message: resolution.message,
|
|
429
|
+
hint: resolution.hint,
|
|
430
|
+
candidates: resolution.candidates,
|
|
431
|
+
matches_n: resolution.matches_n,
|
|
432
|
+
});
|
|
257
433
|
}
|
|
434
|
+
return { matches_n: resolution.matches_n, match_level: resolution.match_level };
|
|
435
|
+
}
|
|
436
|
+
/**
|
|
437
|
+
* Parse `--nth <n>` flag, returning the parsed 0-based index or a usage error.
|
|
438
|
+
* The surface mirrors `--depth` etc. in `browser get html --as json`: the flag
|
|
439
|
+
* is optional, must be a non-negative integer when present, and on failure we
|
|
440
|
+
* emit the structured error envelope rather than throwing past the command.
|
|
441
|
+
*/
|
|
442
|
+
function parseNthFlag(raw) {
|
|
443
|
+
if (raw === undefined || raw === null || raw === '')
|
|
444
|
+
return null;
|
|
445
|
+
const str = String(raw);
|
|
446
|
+
if (!/^\d+$/.test(str)) {
|
|
447
|
+
return { error: `--nth must be a non-negative integer, got "${str}"` };
|
|
448
|
+
}
|
|
449
|
+
return Number.parseInt(str, 10);
|
|
450
|
+
}
|
|
451
|
+
/** Emit the `{ error: { code, message, hint?, candidates?, matches_n? } }` envelope used by the selector-first commands. */
|
|
452
|
+
function emitTargetError(err) {
|
|
453
|
+
console.log(JSON.stringify({
|
|
454
|
+
error: {
|
|
455
|
+
code: err.code,
|
|
456
|
+
message: err.message,
|
|
457
|
+
hint: err.hint,
|
|
458
|
+
...(err.candidates && { candidates: err.candidates }),
|
|
459
|
+
...(err.matches_n !== undefined && { matches_n: err.matches_n }),
|
|
460
|
+
},
|
|
461
|
+
}, null, 2));
|
|
258
462
|
}
|
|
259
463
|
/** Wrap browser actions with error handling and optional --json output */
|
|
260
464
|
function browserAction(fn) {
|
|
@@ -272,13 +476,11 @@ export function createProgram(BUILTIN_CLIS, USER_CLIS) {
|
|
|
272
476
|
log.error(`Hint: ${err.hint}`);
|
|
273
477
|
}
|
|
274
478
|
else if (err instanceof TargetError) {
|
|
479
|
+
// Agent-facing structured envelope on stdout + short human line on stderr.
|
|
480
|
+
emitTargetError(err);
|
|
275
481
|
log.error(`[${err.code}] ${err.message}`);
|
|
276
482
|
if (err.hint)
|
|
277
483
|
log.error(`Hint: ${err.hint}`);
|
|
278
|
-
if (err.candidates?.length) {
|
|
279
|
-
log.error('Candidates:');
|
|
280
|
-
err.candidates.forEach((c, i) => log.error(` ${i + 1}. ${c}`));
|
|
281
|
-
}
|
|
282
484
|
}
|
|
283
485
|
else {
|
|
284
486
|
const msg = getErrorMessage(err);
|
|
@@ -352,8 +554,17 @@ export function createProgram(BUILTIN_CLIS, USER_CLIS) {
|
|
|
352
554
|
console.log(JSON.stringify({ closed: validatedTarget }, null, 2));
|
|
353
555
|
}));
|
|
354
556
|
// ── Navigation ──
|
|
355
|
-
/**
|
|
356
|
-
|
|
557
|
+
/**
|
|
558
|
+
* Network interceptor JS — injected on every open/navigate to capture
|
|
559
|
+
* fetch/XHR bodies when the session-level capture channel (CDP/extension)
|
|
560
|
+
* isn't available. Keeps parity with the CDP path's truncation contract:
|
|
561
|
+
* when a body exceeds the per-entry cap, we keep a string prefix and set
|
|
562
|
+
* `bodyTruncated: true` + `bodyFullSize: <original length>` so `browser
|
|
563
|
+
* network` can propagate a visible signal to the agent instead of
|
|
564
|
+
* silently dropping the body. Per-entry cap is 1 MiB and the ring is
|
|
565
|
+
* capped at 200 entries, bounding worst-case in-page memory.
|
|
566
|
+
*/
|
|
567
|
+
const NETWORK_INTERCEPTOR_JS = `(function(){if(window.__opencli_net)return;window.__opencli_net=[];var M=200,B=1048576,F=window.fetch;function capture(url,method,status,text,ct){if(window.__opencli_net.length>=M)return;var full=text?text.length:0,trunc=full>B,stored=trunc?text.slice(0,B):text,body=null;if(stored){if(trunc){body=stored}else{try{body=JSON.parse(stored)}catch(e){body=stored}}}var e={url:url,method:method||'GET',status:status,size:full,ct:ct,body:body};if(trunc){e.bodyTruncated=true;e.bodyFullSize=full}window.__opencli_net.push(e)}window.fetch=async function(){var r=await F.apply(this,arguments);try{var ct=r.headers.get('content-type')||'';if(ct.includes('json')||ct.includes('text')){var c=r.clone(),t=await c.text();capture(r.url||(arguments[0]&&arguments[0].url)||String(arguments[0]),(arguments[1]&&arguments[1].method)||'GET',r.status,t,ct)}}catch(e){}return r};var X=XMLHttpRequest.prototype,O=X.open,S=X.send;X.open=function(m,u){this._om=m;this._ou=u;return O.apply(this,arguments)};X.send=function(){var x=this;x.addEventListener('load',function(){try{var ct=x.getResponseHeader('content-type')||'';if(ct.includes('json')||ct.includes('text')){capture(x._ou,x._om||'GET',x.status,x.responseText||'',ct)}}catch(e){}});return S.apply(this,arguments)}})()`;
|
|
357
568
|
addBrowserTabOption(browser.command('open').argument('<url>').description('Open URL in automation window'))
|
|
358
569
|
.action(browserAction(async (page, url) => {
|
|
359
570
|
// Start session-level capture before navigation (catches initial requests)
|
|
@@ -413,6 +624,118 @@ export function createProgram(BUILTIN_CLIS, USER_CLIS) {
|
|
|
413
624
|
console.log(await page.screenshot({ format: 'png' }));
|
|
414
625
|
}
|
|
415
626
|
}));
|
|
627
|
+
// ── Analyze (site recon, agent-native) ──
|
|
628
|
+
//
|
|
629
|
+
// Mechanizes the `site-recon.md` decision tree into one CLI call. The agent
|
|
630
|
+
// calls `browser analyze <url>` and gets back:
|
|
631
|
+
//
|
|
632
|
+
// - pattern: A/B/C/D (mapped from network + SSR-globals signals)
|
|
633
|
+
// - anti_bot: vendor + evidence + the one-liner for "what to do next"
|
|
634
|
+
// - initial_state: which window globals are populated
|
|
635
|
+
// - nearest_adapter: existing commands for the same site, if any
|
|
636
|
+
// - recommended_next_step: a single imperative sentence
|
|
637
|
+
//
|
|
638
|
+
// Intent: replace the "open → eyeball network → curl → WAF → try again"
|
|
639
|
+
// feedback loop with a single deterministic verdict. Without this, agents
|
|
640
|
+
// burn ~20min per WAF-protected site re-discovering anti-bot posture.
|
|
641
|
+
addBrowserTabOption(browser.command('analyze').argument('<url>'))
|
|
642
|
+
.description('Classify site: anti-bot vendor, pattern (A/B/C/D), nearest adapter, recommended next step')
|
|
643
|
+
.action(browserAction(async (page, url) => {
|
|
644
|
+
const hasSessionCapture = await page.startNetworkCapture?.() ?? false;
|
|
645
|
+
await page.goto(url);
|
|
646
|
+
await page.wait(2);
|
|
647
|
+
if (!hasSessionCapture) {
|
|
648
|
+
try {
|
|
649
|
+
await page.evaluate(NETWORK_INTERCEPTOR_JS);
|
|
650
|
+
}
|
|
651
|
+
catch { /* non-fatal */ }
|
|
652
|
+
}
|
|
653
|
+
await captureNetworkItems(page);
|
|
654
|
+
// Best-effort: give the page another beat so XHR after DOMContentLoaded lands.
|
|
655
|
+
await page.wait(1);
|
|
656
|
+
const rawItems = await captureNetworkItems(page);
|
|
657
|
+
const networkEntries = rawItems.map((e) => ({
|
|
658
|
+
url: e.url,
|
|
659
|
+
status: e.status,
|
|
660
|
+
contentType: e.ct,
|
|
661
|
+
bodyPreview: typeof e.body === 'string'
|
|
662
|
+
? e.body.slice(0, 2000)
|
|
663
|
+
: (e.body ? JSON.stringify(e.body).slice(0, 2000) : null),
|
|
664
|
+
}));
|
|
665
|
+
const probeJs = `(function(){
|
|
666
|
+
return {
|
|
667
|
+
cookieNames: (document.cookie || '').split(';').map(function(c){ return c.trim().split('=')[0]; }).filter(Boolean),
|
|
668
|
+
initialState: {
|
|
669
|
+
__INITIAL_STATE__: typeof window.__INITIAL_STATE__ !== 'undefined',
|
|
670
|
+
__NUXT__: typeof window.__NUXT__ !== 'undefined',
|
|
671
|
+
__NEXT_DATA__: typeof window.__NEXT_DATA__ !== 'undefined',
|
|
672
|
+
__APOLLO_STATE__: typeof window.__APOLLO_STATE__ !== 'undefined',
|
|
673
|
+
},
|
|
674
|
+
title: document.title || '',
|
|
675
|
+
finalUrl: location.href,
|
|
676
|
+
};
|
|
677
|
+
})()`;
|
|
678
|
+
const probe = await page.evaluate(probeJs);
|
|
679
|
+
const browserCookieNames = (await page.getCookies({ url: probe.finalUrl || url }).catch(() => []))
|
|
680
|
+
.map((c) => c.name)
|
|
681
|
+
.filter(Boolean);
|
|
682
|
+
const cookieNames = [...new Set([...probe.cookieNames, ...browserCookieNames])];
|
|
683
|
+
const signals = {
|
|
684
|
+
requestedUrl: url,
|
|
685
|
+
finalUrl: probe.finalUrl,
|
|
686
|
+
cookieNames,
|
|
687
|
+
networkEntries,
|
|
688
|
+
initialState: probe.initialState,
|
|
689
|
+
title: probe.title,
|
|
690
|
+
};
|
|
691
|
+
const report = analyzeSite(signals, getRegistry());
|
|
692
|
+
console.log(JSON.stringify(report, null, 2));
|
|
693
|
+
}));
|
|
694
|
+
// ── Find (structured CSS query, agent-native) ──
|
|
695
|
+
//
|
|
696
|
+
// `browser find --css <sel>` lets agents jump straight from a semantic
|
|
697
|
+
// selector to a JSON list of matching elements, without having to parse
|
|
698
|
+
// the free-text state snapshot to recover indices.
|
|
699
|
+
addBrowserTabOption(browser.command('find')
|
|
700
|
+
.option('--css <selector>', 'CSS selector (required)')
|
|
701
|
+
.option('--limit <n>', 'Max entries returned', '50')
|
|
702
|
+
.option('--text-max <n>', 'Max chars of trimmed text per entry', '120')
|
|
703
|
+
.description('Find DOM elements by CSS selector — returns JSON {matches_n, entries[]}'))
|
|
704
|
+
.action(browserAction(async (page, opts) => {
|
|
705
|
+
if (!opts.css || typeof opts.css !== 'string') {
|
|
706
|
+
console.log(JSON.stringify({
|
|
707
|
+
error: {
|
|
708
|
+
code: 'usage_error',
|
|
709
|
+
message: '--css <selector> is required',
|
|
710
|
+
hint: 'Example: opencli browser find --css ".btn.primary"',
|
|
711
|
+
},
|
|
712
|
+
}, null, 2));
|
|
713
|
+
process.exitCode = EXIT_CODES.USAGE_ERROR;
|
|
714
|
+
return;
|
|
715
|
+
}
|
|
716
|
+
const limit = parseNthFlag(opts.limit);
|
|
717
|
+
if (limit && typeof limit === 'object' && 'error' in limit) {
|
|
718
|
+
console.log(JSON.stringify({ error: { code: 'usage_error', message: limit.error.replace('--nth', '--limit') } }, null, 2));
|
|
719
|
+
process.exitCode = EXIT_CODES.USAGE_ERROR;
|
|
720
|
+
return;
|
|
721
|
+
}
|
|
722
|
+
const textMax = parseNthFlag(opts.textMax);
|
|
723
|
+
if (textMax && typeof textMax === 'object' && 'error' in textMax) {
|
|
724
|
+
console.log(JSON.stringify({ error: { code: 'usage_error', message: textMax.error.replace('--nth', '--text-max') } }, null, 2));
|
|
725
|
+
process.exitCode = EXIT_CODES.USAGE_ERROR;
|
|
726
|
+
return;
|
|
727
|
+
}
|
|
728
|
+
const result = await page.evaluate(buildFindJs(opts.css, {
|
|
729
|
+
limit: limit ?? undefined,
|
|
730
|
+
textMax: textMax ?? undefined,
|
|
731
|
+
}));
|
|
732
|
+
if (isFindError(result)) {
|
|
733
|
+
console.log(JSON.stringify(result, null, 2));
|
|
734
|
+
process.exitCode = EXIT_CODES.GENERIC_ERROR;
|
|
735
|
+
return;
|
|
736
|
+
}
|
|
737
|
+
console.log(JSON.stringify(result, null, 2));
|
|
738
|
+
}));
|
|
416
739
|
// ── Get commands (structured data extraction) ──
|
|
417
740
|
const get = browser.command('get').description('Get page properties');
|
|
418
741
|
addBrowserTabOption(get.command('title').description('Page title'))
|
|
@@ -423,65 +746,271 @@ export function createProgram(BUILTIN_CLIS, USER_CLIS) {
|
|
|
423
746
|
.action(browserAction(async (page) => {
|
|
424
747
|
console.log(await page.getCurrentUrl?.() ?? await page.evaluate('location.href'));
|
|
425
748
|
}));
|
|
426
|
-
|
|
427
|
-
|
|
428
|
-
|
|
429
|
-
|
|
430
|
-
|
|
431
|
-
|
|
432
|
-
|
|
433
|
-
|
|
434
|
-
|
|
435
|
-
const
|
|
436
|
-
|
|
437
|
-
|
|
438
|
-
|
|
749
|
+
// Read commands (`get text/value/attributes`) always emit a JSON envelope:
|
|
750
|
+
//
|
|
751
|
+
// { value, matches_n } — success
|
|
752
|
+
// { error: { code, message, hint, matches_n? } } — structured failure
|
|
753
|
+
//
|
|
754
|
+
// `<target>` accepts either a numeric ref (from `browser state`/`browser find`)
|
|
755
|
+
// or a CSS selector. On multi-match CSS, the first element wins and the real
|
|
756
|
+
// match count is exposed via `matches_n`; `--nth <n>` picks a specific one.
|
|
757
|
+
const runGetCommand = async (page, target, opts, evalJs, field) => {
|
|
758
|
+
const nth = parseNthFlag(opts.nth);
|
|
759
|
+
if (nth && typeof nth === 'object' && 'error' in nth) {
|
|
760
|
+
console.log(JSON.stringify({ error: { code: 'usage_error', message: nth.error } }, null, 2));
|
|
761
|
+
process.exitCode = EXIT_CODES.USAGE_ERROR;
|
|
762
|
+
return;
|
|
763
|
+
}
|
|
764
|
+
const { matches_n, match_level } = await resolveRef(page, String(target), {
|
|
765
|
+
firstOnMulti: nth === null,
|
|
766
|
+
...(typeof nth === 'number' ? { nth } : {}),
|
|
767
|
+
});
|
|
768
|
+
const raw = await page.evaluate(evalJs);
|
|
769
|
+
let value;
|
|
770
|
+
if (field === 'attributes') {
|
|
771
|
+
// getAttributesResolvedJs stringifies the attribute record — parse it back so
|
|
772
|
+
// the JSON envelope contains a real object rather than a nested JSON string.
|
|
773
|
+
try {
|
|
774
|
+
value = raw == null ? {} : JSON.parse(String(raw));
|
|
775
|
+
}
|
|
776
|
+
catch {
|
|
777
|
+
value = raw;
|
|
778
|
+
}
|
|
779
|
+
}
|
|
780
|
+
else {
|
|
781
|
+
value = raw ?? null;
|
|
782
|
+
}
|
|
783
|
+
console.log(JSON.stringify({ value, matches_n, match_level }, null, 2));
|
|
784
|
+
};
|
|
785
|
+
addBrowserTabOption(get.command('text')
|
|
786
|
+
.argument('<target>', 'Numeric ref (from browser state / find) or CSS selector')
|
|
787
|
+
.option('--nth <n>', 'Pick the nth match (0-based) when <target> is a multi-match CSS selector')
|
|
788
|
+
.description('Element text content — JSON envelope {value, matches_n}'))
|
|
789
|
+
.action(browserAction(async (page, target, opts) => runGetCommand(page, String(target), opts ?? {}, getTextResolvedJs(), 'text')));
|
|
790
|
+
addBrowserTabOption(get.command('value')
|
|
791
|
+
.argument('<target>', 'Numeric ref (from browser state / find) or CSS selector')
|
|
792
|
+
.option('--nth <n>', 'Pick the nth match (0-based) when <target> is a multi-match CSS selector')
|
|
793
|
+
.description('Input/textarea value — JSON envelope {value, matches_n}'))
|
|
794
|
+
.action(browserAction(async (page, target, opts) => runGetCommand(page, String(target), opts ?? {}, getValueResolvedJs(), 'value')));
|
|
795
|
+
addBrowserTabOption(get.command('html')
|
|
796
|
+
.option('--selector <css>', 'CSS selector scope (first match)')
|
|
797
|
+
.option('--as <format>', 'Output format: "html" (default) or "json" for structured tree', 'html')
|
|
798
|
+
.option('--max <n>', 'Max characters of raw HTML to return (0 = unlimited)', '0')
|
|
799
|
+
.option('--depth <n>', '(--as json) Max tree depth below root (0 = root only, 0 disables = unlimited via empty)', '')
|
|
800
|
+
.option('--children-max <n>', '(--as json) Max element children kept per node (empty = unlimited)', '')
|
|
801
|
+
.option('--text-max <n>', '(--as json) Max chars of direct text kept per node (empty = unlimited)', '')
|
|
802
|
+
.description('Page HTML (or scoped); use --as json for a {tag, attrs, text, children} tree'))
|
|
439
803
|
.action(browserAction(async (page, opts) => {
|
|
804
|
+
const format = String(opts.as || 'html').toLowerCase();
|
|
805
|
+
if (format !== 'html' && format !== 'json') {
|
|
806
|
+
console.log(JSON.stringify({ error: { code: 'invalid_format', message: `--as must be "html" or "json", got "${opts.as}"` } }, null, 2));
|
|
807
|
+
process.exitCode = EXIT_CODES.USAGE_ERROR;
|
|
808
|
+
return;
|
|
809
|
+
}
|
|
810
|
+
// `--max` is validated up-front (before touching the page) so a bad value
|
|
811
|
+
// gets the same structured error regardless of selector/format path.
|
|
812
|
+
const rawMax = String(opts.max ?? '0');
|
|
813
|
+
if (!/^\d+$/.test(rawMax)) {
|
|
814
|
+
console.log(JSON.stringify({ error: { code: 'invalid_max', message: `--max must be a non-negative integer, got "${opts.max}"` } }, null, 2));
|
|
815
|
+
process.exitCode = EXIT_CODES.USAGE_ERROR;
|
|
816
|
+
return;
|
|
817
|
+
}
|
|
818
|
+
const max = Number.parseInt(rawMax, 10);
|
|
819
|
+
if (format === 'json') {
|
|
820
|
+
const parseBudget = (flag, value) => {
|
|
821
|
+
const raw = value === undefined || value === null ? '' : String(value);
|
|
822
|
+
if (raw === '')
|
|
823
|
+
return null;
|
|
824
|
+
if (!/^\d+$/.test(raw))
|
|
825
|
+
return { error: `${flag} must be a non-negative integer, got "${raw}"` };
|
|
826
|
+
return Number.parseInt(raw, 10);
|
|
827
|
+
};
|
|
828
|
+
const depth = parseBudget('--depth', opts.depth);
|
|
829
|
+
const childrenMax = parseBudget('--children-max', opts.childrenMax);
|
|
830
|
+
const textMax = parseBudget('--text-max', opts.textMax);
|
|
831
|
+
for (const budget of [depth, childrenMax, textMax]) {
|
|
832
|
+
if (budget && typeof budget === 'object' && 'error' in budget) {
|
|
833
|
+
console.log(JSON.stringify({ error: { code: 'invalid_budget', message: budget.error } }, null, 2));
|
|
834
|
+
process.exitCode = EXIT_CODES.USAGE_ERROR;
|
|
835
|
+
return;
|
|
836
|
+
}
|
|
837
|
+
}
|
|
838
|
+
const js = buildHtmlTreeJs({
|
|
839
|
+
selector: opts.selector ?? null,
|
|
840
|
+
depth: depth,
|
|
841
|
+
childrenMax: childrenMax,
|
|
842
|
+
textMax: textMax,
|
|
843
|
+
});
|
|
844
|
+
const result = await page.evaluate(js);
|
|
845
|
+
if (result && typeof result === 'object' && 'invalidSelector' in result && result.invalidSelector) {
|
|
846
|
+
console.log(JSON.stringify({
|
|
847
|
+
error: { code: 'invalid_selector', message: `Selector "${opts.selector}" is not a valid CSS selector: ${result.reason}` },
|
|
848
|
+
}, null, 2));
|
|
849
|
+
process.exitCode = EXIT_CODES.USAGE_ERROR;
|
|
850
|
+
return;
|
|
851
|
+
}
|
|
852
|
+
const ok = result;
|
|
853
|
+
if (!ok || ok.matched === 0) {
|
|
854
|
+
console.log(JSON.stringify({
|
|
855
|
+
error: {
|
|
856
|
+
code: 'selector_not_found',
|
|
857
|
+
message: opts.selector
|
|
858
|
+
? `Selector "${opts.selector}" matched 0 elements.`
|
|
859
|
+
: 'Page has no documentElement.',
|
|
860
|
+
},
|
|
861
|
+
}, null, 2));
|
|
862
|
+
process.exitCode = EXIT_CODES.USAGE_ERROR;
|
|
863
|
+
return;
|
|
864
|
+
}
|
|
865
|
+
console.log(JSON.stringify(ok, null, 2));
|
|
866
|
+
return;
|
|
867
|
+
}
|
|
868
|
+
// Raw HTML path — unbounded by default; --max optionally caps with a visible marker.
|
|
869
|
+
// Selector lookup is wrapped in try/catch inside page context so an invalid
|
|
870
|
+
// selector returns a structured signal instead of throwing through page.evaluate.
|
|
440
871
|
const sel = opts.selector ? JSON.stringify(opts.selector) : 'null';
|
|
441
|
-
const
|
|
442
|
-
|
|
443
|
-
|
|
444
|
-
|
|
445
|
-
|
|
446
|
-
|
|
447
|
-
|
|
448
|
-
|
|
872
|
+
const rawResult = await page.evaluate(`(() => {
|
|
873
|
+
const s = ${sel};
|
|
874
|
+
if (s) {
|
|
875
|
+
try {
|
|
876
|
+
const el = document.querySelector(s);
|
|
877
|
+
return { kind: 'ok', html: el ? el.outerHTML : null };
|
|
878
|
+
} catch (e) {
|
|
879
|
+
return { kind: 'invalid_selector', reason: (e && e.message) || String(e) };
|
|
880
|
+
}
|
|
881
|
+
}
|
|
882
|
+
return { kind: 'ok', html: document.documentElement ? document.documentElement.outerHTML : null };
|
|
883
|
+
})()`);
|
|
884
|
+
if (rawResult.kind === 'invalid_selector') {
|
|
885
|
+
console.log(JSON.stringify({
|
|
886
|
+
error: { code: 'invalid_selector', message: `Selector "${opts.selector}" is not a valid CSS selector: ${rawResult.reason}` },
|
|
887
|
+
}, null, 2));
|
|
888
|
+
process.exitCode = EXIT_CODES.USAGE_ERROR;
|
|
889
|
+
return;
|
|
890
|
+
}
|
|
891
|
+
const html = rawResult.html;
|
|
892
|
+
if (html === null) {
|
|
893
|
+
if (opts.selector) {
|
|
894
|
+
console.log(JSON.stringify({
|
|
895
|
+
error: { code: 'selector_not_found', message: `Selector "${opts.selector}" matched 0 elements.` },
|
|
896
|
+
}, null, 2));
|
|
897
|
+
process.exitCode = EXIT_CODES.USAGE_ERROR;
|
|
898
|
+
return;
|
|
899
|
+
}
|
|
900
|
+
console.log('(empty)');
|
|
901
|
+
return;
|
|
902
|
+
}
|
|
903
|
+
if (max > 0 && html.length > max) {
|
|
904
|
+
console.log(`<!-- opencli: truncated ${max} of ${html.length} chars; re-run without --max (or --max 0) for full -->\n${html.slice(0, max)}`);
|
|
905
|
+
return;
|
|
906
|
+
}
|
|
907
|
+
console.log(html);
|
|
449
908
|
}));
|
|
909
|
+
addBrowserTabOption(get.command('attributes')
|
|
910
|
+
.argument('<target>', 'Numeric ref (from browser state / find) or CSS selector')
|
|
911
|
+
.option('--nth <n>', 'Pick the nth match (0-based) when <target> is a multi-match CSS selector')
|
|
912
|
+
.description('Element attributes — JSON envelope {value, matches_n}'))
|
|
913
|
+
.action(browserAction(async (page, target, opts) => runGetCommand(page, String(target), opts ?? {}, getAttributesResolvedJs(), 'attributes')));
|
|
450
914
|
// ── Interact ──
|
|
451
|
-
|
|
452
|
-
|
|
453
|
-
|
|
454
|
-
|
|
915
|
+
//
|
|
916
|
+
// Write commands (`click/type/select`) share the same `<target>` contract
|
|
917
|
+
// as the read commands but *reject* multi-match CSS as `selector_ambiguous`
|
|
918
|
+
// unless the caller passes `--nth <n>`. That asymmetry is intentional:
|
|
919
|
+
// clicking "one of three buttons" at random is almost never what the agent
|
|
920
|
+
// meant. Every branch emits a JSON envelope on stdout; error envelopes go
|
|
921
|
+
// through the unified TargetError handler in browserAction.
|
|
922
|
+
/**
|
|
923
|
+
* Parse the `--nth` flag and convert it to `ResolveOptions`.
|
|
924
|
+
* Returns `{ error }` when the flag was malformed (so the command can
|
|
925
|
+
* print the structured usage error and exit) or `{ opts }` to feed
|
|
926
|
+
* into resolveRef / page.click / page.typeText.
|
|
927
|
+
*/
|
|
928
|
+
function nthToResolveOpts(raw) {
|
|
929
|
+
const parsed = parseNthFlag(raw);
|
|
930
|
+
if (parsed && typeof parsed === 'object' && 'error' in parsed)
|
|
931
|
+
return parsed;
|
|
932
|
+
if (typeof parsed === 'number')
|
|
933
|
+
return { opts: { nth: parsed } };
|
|
934
|
+
return { opts: {} };
|
|
935
|
+
}
|
|
936
|
+
addBrowserTabOption(browser.command('click')
|
|
937
|
+
.argument('<target>', 'Numeric ref (from browser state / find) or CSS selector')
|
|
938
|
+
.option('--nth <n>', 'When <target> is a multi-match CSS selector, pick the nth match (0-based)')
|
|
939
|
+
.description('Click element — JSON envelope {clicked, target, matches_n}'))
|
|
940
|
+
.action(browserAction(async (page, target, opts) => {
|
|
941
|
+
const parsed = nthToResolveOpts(opts?.nth);
|
|
942
|
+
if ('error' in parsed) {
|
|
943
|
+
console.log(JSON.stringify({ error: { code: 'usage_error', message: parsed.error } }, null, 2));
|
|
944
|
+
process.exitCode = EXIT_CODES.USAGE_ERROR;
|
|
945
|
+
return;
|
|
946
|
+
}
|
|
947
|
+
const { matches_n, match_level } = await page.click(String(target), parsed.opts);
|
|
948
|
+
console.log(JSON.stringify({ clicked: true, target: String(target), matches_n, match_level }, null, 2));
|
|
455
949
|
}));
|
|
456
|
-
addBrowserTabOption(browser.command('type')
|
|
457
|
-
.
|
|
458
|
-
.
|
|
459
|
-
|
|
950
|
+
addBrowserTabOption(browser.command('type')
|
|
951
|
+
.argument('<target>', 'Numeric ref (from browser state / find) or CSS selector')
|
|
952
|
+
.argument('<text>', 'Text to type')
|
|
953
|
+
.option('--nth <n>', 'When <target> is a multi-match CSS selector, pick the nth match (0-based)')
|
|
954
|
+
.description('Click element, then type text — JSON envelope {typed, text, target, matches_n, autocomplete}'))
|
|
955
|
+
.action(browserAction(async (page, target, text, opts) => {
|
|
956
|
+
const parsed = nthToResolveOpts(opts?.nth);
|
|
957
|
+
if ('error' in parsed) {
|
|
958
|
+
console.log(JSON.stringify({ error: { code: 'usage_error', message: parsed.error } }, null, 2));
|
|
959
|
+
process.exitCode = EXIT_CODES.USAGE_ERROR;
|
|
960
|
+
return;
|
|
961
|
+
}
|
|
962
|
+
// Click first (focuses the field), wait briefly, then type.
|
|
963
|
+
await page.click(String(target), parsed.opts);
|
|
460
964
|
await page.wait(0.3);
|
|
461
|
-
await page.typeText(
|
|
462
|
-
//
|
|
463
|
-
// __resolved is already set by typeText's resolver call
|
|
965
|
+
const { matches_n, match_level } = await page.typeText(String(target), String(text), parsed.opts);
|
|
966
|
+
// __resolved is already set by the resolver call inside page.typeText
|
|
464
967
|
const isAutocomplete = await page.evaluate(isAutocompleteResolvedJs());
|
|
465
|
-
if (isAutocomplete)
|
|
968
|
+
if (isAutocomplete)
|
|
466
969
|
await page.wait(0.4);
|
|
467
|
-
|
|
468
|
-
|
|
469
|
-
|
|
470
|
-
|
|
471
|
-
|
|
970
|
+
console.log(JSON.stringify({
|
|
971
|
+
typed: true,
|
|
972
|
+
text: String(text),
|
|
973
|
+
target: String(target),
|
|
974
|
+
matches_n,
|
|
975
|
+
match_level,
|
|
976
|
+
autocomplete: !!isAutocomplete,
|
|
977
|
+
}, null, 2));
|
|
472
978
|
}));
|
|
473
|
-
addBrowserTabOption(browser.command('select')
|
|
474
|
-
.
|
|
475
|
-
.
|
|
476
|
-
|
|
477
|
-
|
|
979
|
+
addBrowserTabOption(browser.command('select')
|
|
980
|
+
.argument('<target>', 'Numeric ref (from browser state / find) or CSS selector of a <select> element')
|
|
981
|
+
.argument('<option>', 'Option text (or value) to select')
|
|
982
|
+
.option('--nth <n>', 'When <target> is a multi-match CSS selector, pick the nth match (0-based)')
|
|
983
|
+
.description('Select dropdown option — JSON envelope {selected, target, matches_n}'))
|
|
984
|
+
.action(browserAction(async (page, target, option, opts) => {
|
|
985
|
+
const parsed = nthToResolveOpts(opts?.nth);
|
|
986
|
+
if ('error' in parsed) {
|
|
987
|
+
console.log(JSON.stringify({ error: { code: 'usage_error', message: parsed.error } }, null, 2));
|
|
988
|
+
process.exitCode = EXIT_CODES.USAGE_ERROR;
|
|
989
|
+
return;
|
|
990
|
+
}
|
|
991
|
+
const { matches_n, match_level } = await resolveRef(page, String(target), parsed.opts);
|
|
992
|
+
const result = await page.evaluate(selectResolvedJs(String(option)));
|
|
478
993
|
if (result?.error) {
|
|
479
|
-
|
|
994
|
+
// The select-specific "Not a <select>" / "Option not found" errors
|
|
995
|
+
// are domain-level failures — emit a structured envelope so agents
|
|
996
|
+
// can branch on code rather than scrape a log line.
|
|
997
|
+
console.log(JSON.stringify({
|
|
998
|
+
error: {
|
|
999
|
+
code: result.error === 'Not a <select>' ? 'not_a_select' : 'option_not_found',
|
|
1000
|
+
message: result.error,
|
|
1001
|
+
...(result.available && { available: result.available }),
|
|
1002
|
+
matches_n,
|
|
1003
|
+
},
|
|
1004
|
+
}, null, 2));
|
|
480
1005
|
process.exitCode = EXIT_CODES.GENERIC_ERROR;
|
|
1006
|
+
return;
|
|
481
1007
|
}
|
|
482
|
-
|
|
483
|
-
|
|
484
|
-
|
|
1008
|
+
console.log(JSON.stringify({
|
|
1009
|
+
selected: result?.selected ?? String(option),
|
|
1010
|
+
target: String(target),
|
|
1011
|
+
matches_n,
|
|
1012
|
+
match_level,
|
|
1013
|
+
}, null, 2));
|
|
485
1014
|
}));
|
|
486
1015
|
addBrowserTabOption(browser.command('keys').argument('<key>', 'Key to press (Enter, Escape, Tab, Control+a)'))
|
|
487
1016
|
.description('Press keyboard key')
|
|
@@ -491,10 +1020,10 @@ export function createProgram(BUILTIN_CLIS, USER_CLIS) {
|
|
|
491
1020
|
}));
|
|
492
1021
|
// ── Wait commands ──
|
|
493
1022
|
addBrowserTabOption(browser.command('wait'))
|
|
494
|
-
.argument('<type>', 'selector, text, or
|
|
495
|
-
.argument('[value]', 'CSS selector, text string, or
|
|
1023
|
+
.argument('<type>', 'selector, text, time, or xhr')
|
|
1024
|
+
.argument('[value]', 'CSS selector, text string, seconds, or XHR URL regex')
|
|
496
1025
|
.option('--timeout <ms>', 'Timeout in milliseconds', '10000')
|
|
497
|
-
.description('Wait for selector, text, or
|
|
1026
|
+
.description('Wait for selector, text, time, or matching XHR (e.g. wait selector ".loaded", wait text "Success", wait time 3, wait xhr "/api/search")')
|
|
498
1027
|
.action(browserAction(async (page, type, value, opts) => {
|
|
499
1028
|
const timeout = parseInt(opts.timeout, 10);
|
|
500
1029
|
if (type === 'time') {
|
|
@@ -520,8 +1049,59 @@ export function createProgram(BUILTIN_CLIS, USER_CLIS) {
|
|
|
520
1049
|
await page.wait({ text: value, timeout: timeout / 1000 });
|
|
521
1050
|
console.log(`Text "${value}" appeared`);
|
|
522
1051
|
}
|
|
1052
|
+
else if (type === 'xhr') {
|
|
1053
|
+
// Poll the capture ring until an entry matches the URL regex — turns
|
|
1054
|
+
// the common "open page, wait N seconds, hope the data landed" idiom
|
|
1055
|
+
// into a deterministic barrier keyed on the API the agent actually
|
|
1056
|
+
// cares about. Prevents silent "empty DOM" failures on slow SPAs.
|
|
1057
|
+
if (!value) {
|
|
1058
|
+
console.error('Missing XHR URL regex');
|
|
1059
|
+
process.exitCode = EXIT_CODES.USAGE_ERROR;
|
|
1060
|
+
return;
|
|
1061
|
+
}
|
|
1062
|
+
let re;
|
|
1063
|
+
try {
|
|
1064
|
+
re = new RegExp(value);
|
|
1065
|
+
}
|
|
1066
|
+
catch (err) {
|
|
1067
|
+
console.error(`Invalid regex "${value}": ${err instanceof Error ? err.message : String(err)}`);
|
|
1068
|
+
process.exitCode = EXIT_CODES.USAGE_ERROR;
|
|
1069
|
+
return;
|
|
1070
|
+
}
|
|
1071
|
+
const hasSessionCapture = await page.startNetworkCapture?.() ?? false;
|
|
1072
|
+
if (!hasSessionCapture) {
|
|
1073
|
+
try {
|
|
1074
|
+
await page.evaluate(NETWORK_INTERCEPTOR_JS);
|
|
1075
|
+
}
|
|
1076
|
+
catch { /* non-fatal */ }
|
|
1077
|
+
}
|
|
1078
|
+
await captureNetworkItems(page);
|
|
1079
|
+
const deadline = Date.now() + timeout;
|
|
1080
|
+
const pollMs = 400;
|
|
1081
|
+
let matched = null;
|
|
1082
|
+
while (Date.now() < deadline && !matched) {
|
|
1083
|
+
const items = await captureNetworkItems(page);
|
|
1084
|
+
matched = items.find((e) => re.test(e.url)) ?? null;
|
|
1085
|
+
if (!matched)
|
|
1086
|
+
await new Promise((r) => setTimeout(r, pollMs));
|
|
1087
|
+
}
|
|
1088
|
+
if (!matched) {
|
|
1089
|
+
console.log(JSON.stringify({
|
|
1090
|
+
error: {
|
|
1091
|
+
code: 'xhr_not_seen',
|
|
1092
|
+
message: `No captured XHR matched /${value}/ within ${timeout}ms`,
|
|
1093
|
+
hint: 'Check the pattern against `browser network` output; the endpoint may not have fired yet, or capture is disabled.',
|
|
1094
|
+
},
|
|
1095
|
+
}, null, 2));
|
|
1096
|
+
process.exitCode = EXIT_CODES.GENERIC_ERROR;
|
|
1097
|
+
return;
|
|
1098
|
+
}
|
|
1099
|
+
console.log(JSON.stringify({
|
|
1100
|
+
matched: { url: matched.url, status: matched.status, contentType: matched.ct },
|
|
1101
|
+
}, null, 2));
|
|
1102
|
+
}
|
|
523
1103
|
else {
|
|
524
|
-
console.error(`Unknown wait type "${type}". Use: selector, text, or
|
|
1104
|
+
console.error(`Unknown wait type "${type}". Use: selector, text, time, or xhr`);
|
|
525
1105
|
process.exitCode = EXIT_CODES.USAGE_ERROR;
|
|
526
1106
|
}
|
|
527
1107
|
}));
|
|
@@ -552,86 +1132,232 @@ export function createProgram(BUILTIN_CLIS, USER_CLIS) {
|
|
|
552
1132
|
else
|
|
553
1133
|
console.log(JSON.stringify(result, null, 2));
|
|
554
1134
|
}));
|
|
1135
|
+
// ── Extract (content reading) ──
|
|
1136
|
+
//
|
|
1137
|
+
// `extract` answers the "read this page" question that `get html` / `get text`
|
|
1138
|
+
// can't: denoise → markdown → paragraph-aware chunking. Agents walk long pages
|
|
1139
|
+
// by passing back the `next_start_char` cursor instead of juggling selectors.
|
|
1140
|
+
addBrowserTabOption(browser.command('extract')
|
|
1141
|
+
.option('--selector <css>', 'CSS selector scope; defaults to <main>/<article>/<body>')
|
|
1142
|
+
.option('--chunk-size <chars>', 'Target chunk size in chars', '20000')
|
|
1143
|
+
.option('--start <char>', 'Start offset (use next_start_char from a previous extract)', '0')
|
|
1144
|
+
.description('Extract page content as markdown, paragraph-aware chunks for long pages'))
|
|
1145
|
+
.action(browserAction(async (page, opts) => {
|
|
1146
|
+
const rawChunk = String(opts.chunkSize ?? '20000');
|
|
1147
|
+
if (!/^\d+$/.test(rawChunk) || Number.parseInt(rawChunk, 10) <= 0) {
|
|
1148
|
+
console.log(JSON.stringify({ error: { code: 'invalid_chunk_size', message: `--chunk-size must be a positive integer, got "${opts.chunkSize}"` } }, null, 2));
|
|
1149
|
+
process.exitCode = EXIT_CODES.USAGE_ERROR;
|
|
1150
|
+
return;
|
|
1151
|
+
}
|
|
1152
|
+
const rawStart = String(opts.start ?? '0');
|
|
1153
|
+
if (!/^\d+$/.test(rawStart)) {
|
|
1154
|
+
console.log(JSON.stringify({ error: { code: 'invalid_start', message: `--start must be a non-negative integer, got "${opts.start}"` } }, null, 2));
|
|
1155
|
+
process.exitCode = EXIT_CODES.USAGE_ERROR;
|
|
1156
|
+
return;
|
|
1157
|
+
}
|
|
1158
|
+
const chunkSize = Number.parseInt(rawChunk, 10);
|
|
1159
|
+
const start = Number.parseInt(rawStart, 10);
|
|
1160
|
+
const selector = typeof opts.selector === 'string' && opts.selector.length > 0 ? opts.selector : null;
|
|
1161
|
+
const js = buildExtractHtmlJs(selector);
|
|
1162
|
+
const res = await page.evaluate(js);
|
|
1163
|
+
if (!res) {
|
|
1164
|
+
console.log(JSON.stringify({ error: { code: 'extract_failed', message: 'Page returned no root element.' } }, null, 2));
|
|
1165
|
+
process.exitCode = EXIT_CODES.USAGE_ERROR;
|
|
1166
|
+
return;
|
|
1167
|
+
}
|
|
1168
|
+
if ('invalidSelector' in res) {
|
|
1169
|
+
console.log(JSON.stringify({ error: { code: 'invalid_selector', message: `Selector "${selector}" is not a valid CSS selector: ${res.reason}` } }, null, 2));
|
|
1170
|
+
process.exitCode = EXIT_CODES.USAGE_ERROR;
|
|
1171
|
+
return;
|
|
1172
|
+
}
|
|
1173
|
+
if ('notFound' in res) {
|
|
1174
|
+
console.log(JSON.stringify({ error: { code: 'selector_not_found', message: selector ? `Selector "${selector}" matched 0 elements.` : 'Page has no body/main/article element.' } }, null, 2));
|
|
1175
|
+
process.exitCode = EXIT_CODES.USAGE_ERROR;
|
|
1176
|
+
return;
|
|
1177
|
+
}
|
|
1178
|
+
const envelope = runExtractFromHtml({
|
|
1179
|
+
html: res.html,
|
|
1180
|
+
url: res.url,
|
|
1181
|
+
title: res.title,
|
|
1182
|
+
selector,
|
|
1183
|
+
start,
|
|
1184
|
+
chunkSize,
|
|
1185
|
+
});
|
|
1186
|
+
console.log(JSON.stringify(envelope, null, 2));
|
|
1187
|
+
}));
|
|
555
1188
|
// ── Network (API discovery) ──
|
|
1189
|
+
//
|
|
1190
|
+
// Default output is JSON (agent-native). Each entry carries a stable `key`
|
|
1191
|
+
// (GraphQL operationName or `METHOD host+pathname`) so agents can fetch
|
|
1192
|
+
// full bodies with `--detail <key>` even after subsequent commands.
|
|
1193
|
+
// Captures are persisted per workspace under ~/.opencli/cache/browser-network/.
|
|
556
1194
|
addBrowserTabOption(browser.command('network'))
|
|
557
|
-
.option('--detail <
|
|
558
|
-
.option('--all', '
|
|
559
|
-
.
|
|
1195
|
+
.option('--detail <key>', 'Emit full body for the entry with this key')
|
|
1196
|
+
.option('--all', 'Include static resources (js/css/images/telemetry)')
|
|
1197
|
+
.option('--raw', 'Emit full bodies for every entry (skip shape preview)')
|
|
1198
|
+
.option('--filter <fields>', 'Comma-separated field names; keep only entries whose body shape has ALL names as path segments')
|
|
1199
|
+
.option('--max-body <chars>', 'With --detail: cap the emitted body at N chars (0 = unlimited, default)', '0')
|
|
1200
|
+
.option('--ttl <ms>', 'Cache TTL in ms for --detail lookups', String(DEFAULT_TTL_MS))
|
|
1201
|
+
.description('Capture network requests as shape previews; retrieve full bodies by key')
|
|
560
1202
|
.action(browserAction(async (page, opts) => {
|
|
561
|
-
|
|
562
|
-
|
|
563
|
-
|
|
564
|
-
|
|
565
|
-
|
|
566
|
-
|
|
567
|
-
|
|
568
|
-
|
|
569
|
-
|
|
570
|
-
|
|
571
|
-
|
|
572
|
-
body = JSON.parse(preview);
|
|
573
|
-
}
|
|
574
|
-
catch {
|
|
575
|
-
body = preview;
|
|
576
|
-
}
|
|
577
|
-
}
|
|
578
|
-
return {
|
|
579
|
-
url: e.url || '',
|
|
580
|
-
method: e.method || 'GET',
|
|
581
|
-
status: e.responseStatus || 0,
|
|
582
|
-
size: preview ? preview.length : 0,
|
|
583
|
-
ct: e.responseContentType || '',
|
|
584
|
-
body,
|
|
585
|
-
};
|
|
586
|
-
});
|
|
1203
|
+
const ttlMs = parsePositiveIntOption(opts.ttl, 'ttl', DEFAULT_TTL_MS);
|
|
1204
|
+
const workspace = DEFAULT_BROWSER_WORKSPACE;
|
|
1205
|
+
const hasDetail = typeof opts.detail === 'string' && opts.detail.length > 0;
|
|
1206
|
+
const hasFilter = typeof opts.filter === 'string';
|
|
1207
|
+
// --detail and --filter do different things (one request by key vs. narrow
|
|
1208
|
+
// the list by shape), don't compose, and combining them has no sensible
|
|
1209
|
+
// semantic. Reject up front with a structured error instead of silently
|
|
1210
|
+
// dropping one.
|
|
1211
|
+
if (hasDetail && hasFilter) {
|
|
1212
|
+
emitNetworkError('invalid_args', '--filter and --detail cannot be used together (one narrows a list, the other fetches a specific entry).');
|
|
1213
|
+
return;
|
|
587
1214
|
}
|
|
588
|
-
|
|
589
|
-
|
|
590
|
-
const
|
|
591
|
-
|
|
592
|
-
|
|
593
|
-
|
|
594
|
-
try {
|
|
595
|
-
items = JSON.parse(requests);
|
|
1215
|
+
let filterFields = null;
|
|
1216
|
+
if (hasFilter) {
|
|
1217
|
+
const parsed = parseFilter(opts.filter);
|
|
1218
|
+
if ('reason' in parsed) {
|
|
1219
|
+
emitNetworkError('invalid_filter', parsed.reason);
|
|
1220
|
+
return;
|
|
596
1221
|
}
|
|
597
|
-
|
|
598
|
-
|
|
1222
|
+
filterFields = parsed.fields;
|
|
1223
|
+
}
|
|
1224
|
+
// --detail short-circuits: read from cache only, no live capture needed.
|
|
1225
|
+
if (hasDetail) {
|
|
1226
|
+
const res = loadNetworkCache(workspace, { ttlMs });
|
|
1227
|
+
if (res.status === 'missing') {
|
|
1228
|
+
emitNetworkError('cache_missing', `No cached capture. Run "browser network" first (in workspace "${workspace}").`);
|
|
1229
|
+
return;
|
|
1230
|
+
}
|
|
1231
|
+
if (res.status === 'expired') {
|
|
1232
|
+
emitNetworkError('cache_expired', `Cache is stale (age ${res.ageMs}ms > ttl ${ttlMs}ms). Re-run "browser network" to refresh.`);
|
|
1233
|
+
return;
|
|
1234
|
+
}
|
|
1235
|
+
if (res.status === 'corrupt' || !res.file) {
|
|
1236
|
+
emitNetworkError('cache_corrupt', 'Cache file is malformed; re-run "browser network" to regenerate.');
|
|
599
1237
|
return;
|
|
600
1238
|
}
|
|
1239
|
+
const entry = findEntry(res.file, opts.detail);
|
|
1240
|
+
if (!entry) {
|
|
1241
|
+
emitNetworkError('key_not_found', `Key "${opts.detail}" not in cache.`, {
|
|
1242
|
+
available_keys: res.file.entries.map((e) => e.key),
|
|
1243
|
+
});
|
|
1244
|
+
return;
|
|
1245
|
+
}
|
|
1246
|
+
const rawMaxBody = String(opts.maxBody ?? '0');
|
|
1247
|
+
if (!/^\d+$/.test(rawMaxBody)) {
|
|
1248
|
+
emitNetworkError('invalid_max_body', `--max-body must be a non-negative integer, got "${opts.maxBody}"`);
|
|
1249
|
+
return;
|
|
1250
|
+
}
|
|
1251
|
+
const maxBody = Number.parseInt(rawMaxBody, 10);
|
|
1252
|
+
// Body shape/source:
|
|
1253
|
+
// - If capture already truncated it (entry.body_truncated), the body is a string.
|
|
1254
|
+
// - If the adapter stored a JSON value, it parsed cleanly at capture time; leave it.
|
|
1255
|
+
// - --max-body applies a transport-level cap when the caller wants to keep output small.
|
|
1256
|
+
let outputBody = entry.body;
|
|
1257
|
+
let transportTruncated = false;
|
|
1258
|
+
if (maxBody > 0 && typeof entry.body === 'string' && entry.body.length > maxBody) {
|
|
1259
|
+
outputBody = entry.body.slice(0, maxBody);
|
|
1260
|
+
transportTruncated = true;
|
|
1261
|
+
}
|
|
1262
|
+
const captureTruncated = entry.body_truncated === true;
|
|
1263
|
+
const detailEnvelope = {
|
|
1264
|
+
key: entry.key,
|
|
1265
|
+
url: entry.url,
|
|
1266
|
+
method: entry.method,
|
|
1267
|
+
status: entry.status,
|
|
1268
|
+
ct: entry.ct,
|
|
1269
|
+
size: entry.size,
|
|
1270
|
+
shape: inferShape(entry.body),
|
|
1271
|
+
body: outputBody,
|
|
1272
|
+
};
|
|
1273
|
+
if (captureTruncated || transportTruncated) {
|
|
1274
|
+
detailEnvelope.body_truncated = true;
|
|
1275
|
+
detailEnvelope.body_full_size = entry.body_full_size ?? entry.size;
|
|
1276
|
+
detailEnvelope.body_truncation_reason = captureTruncated
|
|
1277
|
+
? 'capture-limit'
|
|
1278
|
+
: 'max-body';
|
|
1279
|
+
}
|
|
1280
|
+
console.log(JSON.stringify(detailEnvelope, null, 2));
|
|
1281
|
+
return;
|
|
1282
|
+
}
|
|
1283
|
+
// Fresh capture path.
|
|
1284
|
+
let rawItems;
|
|
1285
|
+
try {
|
|
1286
|
+
rawItems = await captureNetworkItems(page);
|
|
601
1287
|
}
|
|
602
|
-
|
|
603
|
-
|
|
1288
|
+
catch (err) {
|
|
1289
|
+
emitNetworkError('capture_failed', `Could not read network capture: ${err.message}`);
|
|
604
1290
|
return;
|
|
605
1291
|
}
|
|
606
|
-
|
|
607
|
-
|
|
608
|
-
|
|
609
|
-
|
|
610
|
-
|
|
611
|
-
|
|
612
|
-
|
|
613
|
-
|
|
614
|
-
|
|
615
|
-
|
|
616
|
-
|
|
617
|
-
|
|
618
|
-
|
|
619
|
-
|
|
620
|
-
|
|
621
|
-
|
|
622
|
-
|
|
623
|
-
|
|
1292
|
+
const items = opts.all ? rawItems : filterNetworkItems(rawItems);
|
|
1293
|
+
const filteredOut = rawItems.length - items.length;
|
|
1294
|
+
const keyed = assignKeys(items);
|
|
1295
|
+
const cacheEntries = keyed.map((it) => ({
|
|
1296
|
+
key: it.key,
|
|
1297
|
+
url: it.url,
|
|
1298
|
+
method: it.method,
|
|
1299
|
+
status: it.status,
|
|
1300
|
+
size: it.size,
|
|
1301
|
+
ct: it.ct,
|
|
1302
|
+
body: it.body,
|
|
1303
|
+
...(it.bodyTruncated ? { body_truncated: true } : {}),
|
|
1304
|
+
...(it.bodyTruncated && typeof it.bodyFullSize === 'number'
|
|
1305
|
+
? { body_full_size: it.bodyFullSize }
|
|
1306
|
+
: {}),
|
|
1307
|
+
}));
|
|
1308
|
+
// Soft failure: the caller already has the data, so surface a warning
|
|
1309
|
+
// via the output envelope rather than erroring out the whole command.
|
|
1310
|
+
let cacheWarning = null;
|
|
1311
|
+
try {
|
|
1312
|
+
saveNetworkCache(workspace, cacheEntries);
|
|
624
1313
|
}
|
|
625
|
-
|
|
626
|
-
|
|
627
|
-
|
|
628
|
-
|
|
629
|
-
|
|
630
|
-
|
|
631
|
-
|
|
632
|
-
|
|
633
|
-
|
|
1314
|
+
catch (err) {
|
|
1315
|
+
cacheWarning = `Could not persist capture cache: ${err.message}. --detail lookups may miss this capture.`;
|
|
1316
|
+
}
|
|
1317
|
+
// Pair each cache entry with its shape up front so --filter can read
|
|
1318
|
+
// segments without recomputing, and the --raw view can keep the full
|
|
1319
|
+
// body. Cache persistence above stored the unfiltered set on purpose:
|
|
1320
|
+
// later `--detail <key>` lookups must still see requests that the
|
|
1321
|
+
// current --filter narrowed out.
|
|
1322
|
+
const shaped = cacheEntries.map((e) => ({ entry: e, shape: inferShape(e.body) }));
|
|
1323
|
+
const visible = filterFields
|
|
1324
|
+
? shaped.filter((s) => shapeMatchesFilter(s.shape, filterFields))
|
|
1325
|
+
: shaped;
|
|
1326
|
+
const filterDropped = filterFields ? shaped.length - visible.length : 0;
|
|
1327
|
+
const envelope = {
|
|
1328
|
+
workspace,
|
|
1329
|
+
captured_at: new Date().toISOString(),
|
|
1330
|
+
count: visible.length,
|
|
1331
|
+
filtered_out: filteredOut,
|
|
1332
|
+
};
|
|
1333
|
+
if (filterFields) {
|
|
1334
|
+
envelope.filter = filterFields;
|
|
1335
|
+
envelope.filter_dropped = filterDropped;
|
|
1336
|
+
}
|
|
1337
|
+
if (cacheWarning)
|
|
1338
|
+
envelope.cache_warning = cacheWarning;
|
|
1339
|
+
const truncatedCount = visible.filter((s) => s.entry.body_truncated).length;
|
|
1340
|
+
if (truncatedCount > 0) {
|
|
1341
|
+
envelope.body_truncated_count = truncatedCount;
|
|
1342
|
+
envelope.body_truncated_hint = 'Some bodies exceeded the capture limit; their `shape` reflects only the captured prefix.';
|
|
634
1343
|
}
|
|
1344
|
+
if (opts.raw) {
|
|
1345
|
+
envelope.entries = visible.map((s) => s.entry);
|
|
1346
|
+
}
|
|
1347
|
+
else {
|
|
1348
|
+
envelope.entries = visible.map((s) => ({
|
|
1349
|
+
key: s.entry.key,
|
|
1350
|
+
method: s.entry.method,
|
|
1351
|
+
status: s.entry.status,
|
|
1352
|
+
url: s.entry.url,
|
|
1353
|
+
ct: s.entry.ct,
|
|
1354
|
+
size: s.entry.size,
|
|
1355
|
+
shape: s.shape,
|
|
1356
|
+
...(s.entry.body_truncated ? { body_truncated: true } : {}),
|
|
1357
|
+
}));
|
|
1358
|
+
envelope.detail_hint = 'Run "browser network --detail <key>" for full body.';
|
|
1359
|
+
}
|
|
1360
|
+
console.log(JSON.stringify(envelope, null, 2));
|
|
635
1361
|
}));
|
|
636
1362
|
// ── Init (adapter scaffolding) ──
|
|
637
1363
|
browser.command('init')
|
|
@@ -707,8 +1433,12 @@ cli({
|
|
|
707
1433
|
// ── Verify (test adapter) ──
|
|
708
1434
|
browser.command('verify')
|
|
709
1435
|
.argument('<name>', 'Adapter name in site/command format (e.g. hn/top)')
|
|
710
|
-
.
|
|
711
|
-
.
|
|
1436
|
+
.option('--write-fixture', 'Write a starter fixture to ~/.opencli/sites/<site>/verify/<command>.json if none exists')
|
|
1437
|
+
.option('--update-fixture', 'Overwrite an existing fixture with one derived from current output')
|
|
1438
|
+
.option('--no-fixture', 'Ignore any fixture file for this run (no value-level validation)')
|
|
1439
|
+
.option('--strict-memory', 'Fail (not just warn) when ~/.opencli/sites/<site>/endpoints.json or notes.md is missing')
|
|
1440
|
+
.description('Execute an adapter and validate output; uses fixture at ~/.opencli/sites/<site>/verify/<cmd>.json when present')
|
|
1441
|
+
.action(async (name, opts = {}) => {
|
|
712
1442
|
try {
|
|
713
1443
|
const parts = name.split('/');
|
|
714
1444
|
if (parts.length !== 2) {
|
|
@@ -723,7 +1453,7 @@ cli({
|
|
|
723
1453
|
return;
|
|
724
1454
|
}
|
|
725
1455
|
const { execFileSync } = await import('node:child_process');
|
|
726
|
-
const
|
|
1456
|
+
const { loadFixture, writeFixture, deriveFixture, validateRows, fixturePath, expandFixtureArgs } = await import('./browser/verify-fixture.js');
|
|
727
1457
|
const filePath = path.join(os.homedir(), '.opencli', 'clis', site, `${command}.js`);
|
|
728
1458
|
if (!fs.existsSync(filePath)) {
|
|
729
1459
|
console.error(`Adapter not found: ${filePath}`);
|
|
@@ -733,14 +1463,24 @@ cli({
|
|
|
733
1463
|
}
|
|
734
1464
|
console.log(`🔍 Verifying ${name}...\n`);
|
|
735
1465
|
console.log(` Loading: ${filePath}`);
|
|
736
|
-
|
|
1466
|
+
const useFixture = opts.fixture !== false;
|
|
1467
|
+
let fixture = useFixture ? loadFixture(site, command) : null;
|
|
1468
|
+
// Build adapter args: fixture.args override the legacy --limit 3 heuristic.
|
|
1469
|
+
// - object form { "limit": 3 } → `--limit 3`
|
|
1470
|
+
// - array form ["123", "--limit", "3"] → verbatim (for positional subjects)
|
|
737
1471
|
const adapterSrc = fs.readFileSync(filePath, 'utf-8');
|
|
738
1472
|
const hasLimitArg = /['"]limit['"]/.test(adapterSrc);
|
|
739
|
-
const
|
|
740
|
-
const
|
|
1473
|
+
const fixtureArgs = fixture?.args;
|
|
1474
|
+
const cliArgs = expandFixtureArgs(fixtureArgs);
|
|
1475
|
+
if (cliArgs.length === 0 && hasLimitArg)
|
|
1476
|
+
cliArgs.push('--limit', '3');
|
|
1477
|
+
const argDisplay = cliArgs.join(' ');
|
|
741
1478
|
const invocation = resolveBrowserVerifyInvocation();
|
|
1479
|
+
// Always request JSON so we can validate structurally.
|
|
1480
|
+
const execArgs = [...invocation.args, site, command, ...cliArgs, '--format', 'json'];
|
|
1481
|
+
let rawJson;
|
|
742
1482
|
try {
|
|
743
|
-
|
|
1483
|
+
rawJson = execFileSync(invocation.binary, execArgs, {
|
|
744
1484
|
cwd: invocation.cwd,
|
|
745
1485
|
timeout: 30000,
|
|
746
1486
|
encoding: 'utf-8',
|
|
@@ -748,13 +1488,9 @@ cli({
|
|
|
748
1488
|
stdio: ['pipe', 'pipe', 'pipe'],
|
|
749
1489
|
...(invocation.shell ? { shell: true } : {}),
|
|
750
1490
|
});
|
|
751
|
-
console.log(` Executing: opencli ${site} ${command}${limitFlag}\n`);
|
|
752
|
-
console.log(output);
|
|
753
|
-
console.log(`\n ✓ Adapter works!`);
|
|
754
1491
|
}
|
|
755
1492
|
catch (err) {
|
|
756
|
-
console.log(` Executing: opencli ${site} ${command}${
|
|
757
|
-
// execFileSync attaches captured stdout/stderr on its thrown Error.
|
|
1493
|
+
console.log(` Executing: opencli ${site} ${command} ${argDisplay}\n`);
|
|
758
1494
|
const execErr = err;
|
|
759
1495
|
if (execErr.stdout)
|
|
760
1496
|
console.log(String(execErr.stdout));
|
|
@@ -762,7 +1498,66 @@ cli({
|
|
|
762
1498
|
console.error(String(execErr.stderr).slice(0, 500));
|
|
763
1499
|
console.log(`\n ✗ Adapter failed. Fix the code and try again.`);
|
|
764
1500
|
process.exitCode = EXIT_CODES.GENERIC_ERROR;
|
|
1501
|
+
return;
|
|
1502
|
+
}
|
|
1503
|
+
console.log(` Executing: opencli ${site} ${command} ${argDisplay}\n`);
|
|
1504
|
+
let rows;
|
|
1505
|
+
try {
|
|
1506
|
+
rows = normalizeVerifyRows(JSON.parse(rawJson));
|
|
765
1507
|
}
|
|
1508
|
+
catch {
|
|
1509
|
+
console.log(rawJson);
|
|
1510
|
+
console.log('\n ✗ Could not parse adapter output as JSON. Is `--format json` broken?');
|
|
1511
|
+
process.exitCode = EXIT_CODES.GENERIC_ERROR;
|
|
1512
|
+
return;
|
|
1513
|
+
}
|
|
1514
|
+
console.log(renderVerifyPreview(rows));
|
|
1515
|
+
console.log(`\n → ${rows.length} row${rows.length === 1 ? '' : 's'}`);
|
|
1516
|
+
// ── Fixture handling ───────────────────────────────────────────
|
|
1517
|
+
if (opts.writeFixture || opts.updateFixture) {
|
|
1518
|
+
if (fixture && !opts.updateFixture) {
|
|
1519
|
+
console.log(`\n Fixture already exists at ${fixturePath(site, command)}.`);
|
|
1520
|
+
console.log(` Use --update-fixture to overwrite.`);
|
|
1521
|
+
}
|
|
1522
|
+
else {
|
|
1523
|
+
const seedArgs = fixtureArgs !== undefined
|
|
1524
|
+
? fixtureArgs
|
|
1525
|
+
: (hasLimitArg ? { limit: 3 } : undefined);
|
|
1526
|
+
const derived = deriveFixture(rows, seedArgs);
|
|
1527
|
+
const p = writeFixture(site, command, derived);
|
|
1528
|
+
console.log(`\n ${fixture ? '↻ Updated' : '✎ Wrote'} fixture: ${p}`);
|
|
1529
|
+
console.log(` Review and hand-tune the derived expectations (add patterns / notEmpty, tighten rowCount).`);
|
|
1530
|
+
fixture = derived;
|
|
1531
|
+
}
|
|
1532
|
+
}
|
|
1533
|
+
if (!fixture) {
|
|
1534
|
+
console.log(`\n ✓ Adapter runs. (No fixture at ${fixturePath(site, command)} — consider --write-fixture to seed one.)`);
|
|
1535
|
+
const memoryReport = checkSiteMemory(site);
|
|
1536
|
+
printSiteMemoryReport(memoryReport, opts.strictMemory);
|
|
1537
|
+
if (!memoryReport.ok && opts.strictMemory) {
|
|
1538
|
+
process.exitCode = EXIT_CODES.GENERIC_ERROR;
|
|
1539
|
+
}
|
|
1540
|
+
return;
|
|
1541
|
+
}
|
|
1542
|
+
const failures = validateRows(rows, fixture);
|
|
1543
|
+
if (failures.length === 0) {
|
|
1544
|
+
console.log(`\n ✓ Adapter matches fixture (${fixturePath(site, command)}).`);
|
|
1545
|
+
const memoryReport = checkSiteMemory(site);
|
|
1546
|
+
printSiteMemoryReport(memoryReport, opts.strictMemory);
|
|
1547
|
+
if (!memoryReport.ok && opts.strictMemory) {
|
|
1548
|
+
process.exitCode = EXIT_CODES.GENERIC_ERROR;
|
|
1549
|
+
}
|
|
1550
|
+
return;
|
|
1551
|
+
}
|
|
1552
|
+
console.log(`\n ✗ Adapter output does not match fixture:`);
|
|
1553
|
+
for (const f of failures.slice(0, 20)) {
|
|
1554
|
+
const where = f.rowIndex !== undefined ? `row[${f.rowIndex}] ` : '';
|
|
1555
|
+
console.log(` - [${f.rule}] ${where}${f.detail}`);
|
|
1556
|
+
}
|
|
1557
|
+
if (failures.length > 20) {
|
|
1558
|
+
console.log(` ... and ${failures.length - 20} more failure(s)`);
|
|
1559
|
+
}
|
|
1560
|
+
process.exitCode = EXIT_CODES.GENERIC_ERROR;
|
|
766
1561
|
}
|
|
767
1562
|
catch (err) {
|
|
768
1563
|
console.error(`Error: ${err instanceof Error ? err.message : String(err)}`);
|