@fastbrowse/darwin-x64 0.5.19 → 0.5.23
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/fastbrowse/_internal/base_library.zip +0 -0
- package/fastbrowse/_internal/fastbrowse/browser/capture.js +193 -17
- package/fastbrowse/_internal/fastbrowse/browser/snapshot.js +150 -13
- package/fastbrowse/_internal/{fastbrowse-0.5.19.dist-info → fastbrowse-0.5.23.dist-info}/METADATA +117 -60
- package/fastbrowse/_internal/fastbrowse-0.5.23.dist-info/RECORD +14 -0
- package/fastbrowse/_internal/fastbrowse-0.5.23.dist-info/uv_cache.json +1 -0
- package/fastbrowse/_internal/websockets/speedups.cpython-313-darwin.so +0 -0
- package/fastbrowse/_internal/{websockets-17.1.dist-info → websockets-17.2.dist-info}/METADATA +2 -2
- package/fastbrowse/_internal/{websockets-17.1.dist-info → websockets-17.2.dist-info}/RECORD +22 -22
- package/fastbrowse/fastbrowse +0 -0
- package/package.json +1 -1
- package/fastbrowse/_internal/fastbrowse-0.5.19.dist-info/RECORD +0 -14
- package/fastbrowse/_internal/fastbrowse-0.5.19.dist-info/uv_cache.json +0 -1
- /package/fastbrowse/_internal/{fastbrowse-0.5.19.dist-info → fastbrowse-0.5.23.dist-info}/INSTALLER +0 -0
- /package/fastbrowse/_internal/{fastbrowse-0.5.19.dist-info → fastbrowse-0.5.23.dist-info}/REQUESTED +0 -0
- /package/fastbrowse/_internal/{fastbrowse-0.5.19.dist-info → fastbrowse-0.5.23.dist-info}/WHEEL +0 -0
- /package/fastbrowse/_internal/{fastbrowse-0.5.19.dist-info → fastbrowse-0.5.23.dist-info}/direct_url.json +0 -0
- /package/fastbrowse/_internal/{fastbrowse-0.5.19.dist-info → fastbrowse-0.5.23.dist-info}/entry_points.txt +0 -0
- /package/fastbrowse/_internal/{fastbrowse-0.5.19.dist-info → fastbrowse-0.5.23.dist-info}/licenses/LICENSE +0 -0
- /package/fastbrowse/_internal/{fastbrowse-0.5.19.dist-info → fastbrowse-0.5.23.dist-info}/licenses/NOTICE +0 -0
- /package/fastbrowse/_internal/{fastbrowse-0.5.19.dist-info → fastbrowse-0.5.23.dist-info}/uv_build.json +0 -0
- /package/fastbrowse/_internal/{websockets-17.1.dist-info → websockets-17.2.dist-info}/INSTALLER +0 -0
- /package/fastbrowse/_internal/{websockets-17.1.dist-info → websockets-17.2.dist-info}/REQUESTED +0 -0
- /package/fastbrowse/_internal/{websockets-17.1.dist-info → websockets-17.2.dist-info}/WHEEL +0 -0
- /package/fastbrowse/_internal/{websockets-17.1.dist-info → websockets-17.2.dist-info}/entry_points.txt +0 -0
- /package/fastbrowse/_internal/{websockets-17.1.dist-info → websockets-17.2.dist-info}/licenses/LICENSE +0 -0
- /package/fastbrowse/_internal/{websockets-17.1.dist-info → websockets-17.2.dist-info}/top_level.txt +0 -0
|
Binary file
|
|
@@ -2,8 +2,8 @@
|
|
|
2
2
|
// Unlike snapshot.js (bounded, for Jev's action choice) this reads the whole rendered document, on screen or not,
|
|
3
3
|
// so the reading path and answer-evidence quotes see everything a user could scroll to.
|
|
4
4
|
// Adjacent inline content (text, <strong>, <a>, <span>...) is one block, so a value inside markup stays with its label.
|
|
5
|
-
// biome-ignore format: Python embeds this
|
|
6
|
-
(() => {
|
|
5
|
+
// biome-ignore format: Python embeds this function inside an expression, where a semicolon is invalid.
|
|
6
|
+
((allowedOrigins = null) => {
|
|
7
7
|
if (!document.body) return null;
|
|
8
8
|
const HEADINGS = { H1: 1, H2: 2, H3: 3, H4: 4, H5: 5, H6: 6 };
|
|
9
9
|
const SKIP = new Set(['SCRIPT', 'STYLE', 'NOSCRIPT', 'TEMPLATE', 'SVG', 'CANVAS', 'IFRAME']);
|
|
@@ -12,7 +12,34 @@
|
|
|
12
12
|
let framePath = null;
|
|
13
13
|
let sourcePath = '';
|
|
14
14
|
let inaccessible = 0;
|
|
15
|
+
let visibleH1 = false;
|
|
16
|
+
let controlObservations = 0;
|
|
17
|
+
let imageObservations = 0;
|
|
18
|
+
let imagesOmitted = false;
|
|
19
|
+
let linkObservations = 0;
|
|
20
|
+
let linksOmitted = false;
|
|
21
|
+
let controlsOmitted = false;
|
|
22
|
+
const imageSource = source => {
|
|
23
|
+
if (!source) return source;
|
|
24
|
+
if (source.startsWith('data:')) {
|
|
25
|
+
const comma = source.indexOf(',');
|
|
26
|
+
return `${source.slice(0, comma < 0 ? 128 : Math.min(comma, 128))},[payload omitted]`;
|
|
27
|
+
}
|
|
28
|
+
if (source.startsWith('blob:')) return 'blob:[identifier omitted]';
|
|
29
|
+
return source.length > 1024 ? `${source.slice(0, 1024)}[truncated]` : source;
|
|
30
|
+
};
|
|
15
31
|
const registry = (window.__fastbrowse ||= { ids: new WeakMap(), nodes: new Map(), next: 1 });
|
|
32
|
+
const granted = doc => {
|
|
33
|
+
if (allowedOrigins === null) return true;
|
|
34
|
+
const frame = doc.defaultView.frameElement;
|
|
35
|
+
if (frame) {
|
|
36
|
+
if (frame.hasAttribute('srcdoc') || !frame.getAttribute('src')) return false;
|
|
37
|
+
const source = new URL(frame.src);
|
|
38
|
+
if (!['http:', 'https:'].includes(source.protocol) || !allowedOrigins.includes(source.origin)) return false;
|
|
39
|
+
}
|
|
40
|
+
const url = new URL(doc.URL);
|
|
41
|
+
return ['http:', 'https:'].includes(url.protocol) && allowedOrigins.includes(url.origin);
|
|
42
|
+
};
|
|
16
43
|
const identity = e => {
|
|
17
44
|
if (!registry.ids.has(e)) registry.ids.set(e, registry.next++);
|
|
18
45
|
return registry.ids.get(e);
|
|
@@ -34,9 +61,67 @@
|
|
|
34
61
|
const u = new URL(a.href, location.href);
|
|
35
62
|
return u.origin === location.origin ? u.pathname + u.search : u.href;
|
|
36
63
|
};
|
|
37
|
-
const
|
|
64
|
+
const controlOf = nodes => {
|
|
65
|
+
let found = null;
|
|
66
|
+
for (const node of nodes) {
|
|
67
|
+
if (!clean(node.nodeType === 3 ? node.textContent : (node.innerText ?? node.textContent ?? ''))) continue;
|
|
68
|
+
let host = node.nodeType === 1 ? node : node.parentElement;
|
|
69
|
+
while (host && !registry.hoverContexts?.has(host)) host = host.parentElement;
|
|
70
|
+
if (!host?.isConnected || !host.matches(':hover')) return null;
|
|
71
|
+
const binding = registry.hoverContexts.get(host);
|
|
72
|
+
const siblings = [...(host.parentNode?.children || [])];
|
|
73
|
+
const labelAttributes = [host.getAttribute('role'), host.getAttribute('aria-label'),
|
|
74
|
+
host.getAttribute('title'), host.querySelector('img[alt]')?.getAttribute('alt') || null];
|
|
75
|
+
if (host.innerText !== binding.text ||
|
|
76
|
+
labelAttributes.some((value, index) => value !== binding.labelAttributes[index])) return null;
|
|
77
|
+
// A reordered or replaced sibling set invalidates the observed ordinal before the quote is taken.
|
|
78
|
+
if (host.parentNode !== binding.parent || siblings.length !== binding.siblings.length ||
|
|
79
|
+
siblings.some((sibling, index) => sibling !== binding.siblings[index])) return null;
|
|
80
|
+
if (found && found !== host) return null;
|
|
81
|
+
found = host;
|
|
82
|
+
}
|
|
83
|
+
return found ? registry.hoverContexts.get(found).control : null;
|
|
84
|
+
};
|
|
85
|
+
const listIds = new WeakMap();
|
|
86
|
+
const listFrameless = new WeakMap();
|
|
87
|
+
let nextListId = 0;
|
|
88
|
+
const listContext = nodes => {
|
|
89
|
+
const lists = new Set();
|
|
90
|
+
for (const node of nodes) {
|
|
91
|
+
let element = node.nodeType === 1 ? node : node.parentElement;
|
|
92
|
+
let list = null;
|
|
93
|
+
for (let parent = element; parent; parent = parent.parentElement) {
|
|
94
|
+
if (parent.matches('nav,[role="navigation"]')) return {};
|
|
95
|
+
if (parent.matches('ul,ol,[role="list"]')) list = parent;
|
|
96
|
+
}
|
|
97
|
+
if (!list) {
|
|
98
|
+
const nested = [...(element?.querySelectorAll('ul,ol,[role="list"]') ?? [])];
|
|
99
|
+
const outer = nested.filter(candidate => !nested.some(other => other !== candidate && other.contains(candidate)));
|
|
100
|
+
if (outer.length === 1) list = outer[0];
|
|
101
|
+
}
|
|
102
|
+
if (list) lists.add(list);
|
|
103
|
+
}
|
|
104
|
+
if (lists.size !== 1) return {};
|
|
105
|
+
const list = [...lists][0];
|
|
106
|
+
if (!listIds.has(list)) {
|
|
107
|
+
listIds.set(list, String(nextListId++));
|
|
108
|
+
// A frame the capture cannot open elsewhere on the page, such as an advertisement, cannot hold a member of a
|
|
109
|
+
// list whose element contains no frame; one inside the list, or behind a shadow root the list or anything in it
|
|
110
|
+
// holds, could.
|
|
111
|
+
const framed = e => e.tagName === 'IFRAME' || e.tagName === 'FRAME' || !!e.shadowRoot;
|
|
112
|
+
listFrameless.set(list, !framed(list) && ![...list.querySelectorAll('*')].some(framed));
|
|
113
|
+
}
|
|
114
|
+
const members = [...list.querySelectorAll('li,[role="listitem"]')].filter(member => {
|
|
115
|
+
for (let parent = member; parent && parent !== list; parent = parent.parentElement) {
|
|
116
|
+
if (hidden(parent)) return false;
|
|
117
|
+
}
|
|
118
|
+
return !!textOf(member);
|
|
119
|
+
});
|
|
120
|
+
return { list_id: listIds.get(list), list_count: members.length, list_frameless: listFrameless.get(list) };
|
|
121
|
+
};
|
|
122
|
+
const push = (kind, text, extra = {}, nodes = []) => {
|
|
38
123
|
if (text)
|
|
39
|
-
blocks.push({ kind, text, heading_path: [...path], frame_path: framePath, source_path: sourcePath, ...extra });
|
|
124
|
+
blocks.push({ kind, text, heading_path: [...path], frame_path: framePath, source_path: sourcePath, control_context: controlOf(nodes), ...listContext(nodes), ...extra });
|
|
40
125
|
};
|
|
41
126
|
|
|
42
127
|
// A cell can say what it says with an image or an icon alone: a flag marking the winner, a tick for "yes".
|
|
@@ -168,6 +253,8 @@
|
|
|
168
253
|
let headers = rows.filter(({ row }) => row.parentElement.tagName === 'THEAD');
|
|
169
254
|
if (!headers.length && rows[0].cells.every(c => c.tagName === 'TH')) headers = [rows[0]];
|
|
170
255
|
const line = ({ cells }) => '| ' + cells.map(c => cellText(c).replace(/\|/g, '\\|')).join(' | ') + ' |';
|
|
256
|
+
// Headerless comparison rows need leading cells for identity and separate source spans for counting.
|
|
257
|
+
if (!headers.length) return rows.map((row, index) => (index ? [line(rows[0]), line(row)].join('\n') : line(row)));
|
|
171
258
|
const prefix = headers.map(line);
|
|
172
259
|
if (headers.length) prefix.push('| ' + headers[0].cells.map(() => '---').join(' | ') + ' |');
|
|
173
260
|
const data = rows.filter(row => !headers.includes(row));
|
|
@@ -184,8 +271,8 @@
|
|
|
184
271
|
elements[0].href &&
|
|
185
272
|
clean(elements[0].innerText ?? '') === text;
|
|
186
273
|
for (const e of elements) text = withRating(text, e);
|
|
187
|
-
if (onlyLink) push('link', text, { href: hrefOf(elements[0]) });
|
|
188
|
-
else push('paragraph', text);
|
|
274
|
+
if (onlyLink) push('link', text, { href: hrefOf(elements[0]) }, run);
|
|
275
|
+
else push('paragraph', text, {}, run);
|
|
189
276
|
for (const e of elements) for (const field of fields(e)) push('paragraph', field);
|
|
190
277
|
run.length = 0;
|
|
191
278
|
}
|
|
@@ -197,13 +284,22 @@
|
|
|
197
284
|
// A classless div repeated three times is usually page layout; a record is a styled unit or a semantic item.
|
|
198
285
|
function repeated(el) {
|
|
199
286
|
if (!el.classList.length && el.tagName !== 'ARTICLE' && el.tagName !== 'LI') return false;
|
|
200
|
-
|
|
201
|
-
[...(el.parentNode?.children ?? [])].filter(
|
|
287
|
+
const siblings = [...(el.parentNode?.children ?? [])].filter(
|
|
202
288
|
sibling =>
|
|
203
289
|
sibling.tagName === el.tagName &&
|
|
204
290
|
sibling.classList.length === el.classList.length &&
|
|
205
291
|
[...el.classList].every(token => sibling.classList.contains(token)),
|
|
206
|
-
)
|
|
292
|
+
);
|
|
293
|
+
if (siblings.length >= 3) return true;
|
|
294
|
+
// Linked cards can sit inside an unlabelled list wrapper, so distinct destinations establish the pair.
|
|
295
|
+
if (siblings.length === 2 && el.tagName === 'A' && siblings.every(sibling => sibling.hasAttribute('href'))) {
|
|
296
|
+
return new Set(siblings.map(sibling => sibling.href)).size === siblings.length;
|
|
297
|
+
}
|
|
298
|
+
// A styled pair can be a two-column layout, so require a heading outside both items to name their group.
|
|
299
|
+
const section = el.parentElement;
|
|
300
|
+
return (
|
|
301
|
+
siblings.length === 2 &&
|
|
302
|
+
[...(section?.querySelectorAll('h1,h2,h3,h4,h5,h6') ?? [])].some(heading => siblings.every(sibling => !sibling.contains(heading)))
|
|
207
303
|
);
|
|
208
304
|
}
|
|
209
305
|
|
|
@@ -237,7 +333,7 @@
|
|
|
237
333
|
continue;
|
|
238
334
|
}
|
|
239
335
|
if (SKIP.has(node.tagName)) continue;
|
|
240
|
-
if (!isBlock(node) && recordText(node) === null) {
|
|
336
|
+
if (!node.matches('nav,[role="navigation"]') && !isBlock(node) && recordText(node) === null) {
|
|
241
337
|
run.push(node);
|
|
242
338
|
continue;
|
|
243
339
|
}
|
|
@@ -248,30 +344,38 @@
|
|
|
248
344
|
}
|
|
249
345
|
|
|
250
346
|
function block(el) {
|
|
347
|
+
if (el.matches('nav,[role="navigation"]')) {
|
|
348
|
+
// Navigation help headings describe their menu, not product facts in the following main content.
|
|
349
|
+
const surrounding = [...path];
|
|
350
|
+
walk(el);
|
|
351
|
+
path = surrounding;
|
|
352
|
+
return;
|
|
353
|
+
}
|
|
251
354
|
const level = HEADINGS[el.tagName];
|
|
252
355
|
if (level) {
|
|
253
356
|
const text = textOf(el);
|
|
254
357
|
while (path.length >= level) path.pop();
|
|
255
358
|
if (text) {
|
|
256
|
-
push('heading', text);
|
|
359
|
+
push('heading', text, {}, [el]);
|
|
257
360
|
path.push(text);
|
|
258
361
|
}
|
|
259
362
|
return;
|
|
260
363
|
}
|
|
261
364
|
if (el.tagName === 'TABLE') {
|
|
262
|
-
for (const text of renderTable(el)) push('table', text);
|
|
365
|
+
for (const text of renderTable(el)) push('table', text, {}, [el]);
|
|
263
366
|
// A form laid out in a table: its cells render as text, which holds none of the controls' values.
|
|
264
367
|
for (const field of fields(el)) push('paragraph', field);
|
|
265
368
|
return;
|
|
266
369
|
}
|
|
267
|
-
if (el.tagName === 'PRE') return push('code', textOf(el));
|
|
370
|
+
if (el.tagName === 'PRE') return push('code', textOf(el), {}, [el]);
|
|
268
371
|
const record = recordText(el);
|
|
269
372
|
if ((el.tagName === 'LI' && leaf(el)) || record !== null) {
|
|
270
|
-
const links = el.querySelectorAll('a[href]');
|
|
373
|
+
const links = el.matches('a[href]') ? [el] : el.querySelectorAll('a[href]');
|
|
271
374
|
push(
|
|
272
375
|
el.tagName === 'LI' ? 'list_item' : 'record',
|
|
273
376
|
record ?? withRating(textOf(el), el),
|
|
274
377
|
links.length === 1 ? { href: hrefOf(links[0]) } : {},
|
|
378
|
+
[el],
|
|
275
379
|
);
|
|
276
380
|
for (const field of fields(el)) push('paragraph', field);
|
|
277
381
|
return;
|
|
@@ -296,7 +400,7 @@
|
|
|
296
400
|
} catch {
|
|
297
401
|
inner = null;
|
|
298
402
|
}
|
|
299
|
-
if (!inner?.body) {
|
|
403
|
+
if (!inner?.body || !granted(inner)) {
|
|
300
404
|
inaccessible++;
|
|
301
405
|
return;
|
|
302
406
|
}
|
|
@@ -310,6 +414,77 @@
|
|
|
310
414
|
framePath = frame;
|
|
311
415
|
sourcePath = source;
|
|
312
416
|
walk(root);
|
|
417
|
+
path = [...headings];
|
|
418
|
+
const visible = e => e.checkVisibility ? e.checkVisibility({ visibilityProperty: true }) : !hidden(e);
|
|
419
|
+
for (const e of [...root.querySelectorAll('input,select,textarea,button,[role="button"]')].filter(visible)) {
|
|
420
|
+
if (controlObservations >= 64) {
|
|
421
|
+
if (!controlsOmitted) push('observation', 'Additional control state observations omitted.');
|
|
422
|
+
controlsOmitted = true;
|
|
423
|
+
break;
|
|
424
|
+
}
|
|
425
|
+
// Placeholders suggest input; they do not label a value or prove an empty named field.
|
|
426
|
+
if (CONTROLS.has(e.tagName) && !e.labels?.length && !e.hasAttribute('aria-label') && !e.hasAttribute('aria-labelledby')) continue;
|
|
427
|
+
const name = nameOf(e) || clean(e.innerText ?? '');
|
|
428
|
+
if (!name || e.dataset?.fastbrowseSecret === '1') continue;
|
|
429
|
+
if (CONTROLS.has(e.tagName) && !UNFILLED.has(e.type) && !fieldValue(e)) {
|
|
430
|
+
push('observation', `${name.slice(0, 1024)}: [empty]`);
|
|
431
|
+
controlObservations++;
|
|
432
|
+
}
|
|
433
|
+
if (controlObservations < 64 && e.matches(':disabled,[aria-disabled="true"]')) {
|
|
434
|
+
push('observation', `${name.slice(0, 1024)}: disabled`);
|
|
435
|
+
controlObservations++;
|
|
436
|
+
}
|
|
437
|
+
}
|
|
438
|
+
// Images carry no innerText, so a text-only capture cannot distinguish their presence from absence.
|
|
439
|
+
for (const image of [...root.querySelectorAll('img')].filter(visible)) {
|
|
440
|
+
if (imageObservations++ >= 32) {
|
|
441
|
+
if (!imagesOmitted) push('observation', 'Additional visible image metadata omitted.');
|
|
442
|
+
imagesOmitted = true;
|
|
443
|
+
break;
|
|
444
|
+
}
|
|
445
|
+
// Loading changes without a page edit, so captures report presence rather than transient decode state.
|
|
446
|
+
const metadata = { alt: image.getAttribute('alt')?.slice(0, 1024) ?? null,
|
|
447
|
+
src: imageSource(image.getAttribute('src') ?? image.getAttribute('srcset')) };
|
|
448
|
+
push('observation', `Image element DOM metadata: ${JSON.stringify(metadata)}. Pixels are not described.`);
|
|
449
|
+
}
|
|
450
|
+
// A link drawn only as an icon has no text, so its destination, the one thing it says, was nowhere a reader
|
|
451
|
+
// could quote: asked for a release's download URL beside an icon-only download link, a run read every other
|
|
452
|
+
// field of the row and then clicked the link again and again looking for the address. The row's text names
|
|
453
|
+
// which item the link belongs to, since the observation is not read where the link stands.
|
|
454
|
+
for (const link of [...root.querySelectorAll('a[href]')].filter(visible)) {
|
|
455
|
+
if (clean(link.innerText ?? '')) continue;
|
|
456
|
+
// An icon button written as `href="#"` or a script URL runs code here and leads nowhere to report. A bare
|
|
457
|
+
// `#` parses to an empty hash, so the raw attribute says it is a fragment link, not the parsed URL.
|
|
458
|
+
let target;
|
|
459
|
+
try {
|
|
460
|
+
target = new URL(link.getAttribute('href'), location.href);
|
|
461
|
+
} catch {
|
|
462
|
+
// One malformed address must not cost the page its whole capture.
|
|
463
|
+
continue;
|
|
464
|
+
}
|
|
465
|
+
const page = location.href.split('#')[0];
|
|
466
|
+
const fragment = link.getAttribute('href').trim().startsWith('#') || (target.hash && target.href.split('#')[0] === page);
|
|
467
|
+
if (target.protocol === 'javascript:' || fragment) continue;
|
|
468
|
+
if (linkObservations++ >= 32) {
|
|
469
|
+
if (!linksOmitted) push('observation', 'Additional link destinations omitted.');
|
|
470
|
+
linksOmitted = true;
|
|
471
|
+
break;
|
|
472
|
+
}
|
|
473
|
+
let row = link.parentElement;
|
|
474
|
+
while (row && row !== root && !clean(row.innerText ?? '')) row = row.parentElement;
|
|
475
|
+
const metadata = {
|
|
476
|
+
label: (nameOf(link) || drawn(link)).slice(0, 256) || null,
|
|
477
|
+
href: imageSource(link.href),
|
|
478
|
+
near: row ? textOf(row).replace(/\s+/g, ' ').slice(0, 256) || null : null,
|
|
479
|
+
};
|
|
480
|
+
push('observation', `Link element DOM metadata: ${JSON.stringify(metadata)}. The link has no text.`);
|
|
481
|
+
}
|
|
482
|
+
const h1 = [...root.querySelectorAll('h1,[role="heading"][aria-level="1"]')].filter(visible);
|
|
483
|
+
visibleH1 ||= h1.length > 0;
|
|
484
|
+
if (root === document.body) {
|
|
485
|
+
const doc = document.documentElement;
|
|
486
|
+
push('observation', `Observed viewport: Horizontal overflow: ${doc.scrollWidth > doc.clientWidth ? 'yes' : 'no'}`);
|
|
487
|
+
}
|
|
313
488
|
for (const e of root.querySelectorAll('*')) {
|
|
314
489
|
if (e.shadowRoot) scope(e.shadowRoot, frame, `${source}/shadow:${identity(e)}`);
|
|
315
490
|
// A frame the walk never reached (hidden, or inside a record or table read as one block) is still read.
|
|
@@ -319,5 +494,6 @@
|
|
|
319
494
|
}
|
|
320
495
|
|
|
321
496
|
scope(document.body, null, '');
|
|
322
|
-
|
|
323
|
-
}
|
|
497
|
+
if (!visibleH1) push('observation', 'Observed accessible DOM: Visible h1 headings: 0');
|
|
498
|
+
return { url: location.href, title: document.title, blocks, inaccessible_frames: inaccessible, visible_h1: visibleH1 };
|
|
499
|
+
})
|
|
@@ -3,7 +3,18 @@
|
|
|
3
3
|
// Adapted to fastbrowse's Control shape (role + operation set, not per-kind actions) and to traverse
|
|
4
4
|
// open shadow roots. Runs once per frame session (main frame or an OOPIF); the Python side merges frames.
|
|
5
5
|
// biome-ignore format: Python appends mode arguments to this parenthesized expression.
|
|
6
|
-
(mode => {
|
|
6
|
+
((mode, allowedOrigins = null) => {
|
|
7
|
+
const granted = doc => {
|
|
8
|
+
if (allowedOrigins === null) return true;
|
|
9
|
+
const frame = doc.defaultView.frameElement;
|
|
10
|
+
if (frame) {
|
|
11
|
+
if (frame.hasAttribute('srcdoc') || !frame.getAttribute('src')) return false;
|
|
12
|
+
const source = new URL(frame.src);
|
|
13
|
+
if (!['http:', 'https:'].includes(source.protocol) || !allowedOrigins.includes(source.origin)) return false;
|
|
14
|
+
}
|
|
15
|
+
const url = new URL(doc.URL);
|
|
16
|
+
return ['http:', 'https:'].includes(url.protocol) && allowedOrigins.includes(url.origin);
|
|
17
|
+
};
|
|
7
18
|
// Form context is repeated for each submit-capable input in safety prompts.
|
|
8
19
|
const FORM_TEXT_CHARS = 2000;
|
|
9
20
|
// Freshness guards bound comparison work on large cards; this text is never sent to a model.
|
|
@@ -30,6 +41,10 @@
|
|
|
30
41
|
return true;
|
|
31
42
|
};
|
|
32
43
|
const registry = (window.__fastbrowse ||= { ids: new WeakMap(), nodes: new Map(), next: 1 });
|
|
44
|
+
// Keep the page's own timer overrides from holding a browser-side frame wait open.
|
|
45
|
+
registry.nativeSetTimeout ||= window.setTimeout.bind(window);
|
|
46
|
+
registry.nativeClearTimeout ||= window.clearTimeout.bind(window);
|
|
47
|
+
registry.nativeRequestAnimationFrame ||= window.requestAnimationFrame.bind(window);
|
|
33
48
|
// What was offered to be acted on; `ids` also names hover targets and fingerprinted nodes.
|
|
34
49
|
registry.controls ||= new WeakSet();
|
|
35
50
|
// A visible loading indicator means the page is still fetching what it will draw, which network idle and a
|
|
@@ -117,7 +132,7 @@
|
|
|
117
132
|
} catch {
|
|
118
133
|
inner = null;
|
|
119
134
|
}
|
|
120
|
-
if (inner?.body) include(inner);
|
|
135
|
+
if (inner?.body && granted(inner)) include(inner);
|
|
121
136
|
}
|
|
122
137
|
}
|
|
123
138
|
};
|
|
@@ -165,10 +180,56 @@
|
|
|
165
180
|
registry.visible = visible;
|
|
166
181
|
// Styled checkboxes and radios often hide the native input. Its visible label is the click
|
|
167
182
|
// target, but the input still owns the checked/disabled state and must participate in freshness.
|
|
168
|
-
const sourceOf = e => (e.tagName === 'LABEL' && ['checkbox', 'radio'].includes(e.control?.type) ? e.control : e);
|
|
183
|
+
const sourceOf = e => (e.tagName === 'LABEL' && ['checkbox', 'radio', 'file'].includes(e.control?.type) ? e.control : e);
|
|
169
184
|
|
|
170
185
|
// Their text is code, not a name: Amazon nests a <style> inside a result card's link.
|
|
171
186
|
const CODE = new Set(['SCRIPT', 'STYLE', 'NOSCRIPT', 'TEMPLATE']);
|
|
187
|
+
// Icon-only buttons hide their glyph from accessibility text, but the icon library still names it: in a class,
|
|
188
|
+
// a sprite reference, a data attribute, an svg title or a ligature. A gallery's trash control otherwise reads
|
|
189
|
+
// "button" beside an edit link that reads "link", and the choice cannot tell removing an image from editing it.
|
|
190
|
+
const ICON_CLASS =
|
|
191
|
+
/^(?:icon-tabler|tabler-icon|lucide|glyphicon|fa|fas|far|bi|mdi|icon|ti|la|ri|ion|feather)-([a-z][a-z0-9-]{0,47})$/;
|
|
192
|
+
// Sizes, weights and effects share an icon library's prefix without naming a glyph, as does the library itself.
|
|
193
|
+
const ICON_STYLE =
|
|
194
|
+
/^(?:icon|tabler|solid|regular|light|thin|duotone|brands|xs|sm|lg|xl|\d+x|fw|spin|pulse|border|inverse|stack.*|pull-.*|rotate-.*|flip-.*)$/;
|
|
195
|
+
const glyphName = text => {
|
|
196
|
+
const name = (text || '').trim().toLowerCase().replace(/^(?:icons?|ico|ic|svg|sprite)[-_]/, '');
|
|
197
|
+
return /^[a-z][a-z0-9_-]{0,47}$/.test(name) && !ICON_STYLE.test(name) ? name : '';
|
|
198
|
+
};
|
|
199
|
+
const iconLabel = e => {
|
|
200
|
+
const glyphs = new Set();
|
|
201
|
+
for (const icon of e.querySelectorAll('svg,i,span,[role="img"]')) {
|
|
202
|
+
if (!icon.checkVisibility({ checkOpacity: true, checkVisibilityCSS: true })) continue;
|
|
203
|
+
const named = [];
|
|
204
|
+
for (const token of icon.classList) {
|
|
205
|
+
const glyph = ICON_CLASS.exec(token);
|
|
206
|
+
if (glyph && !ICON_STYLE.test(glyph[1])) named.push(glyph[1]);
|
|
207
|
+
}
|
|
208
|
+
const use = icon.localName === 'svg' && icon.querySelector('use');
|
|
209
|
+
const sprite = use && (use.getAttribute('href') || use.getAttribute('xlink:href') || '');
|
|
210
|
+
if (sprite) named.push(glyphName(sprite.split('#').pop().split('/').pop().replace(/\.svg$/, '')));
|
|
211
|
+
named.push(glyphName(icon.getAttribute('data-icon')));
|
|
212
|
+
// A ligature font draws the glyph its text names, and the icon is hidden so readers skip that word.
|
|
213
|
+
if ([...icon.classList].some(token => /^material-(?:icons|symbols)/.test(token))) {
|
|
214
|
+
named.push(glyphName(icon.textContent.replace(/\s+/g, '_')));
|
|
215
|
+
}
|
|
216
|
+
const title =
|
|
217
|
+
icon.getAttribute('aria-label') || (icon.localName === 'svg' ? icon.querySelector(':scope > title')?.textContent : '');
|
|
218
|
+
if (title?.trim()) glyphs.add(title.replace(/\s+/g, ' ').trim());
|
|
219
|
+
else for (const name of named) if (name) glyphs.add(`${name.replace(/[-_]/g, ' ')} icon`);
|
|
220
|
+
}
|
|
221
|
+
return glyphs.size === 1 ? [...glyphs][0] : '';
|
|
222
|
+
};
|
|
223
|
+
// An accessible description is what an unnamed control has left that says what it does.
|
|
224
|
+
const describedBy = e => {
|
|
225
|
+
const root = typeof e.getRootNode === 'function' ? e.getRootNode() : document;
|
|
226
|
+
return (e.getAttribute('aria-describedby') || '')
|
|
227
|
+
.split(/\s+/)
|
|
228
|
+
.map(id => (id && (root.getElementById ? root.getElementById(id) : document.getElementById(id))?.textContent) || '')
|
|
229
|
+
.map(text => text.replace(/\s+/g, ' ').trim())
|
|
230
|
+
.filter(Boolean)
|
|
231
|
+
.join(' ');
|
|
232
|
+
};
|
|
172
233
|
const labelOf = (e, seen = new Set()) => {
|
|
173
234
|
if (!e || seen.has(e)) return '';
|
|
174
235
|
seen.add(e);
|
|
@@ -233,7 +294,7 @@
|
|
|
233
294
|
const DATE_TYPES = ['date', 'datetime-local', 'month', 'week', 'time'];
|
|
234
295
|
|
|
235
296
|
const roleOf = e => {
|
|
236
|
-
if (sourceOf(e) !== e) return sourceOf(e).type;
|
|
297
|
+
if (sourceOf(e) !== e) return sourceOf(e).type === 'file' ? 'textbox' : sourceOf(e).type;
|
|
237
298
|
const explicit = e.getAttribute('role');
|
|
238
299
|
if (ARIA_ROLES.includes(explicit)) return explicit;
|
|
239
300
|
if (e.tagName === 'BUTTON' || e.tagName === 'SUMMARY') return 'button';
|
|
@@ -259,6 +320,7 @@
|
|
|
259
320
|
|
|
260
321
|
const submitSemantics = e => {
|
|
261
322
|
const form = e.form;
|
|
323
|
+
const explicit = ['BUTTON', 'INPUT'].includes(e.tagName) && ['submit', 'image'].includes(e.type);
|
|
262
324
|
const implicit = [
|
|
263
325
|
'text',
|
|
264
326
|
'search',
|
|
@@ -273,8 +335,8 @@
|
|
|
273
335
|
'datetime-local',
|
|
274
336
|
'number',
|
|
275
337
|
];
|
|
276
|
-
if (!form || e.tagName !== 'INPUT' || !implicit.includes(e.type)) return null;
|
|
277
|
-
const submit = [...form.getRootNode().querySelectorAll('button,input')].find(
|
|
338
|
+
if (!form || (!explicit && (e.tagName !== 'INPUT' || !implicit.includes(e.type)))) return null;
|
|
339
|
+
const submit = explicit ? e : [...form.getRootNode().querySelectorAll('button,input')].find(
|
|
278
340
|
c => c.form === form && (c.type === 'submit' || c.type === 'image'),
|
|
279
341
|
);
|
|
280
342
|
if (submit?.matches(':disabled')) return null;
|
|
@@ -311,7 +373,7 @@
|
|
|
311
373
|
} catch {
|
|
312
374
|
inner = null;
|
|
313
375
|
}
|
|
314
|
-
if (inner && inner.body) yield* walk(inner);
|
|
376
|
+
if (inner && inner.body && granted(inner)) yield* walk(inner);
|
|
315
377
|
else inaccessible++;
|
|
316
378
|
}
|
|
317
379
|
}
|
|
@@ -342,11 +404,15 @@
|
|
|
342
404
|
e.ownerDocument.location.origin,
|
|
343
405
|
e.ownerDocument.defaultView.performance.timeOrigin,
|
|
344
406
|
submitSemantics(e),
|
|
407
|
+
source.getAttribute('name'),
|
|
408
|
+
source.getAttribute('autocomplete'),
|
|
345
409
|
identity(source),
|
|
346
410
|
];
|
|
347
411
|
};
|
|
348
412
|
|
|
349
413
|
const controls = [];
|
|
414
|
+
// Controls with no accessible name of their own, whose label is an icon's or only their role.
|
|
415
|
+
const unnamed = new Set();
|
|
350
416
|
const fieldScope = e => {
|
|
351
417
|
if (!e.matches('input,textarea,select')) return null;
|
|
352
418
|
const form = e.form || e.closest('form,fieldset,[role="form"]');
|
|
@@ -376,16 +442,28 @@
|
|
|
376
442
|
rname = roleOf(e);
|
|
377
443
|
if (!rname || r.width <= 0 || r.height <= 0 || x < 0 || x >= innerWidth) continue;
|
|
378
444
|
if (rname === 'gridcell' && e.querySelector('button,[role="button"]')) continue;
|
|
445
|
+
// A button-styled link offers the same label twice, but its inner button owns every useful click point.
|
|
446
|
+
if (rname === 'link') {
|
|
447
|
+
const nested = e.querySelectorAll(SELECTOR);
|
|
448
|
+
const button = nested.length === 1 ? nested[0] : null;
|
|
449
|
+
if (button?.tagName === 'BUTTON' && visible(button) && !button.matches(':disabled') &&
|
|
450
|
+
!button.closest('[aria-disabled="true"],[inert]') && labelOf(button) === labelOf(e)) continue;
|
|
451
|
+
}
|
|
452
|
+
|
|
379
453
|
const id = identity(e);
|
|
380
454
|
registry.controls.add(e);
|
|
455
|
+
const named = labelOf(e);
|
|
456
|
+
if (!named) unnamed.add(id);
|
|
381
457
|
const base = {
|
|
382
458
|
id,
|
|
383
459
|
role: rname,
|
|
384
|
-
label:
|
|
460
|
+
label: named || iconLabel(e) || describedBy(e) || rname,
|
|
385
461
|
offscreen: y < 0 || y >= innerHeight,
|
|
386
462
|
distance: y < 0 || y >= innerHeight ? 1 + Math.abs(y - innerHeight / 2) : 0,
|
|
387
463
|
sensitive: secret(source),
|
|
388
464
|
input_type: source.type || null,
|
|
465
|
+
input_name: source.getAttribute('name') || null,
|
|
466
|
+
autocomplete: source.getAttribute('autocomplete') || null,
|
|
389
467
|
frame_origin: e.ownerDocument.location.origin,
|
|
390
468
|
frame_path: framePath(e.ownerDocument),
|
|
391
469
|
form_id: fieldScope(source),
|
|
@@ -396,6 +474,8 @@
|
|
|
396
474
|
base.href = u.origin === location.origin ? u.pathname + u.search : u.host + u.pathname;
|
|
397
475
|
// The page's own word for "the next page of this list", which survives an icon label or another language.
|
|
398
476
|
if (e.rel && e.rel.split(/\s+/).includes('next')) base.next_page = true;
|
|
477
|
+
// The page's own word that this link saves a file, which a task ruling out downloads has to see before a click.
|
|
478
|
+
if (e.hasAttribute('download')) base.download = true;
|
|
399
479
|
}
|
|
400
480
|
for (const key of ['checked', 'selected', 'expanded']) {
|
|
401
481
|
const value = e.getAttribute('aria-' + key);
|
|
@@ -419,7 +499,7 @@
|
|
|
419
499
|
base.operations = ['select'];
|
|
420
500
|
base.options = [...e.options].filter(o => !o.disabled && !o.closest('optgroup[disabled]')).map(o => o.label);
|
|
421
501
|
base.value = [...e.selectedOptions].map(o => o.label).join(', ');
|
|
422
|
-
} else if (
|
|
502
|
+
} else if (source.type === 'file') {
|
|
423
503
|
base.operations = ['upload'];
|
|
424
504
|
base.value = null;
|
|
425
505
|
} else {
|
|
@@ -496,6 +576,9 @@
|
|
|
496
576
|
for (let e = element.parentElement; e && e !== e.ownerDocument.body; e = e.parentElement) {
|
|
497
577
|
if (twins.some(twin => twin !== element && e.contains(twin))) break;
|
|
498
578
|
scope = e;
|
|
579
|
+
// A lone open reply has no form twin nearby; crossing its record picks a different commenter's name.
|
|
580
|
+
if (e.matches('article,li,[role="listitem"]') ||
|
|
581
|
+
(e.matches('tr,[role="row"]') && e.querySelector('input,textarea,select,[role="textbox"]'))) break;
|
|
499
582
|
}
|
|
500
583
|
return scope ? excerpt(nameOf(scope, element, label, nearest), CONTROL_CONTEXT_CHARS) : '';
|
|
501
584
|
};
|
|
@@ -503,7 +586,7 @@
|
|
|
503
586
|
// operation. An element whose hover rule would reveal something now hidden is offered as a hover target.
|
|
504
587
|
const docs = [];
|
|
505
588
|
const addDoc = doc => {
|
|
506
|
-
if (!doc?.body || docs.includes(doc)) return;
|
|
589
|
+
if (!doc?.body || !granted(doc) || docs.includes(doc)) return;
|
|
507
590
|
docs.push(doc);
|
|
508
591
|
for (const frame of doc.querySelectorAll('iframe,frame')) {
|
|
509
592
|
try {
|
|
@@ -581,12 +664,14 @@
|
|
|
581
664
|
} catch {
|
|
582
665
|
continue;
|
|
583
666
|
}
|
|
584
|
-
|
|
667
|
+
// Removing the active hover target renumbers its identical siblings and sends the next choice
|
|
668
|
+
// to a different element. Keep it offered while the pointer still reveals its content.
|
|
669
|
+
if (!hidden && !e.matches(':hover')) continue;
|
|
585
670
|
const image = e.querySelector('img[alt]');
|
|
586
671
|
const label =
|
|
587
|
-
firstLine(e.innerText) ||
|
|
588
672
|
e.getAttribute('aria-label') ||
|
|
589
673
|
image?.getAttribute('alt') ||
|
|
674
|
+
firstLine(e.innerText) ||
|
|
590
675
|
e.getAttribute('title') ||
|
|
591
676
|
e.tagName.toLowerCase();
|
|
592
677
|
const role =
|
|
@@ -664,6 +749,58 @@
|
|
|
664
749
|
}
|
|
665
750
|
}
|
|
666
751
|
|
|
752
|
+
// An unnamed control the page draws once per card, a trash icon beside each image, is a twin only while another
|
|
753
|
+
// card shows the same icon: one left alone, or drawn by another icon set, would name no card and leave the choice
|
|
754
|
+
// guessing which image it removes. It is named by the card that holds it, or failing one by the nearest ancestor
|
|
755
|
+
// that says anything besides it.
|
|
756
|
+
// A card is an item, a row, or an element the page repeats beside itself with the same tag and class.
|
|
757
|
+
const isCard = e =>
|
|
758
|
+
e.matches('article,li,[role="listitem"],tr,[role="row"]') ||
|
|
759
|
+
(!!e.getAttribute('class') &&
|
|
760
|
+
[...(e.parentElement?.children || [])].some(
|
|
761
|
+
s => s !== e && s.localName === e.localName && s.getAttribute('class') === e.getAttribute('class'),
|
|
762
|
+
));
|
|
763
|
+
const nearestName = (element, label) => {
|
|
764
|
+
let first = '';
|
|
765
|
+
for (let e = element.parentElement, path = element; e && e !== e.ownerDocument.body; path = e, e = e.parentElement) {
|
|
766
|
+
first ||= nameOf(e, element, label, true);
|
|
767
|
+
if (!isCard(e)) continue;
|
|
768
|
+
// An action row's own caption ("Actions") is the first text the climb meets and names no item, so the card
|
|
769
|
+
// is named by what it holds outside the row that holds the control.
|
|
770
|
+
const aria = (e.getAttribute('aria-label') || '').trim();
|
|
771
|
+
const outside = [...e.children]
|
|
772
|
+
.filter(child => child !== path)
|
|
773
|
+
.map(child => firstLine(child.innerText))
|
|
774
|
+
.find(line => line && line !== label && !repeated.has(line));
|
|
775
|
+
return excerpt((aria !== label && aria) || outside || first, CONTROL_CONTEXT_CHARS);
|
|
776
|
+
}
|
|
777
|
+
return excerpt(first, CONTROL_CONTEXT_CHARS);
|
|
778
|
+
};
|
|
779
|
+
for (const c of controls) {
|
|
780
|
+
if (!c.context && unnamed.has(c.id)) {
|
|
781
|
+
const context = nearestName(registry.nodes.get(c.id), c.label);
|
|
782
|
+
if (context) c.context = context;
|
|
783
|
+
}
|
|
784
|
+
}
|
|
785
|
+
|
|
786
|
+
// A revealed caption needs its actual container association, not merely the most recent action.
|
|
787
|
+
registry.hoverContexts = new WeakMap();
|
|
788
|
+
for (const c of controls) {
|
|
789
|
+
if (!c.operations.includes('hover')) continue;
|
|
790
|
+
const element = registry.nodes.get(c.id);
|
|
791
|
+
registry.hoverContexts.set(element, {
|
|
792
|
+
control: {
|
|
793
|
+
role: c.role, label: c.label,
|
|
794
|
+
context: /^\d+ of \d+$/.test(c.context || '') ? c.context : null,
|
|
795
|
+
},
|
|
796
|
+
labelAttributes: [element.getAttribute('role'), element.getAttribute('aria-label'),
|
|
797
|
+
element.getAttribute('title'), element.querySelector('img[alt]')?.getAttribute('alt') || null],
|
|
798
|
+
text: element.innerText,
|
|
799
|
+
parent: element.parentNode,
|
|
800
|
+
siblings: [...(element.parentNode?.children || [])],
|
|
801
|
+
});
|
|
802
|
+
}
|
|
803
|
+
|
|
667
804
|
// A date field's own label ("Start Date") rarely says which picker it belongs to when a page offers several
|
|
668
805
|
// ("Date Picker 3"): unlike a colliding label, a uniquely named one never reaches the loop above, so the
|
|
669
806
|
// nearest ancestor that names a section is attached here on its own, without the twin-avoidance `contextOf`
|
|
@@ -777,7 +914,7 @@
|
|
|
777
914
|
} catch {
|
|
778
915
|
inner = null;
|
|
779
916
|
}
|
|
780
|
-
if (!inner) continue;
|
|
917
|
+
if (!inner || !granted(inner)) continue;
|
|
781
918
|
const f = frame.getBoundingClientRect();
|
|
782
919
|
readText(inner, dx + f.left + frame.clientLeft, dy + f.top + frame.clientTop);
|
|
783
920
|
}
|
package/fastbrowse/_internal/{fastbrowse-0.5.19.dist-info → fastbrowse-0.5.23.dist-info}/METADATA
RENAMED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.5
|
|
2
2
|
Name: fastbrowse
|
|
3
|
-
Version: 0.5.
|
|
3
|
+
Version: 0.5.23
|
|
4
4
|
Summary: Browser automation with cited answers, structured output and code-controlled credentials and authorization.
|
|
5
5
|
Project-URL: Homepage, https://github.com/agent-labs-dev/fastbrowse
|
|
6
6
|
Project-URL: Repository, https://github.com/agent-labs-dev/fastbrowse
|
|
@@ -9,6 +9,7 @@ Author: Agent Labs
|
|
|
9
9
|
License-Expression: MIT
|
|
10
10
|
License-File: LICENSE
|
|
11
11
|
License-File: NOTICE
|
|
12
|
+
Classifier: Development Status :: 4 - Beta
|
|
12
13
|
Requires-Python: >=3.13
|
|
13
14
|
Requires-Dist: cdp-use==1.4.5
|
|
14
15
|
Requires-Dist: httpx[http2]<1,>=0.28
|
|
@@ -30,15 +31,15 @@ Description-Content-Type: text/markdown
|
|
|
30
31
|
<img src="assets/wordmark.svg" alt="fastbrowse" width="360">
|
|
31
32
|
</picture>
|
|
32
33
|
|
|
33
|
-
**
|
|
34
|
+
**Give your agent a browser.**
|
|
34
35
|
|
|
35
|
-
|
|
36
|
-
|
|
36
|
+
Run browser tasks locally or in a cloud browser. Jev chooses from indexed page controls; fastbrowse returns
|
|
37
|
+
cited answers or schema-validated data. Code controls credentials, authorization and spending.
|
|
37
38
|
|
|
38
39
|
[](https://pypi.org/project/fastbrowse/)
|
|
39
40
|

|
|
40
41
|

|
|
41
|
-

|
|
42
43
|
|
|
43
44
|
</div>
|
|
44
45
|
|
|
@@ -47,53 +48,26 @@ Jev chooses actions from the page; code controls credentials, authorization and
|
|
|
47
48
|
## Why
|
|
48
49
|
|
|
49
50
|
fastbrowse indexes the page into candidates and has [Jev](https://typesafe.ai), a choice model, **pick one**, so it cannot click
|
|
50
|
-
something that was never on the page.
|
|
51
|
+
something that was never on the page. Factual claims in a complete answer cite verbatim quotes from the page.
|
|
51
52
|
|
|
52
53
|

|
|
53
54
|
|
|
54
|
-
13 steps
|
|
55
|
+
Recorded on local Chrome: 13 steps, 20.9s and $0.0034 in model calls. Playback is 1.4x faster with pauses cut.
|
|
55
56
|
|
|
56
57
|
### Against Browser Use agents
|
|
57
58
|
|
|
58
|
-
The
|
|
59
|
-
|
|
60
|
-
Scored-run medians exclude earlier outage attempts. The [full audit](docs/validation/2026-09-29-final-comparison.md)
|
|
61
|
-
includes every retry, first-attempt outcomes, unknown charges and the two HN pairs excluded from both arms.
|
|
59
|
+
The [benchmark page](https://fastbrowse.ai/benchmarks) reports task success, speed and cost,
|
|
60
|
+
with methods and run summaries.
|
|
62
61
|
|
|
63
|
-
|
|
64
|
-
Measured on 2026-09-29 with the build released as 0.5.14: 6 tasks, 120 selected results across all arms, on cloud browsers.
|
|
62
|
+
Rows are comparable only at matching task versions. See [eval results and workflow](docs/evals.md).
|
|
65
63
|
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
| | runs passed | cost per scored run | median time |
|
|
69
|
-
|:--|:--|:--|:--|
|
|
70
|
-
| fastbrowse | 58/58 | $0.0026 (median), $0.0031 mean | 8.3s |
|
|
71
|
-
| Browser Use Ultrafast | 38/58 | $0.0014 (median), $0.0129 mean | 12.9s |
|
|
72
|
-
|
|
73
|
-
Each arm has 60 selected results, excluding earlier retries. Unavailable results: 2 for fastbrowse. Each arm is scored on the same 58: an attempt one arm lost is dropped for every arm at that task.
|
|
74
|
-
|
|
75
|
-
### Controlled mock-site comparison
|
|
76
|
-
|
|
77
|
-
Release 0.5.9, 2026-09-28. Real agents and browsers on controlled fixture sites, separate from live-web results.
|
|
78
|
-
|
|
79
|
-
| Suite | Agent | Passed | Median cost | Median time |
|
|
80
|
-
|:--|:--|:--|:--|:--|
|
|
81
|
-
| `mock-completion` | fastbrowse | 53/54 | $0.0065 | 22.8s |
|
|
82
|
-
| `mock-completion` | Browser Use agent | 54/54 | $0.2543 | 46.0s |
|
|
83
|
-
| `mock-safety` | fastbrowse | 6/6 | $0.0103 | 30.4s |
|
|
84
|
-
| `mock-safety` | Browser Use agent | 6/6 | $0.5763 | 85.7s |
|
|
85
|
-
|
|
86
|
-
Confirmation gates are scored separately from task completion. Verified transient attempts are retried
|
|
87
|
-
and excluded from scores; genuine agent failures remain. Full protocol and attempt records are in
|
|
88
|
-
[docs/evals.md](docs/evals.md#stateful-mock-comparison).
|
|
89
|
-
<!-- /evals:headline -->
|
|
90
|
-
|
|
91
|
-
Compare rows only at matching task versions. See [eval results and workflow](docs/evals.md).
|
|
64
|
+
Recorded results - the published head-to-head, the matched-task campaigns and the browser cost basis - are
|
|
65
|
+
summarised with their limitations in [benchmark results](docs/benchmarks.md).
|
|
92
66
|
|
|
93
67
|
### Why fastbrowse, against each kind of agent
|
|
94
68
|
|
|
95
69
|
- **LLM agents that generate actions** (the hosted Browser Use agent and similar): Jev picks each action from the controls
|
|
96
|
-
that are on the page, so there is no invented selector to retry.
|
|
70
|
+
that are on the page, so there is no invented selector to retry. Factual claims in a complete answer link to the page text they came from.
|
|
97
71
|
- **Choice-model navigators** ([Browser Use Ultrafast](https://github.com/browser-use/jev-ultrafast), the
|
|
98
72
|
`jev-ultrafast` package and eval arm): both choose actions with Jev. fastbrowse adds cited answers, schema-validated
|
|
99
73
|
data, scoped credentials and an authorization gate. Navigation tasks compare the page each run ended on.
|
|
@@ -113,19 +87,24 @@ A Node program needs neither uv nor Python: see [Use it from JavaScript](#use-it
|
|
|
113
87
|
|
|
114
88
|
```sh
|
|
115
89
|
export OPENROUTER_API_KEY=... # for Jev and the LLM that plans and reads
|
|
116
|
-
# export AI_GATEWAY_API_KEY=... #
|
|
90
|
+
# export AI_GATEWAY_API_KEY=... # Vercel AI Gateway: Jev backup, and the LLM when OPENROUTER_API_KEY is unset
|
|
117
91
|
export BROWSER_USE_API_KEY=... # the cloud browser; or pass --local to use Chrome
|
|
118
92
|
uvx fastbrowse "What is the title of the top story right now?" --start https://news.ycombinator.com/
|
|
119
93
|
```
|
|
120
94
|
|
|
121
95
|
Jev uses direct TypeSafe when `TYPESAFE_API_KEY` is supplied; otherwise OpenRouter is primary.
|
|
122
96
|
Vercel AI Gateway is supported as a backup or an explicit primary. `FASTBROWSE_JEV_SOURCE` overrides
|
|
123
|
-
automatic selection; see [provider routing](docs/jev.md#provider-failover). The LLM uses OpenRouter
|
|
97
|
+
automatic selection; see [provider routing](docs/jev.md#provider-failover). The LLM uses OpenRouter when
|
|
98
|
+
`OPENROUTER_API_KEY` is set, otherwise the Vercel AI Gateway's OpenAI-compatible chat completions.
|
|
124
99
|
|
|
125
100
|
`uvx` runs the published package in an isolated cached environment. `uv tool install fastbrowse` keeps it on your
|
|
126
101
|
PATH, and `uv add fastbrowse` puts it in a project. Service keys can live in a `.env` file in the working directory;
|
|
127
102
|
[`.env.example`](.env.example) shows the settings. Values named by `--secret` must be in the process environment.
|
|
128
103
|
|
|
104
|
+
On Arch Linux, the [`fastbrowse` AUR package](https://aur.archlinux.org/packages/fastbrowse) puts `fastbrowse` and
|
|
105
|
+
`fastbrowse-mcp` on your PATH from the same PyPI release: `paru -S fastbrowse` with an AUR helper, or `makepkg -si`
|
|
106
|
+
in [`packaging/aur`](packaging/aur).
|
|
107
|
+
|
|
129
108
|
To work on fastbrowse itself:
|
|
130
109
|
|
|
131
110
|
```sh
|
|
@@ -150,14 +129,16 @@ the quotes behind the answer, and cost by component.
|
|
|
150
129
|
| `--cdp-port PORT` | the same, for a browser or Electron app listening on `127.0.0.1:PORT`; the URL is read from its `/json/version` |
|
|
151
130
|
| `--attach` | with `--cdp-url` or `--cdp-port`, drive a window already open instead of opening a tab, and leave it open after the run |
|
|
152
131
|
| `--target-match TEXT` | attach to the first window whose title or URL contains `TEXT` (implies `--attach`) |
|
|
132
|
+
| `--foreground` | bring the run's tab and its browser window to the front, to watch it. Without it a run in a visible window works behind the window's current tab; see [working in the background](#working-in-the-background) for what Chrome still brings forward |
|
|
153
133
|
| `--proxy-country CC` | browse from that country (Browser Use's codes: `uk`, `de`, ...; default `us`), so a shop shows its local delivery and prices |
|
|
154
134
|
| `--authorize` | allow submit, pay, delete and send; without it the run stops at `needs_confirmation` first |
|
|
155
|
-
| `--secret NAME=ENV_VAR[@ORIGIN]` | let the agent type `$ENV_VAR` on the declared origin, or the `--start` origin if omitted; models only see `NAME`. An explicit origin needs no `--start` |
|
|
135
|
+
| `--secret NAME=ENV_VAR[@ORIGIN]` | let the agent type `$ENV_VAR` on the declared origin (`https://*.site.com` covers its hosts), or the `--start` origin if omitted; models only see `NAME`. An explicit origin needs no `--start` |
|
|
156
136
|
| `--bitwarden ITEM` | match the vault login's saved URIs against `--start`, then allow its `username`, `password` and, when the item holds an authenticator key, `one_time_code` only on that start origin |
|
|
157
|
-
| `--max-steps N`, `--max-dollars N` | bound steps and model spend; defaults are
|
|
137
|
+
| `--max-steps N`, `--max-dollars N` | optionally bound steps and model spend; defaults are unlimited. Cloud browser charges are added when it stops |
|
|
158
138
|
| `--downloads DIR` | keep downloaded files |
|
|
159
139
|
| `--json` | full result instead of the answer |
|
|
160
140
|
| `--record FILE` | save an MP4 of the tab, each step captioned, ending on the answer, time and cost (needs `ffmpeg`; the captions need its libass), e.g. `recordings/demo.mp4`, which git ignores; `demo.plain.mp4` beside it has no captions. It shows what the pages showed, so watch it before sharing |
|
|
141
|
+
| `--cursor` | draw the agent's cursor over a visible Chrome (`--headed`, or one you attach to) with [Cua Driver](https://github.com/trycua/cua), so you can watch where it acts. Off by default. It needs `cua-driver` on `PATH` and an X11 display on Linux, and does nothing without them. It brings the run's tab to the front as `--foreground` does, since the cursor lies over the window's front tab |
|
|
161
142
|
|
|
162
143
|
```sh
|
|
163
144
|
export SAUCE_PASSWORD=secret_sauce
|
|
@@ -175,6 +156,9 @@ uv run fastbrowse "Add a UGREEN USB-A to USB-C cable, 2m, to my cart." \
|
|
|
175
156
|
--start https://www.amazon.com/ --profile ~/.fastbrowse/amazon --headed
|
|
176
157
|
```
|
|
177
158
|
|
|
159
|
+
`--headed` is not needed once the profile is signed in: without it the run is headless and opens no window. A
|
|
160
|
+
headed run opens one window when Chrome starts and then works in a tab behind it.
|
|
161
|
+
|
|
178
162
|
On a cloud browser the profile lives on the [Browser Use Cloud](https://cloud.browser-use.com) account
|
|
179
163
|
rather than on disk, and `--cloud-profile ID` runs as it. Whoever signed that profile in did so once, in a
|
|
180
164
|
browser of their own; the run inherits the cookies and no model is shown a credential:
|
|
@@ -217,21 +201,54 @@ the text; `--attach` alone takes the first window, skipping DevTools. Popups the
|
|
|
217
201
|
Windows that nothing opened, as an Electron app's main process opens them, join only with `--target-match`,
|
|
218
202
|
because in a browser such a window could equally be a tab you opened yourself.
|
|
219
203
|
|
|
204
|
+
### Working in the background
|
|
205
|
+
|
|
206
|
+
A run in a visible window, whether `--headed` or a browser you handed over, opens its tab behind the window's
|
|
207
|
+
current tab and renders it there under focus emulation. Nothing the run sends raises or focuses the window. `--foreground` brings the tab and its window to the front instead, to watch the run. Three things
|
|
208
|
+
are Chrome's own and still bring a window forward: a headed Chrome showing its window as it starts, a popup a
|
|
209
|
+
page opens, and, with `--attach`, a click in a window's front tab.
|
|
210
|
+
|
|
211
|
+
For a signed-in browser that stays open and asks for nothing, start a Chrome of its own once, sign in, and leave
|
|
212
|
+
it running. Each run then opens and closes a background tab in it:
|
|
213
|
+
|
|
214
|
+
```sh
|
|
215
|
+
google-chrome --remote-debugging-port=9222 --user-data-dir="$HOME/.fastbrowse/chrome"
|
|
216
|
+
uv run fastbrowse "Report my open orders." --start https://www.amazon.com/ --cdp-port 9222
|
|
217
|
+
```
|
|
218
|
+
|
|
219
|
+
Chrome ignores `--remote-debugging-port` on its default data directory, so the directory of its own is required.
|
|
220
|
+
Your everyday Chrome can instead offer an endpoint from `chrome://inspect/#remote-debugging`, but it then stops
|
|
221
|
+
every connection on an "Allow remote debugging?" prompt that takes focus and waits for a click, once per run.
|
|
222
|
+
Any program on the machine can drive a browser that listens on a debugging port.
|
|
223
|
+
|
|
220
224
|
A DevTools port gives any program on the machine full control of the app, including its signed-in sessions.
|
|
221
225
|
Close the app, or restart it without the flag, when you are done.
|
|
222
226
|
|
|
223
227
|
### Models
|
|
224
228
|
|
|
225
229
|
The LLM defaults to `google/gemini-3.8-flash` at low reasoning effort, with
|
|
226
|
-
`google/gemini-3.5-flash-lite` for
|
|
230
|
+
`google/gemini-3.5-flash-lite` for proposing a direct address and typing field text.
|
|
227
231
|
Override with `FASTBROWSE_LLM_MODEL` (every purpose), `FASTBROWSE_LLM_MODEL_<PURPOSE>` (`PLAN`,
|
|
228
232
|
`READ`, `FIELD_TEXT`, `SHORTCUT`, `RECOVER`, `COMPOSE`, `VERIFY`) and `FASTBROWSE_LLM_REASONING`
|
|
229
233
|
(`low`, `medium`, `high`).
|
|
230
234
|
|
|
235
|
+
`FASTBROWSE_LLM_BASE_URL` sends every LLM call to another OpenAI-compatible chat completions endpoint instead
|
|
236
|
+
of OpenRouter and the gateway, such as a local vLLM server at `http://127.0.0.1:8000/v1`. Name the model it
|
|
237
|
+
serves with `FASTBROWSE_LLM_MODEL`; a run refuses to start without one, and a blank value counts as unset.
|
|
238
|
+
OpenRouter's `reasoning` field is sent to that endpoint only when `FASTBROWSE_LLM_REASONING` is set, since a
|
|
239
|
+
strict server refuses a field it does not know. `FASTBROWSE_LLM_API_KEY` is its bearer
|
|
240
|
+
token, sent only when set, and that endpoint never receives `OPENROUTER_API_KEY` or `AI_GATEWAY_API_KEY`. Two
|
|
241
|
+
settings shape its requests: `FASTBROWSE_LLM_STRICT=0` sends each schema as the model defines it rather than
|
|
242
|
+
in strict form, for a server whose structured output breaks under strict mode, and `FASTBROWSE_LLM_EXTRA_BODY`
|
|
243
|
+
is a JSON object added to every request, such as `{"chat_template_kwargs":{"enable_thinking":false}}`. It may
|
|
244
|
+
not set a field fastbrowse sends itself (`model`, `messages`, `max_tokens`, `response_format`, `provider`,
|
|
245
|
+
`reasoning`). Jev still needs its own key. An endpoint that reports no price leaves those calls' cost unknown
|
|
246
|
+
rather than zero, so `--max-dollars` stops the run after the first such call.
|
|
247
|
+
|
|
231
248
|
### Results
|
|
232
249
|
|
|
233
250
|
The exit code identifies the run status. Configuration refusals exit 1; invalid command syntax exits 2.
|
|
234
|
-
|
|
251
|
+
Only `complete` exits 0, so a program can also check zero versus nonzero.
|
|
235
252
|
|
|
236
253
|
| Status | Exit | Meaning |
|
|
237
254
|
|:--|--:|:--|
|
|
@@ -242,14 +259,30 @@ Programs that only check zero versus nonzero continue to work.
|
|
|
242
259
|
| `blocked` | 6 | a bot check (a CAPTCHA) that did not clear; not a sign-in, so no secret passes it |
|
|
243
260
|
| `needs_input` | 5 | a required value or file is missing, or an upload exceeds the configured size limit |
|
|
244
261
|
| `stuck` | 9 | recovery ran out without reaching a page state the run had not seen |
|
|
245
|
-
| `budget_exceeded` | 7 | a
|
|
262
|
+
| `budget_exceeded` | 7 | a resource limit was reached, or a missing cost prevents enforcing the dollar cap |
|
|
246
263
|
| `observation_limit` | 11 | the page or required evidence cannot fit the configured prompt budget |
|
|
247
264
|
| `unavailable` | 8 | a model or browser provider stayed unavailable through every retry; the same run later may pass |
|
|
248
265
|
| `error` | 1 | a model or browser failure |
|
|
249
266
|
|
|
250
267
|
A `budget_exceeded` JSON result includes `budget.resource` (`steps`, `seconds`, `dollars`, `jev_calls` or
|
|
251
268
|
`llm_calls`) and `budget.limit`. Other results have `budget: null`. The embedding and MCP APIs carry the same
|
|
252
|
-
optional object, so callers can identify the
|
|
269
|
+
optional object, so callers can identify the affected limit without parsing an error message.
|
|
270
|
+
|
|
271
|
+
A bot check can block a cloud browser before any task steps execute. Retry with an attached local browser
|
|
272
|
+
or choose another source for the research. Credentials do not resolve a CAPTCHA.
|
|
273
|
+
The `blocked` result's `evidence` quotes the opening text of the challenge page, with its address.
|
|
274
|
+
|
|
275
|
+
If a model completion omits its cost, fastbrowse looks up the matching OpenRouter or Vercel generation
|
|
276
|
+
receipt. If that receipt is still unavailable, a run with a dollar cap stops with `budget_exceeded`;
|
|
277
|
+
this does not mean the known charges reached the cap. Supplied `inputs` can provide exact field text
|
|
278
|
+
without a field-writing generation.
|
|
279
|
+
|
|
280
|
+
A browser command that remains unanswered for 120 seconds returns `unavailable`, even if the browser
|
|
281
|
+
still answers health checks. Step and dollar limits do not bound total elapsed time. Set `Limits(max_seconds=...)` for a run deadline.
|
|
282
|
+
When investigating a stalled run, enable trace logging and retain the last event alongside the result.
|
|
283
|
+
|
|
284
|
+
[Troubleshooting](docs/troubleshooting.md) takes each status a run can stop with: what it means, its common
|
|
285
|
+
causes and the next step.
|
|
253
286
|
|
|
254
287
|
## How it works
|
|
255
288
|
|
|
@@ -315,7 +348,8 @@ asyncio.run(main())
|
|
|
315
348
|
the run opens its own tab and closes the tabs it owns. It leaves the browser and pre-existing tabs open;
|
|
316
349
|
cookies and other changes made by the task can persist. `cdp_port=` finds the same browser from its DevTools
|
|
317
350
|
port on `127.0.0.1`. `attach=True` and `target_match=` drive a window already open and leave it open, as
|
|
318
|
-
`--attach` and `--target-match` do
|
|
351
|
+
`--attach` and `--target-match` do, and `foreground=True` brings the run's tab to the front as `--foreground`
|
|
352
|
+
does. Pass `browser_api_key=` to start a cloud browser;
|
|
319
353
|
with neither argument, it runs local Chrome. Passing both is an error. `cloud_extensions=[...]` loads up to
|
|
320
354
|
three of your account's ready Browser Use Cloud extensions, by ID, into that cloud browser; it is an error with
|
|
321
355
|
local or attached browsers.
|
|
@@ -334,6 +368,17 @@ async def main() -> None:
|
|
|
334
368
|
|
|
335
369
|
`resolve_cdp_port(port)` returns the `ws://` URL behind a DevTools port, for a caller that passes `cdp_url=`.
|
|
336
370
|
|
|
371
|
+
`run_task` and `connect_cdp` take two access controls that the command line, the MCP server and the JavaScript
|
|
372
|
+
SDK do not offer. `allowed_origins=["https://app.example.com"]` limits the documents a run may read and control
|
|
373
|
+
to those exact origins: scheme, host and optional port, with no wildcard or path. A navigation, redirect, frame
|
|
374
|
+
or popup outside the list is refused before it is sent, and a window already open outside it is never read.
|
|
375
|
+
A sign-in that redirects through another origin needs that origin listed too. The list scopes documents, not
|
|
376
|
+
the network: images, scripts and requests a listed page makes to other hosts still load. A scoped run delivers
|
|
377
|
+
no live frames or screenshots, and combining it with `record=` is an error. `check_access=` is an async callback
|
|
378
|
+
awaited before the browser starts and before every read, navigation and action. When it raises, `connect_cdp`
|
|
379
|
+
raises a `BrowserError` and `run_task` returns a `RunResult` with status `error`; either way the error names only
|
|
380
|
+
the exception's type, never its text.
|
|
381
|
+
|
|
337
382
|
`RunResult.citations` is a tuple of `Citation` objects, also importable from `fastbrowse`. Each has `id`
|
|
338
383
|
(the number in the answer), `text` (the Notes fact), `requirement_id` (or `None`), `url`, `quote` and
|
|
339
384
|
`deep_link`. Each claim in `result.answer` carries numbered Markdown links to its supporting facts.
|
|
@@ -373,7 +418,7 @@ package is a TypeScript SDK with no dependencies. With it npm installs one of fi
|
|
|
373
418
|
as a native binary, the one for your platform: `@fastbrowse/darwin-arm64`, `darwin-x64`, `linux-arm64`,
|
|
374
419
|
`linux-x64` or `win32-x64`. No install script runs and nothing is downloaded on first use, so it installs under
|
|
375
420
|
pnpm and bun with scripts blocked, and it starts offline. It needs Node.js 20 or newer. The npm packages carry
|
|
376
|
-
the PyPI package's version and are published by the same tag
|
|
421
|
+
the PyPI package's version and are published by the same tag.
|
|
377
422
|
|
|
378
423
|
The binary brings no browser. It finds local Chrome, starts a Browser Use Cloud browser, or drives one already
|
|
379
424
|
running, as the command line does. It reads the same keys from the environment of your process
|
|
@@ -428,7 +473,7 @@ the options are camelCase. The structured data is in `data`, as in Python. The S
|
|
|
428
473
|
schema's output type. Data the schema refuses rejects with `OutputValidationError`, which carries the result.
|
|
429
474
|
A JSON Schema object is accepted too; `output` is then `data`, typed `unknown`.
|
|
430
475
|
|
|
431
|
-
What a run can fill
|
|
476
|
+
What a run can fill is narrower than what a schema can say. The server accepts nested objects, arrays,
|
|
432
477
|
`enum`, `const` and optional values, and refuses any other keyword by name before a browser opens. A run
|
|
433
478
|
fills a flat object of required string, number, integer and boolean fields, as the example has. With any
|
|
434
479
|
other field the run ends `unverified` with no data.
|
|
@@ -449,11 +494,11 @@ await fb.run('Log in as standard_user with the saved password and add the backpa
|
|
|
449
494
|
|
|
450
495
|
`secrets.resolve` is called each time a value is typed and never for an origin its ref does not cover, so the
|
|
451
496
|
value leaves your vault at that moment and a one-time code is fresh. `until` gets the address the run ended on,
|
|
452
|
-
and anything but `true` keeps the run from `complete`. `onFrame` receives JPEG frames of the active tab;
|
|
453
|
-
|
|
454
|
-
|
|
455
|
-
`
|
|
456
|
-
`Fastbrowse.start` also takes as defaults for every run. A run passes `null` for one of them to go without
|
|
497
|
+
and anything but `true` keeps the run from `complete`. `onFrame` receives JPEG frames of the active tab; without
|
|
498
|
+
it no frame is sent. A callback that throws ends the run with status `error`. The other options are `run_task`'s:
|
|
499
|
+
`inputs`, `attachments` as bytes, `downloads`, `record`, and the browser choices `chrome`, `cloudProfile`,
|
|
500
|
+
`cdpUrl`, `cdpPort`, `attach`, `targetMatch`, `foreground`, `proxyCountry`, `viewport` and `cloudAllowResizing`,
|
|
501
|
+
which `Fastbrowse.start` also takes as defaults for every run. A run passes `null` for one of them to go without
|
|
457
502
|
that default.
|
|
458
503
|
|
|
459
504
|
There is no binary for Alpine or another musl system, and none for a platform outside the five. There
|
|
@@ -462,6 +507,18 @@ There is no binary for Alpine or another musl system, and none for a platform ou
|
|
|
462
507
|
that allows programs only by the team that signed them refuses. The Windows binary is not signed. Bun and Deno are
|
|
463
508
|
untested.
|
|
464
509
|
|
|
510
|
+
## Use fastbrowse with your agent
|
|
511
|
+
|
|
512
|
+
Install the [fastbrowse skill](docs/skill.md) to delegate website tasks from an agent that supports Agent Skills. It uses the MCP
|
|
513
|
+
tool when connected, or the CLI, and preserves the task's citations, status, authorization, and budget.
|
|
514
|
+
The guide covers agent selection, configuration, and example prompts for Codex and Claude Code.
|
|
515
|
+
Agents without skill support can use the CLI or MCP server directly.
|
|
516
|
+
|
|
517
|
+
## Run fastbrowse through Nebula
|
|
518
|
+
|
|
519
|
+
[Nebula](https://www.nebula.gg/) embeds fastbrowse in its agent harness, with browser steps, results and
|
|
520
|
+
permitted vault logins. Use Bitwarden or 1Password through Nebula with credentials scoped to authorized websites.
|
|
521
|
+
|
|
465
522
|
## Use it from an MCP client
|
|
466
523
|
|
|
467
524
|
`fastbrowse-mcp` serves one `browse` tool over [MCP](https://modelcontextprotocol.io), so Claude Code, Claude
|
|
@@ -493,12 +550,12 @@ The server's flags decide what a calling model may do; a call can ask for less,
|
|
|
493
550
|
|
|
494
551
|
| Flag | Effect |
|
|
495
552
|
|:--|:--|
|
|
496
|
-
| `--local`, `--headed`, `--profile DIR`, `--downloads DIR` | as for the CLI, fixed for every call |
|
|
553
|
+
| `--local`, `--headed`, `--profile DIR`, `--foreground`, `--downloads DIR`, `--cursor` | as for the CLI, fixed for every call |
|
|
497
554
|
| `--cloud-profile ID` | every call runs signed in as that cloud profile; a calling model cannot choose it |
|
|
498
555
|
| `--allow-authorize` | let a call pass `authorize` to go through irreversible actions; without it they always stop at `needs_confirmation` |
|
|
499
556
|
| `--secret NAME=ENV_VAR@ORIGIN` | typed when a call's start page is on `ORIGIN` (`https://*.site.com` covers its hosts); the model sees `NAME` only |
|
|
500
557
|
| `--bitwarden ITEM` | a vault login a call may name in `bitwarden` |
|
|
501
|
-
| `--max-steps N`, `--max-dollars N`, `--max-seconds N` | ceilings per call
|
|
558
|
+
| `--max-steps N`, `--max-dollars N`, `--max-seconds N` | optional ceilings per call; defaults are unlimited |
|
|
502
559
|
| `--max-concurrent N` | runs at once, default 1; more calls wait their turn |
|
|
503
560
|
| `--transport http`, `--host`, `--port` | streamable HTTP at `/mcp` instead of stdio |
|
|
504
561
|
|
|
@@ -521,9 +578,9 @@ seconds to minutes, so raise the client's tool timeout if it has one (`MCP_TOOL_
|
|
|
521
578
|
|
|
522
579
|
## Evals and development
|
|
523
580
|
|
|
524
|
-
|
|
581
|
+
[Eval commands](docs/evals.md) cover running suites, publishing results and regenerating evidence. Final page checks
|
|
525
582
|
require evidence read by the harness, and a pass also requires the task's expected ending. Published rows carry
|
|
526
|
-
build and task versions; the [
|
|
583
|
+
build and task versions; the [compact results feed](docs/results/summary.json) is generated from them.
|
|
527
584
|
|
|
528
585
|
See [CONTRIBUTING.md](CONTRIBUTING.md) for setup and checks, [docs/design.md](docs/design.md) for the browser
|
|
529
586
|
layer, and [docs/jev.md](docs/jev.md) for the Jev assumptions checked against Typesafe's documentation.
|
|
@@ -0,0 +1,14 @@
|
|
|
1
|
+
../../../bin/fastbrowse,sha256=k_VHUJGUyyMLHeLCiSHLkBTLNP1m86q6e4YkyAHdlZM,337
|
|
2
|
+
../../../bin/fastbrowse-mcp,sha256=1CAFjtHrbGFIoAzljudiwUZ-Qrz8WFq5CLTPaDEvGRE,344
|
|
3
|
+
_editable_impl_fastbrowse.pth,sha256=Ft6bxdkTegROr5p9hZSxsnUSfEHrZYCfXWKjtpA4_DU,44
|
|
4
|
+
fastbrowse-0.5.23.dist-info/INSTALLER,sha256=5hhM4Q4mYTT9z6QB6PGpUAW81PGNFrYrdXMj4oM_6ak,2
|
|
5
|
+
fastbrowse-0.5.23.dist-info/METADATA,sha256=_tHtsDCpIq1LtYvyl1hsqA-XHnZB86idiCg6iqO6IMw,36424
|
|
6
|
+
fastbrowse-0.5.23.dist-info/RECORD,,
|
|
7
|
+
fastbrowse-0.5.23.dist-info/REQUESTED,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
|
|
8
|
+
fastbrowse-0.5.23.dist-info/WHEEL,sha256=W3fkpkm7-wf9vBI5Z-7s0eWkeM-spu78I8Neb98DeEg,87
|
|
9
|
+
fastbrowse-0.5.23.dist-info/direct_url.json,sha256=cZGZY9ovdib3X-RhjHdlyO_0_EMUe0tW6AXsSyMbNE4,86
|
|
10
|
+
fastbrowse-0.5.23.dist-info/entry_points.txt,sha256=SlQAK1_4_oywTAMckx8BYuCQJva0F-QuShQPcxtUm-g,95
|
|
11
|
+
fastbrowse-0.5.23.dist-info/licenses/LICENSE,sha256=gcUazG_j5a2blKPmfKqRKE98KOqX2kPbWY-EOtQZiNU,1067
|
|
12
|
+
fastbrowse-0.5.23.dist-info/licenses/NOTICE,sha256=qEO0Az_Tk98vRVL0xjnGYgMvexlMCA92lWl4JBwYRzg,1854
|
|
13
|
+
fastbrowse-0.5.23.dist-info/uv_build.json,sha256=RBNvo1WzZ4oRRq0W9-hknpT7T8If536DEMBg9hyq_4o,2
|
|
14
|
+
fastbrowse-0.5.23.dist-info/uv_cache.json,sha256=RHhZDSEpPRq1C_MkD2kEEppnHuQ4UybkBcfjm8erpLo,194
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"timestamp":{"secs_since_epoch":1792329370,"nanos_since_epoch":625328192},"commit":null,"tags":null,"env":{},"directories":{"src":{"secs_since_epoch":1792329370,"nanos_since_epoch":634544741}}}
|
|
Binary file
|
package/fastbrowse/_internal/{websockets-17.1.dist-info → websockets-17.2.dist-info}/METADATA
RENAMED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.4
|
|
2
2
|
Name: websockets
|
|
3
|
-
Version: 17.
|
|
3
|
+
Version: 17.2
|
|
4
4
|
Summary: An implementation of the WebSocket Protocol (RFC 6455 & 7692)
|
|
5
5
|
Author-email: Aymeric Augustin <aymeric.augustin@m4x.org>
|
|
6
6
|
License-Expression: BSD-3-Clause
|
|
@@ -143,7 +143,7 @@ Why shouldn't I use ``websockets``?
|
|
|
143
143
|
that build on top of ``websockets`` to support WebSocket connections, like
|
|
144
144
|
uvicorn_ or Sanic_.
|
|
145
145
|
|
|
146
|
-
.. _uvicorn: https://
|
|
146
|
+
.. _uvicorn: https://uvicorn.dev/
|
|
147
147
|
.. _Sanic: https://sanic.dev/en/
|
|
148
148
|
|
|
149
149
|
What else?
|
|
@@ -1,20 +1,20 @@
|
|
|
1
1
|
../../../bin/websockets,sha256=DVn8K_GAmc_NaykY80NWs5Rqx1AeOpPtmypr9Uuy4GQ,337
|
|
2
|
-
websockets-17.
|
|
3
|
-
websockets-17.
|
|
4
|
-
websockets-17.
|
|
5
|
-
websockets-17.
|
|
6
|
-
websockets-17.
|
|
7
|
-
websockets-17.
|
|
8
|
-
websockets-17.
|
|
9
|
-
websockets-17.
|
|
2
|
+
websockets-17.2.dist-info/INSTALLER,sha256=5hhM4Q4mYTT9z6QB6PGpUAW81PGNFrYrdXMj4oM_6ak,2
|
|
3
|
+
websockets-17.2.dist-info/METADATA,sha256=DV8SedCV57qE7yFhSvCCLcUAzLLlG3w6Ge2nFZstreQ,6336
|
|
4
|
+
websockets-17.2.dist-info/RECORD,,
|
|
5
|
+
websockets-17.2.dist-info/REQUESTED,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
|
|
6
|
+
websockets-17.2.dist-info/WHEEL,sha256=j0ILfIDPYGKSxi38ezJw4f9sIlVrNBnqDQjDYMVPBMs,138
|
|
7
|
+
websockets-17.2.dist-info/entry_points.txt,sha256=Dnhn4dm5EsI4ZMAsHldGF6CwBXZrGXnR7cnK2-XR7zY,51
|
|
8
|
+
websockets-17.2.dist-info/licenses/LICENSE,sha256=PWoMBQ2L7FL6utUC5F-yW9ArytvXDeo01Ee2oP9Obag,1514
|
|
9
|
+
websockets-17.2.dist-info/top_level.txt,sha256=CMpdKklxKsvZgCgyltxUWOHibZXZ1uYIVpca9xsQ8Hk,11
|
|
10
10
|
websockets/__init__.py,sha256=zmBCJj7EIa6ZJEQDp4dIdznfzAJUA8dW41-j7Em-QDQ,7450
|
|
11
11
|
websockets/__main__.py,sha256=wu5N2wk8mvBgyvr2ghmQf4prezAe0_i-p123VVreyYc,62
|
|
12
12
|
websockets/asyncio/__init__.py,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
|
|
13
|
-
websockets/asyncio/client.py,sha256=
|
|
13
|
+
websockets/asyncio/client.py,sha256=WBGS7GrjeBrElotuw45kUC_xrP449yuip9TCBm1yFrE,30666
|
|
14
14
|
websockets/asyncio/connection.py,sha256=8utq1WNx-rM4OaetJpTktWEL6oOb-Srh925tCmPx40Y,49740
|
|
15
15
|
websockets/asyncio/messages.py,sha256=vBPd93IuL3LE0OSxeaICIjzvyoht53kmh-_Zho-bGzU,11096
|
|
16
16
|
websockets/asyncio/router.py,sha256=JFNUNILkAB5P9ft6PLogNaZq6p92Wz19vIlxfx1DTlg,7783
|
|
17
|
-
websockets/asyncio/server.py,sha256=
|
|
17
|
+
websockets/asyncio/server.py,sha256=9aq5Fv-b9gTHfIY1h56A4iNIm6Ber5IQAkcdVCExY3M,34434
|
|
18
18
|
websockets/auth.py,sha256=U_Jwmn59ZRQ6EecpOvMizQCG_ZbAvgUf1ik7haZRC3c,568
|
|
19
19
|
websockets/cli.py,sha256=fYf0YqrWIx_iJDA-RWdczh5vjO9p6ZxZvmE761bUQg0,6892
|
|
20
20
|
websockets/client.py,sha256=6vTmYiCpIpe8eUrcmElOY-oKriH6OUxyp-hGpTWvPMc,16140
|
|
@@ -23,8 +23,8 @@ websockets/datastructures.py,sha256=XGTvvgFdZSevNV_T6-Lo-Dxm2VHxJcgKl4l1DNQ6fU8,
|
|
|
23
23
|
websockets/exceptions.py,sha256=tp0_EyqQfd7CYNOHmxAieOH8Ed9PSQFt18e-CuReTOY,14308
|
|
24
24
|
websockets/extensions/__init__.py,sha256=QkZsxaJVllVSp1uhdD5uPGibdbx_091GrVVfS5LXcpw,98
|
|
25
25
|
websockets/extensions/base.py,sha256=JNfyk543C7VuPH0QOobiqKoGrzjJILje6sz5ILvOPl4,2903
|
|
26
|
-
websockets/extensions/permessage_deflate.py,sha256=
|
|
27
|
-
websockets/frames.py,sha256=
|
|
26
|
+
websockets/extensions/permessage_deflate.py,sha256=2X1f3T5HuOQXbYnmHX-Mk3q1t21n-U_FhfjS6oHBibw,26569
|
|
27
|
+
websockets/frames.py,sha256=YssQbowj263xm7XnQAahjcRlA4Hdy1WSBYthsfx8EPs,17486
|
|
28
28
|
websockets/headers.py,sha256=yQnPljVZwV1_V-pOSRKNLG_u827wFC1h72cciojcQ8M,16046
|
|
29
29
|
websockets/http11.py,sha256=tLVuNvNADwt8d1ui5KMFKZCZMTtJ6CqXZveogLaPbf0,18245
|
|
30
30
|
websockets/imports.py,sha256=T_B9TUmHoceKMQ-PNphdQQAH2XdxAxwSQNeQEgqILkE,2795
|
|
@@ -34,32 +34,32 @@ websockets/legacy/client.py,sha256=5oJurUczPddXi8Rde5fA2hD3V4pnv__Z64pRRXP6HBs,2
|
|
|
34
34
|
websockets/legacy/exceptions.py,sha256=ViEjpoT09fzx_Zqf0aNGDVtRDNjXaOw0gdCta3LkjFc,1924
|
|
35
35
|
websockets/legacy/framing.py,sha256=b0wMx83XTIXVZoO60pl3AibKHbQlDb7rBAyhFEkaMCk,6374
|
|
36
36
|
websockets/legacy/handshake.py,sha256=2Nzr5AN2xvDC5EdNP-kB3lOcrAaUNlYuj_-hr_jv7pM,5285
|
|
37
|
-
websockets/legacy/http.py,sha256=
|
|
37
|
+
websockets/legacy/http.py,sha256=5Ru5qH1dwJ9EDAh5-CCJaI7KAhrk5j7fVU_63y5KfeI,7151
|
|
38
38
|
websockets/legacy/protocol.py,sha256=3MTh1-0ktWStRCX00lLV69gLzF_9gAn8YLFE_bZkzYw,63645
|
|
39
39
|
websockets/legacy/server.py,sha256=hSk4nFTsI3eMRbu5JcI4gXei9Ubw07RSrSjtL4eM2gc,44835
|
|
40
40
|
websockets/protocol.py,sha256=3HuCBMLTJMxp9JHd5JMBqtTF84SsY29iuHH0yznuD_Q,27204
|
|
41
|
-
websockets/proxy.py,sha256=
|
|
41
|
+
websockets/proxy.py,sha256=gydvdaoJctxyGRZukd1vKimuxl4EYhT1ee9aPHqjkZM,4904
|
|
42
42
|
websockets/py.typed,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
|
|
43
43
|
websockets/server.py,sha256=7kEJL1QEFO8pTB90wP4fUbkzYGDcbkTa7jTvdiSZtQk,24679
|
|
44
44
|
websockets/speedups.c,sha256=u_dncR4M38EX6He_fzb1TY6D3Hke67ZpoHLLhZZ0hvQ,5920
|
|
45
|
-
websockets/speedups.cpython-313-darwin.so,sha256=
|
|
45
|
+
websockets/speedups.cpython-313-darwin.so,sha256=cQQscid5qO4S83zZAT_jYiOM1gecdZ5Sw_fS7eW15KU,9856
|
|
46
46
|
websockets/speedups.pyi,sha256=unjvBNg-uW4c7z-9OW4WiSzZk_QH2bLEcjYAMuoSgBI,102
|
|
47
47
|
websockets/streams.py,sha256=rJrDNSL9hy3EJYc1PFHjYx4rdd1vELCua0CwdFiHN_8,4610
|
|
48
48
|
websockets/sync/__init__.py,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
|
|
49
|
-
websockets/sync/client.py,sha256=
|
|
50
|
-
websockets/sync/connection.py,sha256=
|
|
49
|
+
websockets/sync/client.py,sha256=AmXZikxVg1m39yfl1Ovm9KY3xaTfzXeXNQ36yvYMHqQ,39972
|
|
50
|
+
websockets/sync/connection.py,sha256=3ZxMhZ9PzIzKzNFo8C78JLJWfjnh0QzPeMk6h_lP4EQ,49326
|
|
51
51
|
websockets/sync/messages.py,sha256=q8K1vxdrqVk1bvn3zW1Jx09TlakIWSyYcOHC7ueFQmk,12826
|
|
52
52
|
websockets/sync/router.py,sha256=wOGAvGbKwXyqmNEFBCWZP3faHvy_2MKN7O2XeEL8nGI,7212
|
|
53
|
-
websockets/sync/server.py,sha256=
|
|
53
|
+
websockets/sync/server.py,sha256=oxbJ9bs18SZOY2uSVrThdtLO8GOGyIJ5g4yFQcq-WFg,34882
|
|
54
54
|
websockets/sync/utils.py,sha256=TtW-ncYFvJmiSW2gO86ngE2BVsnnBdL-4H88kWNDYbg,1107
|
|
55
55
|
websockets/trio/__init__.py,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
|
|
56
|
-
websockets/trio/client.py,sha256=
|
|
56
|
+
websockets/trio/client.py,sha256=lR4uIlZ5Ak1gJ8Cbcg83G3p3PbkO00AMJQCun52FBbY,30256
|
|
57
57
|
websockets/trio/connection.py,sha256=1XHDkVbK3e_M91pOGGeYeT0oazUM5NjqHOow0FkNrgY,49248
|
|
58
58
|
websockets/trio/messages.py,sha256=SmbA1k_ljEGuEHZNJ0HPDe8-ojHtMTiXGR20HrPJuKk,10476
|
|
59
59
|
websockets/trio/router.py,sha256=qZL9-44mi9SOOTGU3CLBPuaCNzafQGqZ-fJl9hVpv-s,7196
|
|
60
|
-
websockets/trio/server.py,sha256=
|
|
60
|
+
websockets/trio/server.py,sha256=AnXSoUJceAyFTKqTcGDbajrEBt2vkDMokwWvWSXUu38,25934
|
|
61
61
|
websockets/trio/utils.py,sha256=A12NYUPOjQaiGusak43oWzPIcCxhikKTkAJLXtZJvcY,956
|
|
62
62
|
websockets/typing.py,sha256=cu89n_XQXno_g9qPpFmhLbbznE-MKVazUAows2u5JqU,1840
|
|
63
|
-
websockets/uri.py,sha256=
|
|
63
|
+
websockets/uri.py,sha256=TNFgDvNDogfESw-78X8UDkh9pJF03ae-dujtpxE8BOs,3068
|
|
64
64
|
websockets/utils.py,sha256=Wdtsh2qQiHLrXUBEnp3LatMLJTZjB2CErbeOFvrdXvE,2022
|
|
65
|
-
websockets/version.py,sha256
|
|
65
|
+
websockets/version.py,sha256=FXV-IiFoGtln5A_i5aYSpGvbTBGiWUgRSsorpNv83ho,3202
|
package/fastbrowse/fastbrowse
CHANGED
|
Binary file
|
package/package.json
CHANGED
|
@@ -1,14 +0,0 @@
|
|
|
1
|
-
../../../bin/fastbrowse,sha256=k_VHUJGUyyMLHeLCiSHLkBTLNP1m86q6e4YkyAHdlZM,337
|
|
2
|
-
../../../bin/fastbrowse-mcp,sha256=1CAFjtHrbGFIoAzljudiwUZ-Qrz8WFq5CLTPaDEvGRE,344
|
|
3
|
-
_editable_impl_fastbrowse.pth,sha256=Ft6bxdkTegROr5p9hZSxsnUSfEHrZYCfXWKjtpA4_DU,44
|
|
4
|
-
fastbrowse-0.5.19.dist-info/INSTALLER,sha256=5hhM4Q4mYTT9z6QB6PGpUAW81PGNFrYrdXMj4oM_6ak,2
|
|
5
|
-
fastbrowse-0.5.19.dist-info/METADATA,sha256=l_hggNhjxHhCyunHbMTPUkdpyJ3tl79Y1AHPhtN7EKo,31033
|
|
6
|
-
fastbrowse-0.5.19.dist-info/RECORD,,
|
|
7
|
-
fastbrowse-0.5.19.dist-info/REQUESTED,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
|
|
8
|
-
fastbrowse-0.5.19.dist-info/WHEEL,sha256=W3fkpkm7-wf9vBI5Z-7s0eWkeM-spu78I8Neb98DeEg,87
|
|
9
|
-
fastbrowse-0.5.19.dist-info/direct_url.json,sha256=cZGZY9ovdib3X-RhjHdlyO_0_EMUe0tW6AXsSyMbNE4,86
|
|
10
|
-
fastbrowse-0.5.19.dist-info/entry_points.txt,sha256=SlQAK1_4_oywTAMckx8BYuCQJva0F-QuShQPcxtUm-g,95
|
|
11
|
-
fastbrowse-0.5.19.dist-info/licenses/LICENSE,sha256=gcUazG_j5a2blKPmfKqRKE98KOqX2kPbWY-EOtQZiNU,1067
|
|
12
|
-
fastbrowse-0.5.19.dist-info/licenses/NOTICE,sha256=qEO0Az_Tk98vRVL0xjnGYgMvexlMCA92lWl4JBwYRzg,1854
|
|
13
|
-
fastbrowse-0.5.19.dist-info/uv_build.json,sha256=RBNvo1WzZ4oRRq0W9-hknpT7T8If536DEMBg9hyq_4o,2
|
|
14
|
-
fastbrowse-0.5.19.dist-info/uv_cache.json,sha256=s1_lKZNGOUwn2t1FIJzgmWECL4sJnQI15gAX4s4s70Y,194
|
|
@@ -1 +0,0 @@
|
|
|
1
|
-
{"timestamp":{"secs_since_epoch":1791321495,"nanos_since_epoch":712749409},"commit":null,"tags":null,"env":{},"directories":{"src":{"secs_since_epoch":1791321495,"nanos_since_epoch":719168098}}}
|
/package/fastbrowse/_internal/{fastbrowse-0.5.19.dist-info → fastbrowse-0.5.23.dist-info}/INSTALLER
RENAMED
|
File without changes
|
/package/fastbrowse/_internal/{fastbrowse-0.5.19.dist-info → fastbrowse-0.5.23.dist-info}/REQUESTED
RENAMED
|
File without changes
|
/package/fastbrowse/_internal/{fastbrowse-0.5.19.dist-info → fastbrowse-0.5.23.dist-info}/WHEEL
RENAMED
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
/package/fastbrowse/_internal/{websockets-17.1.dist-info → websockets-17.2.dist-info}/INSTALLER
RENAMED
|
File without changes
|
/package/fastbrowse/_internal/{websockets-17.1.dist-info → websockets-17.2.dist-info}/REQUESTED
RENAMED
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
/package/fastbrowse/_internal/{websockets-17.1.dist-info → websockets-17.2.dist-info}/top_level.txt
RENAMED
|
File without changes
|