@fastbrowse/win32-x64 0.5.25 → 0.7.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (42) hide show
  1. package/fastbrowse/_internal/VCRUNTIME140.dll +0 -0
  2. package/fastbrowse/_internal/VCRUNTIME140_1.dll +0 -0
  3. package/fastbrowse/_internal/_asyncio.pyd +0 -0
  4. package/fastbrowse/_internal/_bz2.pyd +0 -0
  5. package/fastbrowse/_internal/_ctypes.pyd +0 -0
  6. package/fastbrowse/_internal/_decimal.pyd +0 -0
  7. package/fastbrowse/_internal/_hashlib.pyd +0 -0
  8. package/fastbrowse/_internal/_lzma.pyd +0 -0
  9. package/fastbrowse/_internal/_multiprocessing.pyd +0 -0
  10. package/fastbrowse/_internal/_overlapped.pyd +0 -0
  11. package/fastbrowse/_internal/_queue.pyd +0 -0
  12. package/fastbrowse/_internal/_socket.pyd +0 -0
  13. package/fastbrowse/_internal/_ssl.pyd +0 -0
  14. package/fastbrowse/_internal/_uuid.pyd +0 -0
  15. package/fastbrowse/_internal/_wmi.pyd +0 -0
  16. package/fastbrowse/_internal/_zoneinfo.pyd +0 -0
  17. package/fastbrowse/_internal/base_library.zip +0 -0
  18. package/fastbrowse/_internal/fastbrowse/browser/snapshot.js +122 -36
  19. package/fastbrowse/_internal/{fastbrowse-0.5.25.dist-info → fastbrowse-0.7.0.dist-info}/METADATA +28 -18
  20. package/fastbrowse/_internal/fastbrowse-0.7.0.dist-info/RECORD +14 -0
  21. package/fastbrowse/_internal/fastbrowse-0.7.0.dist-info/uv_cache.json +1 -0
  22. package/fastbrowse/_internal/libcrypto-3-x64.dll +0 -0
  23. package/fastbrowse/_internal/libffi-8.dll +0 -0
  24. package/fastbrowse/_internal/libssl-3-x64.dll +0 -0
  25. package/fastbrowse/_internal/pyexpat.pyd +0 -0
  26. package/fastbrowse/_internal/python313.dll +0 -0
  27. package/fastbrowse/_internal/select.pyd +0 -0
  28. package/fastbrowse/_internal/unicodedata.pyd +0 -0
  29. package/fastbrowse/fastbrowse.exe +0 -0
  30. package/package.json +1 -1
  31. package/fastbrowse/_internal/fastbrowse-0.5.25.dist-info/RECORD +0 -14
  32. package/fastbrowse/_internal/fastbrowse-0.5.25.dist-info/uv_cache.json +0 -1
  33. package/fastbrowse/_internal/libcrypto-3.dll +0 -0
  34. package/fastbrowse/_internal/libssl-3.dll +0 -0
  35. /package/fastbrowse/_internal/{fastbrowse-0.5.25.dist-info → fastbrowse-0.7.0.dist-info}/INSTALLER +0 -0
  36. /package/fastbrowse/_internal/{fastbrowse-0.5.25.dist-info → fastbrowse-0.7.0.dist-info}/REQUESTED +0 -0
  37. /package/fastbrowse/_internal/{fastbrowse-0.5.25.dist-info → fastbrowse-0.7.0.dist-info}/WHEEL +0 -0
  38. /package/fastbrowse/_internal/{fastbrowse-0.5.25.dist-info → fastbrowse-0.7.0.dist-info}/direct_url.json +0 -0
  39. /package/fastbrowse/_internal/{fastbrowse-0.5.25.dist-info → fastbrowse-0.7.0.dist-info}/entry_points.txt +0 -0
  40. /package/fastbrowse/_internal/{fastbrowse-0.5.25.dist-info → fastbrowse-0.7.0.dist-info}/licenses/LICENSE +0 -0
  41. /package/fastbrowse/_internal/{fastbrowse-0.5.25.dist-info → fastbrowse-0.7.0.dist-info}/licenses/NOTICE +0 -0
  42. /package/fastbrowse/_internal/{fastbrowse-0.5.25.dist-info → fastbrowse-0.7.0.dist-info}/uv_build.json +0 -0
Binary file
Binary file
Binary file
Binary file
Binary file
Binary file
Binary file
Binary file
Binary file
Binary file
Binary file
Binary file
@@ -3,7 +3,7 @@
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, allowedOrigins = null) => {
6
+ ((mode, allowedOrigins = null, limits = null) => {
7
7
  const granted = doc => {
8
8
  if (allowedOrigins === null) return true;
9
9
  const frame = doc.defaultView.frameElement;
@@ -94,7 +94,17 @@
94
94
  };
95
95
  registry.track(document);
96
96
  }
97
- if (mode === 'fingerprint') {
97
+ const clock = () => ({
98
+ ready: document.readyState === 'interactive' || document.readyState === 'complete',
99
+ quietFor: performance.now() - registry.lastMutation,
100
+ hidden: document.hidden,
101
+ });
102
+ // The settle loop reads the clock alone until the page has gone quiet: the walk below reads every element and
103
+ // all the page's text, which on a large page costs more than the poll interval leaves room for.
104
+ if (mode === 'quiet') return clock();
105
+ // `loading` walks for indicators, and to watch new shadow roots and frames, but hashes nothing.
106
+ if (mode === 'fingerprint' || mode === 'loading') {
107
+ const hashing = mode === 'fingerprint';
98
108
  let hash = 2166136261;
99
109
  let loading = false;
100
110
  const hashText = text => {
@@ -103,8 +113,7 @@
103
113
  const include = root => {
104
114
  registry.track(root);
105
115
  loading ||= [...root.querySelectorAll(LOADING)].some(showing);
106
- const text = root.body?.innerText ?? root.textContent ?? '';
107
- hashText(text);
116
+ if (hashing) hashText(root.body?.innerText ?? root.textContent ?? '');
108
117
  // innerText omits shadow trees and child documents even when their content is visible.
109
118
  for (const e of root.querySelectorAll('*')) {
110
119
  // A filter can change only its checkmark, so text alone reported "Nonstop only" as a no-op.
@@ -112,6 +121,7 @@
112
121
  // Only rendered controls: a hidden carousel's aria-selected dots churn on their own and would read as
113
122
  // progress.
114
123
  if (
124
+ hashing &&
115
125
  ('checked' in e || 'selected' in e || e.hasAttribute('aria-checked') || e.hasAttribute('aria-selected')) &&
116
126
  e.checkVisibility({ checkOpacity: !nativeChoice(e), checkVisibilityCSS: true }) &&
117
127
  (!nativeChoice(e) || shownThrough(e))
@@ -138,10 +148,8 @@
138
148
  };
139
149
  include(document);
140
150
  return {
141
- fingerprint: location.href + '|' + document.title + '|' + (hash >>> 0) + '|' + scrollY,
142
- ready: document.readyState === 'interactive' || document.readyState === 'complete',
143
- quietFor: performance.now() - registry.lastMutation,
144
- hidden: document.hidden,
151
+ ...clock(),
152
+ fingerprint: hashing ? location.href + '|' + document.title + '|' + (hash >>> 0) + '|' + scrollY : null,
145
153
  loading,
146
154
  };
147
155
  }
@@ -153,12 +161,32 @@
153
161
  return id;
154
162
  };
155
163
  for (const [id, e] of registry.nodes) if (!e.isConnected) registry.nodes.delete(id);
164
+ // The DOM cannot change while this script runs, so a label, a text or a section title read once is reused for
165
+ // the rest of it. `registry.guard` reads them again in a later script, against a page that may have changed
166
+ // since, so the reuse ends as soon as this one does, whether it returns or throws.
167
+ const caches = [];
168
+ registry.nativeQueueMicrotask ||= window.queueMicrotask.bind(window);
169
+ registry.nativeQueueMicrotask(() => {
170
+ for (const cache of caches) cache.clear();
171
+ caches.length = 0;
172
+ });
173
+ const memo = read => {
174
+ const cache = new Map();
175
+ caches.push(cache);
176
+ return e => {
177
+ if (!caches.includes(cache)) return read(e);
178
+ if (!cache.has(e)) cache.set(e, read(e));
179
+ return cache.get(e);
180
+ };
181
+ };
182
+ const innerTextOf = memo(e => e.innerText);
156
183
 
157
184
  const safe = e => e.type !== 'hidden';
158
185
  // Password and agent-typed secret values never leave the page: only their length is observed.
159
186
  const secret = e => e.type === 'password' || e.dataset?.fastbrowseSecret === '1';
160
187
  const reveal = e => (typeof e.value !== 'string' ? null : secret(e) ? '•'.repeat(e.value.length) : e.value);
161
- const visible = e => {
188
+ // Asked of each control by the walk and again by its guard, and of each text node's parent.
189
+ const visible = memo(e => {
162
190
  // Todo lists paint the checkmark beside a transparent native input without associating a label.
163
191
  // The input still receives clicks; opacity alone must not remove the only control for that row.
164
192
  // A transparent input with a visible label of its own keeps the label as its click target: TodoMVC's
@@ -176,7 +204,7 @@
176
204
  if (!choice) return true;
177
205
  const r = e.getBoundingClientRect();
178
206
  return r.width > 0 && r.height > 0 && !e.matches(':disabled') && !e.closest('[aria-disabled="true"]');
179
- };
207
+ });
180
208
  registry.visible = visible;
181
209
  // Styled checkboxes and radios often hide the native input. Its visible label is the click
182
210
  // target, but the input still owns the checked/disabled state and must participate in freshness.
@@ -230,21 +258,21 @@
230
258
  .filter(Boolean)
231
259
  .join(' ');
232
260
  };
233
- const labelOf = (e, seen = new Set()) => {
261
+ const labelFrom = (e, seen) => {
234
262
  if (!e || seen.has(e)) return '';
235
263
  seen.add(e);
236
264
  const root = typeof e.getRootNode === 'function' ? e.getRootNode() : document;
237
265
  const byId = id => (root.getElementById ? root.getElementById(id) : document.getElementById(id));
238
266
  const referenced = (e.getAttribute('aria-labelledby') || '')
239
267
  .split(/\s+/)
240
- .map(id => labelOf(byId(id), seen))
268
+ .map(id => labelFrom(byId(id), seen))
241
269
  .filter(Boolean)
242
270
  .join(' ');
243
271
  return (
244
272
  referenced ||
245
273
  e.getAttribute('aria-label') ||
246
274
  [...(e.labels || [])]
247
- .map(l => labelOf(l, seen))
275
+ .map(l => labelFrom(l, seen))
248
276
  .filter(Boolean)
249
277
  .join(' ') ||
250
278
  (['button', 'submit', 'reset'].includes(e.type) ? e.value : '') ||
@@ -256,7 +284,7 @@
256
284
  n.nodeType === 3
257
285
  ? n.textContent
258
286
  : n.nodeType === 1 && !CODE.has(n.tagName) && n.getAttribute('aria-hidden') !== 'true'
259
- ? labelOf(n, seen)
287
+ ? labelFrom(n, seen)
260
288
  : '',
261
289
  )
262
290
  .join(' ')
@@ -267,6 +295,8 @@
267
295
  );
268
296
  };
269
297
 
298
+ const labelOf = memo(e => labelFrom(e, new Set()));
299
+
270
300
  const ARIA_ROLES = [
271
301
  'button',
272
302
  'link',
@@ -318,7 +348,7 @@
318
348
  return null;
319
349
  };
320
350
 
321
- const submitSemantics = e => {
351
+ const submitSemantics = memo(e => {
322
352
  const form = e.form;
323
353
  const explicit = ['BUTTON', 'INPUT'].includes(e.tagName) && ['submit', 'image'].includes(e.type);
324
354
  const implicit = [
@@ -346,9 +376,9 @@
346
376
  label: submit ? labelOf(submit) : labelOf(form),
347
377
  method: submit?.getAttribute('formmethod') || form.method,
348
378
  action: submit?.getAttribute('formaction') || form.action,
349
- text: excerpt(form.innerText, FORM_TEXT_CHARS),
379
+ text: excerpt(innerTextOf(form), FORM_TEXT_CHARS),
350
380
  });
351
- };
381
+ });
352
382
 
353
383
  const framePath = doc => {
354
384
  if (doc === document) return null;
@@ -379,6 +409,23 @@
379
409
  }
380
410
  }
381
411
 
412
+ // Guards and the page key go to Python as digests: each comparison is equality, and the full guards cost more to
413
+ // send than the snapshot takes to compute. Not cryptographic, and it needs not be: a page that wants a guard to
414
+ // hold can simply leave its DOM alone.
415
+ const digest = text => {
416
+ let h1 = 0xdeadbeef,
417
+ h2 = 0x41c6ce57;
418
+ for (let i = 0; i < text.length; i++) {
419
+ const c = text.charCodeAt(i);
420
+ h1 = Math.imul(h1 ^ c, 2654435761);
421
+ h2 = Math.imul(h2 ^ c, 1597334677);
422
+ }
423
+ h1 = Math.imul(h1 ^ (h1 >>> 16), 2246822507) ^ Math.imul(h2 ^ (h2 >>> 13), 3266489909);
424
+ h2 = Math.imul(h2 ^ (h2 >>> 16), 2246822507) ^ Math.imul(h1 ^ (h1 >>> 13), 3266489909);
425
+ return (h2 >>> 0).toString(16).padStart(8, '0') + (h1 >>> 0).toString(16).padStart(8, '0');
426
+ };
427
+ // Sibling controls share their card, so its text is hashed once per card rather than once per control.
428
+ const scopeDigest = memo(scope => digest(innerTextOf(scope)?.slice(0, GUARD_TEXT_CHARS) || ''));
382
429
  registry.guard = e => {
383
430
  if (!e?.isConnected || !visible(e)) return null;
384
431
  const source = sourceOf(e);
@@ -400,7 +447,7 @@
400
447
  e.getAttribute('aria-checked'),
401
448
  e.getAttribute('aria-selected'),
402
449
  e.getAttribute('href'),
403
- scope?.innerText?.slice(0, GUARD_TEXT_CHARS) || '',
450
+ scope ? scopeDigest(scope) : '',
404
451
  e.ownerDocument.location.origin,
405
452
  e.ownerDocument.defaultView.performance.timeOrigin,
406
453
  submitSemantics(e),
@@ -409,6 +456,10 @@
409
456
  identity(source),
410
457
  ];
411
458
  };
459
+ registry.guardKey = e => {
460
+ const guard = registry.guard(e);
461
+ return guard && digest(JSON.stringify(guard));
462
+ };
412
463
 
413
464
  const controls = [];
414
465
  // Controls with no accessible name of their own, whose label is an icon's or only their role.
@@ -444,7 +495,8 @@
444
495
  if (rname === 'gridcell' && e.querySelector('button,[role="button"]')) continue;
445
496
  // A button-styled link offers the same label twice, but its inner button owns every useful click point.
446
497
  if (rname === 'link') {
447
- const nested = e.querySelectorAll(SELECTOR);
498
+ // Most links hold no button, and querying every link for every control was the walk's slowest step.
499
+ const nested = e.querySelector('button') ? e.querySelectorAll(SELECTOR) : [];
448
500
  const button = nested.length === 1 ? nested[0] : null;
449
501
  if (button?.tagName === 'BUTTON' && visible(button) && !button.matches(':disabled') &&
450
502
  !button.closest('[aria-disabled="true"],[inert]') && labelOf(button) === labelOf(e)) continue;
@@ -531,11 +583,11 @@
531
583
  // A label tied to no control titles what follows it, as a heading would: a blog post's "Date Picker 3" is one,
532
584
  // and the post's first heading ("Data Entry Form") named that picker's Submit when only headings counted.
533
585
  // A hidden title names nothing on screen: innerText still returns a `hidden` label's text.
534
- const titles = scope =>
586
+ const titles = memo(scope =>
535
587
  [...scope.querySelectorAll(`${HEADINGS},label`)].filter(
536
588
  e =>
537
589
  (e.localName !== 'label' || !e.control) && e.checkVisibility({ checkOpacity: true, checkVisibilityCSS: true }),
538
- );
590
+ ));
539
591
  // A table's header cell names its own row, and a caption its own table, never what sits outside them: a jQuery UI
540
592
  // datepicker's Next button was named "Su", its weekday header, and every month's Next read the same.
541
593
  const reaches = (title, element) =>
@@ -546,7 +598,7 @@
546
598
  let first = '',
547
599
  before = '';
548
600
  for (const title of titles(scope)) {
549
- const text = firstLine(title.innerText);
601
+ const text = firstLine(innerTextOf(title));
550
602
  if (!text || text === label || title.contains(element) || !reaches(title, element)) continue;
551
603
  first ||= text;
552
604
  if (title.compareDocumentPosition(element) & Node.DOCUMENT_POSITION_FOLLOWING) before = text;
@@ -563,18 +615,18 @@
563
615
  // A line that is itself a repeated control's label ("Prev") reads the same in every repeat, so it names none:
564
616
  // past it, a datepicker's first line is the month it shows. The label is cut out as whole words, since a day
565
617
  // labelled "2" cut out of "October 2026" left "October 0 6".
566
- const lines = (scope.innerText || '')
618
+ const lines = (innerTextOf(scope) || '')
567
619
  .split('\n')
568
620
  .map(line => line.replace(/\s+/g, ' ').trim())
569
621
  .filter(line => line && !repeated.has(line));
570
622
  return firstLine(lines.map(line => (label ? ` ${line} `.split(` ${label} `).join(' ') : line)).join('\n'));
571
623
  };
572
- const contextOf = (element, twins, label, nearest = true) => {
624
+ const contextOf = (element, holding, label, nearest = true) => {
573
625
  // The widest twin-free ancestor is the card, row or section the twins repeat over. A narrower one names
574
626
  // the button's own wrapper, which on a shop is its price rather than the product it belongs to.
575
627
  let scope = null;
576
628
  for (let e = element.parentElement; e && e !== e.ownerDocument.body; e = e.parentElement) {
577
- if (twins.some(twin => twin !== element && e.contains(twin))) break;
629
+ if (holding.get(e) > 1) break;
578
630
  scope = e;
579
631
  // A lone open reply has no form twin nearby; crossing its record picks a different commenter's name.
580
632
  if (e.matches('article,li,[role="listitem"]') ||
@@ -709,12 +761,18 @@
709
761
  for (const group of byLabel.values()) {
710
762
  if (group.length < 2) continue;
711
763
  const twins = group.map(c => registry.nodes.get(c.id));
712
- const nearest = group.map(c => contextOf(registry.nodes.get(c.id), twins, c.label));
764
+ // How many twins each ancestor holds. One that holds two is past the card, and counting once beats asking
765
+ // every ancestor of every twin whether it contains each of the others.
766
+ const holding = new Map();
767
+ for (const twin of twins) {
768
+ for (let e = twin.parentElement; e; e = e.parentElement) holding.set(e, (holding.get(e) || 0) + 1);
769
+ }
770
+ const nearest = group.map(c => contextOf(registry.nodes.get(c.id), holding, c.label));
713
771
  group.forEach((c, i) => {
714
772
  // Cards that each end in an "Options" heading share their nearest title, which would name every twin
715
773
  // alike; a twin whose nearest title another twin shares is named by its card's first title instead.
716
774
  const shared = nearest.indexOf(nearest[i]) !== nearest.lastIndexOf(nearest[i]);
717
- const context = shared ? contextOf(registry.nodes.get(c.id), twins, c.label, false) : nearest[i];
775
+ const context = shared ? contextOf(registry.nodes.get(c.id), holding, c.label, false) : nearest[i];
718
776
  if (context) c.context = context;
719
777
  });
720
778
  // Options that are direct children of one list share every ancestor, and their aria-label hides the text
@@ -770,7 +828,7 @@
770
828
  const aria = (e.getAttribute('aria-label') || '').trim();
771
829
  const outside = [...e.children]
772
830
  .filter(child => child !== path)
773
- .map(child => firstLine(child.innerText))
831
+ .map(child => firstLine(innerTextOf(child)))
774
832
  .find(line => line && line !== label && !repeated.has(line));
775
833
  return excerpt((aria !== label && aria) || outside || first, CONTROL_CONTEXT_CHARS);
776
834
  }
@@ -883,6 +941,7 @@
883
941
  // Text in a same-process child document is on screen as much as the parent's: a frameset page has no text of
884
942
  // its own at all. Each child is read in its own coordinates, shifted by where its frame sits.
885
943
  const words = [];
944
+ // Geometry, the cheaper test, goes first.
886
945
  const readText = (doc, dx, dy) => {
887
946
  if (!doc.body) return;
888
947
  const walker = doc.createTreeWalker(doc.body, NodeFilter.SHOW_TEXT);
@@ -891,7 +950,7 @@
891
950
  while ((node = walker.nextNode())) {
892
951
  const value = node.textContent.trim(),
893
952
  parent = node.parentElement;
894
- if (!value || !parent || parent.closest('script,style,noscript,template') || !visible(parent)) continue;
953
+ if (!value || !parent) continue;
895
954
  range.selectNodeContents(node);
896
955
  const r = range.getBoundingClientRect();
897
956
  const top = r.top + dy,
@@ -902,7 +961,9 @@
902
961
  top + r.height > 0 &&
903
962
  top < innerHeight &&
904
963
  left + r.width > 0 &&
905
- left < innerWidth
964
+ left < innerWidth &&
965
+ !parent.closest('script,style,noscript,template') &&
966
+ visible(parent)
906
967
  ) {
907
968
  words.push(value);
908
969
  }
@@ -922,8 +983,15 @@
922
983
  readText(document, 0, 0);
923
984
  const viewport_text = words.join('\n');
924
985
 
925
- const guards = {};
926
- for (const c of controls) guards[c.id] = registry.guard(registry.nodes.get(c.id));
986
+ const guards = {},
987
+ retarget_keys = {};
988
+ for (const c of controls) {
989
+ const guard = registry.guard(registry.nodes.get(c.id));
990
+ guards[c.id] = guard && digest(JSON.stringify(guard));
991
+ // A redraw changes the node ids at either end of the guard. Everything between them, including the receiving
992
+ // document's timeOrigin and form semantics, must survive before an action can follow it.
993
+ retarget_keys[c.id] = guard && digest(JSON.stringify(guard.slice(1, -1)));
994
+ }
927
995
  // A dependent field can appear without changing page text. Compare all controls,
928
996
  // excusing only the value and required marker of the field just filled.
929
997
  const formState = controls.map(({ distance, offscreen, ...c }) => {
@@ -945,13 +1013,13 @@
945
1013
  }
946
1014
  registry.formObserved = { page: formPage, controls: formState };
947
1015
  // Kept so a later check can ask whether any of these controls changed without sending the guards back.
948
- registry.observed = new Map(controls.map(c => [c.id, JSON.stringify(guards[c.id])]));
1016
+ registry.observed = new Map(controls.map(c => [c.id, guards[c.id]]));
949
1017
  const field_state = [...document.querySelectorAll('input,textarea,select')]
950
1018
  .filter(safe)
951
1019
  .map(e => [identity(e), reveal(e), e.checked, e.selectedIndex, e.disabled, e.readOnly]);
952
1020
  // Compare meaning and identity for the page_key fingerprint; geometry is re-resolved just before input.
953
1021
  const semantics = controls.map(({ distance, ...c }) => c);
954
- const page_key = JSON.stringify([
1022
+ const page_key = digest(JSON.stringify([
955
1023
  location.href,
956
1024
  scrollX,
957
1025
  scrollY,
@@ -961,9 +1029,9 @@
961
1029
  viewport_text,
962
1030
  semantics,
963
1031
  field_state,
964
- ]);
1032
+ ]));
965
1033
 
966
- return {
1034
+ const result = {
967
1035
  url: location.href,
968
1036
  title: document.title,
969
1037
  viewport_text,
@@ -971,6 +1039,24 @@
971
1039
  controls: semantics,
972
1040
  page_key,
973
1041
  guards,
1042
+ retarget_keys,
974
1043
  inaccessible_frames: inaccessible,
975
1044
  };
1045
+ if (!limits) return result;
1046
+ // Python keeps at most `limits.onscreen` of the controls on screen and `limits.offscreen` of those off it, each
1047
+ // the first in this order, and any pager past either cap. A control past the caps is sent with only what decides
1048
+ // whether it is a pager; Python asks for the rest of one it keeps, by this snapshot's token, from what is kept here.
1049
+ const token = (registry.snapshots = (registry.snapshots || 0) + 1);
1050
+ registry.sent = { token, rows: new Map(semantics.map(c => [c.id, [c, guards[c.id], retarget_keys[c.id]]])) };
1051
+ let onscreen = 0,
1052
+ offscreen = 0;
1053
+ result.controls = semantics.map(c => {
1054
+ if (c.offscreen ? offscreen++ < limits.offscreen : onscreen++ < limits.onscreen) return c;
1055
+ delete result.guards[c.id];
1056
+ delete result.retarget_keys[c.id];
1057
+ const { id, role, label, operations, href, next_page } = c;
1058
+ return { id, role, label, operations, href, next_page, offscreen: c.offscreen, partial: true };
1059
+ });
1060
+ result.token = token;
1061
+ return result;
976
1062
  })
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.5
2
2
  Name: fastbrowse
3
- Version: 0.5.25
3
+ Version: 0.7.0
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
@@ -17,7 +17,7 @@ Requires-Dist: pydantic-settings<3,>=2.10
17
17
  Requires-Dist: pydantic<3,>=2.11
18
18
  Requires-Dist: websockets<18,>=14
19
19
  Provides-Extra: browser-use
20
- Requires-Dist: browser-use-sdk==3.11.3; extra == 'browser-use'
20
+ Requires-Dist: browser-use-sdk==3.12.1; extra == 'browser-use'
21
21
  Provides-Extra: eval-data
22
22
  Requires-Dist: pyyaml<7,>=6; extra == 'eval-data'
23
23
  Provides-Extra: mcp
@@ -60,16 +60,19 @@ Benchmarking compares exactly three things:
60
60
 
61
61
  | | Comparison | Arms | Recorded |
62
62
  |:--|:--|:--|:--|
63
- | C1 | agent against agent | fastbrowse against the hosted Browser Use agent | 40 of 42 passed against 14 of 42, median 17.0s against 27.5s, $0.011 against $0.400 a task |
64
- | C2 | backend against backend | fastbrowse on its own browser pool against fastbrowse on a Browser Use Cloud browser | not yet measured |
63
+ | C1 | agent against agent | fastbrowse against the hosted Browser Use agent | 46 tasks, three repeats: 99.2% against 97.8%, median 16.3s against 16.1s, $0.0256 against $0.2840 a result |
64
+ | C2 | backend against backend | fastbrowse on its own browser pool against fastbrowse on a Browser Use Cloud browser | 53 tasks, three repeats: 96.2% against 98.1%, median 15.9s against 15.0s, $0.0192 against $0.0200 a result |
65
65
  | C3 | fastbrowse and its backend against [Browser Use Ultrafast](https://github.com/browser-use/jev-ultrafast) | fastbrowse on its own browser pool against `jev-ultrafast`, 6 tasks | not yet measured |
66
66
 
67
- C1's figures are the recorded head-to-head on 14 answer tasks, three passes each, on identical cloud browsers
68
- under identical limits. [Benchmark results](docs/benchmarks.md) gives each comparison's sources, the internal
69
- matched-task campaigns behind C1, the browser cost basis and the limitations. The
70
- [benchmark page](https://fastbrowse.ai/benchmarks) renders only figures the maintainer approved. Rows are
71
- comparable only at matching task versions; the upstream suites and the Codex with Cua Driver arm are diagnostics,
72
- described in [eval results and workflow](docs/evals.md).
67
+ C1's figures are the recorded head-to-head on 46 tasks, three repeats each, on Browser Use cloud browsers under
68
+ identical limits, with the hosted agent at its defaults. C2 compares browsers, not agents: both arms run the same
69
+ fastbrowse build, tasks and repeats, and only the browser differs. On that backend fastbrowse passes fewer tasks
70
+ on its own pool than on the Browser Use Cloud browser, 96.2% against 98.1%. [Benchmark results](docs/benchmarks.md)
71
+ gives each comparison's sources, the campaigns behind C1, the browser cost basis and the limitations. The
72
+ [benchmark page](https://fastbrowse.ai/benchmarks) renders only figures the maintainer approved; the C1 and C2
73
+ figures come from the 0.5.25 campaign staged in `docs/results/benchmark-candidate.json`, which awaits approval.
74
+ Rows are comparable only at matching task versions; the upstream suites and the Codex with Cua Driver arm are
75
+ diagnostics, described in [eval results and workflow](docs/evals.md).
73
76
 
74
77
  ### Why fastbrowse, against each kind of agent
75
78
 
@@ -94,13 +97,14 @@ A Node program needs neither uv nor Python: see [Use it from JavaScript](#use-it
94
97
 
95
98
  ```sh
96
99
  export OPENROUTER_API_KEY=... # for Jev and the LLM that plans and reads
97
- # export AI_GATEWAY_API_KEY=... # Vercel AI Gateway: Jev backup, and the LLM when OPENROUTER_API_KEY is unset
100
+ # export AI_GATEWAY_API_KEY=... # Vercel AI Gateway: Jev backup, and Jev and the LLM when OPENROUTER_API_KEY is unset
98
101
  export BROWSER_USE_API_KEY=... # the cloud browser; or pass --local to use Chrome
99
102
  uvx fastbrowse "What is the title of the top story right now?" --start https://news.ycombinator.com/
100
103
  ```
101
104
 
102
- Jev uses direct TypeSafe when `TYPESAFE_API_KEY` is supplied; otherwise OpenRouter is primary.
103
- Vercel AI Gateway is supported as a backup or an explicit primary. `FASTBROWSE_JEV_SOURCE` overrides
105
+ Jev uses direct TypeSafe when `TYPESAFE_API_KEY` is supplied, otherwise OpenRouter when `OPENROUTER_API_KEY` is
106
+ set, and the Vercel AI Gateway when neither is. With `AI_GATEWAY_API_KEY` beside another key, the gateway is the
107
+ backup. `FASTBROWSE_JEV_SOURCE` overrides
104
108
  automatic selection; see [provider routing](docs/jev.md#provider-failover). The LLM uses OpenRouter when
105
109
  `OPENROUTER_API_KEY` is set, otherwise the Vercel AI Gateway's OpenAI-compatible chat completions.
106
110
 
@@ -121,8 +125,8 @@ cp .env.example .env # then fill in the keys, BROWSER_USE_API_KEY included, or
121
125
  uv run fastbrowse "What is the title of the top story right now?" --start https://news.ycombinator.com/
122
126
  ```
123
127
 
124
- Steps go to stderr; the status, cost, step count and answer go to stdout. `--json` prints every step,
125
- the quotes behind the answer, and cost by component.
128
+ Steps go to stderr; the status, cost, step count, the reason a run stopped short and the answer go to stdout.
129
+ `--json` prints every step, the quotes behind the answer, and cost by component.
126
130
 
127
131
  | Flag | Effect |
128
132
  |:--|:--|
@@ -259,7 +263,7 @@ Only `complete` exits 0, so a program can also check zero versus nonzero.
259
263
 
260
264
  | Status | Exit | Meaning |
261
265
  |:--|--:|:--|
262
- | `complete` | 0 | every information requirement is backed by a quote, and every action is confirmed on the page |
266
+ | `complete` | 0 | every information requirement is backed by a quote, and every action that changes the site is confirmed on the page |
263
267
  | `unverified` | 10 | it believes it finished but could not back every claim |
264
268
  | `needs_confirmation` | 3 | stopped before an irreversible action; re-run with `--authorize` |
265
269
  | `needs_login` | 4 | a sign-in wall that no `--secret` covers |
@@ -275,12 +279,18 @@ A `budget_exceeded` JSON result includes `budget.resource` (`steps`, `seconds`,
275
279
  `llm_calls`) and `budget.limit`. Other results have `budget: null`. The embedding and MCP APIs carry the same
276
280
  optional object, so callers can identify the affected limit without parsing an error message.
277
281
 
282
+ A run that stops `stuck` or `unverified` before writing its answer, with evidence read for a task that asks for
283
+ one, returns a cited answer that starts `Answer (unverified)`, written by one more composer call. If that call
284
+ fails, the answer is the list of quotes the run collected, as it is for the other statuses that stop with evidence.
285
+
278
286
  A bot check can block a cloud browser before any task steps execute. Retry with an attached local browser
279
287
  or choose another source for the research. Credentials do not resolve a CAPTCHA.
280
288
  The `blocked` result's `evidence` quotes the opening text of the challenge page, with its address.
281
289
 
282
290
  If a model completion omits its cost, fastbrowse looks up the matching OpenRouter or Vercel generation
283
- receipt. If that receipt is still unavailable, a run with a dollar cap stops with `budget_exceeded`;
291
+ receipt. If that receipt is still unavailable, an OpenRouter call is charged at an upper bound from the model's
292
+ listed prices: its whole context window at the highest price. A call that cannot be bounded, through the Vercel AI
293
+ Gateway or a custom endpoint, leaves the cost unknown, and a run with a dollar cap stops with `budget_exceeded`;
284
294
  this does not mean the known charges reached the cap. Supplied `inputs` can provide exact field text
285
295
  without a field-writing generation.
286
296
 
@@ -312,7 +322,7 @@ More in [docs/design.md](docs/design.md).
312
322
  | Signing in | yes | password fields excluded | `--secret` or a Bitwarden vault item; models see names only |
313
323
  | Irreversible actions | explicit task instructions in our fixture tests | no authorization parameter in Agent | classifier plus a code-enforced authorization gate |
314
324
  | Browser | cloud | Chrome through Browser Harness; cloud in these evals | cloud by default, or local Chrome with `--local` |
315
- | Benchmarked in | C1 | C3 | C1 on a cloud browser; C2 and C3 on its own browser pool |
325
+ | Benchmarked in | C1 | C3 | C1 on a cloud browser; C2 on its own pool and on a Browser Use Cloud browser; C3 on its own pool |
316
326
 
317
327
  Browser Use Ultrafast (`jev-ultrafast`) is Browser Use's navigation agent, separate from its hosted
318
328
  LLM agent; fastbrowse shares its core techniques. Its column describes
@@ -0,0 +1,14 @@
1
+ ../../Scripts/fastbrowse-mcp.exe,sha256=I2I08h1Brh_Z0IlP0BL0kO00C2A0VwEuhWK0ngGPh3s,47104
2
+ ../../Scripts/fastbrowse.exe,sha256=Gvhgn6Rkas3oyDv32ThG3N-V1GSHRlufYuH48_FKEFo,47104
3
+ _editable_impl_fastbrowse.pth,sha256=tSUp9-7xHLVBnY2rS0eA3HsN-LQmHLK1u99SbVQnoxg,30
4
+ fastbrowse-0.7.0.dist-info/INSTALLER,sha256=5hhM4Q4mYTT9z6QB6PGpUAW81PGNFrYrdXMj4oM_6ak,2
5
+ fastbrowse-0.7.0.dist-info/METADATA,sha256=7vV9jxG0yGiwE2e_oimnIrnEMsrz3D7N5w-HlzvCHiQ,38538
6
+ fastbrowse-0.7.0.dist-info/RECORD,,
7
+ fastbrowse-0.7.0.dist-info/REQUESTED,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
8
+ fastbrowse-0.7.0.dist-info/WHEEL,sha256=W3fkpkm7-wf9vBI5Z-7s0eWkeM-spu78I8Neb98DeEg,87
9
+ fastbrowse-0.7.0.dist-info/direct_url.json,sha256=17Yl-bY7t4MfSh0BCqmxi7aWPtTEbGMyNMuGnRAqc9o,73
10
+ fastbrowse-0.7.0.dist-info/entry_points.txt,sha256=SlQAK1_4_oywTAMckx8BYuCQJva0F-QuShQPcxtUm-g,95
11
+ fastbrowse-0.7.0.dist-info/licenses/LICENSE,sha256=K_U5s2m7BDEapcEX4SlvotrXk6f1rg7pohD5P6Cn9dM,1088
12
+ fastbrowse-0.7.0.dist-info/licenses/NOTICE,sha256=aLaGCOpkunnLIGPoVVzNiekSAbwRskkM0M3cqNGCv9Y,1887
13
+ fastbrowse-0.7.0.dist-info/uv_build.json,sha256=RBNvo1WzZ4oRRq0W9-hknpT7T8If536DEMBg9hyq_4o,2
14
+ fastbrowse-0.7.0.dist-info/uv_cache.json,sha256=_9qDdHtvJjp4LHLiaQd6609xeDtbPYnhME036e2VbaY,194
@@ -0,0 +1 @@
1
+ {"timestamp":{"secs_since_epoch":1792857033,"nanos_since_epoch":908852900},"commit":null,"tags":null,"env":{},"directories":{"src":{"secs_since_epoch":1792857033,"nanos_since_epoch":908852900}}}
Binary file
Binary file
Binary file
Binary file
Binary file
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@fastbrowse/win32-x64",
3
- "version": "0.5.25",
3
+ "version": "0.7.0",
4
4
  "description": "The fastbrowse binary for Windows on x64.",
5
5
  "license": "MIT",
6
6
  "repository": {
@@ -1,14 +0,0 @@
1
- ../../Scripts/fastbrowse-mcp.exe,sha256=I2I08h1Brh_Z0IlP0BL0kO00C2A0VwEuhWK0ngGPh3s,47104
2
- ../../Scripts/fastbrowse.exe,sha256=Gvhgn6Rkas3oyDv32ThG3N-V1GSHRlufYuH48_FKEFo,47104
3
- _editable_impl_fastbrowse.pth,sha256=tSUp9-7xHLVBnY2rS0eA3HsN-LQmHLK1u99SbVQnoxg,30
4
- fastbrowse-0.5.25.dist-info/INSTALLER,sha256=5hhM4Q4mYTT9z6QB6PGpUAW81PGNFrYrdXMj4oM_6ak,2
5
- fastbrowse-0.5.25.dist-info/METADATA,sha256=PviO3CI9ERI53eHfNlnFhSL0Sn9CNuF-Oe-vEJqhBHg,37292
6
- fastbrowse-0.5.25.dist-info/RECORD,,
7
- fastbrowse-0.5.25.dist-info/REQUESTED,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
8
- fastbrowse-0.5.25.dist-info/WHEEL,sha256=W3fkpkm7-wf9vBI5Z-7s0eWkeM-spu78I8Neb98DeEg,87
9
- fastbrowse-0.5.25.dist-info/direct_url.json,sha256=17Yl-bY7t4MfSh0BCqmxi7aWPtTEbGMyNMuGnRAqc9o,73
10
- fastbrowse-0.5.25.dist-info/entry_points.txt,sha256=SlQAK1_4_oywTAMckx8BYuCQJva0F-QuShQPcxtUm-g,95
11
- fastbrowse-0.5.25.dist-info/licenses/LICENSE,sha256=K_U5s2m7BDEapcEX4SlvotrXk6f1rg7pohD5P6Cn9dM,1088
12
- fastbrowse-0.5.25.dist-info/licenses/NOTICE,sha256=aLaGCOpkunnLIGPoVVzNiekSAbwRskkM0M3cqNGCv9Y,1887
13
- fastbrowse-0.5.25.dist-info/uv_build.json,sha256=RBNvo1WzZ4oRRq0W9-hknpT7T8If536DEMBg9hyq_4o,2
14
- fastbrowse-0.5.25.dist-info/uv_cache.json,sha256=KkB4vTaGWPhuhzErjZXT4qSDNQTtfoENFKu2KupgUrg,194
@@ -1 +0,0 @@
1
- {"timestamp":{"secs_since_epoch":1792479826,"nanos_since_epoch":851860400},"commit":null,"tags":null,"env":{},"directories":{"src":{"secs_since_epoch":1792479826,"nanos_since_epoch":851860400}}}
Binary file