dsh-inner-browser-use 0.34.28 → 0.34.96

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/read.js CHANGED
@@ -22,7 +22,7 @@ const REGISTRY = '__dshRead'
22
22
  const VERSION = 2
23
23
 
24
24
  /** Interactive elements worth numbering, in the order a reader would meet them. */
25
- const SELECTOR = [
25
+ export const SELECTOR = [
26
26
  'a[href]',
27
27
  'button',
28
28
  'input:not([type="hidden"])',
@@ -67,7 +67,7 @@ const SIGIL = { // ui-copy-ignore
67
67
  * @param {number} version - registry version.
68
68
  * @returns {string} an expression to evaluate.
69
69
  */
70
- function seed(selector, version) {
70
+ export function seed(selector, version) {
71
71
  return `(() => {
72
72
  const w = window
73
73
  const current = w.${REGISTRY}
@@ -118,7 +118,7 @@ function seed(selector, version) {
118
118
  * @param {number} max - controls to number at most.
119
119
  * @returns {string} an expression to evaluate.
120
120
  */
121
- function snapshot(mode, budget, max) {
121
+ export function snapshot(mode, budget, max) {
122
122
  const wanted = JSON.stringify(mode)
123
123
  return `(() => {
124
124
  const reg = window.${REGISTRY}
@@ -129,10 +129,10 @@ function snapshot(mode, budget, max) {
129
129
  const name = (el) => {
130
130
  const label = el.labels && el.labels[0] ? String(el.labels[0].innerText ?? '') : ''
131
131
  const pointing = String(el.getAttribute?.('aria-labelledby') ?? '')
132
- const named = pointing.split(/\s+/).filter(Boolean).map((id) => {
132
+ const named = pointing.split(/\\s+/).filter(Boolean).map((id) => {
133
133
  const other = el.ownerDocument.getElementById(id)
134
134
  return other === null ? '' : String(other.innerText ?? other.textContent ?? '')
135
- }).join(' ').replace(/\s+/g, ' ').trim()
135
+ }).join(' ').replace(/\\s+/g, ' ').trim()
136
136
  const own = named || el.getAttribute?.('aria-label') || el.getAttribute?.('title') || el.getAttribute?.('placeholder') || ''
137
137
  const text = String(el.innerText ?? '').replace(/\\s+/g, ' ').trim()
138
138
  const value = el.tagName === 'INPUT' && el.type !== 'password' ? String(el.value ?? '') : ''
@@ -270,7 +270,7 @@ function snapshot(mode, budget, max) {
270
270
  generated image goes missing, and it is silent by default. */
271
271
  if (el.tagName === 'IMG' && el.complete && el.naturalWidth === 0) {
272
272
  missing.push({ width: Math.round(wide), height: Math.round(high) })
273
- missing[missing.length - 1].name = String(el.getAttribute('alt') ?? '').replace(/\s+/g, ' ').trim().slice(0, 60)
273
+ missing[missing.length - 1].name = String(el.getAttribute('alt') ?? '').replace(/\\s+/g, ' ').trim().slice(0, 60)
274
274
  continue
275
275
  }
276
276
  const w = el.tagName === 'CANVAS' ? el.width : el.naturalWidth || Math.round(b.width)
@@ -279,7 +279,7 @@ function snapshot(mode, budget, max) {
279
279
  }
280
280
  if (best === null) return missing.length === 0 ? null : { missing }
281
281
  /* What the read hands back about one picture: not a screen of fields. */
282
- const said = { name: String(best.el.getAttribute?.('alt') ?? best.el.getAttribute?.('aria-label') ?? '').replace(/\s+/g, ' ').trim().slice(0, 60) }
282
+ const said = { name: String(best.el.getAttribute?.('alt') ?? best.el.getAttribute?.('aria-label') ?? '').replace(/\\s+/g, ' ').trim().slice(0, 60) }
283
283
  said.kind = best.kind
284
284
  said.width = best.w
285
285
  said.height = best.h
@@ -310,7 +310,7 @@ function snapshot(mode, budget, max) {
310
310
  /* What no amount of text will tell the reader: the screen shows something
311
311
  that has no words in the tree. Counted here so every read can say it. */
312
312
  const blind = (() => {
313
- const out = { pictures: 0, canvases: 0, videos: 0, frames: 0 }
313
+ const out = { pictures: 0, canvases: 0, videos: 0, frames: 0, flash: 0 }
314
314
  const speaks = (el) => {
315
315
  const said = String(el.getAttribute?.('alt') ?? '').trim() || String(el.getAttribute?.('aria-label') ?? '').trim() || String(el.getAttribute?.('title') ?? '').trim()
316
316
  if (said.length > 1) return true
@@ -337,6 +337,44 @@ function snapshot(mode, budget, max) {
337
337
  /* A tracking pixel of a frame is not something the reader is missing: only
338
338
  frames that occupy the screen are worth a sentence. */
339
339
  for (const el of document.querySelectorAll('iframe')) if (shows(el)) out.frames += 1
340
+ /* A movie this browser refuses to play is the worst kind of blank: the page
341
+ looks loaded and nothing says why nothing happens. Count it so the read
342
+ can say it out loud. */
343
+ for (const el of document.querySelectorAll('embed, object')) {
344
+ const said = String(el.getAttribute?.('type') ?? el.getAttribute?.('src') ?? el.getAttribute?.('data') ?? el.getAttribute?.('classid') ?? '')
345
+ if (!/shockwave|flash|application\\/x-swf/i.test(said) && el.tagName !== 'EMBED') continue
346
+ if (el.tagName === 'EMBED' && !/shockwave|flash|swf/i.test(said)) continue
347
+ if (shows(el)) out.flash += 1
348
+ }
349
+ if (out.flash === 0) {
350
+ const asked = Array.from(document.querySelectorAll('a[href], iframe[src], script[src], link[href]'))
351
+ .map((el) => String(el.getAttribute?.('href') ?? el.getAttribute?.('src') ?? ''))
352
+ .filter((said) => /\\.swf(\\?|$)/i.test(said)).length
353
+ if (asked > 0) out.flash = asked
354
+ }
355
+ if (out.flash === 0) {
356
+ /* Many sites build the movie in a script or load it inside a frame, so
357
+ nothing in the tree names it. The scripts still do. */
358
+ const scripts = Array.from(document.scripts).map((el) => String(el.textContent ?? '')).join(' ')
359
+ const hits = scripts.match(/\\.swf(?=["'\\s?]|$)/gi)
360
+ if (hits !== null && hits.length > 0) out.flash = Math.min(9, hits.length)
361
+ }
362
+ if (out.flash === 0) {
363
+ /* The player may be written into a same-origin frame that nothing in the
364
+ outer tree mentions. Same origin means this page can look inside. */
365
+ try {
366
+ for (const frame of document.querySelectorAll('iframe')) {
367
+ const inside = frame.contentDocument
368
+ if (inside === null || inside === undefined) continue
369
+ const movies = inside.querySelectorAll('embed, object').length
370
+ const inner = Array.from(inside.scripts ?? []).map((el) => String(el.textContent ?? '')).join(' ')
371
+ const found = inner.match(/\\.swf(?=["'\\s?]|$)/gi)
372
+ out.flash = Math.min(9, out.flash + movies + (found === null ? 0 : found.length))
373
+ }
374
+ } catch {
375
+ /* a frame from elsewhere cannot be looked into, which is fine */
376
+ }
377
+ }
340
378
  return out
341
379
  })()
342
380
  return JSON.stringify({
@@ -372,13 +410,102 @@ export function resolve(ref) {
372
410
  const el = hit.el
373
411
  const b = el.getBoundingClientRect()
374
412
  if (b.width < 2 || b.height < 2) return JSON.stringify({ gone: true, off: true })
375
- if (b.top < 0 || b.left < 0 || b.bottom > innerHeight || b.right > innerWidth) el.scrollIntoView({ block: 'center', inline: 'center' })
413
+ /* A control inside a scrolling panel can be outside the panel while still
414
+ inside the window: the point would land on the panel's other content, and
415
+ the click would be reported against the wrong thing. Ask every clipping
416
+ ancestor, not only the window. */
417
+ const clipped = () => {
418
+ const me = el.getBoundingClientRect()
419
+ for (let up = el.parentElement; up; up = up.parentElement) {
420
+ const css = getComputedStyle(up)
421
+ const flow = css.overflowX + ' ' + css.overflowY
422
+ if (!/auto|scroll|hidden|clip/.test(flow)) continue
423
+ const box = up.getBoundingClientRect()
424
+ if (me.top < box.top - 1 || me.bottom > box.bottom + 1 || me.left < box.left - 1 || me.right > box.right + 1) return true
425
+ }
426
+ return false
427
+ }
428
+ if (b.top < 0 || b.left < 0 || b.bottom > innerHeight || b.right > innerWidth || clipped()) el.scrollIntoView({ block: 'center', inline: 'center' })
376
429
  const after = el.getBoundingClientRect()
377
430
  const s = getComputedStyle(el)
378
431
  return JSON.stringify({ gone: false, x: Math.round(after.left + after.width / 2), y: Math.round(after.top + after.height / 2), name: String(el.innerText ?? el.value ?? el.getAttribute?.('aria-label') ?? '').trim().slice(0, 80), kind: String(el.tagName).toLowerCase(), hidden: s.visibility === 'hidden' || s.display === 'none', disabled: el.disabled === true })
379
432
  })()`
380
433
  }
381
434
 
435
+ /**
436
+ * Click the control the page itself registered, instead of moving the mouse to
437
+ * it. A page whose own handlers want a click, or a control an overlay covers,
438
+ * is reachable this way; a page that watches real pointer movement is not.
439
+ * @param {number} ref - ref from a read.
440
+ * @param {{double?: boolean, right?: boolean}} [options] - how to click.
441
+ * @returns {string} an expression that answers with what it hit.
442
+ */
443
+ export function clickInDom(ref, options = {}) {
444
+ const double = options.double === true
445
+ const right = options.right === true
446
+ return `(() => {
447
+ const reg = window.${REGISTRY}
448
+ const hit = reg && reg.find(${Number(ref)})
449
+ if (!hit) return JSON.stringify({ ok: false, gone: true })
450
+ const el = hit.el
451
+ const name = String(el.innerText ?? el.value ?? el.getAttribute?.('aria-label') ?? '').trim().slice(0, 60)
452
+ try { el.scrollIntoView({ block: 'center', inline: 'center' }) } catch { /* a detached node has no place */ }
453
+ try { el.focus({ preventScroll: true }) } catch { /* not every node takes focus */ }
454
+ const send = (type, button) => el.dispatchEvent(new MouseEvent(type, { bubbles: true, cancelable: true, view: window, button, buttons: type === 'mousedown' || type === 'pointerdown' ? (button === 2 ? 2 : 1) : 0 }))
455
+ if (${right}) {
456
+ el.dispatchEvent(new MouseEvent('contextmenu', { bubbles: true, cancelable: true, view: window, button: 2 }))
457
+ } else {
458
+ send('pointerdown', 0)
459
+ send('mousedown', 0)
460
+ el.click ? el.click() : send('click', 0)
461
+ if (${double}) { el.click ? el.click() : send('click', 0) }
462
+ send('pointerup', 0)
463
+ send('mouseup', 0)
464
+ }
465
+ return JSON.stringify({ ok: true, name, tag: String(el.tagName).toLowerCase() })
466
+ })()`
467
+ }
468
+
469
+ /**
470
+ * Put text into a control through the page's own value and input events, the
471
+ * way the page's own code would; no key is pressed and no pointer moves.
472
+ * @param {number} ref - ref from a read.
473
+ * @param {string} text - what to put in.
474
+ * @param {{clear?: boolean, submit?: boolean}} [options] - replace, and send it.
475
+ * @returns {string} an expression that answers with the field's new value.
476
+ */
477
+ export function writeInDom(ref, text, options = {}) {
478
+ return `(() => {
479
+ const reg = window.${REGISTRY}
480
+ const hit = reg && reg.find(${Number(ref)})
481
+ if (!hit) return JSON.stringify({ ok: false, gone: true })
482
+ const el = hit.el
483
+ const whole = ${JSON.stringify(String(text))}
484
+ const clear = ${options.clear === true}
485
+ const submit = ${options.submit === true}
486
+ try { el.focus({ preventScroll: true }) } catch { /* a plain div takes no focus */ }
487
+ if (/^(input|textarea)$/i.test(el.tagName)) {
488
+ const proto = el.tagName === 'TEXTAREA' ? HTMLTextAreaElement.prototype : HTMLInputElement.prototype
489
+ const set = Object.getOwnPropertyDescriptor(proto, 'value')?.set
490
+ const next = clear ? whole : String(el.value ?? '') + whole
491
+ set === undefined ? (el.value = next) : set.call(el, next)
492
+ el.dispatchEvent(new Event('input', { bubbles: true }))
493
+ el.dispatchEvent(new Event('change', { bubbles: true }))
494
+ } else if (el.isContentEditable) {
495
+ el.textContent = clear ? whole : String(el.textContent ?? '') + whole
496
+ el.dispatchEvent(new InputEvent('input', { bubbles: true, data: whole, inputType: 'insertText' }))
497
+ } else {
498
+ return JSON.stringify({ ok: false, why: 'not a field' })
499
+ }
500
+ if (submit) {
501
+ const form = el.form ?? el.closest?.('form') ?? null
502
+ if (form && typeof form.requestSubmit === 'function') form.requestSubmit()
503
+ else el.dispatchEvent(new KeyboardEvent('keydown', { key: 'Enter', keyCode: 13, bubbles: true }))
504
+ }
505
+ return JSON.stringify({ ok: true, value: String(el.value ?? el.textContent ?? '').slice(0, 60) })
506
+ })()`
507
+ }
508
+
382
509
  /**
383
510
  * How big the control is and where it sits, so a picture can be of it alone.
384
511
  * @param {number} ref - ref from a read.
@@ -578,12 +705,57 @@ function asRoute(route) {
578
705
  * @param {number|{contextId?: number, sessionId?: string}} [route] - where to run it.
579
706
  * @returns {Promise<any>} the JSON-safe value.
580
707
  */
581
- async function evaluateIn(page, expression, route) {
708
+ /**
709
+ * Ask the page, but never forever. A navigation whose last request hangs can
710
+ * leave the renderer unable to answer: without a ceiling every read, click and
711
+ * type on such a page waits on the network, and the tool is killed instead of
712
+ * reporting. Timing out says so.
713
+ * @param {any} page - sidecar page.
714
+ * @param {any} params - Runtime.evaluate parameters.
715
+ * @param {string|undefined} sessionId - out-of-process frame, if any.
716
+ * @param {number} raceMs - ceiling in milliseconds.
717
+ * @returns {Promise<any>} the protocol answer.
718
+ */
719
+ async function answer(page, params, sessionId, raceMs) {
720
+ let timer = null
721
+ const call = page.cdp.send('Runtime.evaluate', params, sessionId)
722
+ const raced = await Promise.race([call, new Promise((done) => { timer = setTimeout(() => done(null), raceMs) })])
723
+ clearTimeout(timer)
724
+ if (raced !== null) return raced
725
+ /* The page is probably stuck on a request of its own. A person presses Esc
726
+ and works with what arrived; stopping the load is the same move, and it is
727
+ what makes the next look answer at all. */
728
+ const began = Number(page.navStarted ?? 0)
729
+ const fired = Number(page.loadFired ?? 0)
730
+ if (began > 0 && began > fired) await page.cdp.send('Page.stopLoading', {}).catch(() => {})
731
+ let after = null
732
+ const retry = page.cdp.send('Runtime.evaluate', params, sessionId)
733
+ const again = await Promise.race([retry, new Promise((done) => { after = setTimeout(() => done(null), 600) })])
734
+ clearTimeout(after)
735
+ if (again !== null) return again
736
+ throw new Error(`the page did not answer within ${raceMs} ms (it is stuck loading)`)
737
+ }
738
+
739
+ async function evaluateIn(page, expression, route, raceMs = 3000) {
582
740
  const where = asRoute(route)
583
741
  const params = { expression, returnByValue: true }
584
742
  if (where.contextId !== undefined) params.contextId = where.contextId
585
- const { result } = await page.cdp.send('Runtime.evaluate', params, where.sessionId)
586
- return result?.value
743
+ let said
744
+ try {
745
+ said = await answer(page, params, where.sessionId, raceMs)
746
+ } catch (error) {
747
+ /* The world or the frame is gone: the caller needs to say so, not read an
748
+ empty page as a page that is empty. */
749
+ throw new Error(`the page did not answer (${String(error?.message ?? error).split('\n')[0]})`)
750
+ }
751
+ /* A page script that throws answers with no value and one line of why. Read
752
+ as an empty result, that is indistinguishable from a page with nothing on
753
+ it, and a broken page then looks empty instead of broken. */
754
+ if (said?.exceptionDetails !== undefined) {
755
+ const why = String(said.exceptionDetails.exception?.description ?? said.exceptionDetails.text ?? 'unknown error')
756
+ throw new Error(`the page's own script failed (${why.split('\n')[0].slice(0, 160)})`)
757
+ }
758
+ return said?.result?.value
587
759
  }
588
760
 
589
761
  /** @param {any} page - sidecar page. @param {any} [route] - world to seed. */
@@ -666,15 +838,17 @@ function routeOf(page, frame, slot) {
666
838
  * @returns {Promise<{text: string, controls: any[], frames: number}>} what the frames add.
667
839
  */
668
840
  async function readFrames(page, mode, budget) {
669
- if (typeof page.frameList !== 'function') return { text: '', controls: [], frames: 0 }
841
+ const none = { text: '', controls: [], frames: 0, blind: { pictures: 0, canvases: 0, videos: 0, frames: 0, flash: 0 } }
842
+ if (typeof page.frameList !== 'function') return none
670
843
  let frames = []
671
844
  try {
672
845
  frames = await page.frameList()
673
846
  } catch (error) {
674
- return { text: '', controls: [], frames: 0 }
847
+ return none
675
848
  }
676
849
  const children = frames.slice(1)
677
850
  const controls = []
851
+ const blind = { ...none.blind }
678
852
  let text = ''
679
853
  for (let slot = 0; slot < children.length && slot < 8; slot += 1) {
680
854
  const route = routeOf(page, children[slot], slot + 1)
@@ -689,11 +863,14 @@ async function readFrames(page, mode, budget) {
689
863
  controls.push({ ...item, ref: tag + Number(item.ref), frame: slot + 1, x: Number(item.x ?? 0) + offset.x, y: Number(item.y ?? 0) + offset.y })
690
864
  }
691
865
  if (text.length < 400 && typeof inside.text === 'string' && inside.text !== '') text += `${text === '' ? '' : '\n'}${inside.text.slice(0, 400)}`
866
+ /* What is out of reach inside a frame is out of reach here too: a canvas
867
+ or a movie in a frame is exactly the blank the reader cannot explain. */
868
+ for (const key of Object.keys(blind)) blind[key] += Math.max(0, Math.round(Number(inside.blind?.[key] ?? 0)))
692
869
  } catch (error) {
693
870
  continue
694
871
  }
695
872
  }
696
- return { text, controls, frames: children.length }
873
+ return { text, controls, frames: children.length, blind }
697
874
  }
698
875
 
699
876
  /** Decode a ref into the frame it lives in and the number that frame knows. */
@@ -728,16 +905,359 @@ export async function contextFor(page, ref) {
728
905
  * @param {{mode?: string, maxChars?: number, max?: number}} [options] - how much to read.
729
906
  * @returns {Promise<{url: string, title: string, text: string, textChars: number, scroll?: any, focused?: any, controls: any[]}>} the read.
730
907
  */
908
+ const EMPTY_BLIND = { pictures: 0, canvases: 0, videos: 0, frames: 0, flash: 0 }
909
+
910
+ /**
911
+ * A read that failed: the address and the reason, with nothing invented.
912
+ * @param {any} page - sidecar page.
913
+ * @param {string} fault - why the page could not be read.
914
+ * @returns {any} the read.
915
+ */
916
+ export function faultRead(page, fault) {
917
+ const empty = { text: '', textChars: 0, controls: [], picture: null, scroll: undefined, focused: null, blind: EMPTY_BLIND }
918
+ return Object.assign({ url: String(page.url ?? ''), title: String(page.title ?? ''), fault: String(fault) }, empty)
919
+ }
920
+
921
+ /**
922
+ * The page as a person reads it: headings, paragraphs, list items and links, as
923
+ * Markdown, with no markup and no controls to number. A long article costs a
924
+ * fraction of a snapshot, which is what makes reading a real page affordable.
925
+ * @param {number} budget - character ceiling for the returned text.
926
+ * @returns {string} a page script that returns JSON.
927
+ */
928
+ export function article(budget) {
929
+ return `(() => {
930
+ const limit = ${budget}
931
+ const pick = document.querySelector('article, main, [role="main"]') || document.body
932
+ if (!pick) return JSON.stringify({ text: '', chars: 0, linkCount: 0, truncated: false })
933
+ const skip = new Set(['SCRIPT', 'STYLE', 'NOSCRIPT', 'TEMPLATE', 'SVG', 'IFRAME', 'CANVAS', 'VIDEO', 'HEADER', 'FOOTER', 'NAV', 'ASIDE'])
934
+ const lines = []
935
+ let length = 0
936
+ let truncated = false
937
+ let linkCount = 0
938
+ const walk = (node) => {
939
+ if (truncated || node === null || node === undefined) return
940
+ if (node.nodeType === 3) {
941
+ const text = String(node.textContent ?? '').replace(/\\s+/g, ' ').trim()
942
+ if (text !== '') { lines.push(text); length += text.length + 1 }
943
+ if (length >= limit) truncated = true
944
+ return
945
+ }
946
+ if (node.nodeType !== 1) return
947
+ const tag = node.tagName
948
+ if (skip.has(tag) || node.hidden === true || node.getAttribute?.('aria-hidden') === 'true') return
949
+ const style = getComputedStyle(node)
950
+ if (style.display === 'none' || style.visibility === 'hidden') return
951
+ if (/^H[1-6]$/.test(tag)) {
952
+ const text = String(node.innerText ?? '').replace(/\\s+/g, ' ').trim()
953
+ if (text !== '') { lines.push('#' .repeat(Number(tag.slice(1))) + ' ' + text); length += text.length + Number(tag.slice(1)) + 2 }
954
+ if (length >= limit) truncated = true
955
+ return
956
+ }
957
+ if (tag === 'A') {
958
+ linkCount += 1
959
+ const text = String(node.innerText ?? '').replace(/\\s+/g, ' ').trim()
960
+ const href = String(node.href ?? '')
961
+ if (text !== '' && /^https?:/.test(href)) {
962
+ const line = '[' + text.slice(0, 120) + '](' + href + ')'
963
+ lines.push(line); length += line.length + 1
964
+ } else if (text !== '') { lines.push(text); length += text.length + 1 }
965
+ if (length >= limit) truncated = true
966
+ return
967
+ }
968
+ if (tag === 'LI') {
969
+ const text = String(node.innerText ?? '').replace(/\\s+/g, ' ').trim()
970
+ if (text !== '') { lines.push('- ' + text); length += text.length + 3 }
971
+ if (length >= limit) truncated = true
972
+ return
973
+ }
974
+ if (tag === 'PRE' || tag === 'CODE') {
975
+ const text = String(node.innerText ?? '').slice(0, 1200)
976
+ if (text !== '') { lines.push('~~~'); lines.push(text); lines.push('~~~'); length += text.length + 8 }
977
+ if (length >= limit) truncated = true
978
+ return
979
+ }
980
+ for (const child of node.childNodes) walk(child)
981
+ }
982
+ walk(pick)
983
+ let text = lines.filter((one) => one !== '').join(String.fromCharCode(10)).trim()
984
+ if (text.length > limit) { text = text.slice(0, limit); truncated = true }
985
+ const allLinks = document.querySelectorAll('a[href]').length
986
+ return JSON.stringify({ text, chars: text.length, linkCount: allLinks, truncated })
987
+ })()`
988
+ }
989
+
990
+ /**
991
+ * Find words on the page: numbered controls whose name matches, then plain text
992
+ * that is not a control. A phone-sized page has hundreds of lines; finding the
993
+ * three that mention what the caller wants costs a fraction of reading them.
994
+ * @param {string} query - words to look for.
995
+ * @param {boolean} asRegex - treat the words as a regular expression source.
996
+ * @param {number} limit - matches to return.
997
+ * @returns {string} an expression to evaluate.
998
+ */
999
+ export function finder(query, asRegex, limit) {
1000
+ return `(() => {
1001
+ const reg = window.${REGISTRY}
1002
+ if (!reg) return JSON.stringify({ broken: true })
1003
+ const pattern = ${asRegex === true} ? new RegExp(${JSON.stringify(String(query))}, 'i') : null
1004
+ const needle = ${JSON.stringify(String(query).toLowerCase())}
1005
+ const test = (text) => pattern === null ? text.toLowerCase().includes(needle) : pattern.test(text)
1006
+ const out = []
1007
+ const seen = new Set()
1008
+ /* A control inside a web component lives in a shadow root, where a document
1009
+ query never looks; collect the open roots first, cheaply and bounded. */
1010
+ const roots = [document]
1011
+ for (let i = 0; i < roots.length && roots.length <= 200; i += 1) {
1012
+ for (const host of roots[i].querySelectorAll('*')) {
1013
+ if (host.shadowRoot) roots.push(host.shadowRoot)
1014
+ }
1015
+ }
1016
+ const found = []
1017
+ for (const root of roots) for (const el of root.querySelectorAll(reg.selector)) found.push(el)
1018
+ for (const el of found) {
1019
+ if (out.length >= ${limit}) break
1020
+ if (!el || !el.isConnected) continue
1021
+ const label = String(el.innerText ?? el.value ?? el.getAttribute?.('aria-label') ?? el.getAttribute?.('placeholder') ?? el.getAttribute?.('title') ?? '').replace(/\\s+/g, ' ').trim()
1022
+ if (label === '' || label.length > 300 || !test(label)) continue
1023
+ const b = el.getBoundingClientRect()
1024
+ seen.add(el)
1025
+ out.push({ ref: reg.id(el), name: label.slice(0, 120), tag: String(el.tagName).toLowerCase(), x: Math.round(b.x), y: Math.round(b.y), view: b.bottom > 0 && b.top < innerHeight })
1026
+ }
1027
+ if (out.length < ${limit} && document.body) {
1028
+ const walker = document.createTreeWalker(document.body, NodeFilter.SHOW_TEXT)
1029
+ let node = walker.nextNode()
1030
+ while (node !== null && out.length < ${limit}) {
1031
+ const parent = node.parentElement
1032
+ if (parent !== null && !seen.has(parent) && !['SCRIPT', 'STYLE', 'NOSCRIPT'].includes(parent.tagName)) {
1033
+ const text = String(node.nodeValue ?? '').replace(/\\s+/g, ' ').trim()
1034
+ if (text !== '' && text.length < 400 && test(text)) {
1035
+ const b = parent.getBoundingClientRect()
1036
+ out.push({ ref: 0, name: text.slice(0, 120), tag: String(parent.tagName).toLowerCase(), x: Math.round(b.x), y: Math.round(b.y), view: b.bottom > 0 && b.top < innerHeight })
1037
+ }
1038
+ }
1039
+ node = walker.nextNode()
1040
+ }
1041
+ }
1042
+ return JSON.stringify({ matches: out })
1043
+ })()`
1044
+ }
1045
+
1046
+ /**
1047
+ * Run the finder against a page and give back its matches.
1048
+ * @param {any} page - sidecar page.
1049
+ * @param {string} query - words or pattern.
1050
+ * @param {{regex?: boolean, limit?: number}} [options] - how to look.
1051
+ * @returns {Promise<{matches: any[], fault?: string}>} what matched.
1052
+ */
1053
+ export async function findOnPage(page, query, options = {}) {
1054
+ const limit = Math.max(1, Math.min(40, Number(options.limit ?? 12)))
1055
+ try {
1056
+ await seedPage(page)
1057
+ const raw = String((await evaluateIn(page, finder(query, options.regex === true, limit))) ?? '{}')
1058
+ const said = JSON.parse(raw)
1059
+ if (said?.broken === true) return { matches: [], fault: 'the page has not been read yet — call browser_view first' }
1060
+ return said
1061
+ } catch (error) {
1062
+ return { matches: [], fault: String(error?.message ?? error) }
1063
+ }
1064
+ }
1065
+
1066
+ /**
1067
+ * Collect the page as blocks of prose, each with the heading it sits under and
1068
+ * how much of it is links. Navigation, sidebars and footers are mostly links;
1069
+ * Readability's oldest signal is that such a block is not the content, and it
1070
+ * costs one number to carry instead of a guess.
1071
+ * @param {number} budget - characters of block text to collect.
1072
+ * @returns {string} an expression to evaluate.
1073
+ */
1074
+ export function blocks(budget) {
1075
+ return `(() => {
1076
+ const root = document.querySelector('article, main, [role="main"]') || document.body
1077
+ if (!root) return JSON.stringify({ blocks: [] })
1078
+ const limit = ${budget}
1079
+ const BLOCK = new Set(['P', 'LI', 'TD', 'TH', 'BLOCKQUOTE', 'PRE', 'FIGCAPTION', 'DD', 'DT', 'SUMMARY'])
1080
+ const SKIP = new Set(['SCRIPT', 'STYLE', 'NOSCRIPT', 'TEMPLATE', 'SVG', 'IFRAME', 'CANVAS', 'VIDEO', 'HEADER', 'FOOTER', 'NAV', 'ASIDE', 'FORM', 'BUTTON', 'SELECT', 'TEXTAREA'])
1081
+ const out = []
1082
+ const seen = new Set()
1083
+ let chars = 0
1084
+ let heading = ''
1085
+ const flatten = (text) => String(text ?? '').replace(/\\s+/g, ' ').trim()
1086
+ const push = (el) => {
1087
+ if (chars >= limit) return
1088
+ const text = flatten(el.innerText ?? el.textContent ?? '')
1089
+ if (text === '' || text.length > 4000) return
1090
+ let linkChars = 0
1091
+ for (const link of el.querySelectorAll('a')) linkChars += flatten(link.innerText ?? '').length
1092
+ if (el.tagName === 'A') linkChars += text.length
1093
+ const density = text.length === 0 ? 0 : Math.min(1, linkChars / text.length)
1094
+ out.push({ h: heading, t: text, d: Math.round(density * 100) / 100 })
1095
+ chars += text.length
1096
+ }
1097
+ const walk = (el) => {
1098
+ if (chars >= limit || el === null) return
1099
+ const tag = el.tagName
1100
+ if (SKIP.has(tag) || el.hidden === true || el.getAttribute?.('aria-hidden') === 'true') return
1101
+ const style = getComputedStyle(el)
1102
+ if (style.display === 'none' || style.visibility === 'hidden') return
1103
+ if (/^H[1-6]$/.test(tag)) {
1104
+ heading = flatten(el.innerText ?? '')
1105
+ return
1106
+ }
1107
+ if (BLOCK.has(tag)) {
1108
+ if (!seen.has(el)) { seen.add(el); push(el) }
1109
+ return
1110
+ }
1111
+ for (const child of el.children) walk(child)
1112
+ }
1113
+ walk(root)
1114
+ const title = document.title
1115
+ return JSON.stringify({ title, blocks: out })
1116
+ })()`
1117
+ }
1118
+
1119
+ /** Words as the ranker sees them: ASCII runs, and every CJK character on its own. */
1120
+ const tokenPattern = /[a-z0-9]+|[\u3400-\u9fff]/g
1121
+
1122
+ /**
1123
+ * Cut text into comparable terms.
1124
+ * @param {string} text - any text.
1125
+ * @returns {string[]} lower-case terms.
1126
+ */
1127
+ export function terms(text) {
1128
+ return String(text ?? '').toLowerCase().match(tokenPattern) ?? []
1129
+ }
1130
+
1131
+ /** How often a term may occur before it stops adding meaning. */
1132
+ const SATURATION = 1.5
1133
+
1134
+ /**
1135
+ * Rank blocks against a query the way a search engine ranks a passage: BM25,
1136
+ * with the heading counted as a place a term can match. Then read only the
1137
+ * blocks that carry the query, which is the token saving that matters.
1138
+ * @param {{h?: string, t: string, d?: number}[]} blocks - page blocks.
1139
+ * @param {string} query - what the caller wants to know.
1140
+ * @returns {{block: any, score: number}[]} blocks, best first.
1141
+ */
1142
+ export function rank(blocks, query) {
1143
+ const asked = terms(query)
1144
+ if (asked.length === 0) return []
1145
+ const docs = blocks.map((block) => ({ block, terms: terms(`${block.h ?? ''} ${block.t}`) }))
1146
+ const average = docs.reduce((sum, one) => sum + one.terms.length, 0) / Math.max(1, docs.length)
1147
+ const seen = new Map()
1148
+ for (const one of docs) for (const term of new Set(one.terms)) seen.set(term, (seen.get(term) ?? 0) + 1)
1149
+ const total = Math.max(1, docs.length)
1150
+ const scored = []
1151
+ for (const one of docs) {
1152
+ const counts = new Map()
1153
+ for (const term of one.terms) counts.set(term, (counts.get(term) ?? 0) + 1)
1154
+ let score = 0
1155
+ for (const term of asked) {
1156
+ const seenIn = seen.get(term) ?? 0
1157
+ if (seenIn === 0) continue
1158
+ const idf = Math.log(1 + (total - seenIn + 0.5) / (seenIn + 0.5))
1159
+ const tf = counts.get(term) ?? 0
1160
+ if (tf === 0) continue
1161
+ const length = one.terms.length
1162
+ score += idf * ((tf * (SATURATION + 1)) / (tf + SATURATION * (1 - 0.75 + (0.75 * length) / average)))
1163
+ }
1164
+ /* A block that is nearly all link is a menu: it matches words without
1165
+ saying anything about them. */
1166
+ score *= 1 - 0.8 * Number(one.block.d ?? 0)
1167
+ if (score > 0) scored.push({ block: one.block, score })
1168
+ }
1169
+ scored.sort((a, b) => b.score - a.score)
1170
+ return scored
1171
+ }
1172
+
1173
+ /**
1174
+ * Read only the passages of a page that answer a query, so a long page costs
1175
+ * what the answer costs instead of what the page costs.
1176
+ * @param {any} page - sidecar page.
1177
+ * @param {string} query - what the caller wants to know.
1178
+ * @param {{maxChars?: number, limit?: number}} [options] - ceilings.
1179
+ * @returns {Promise<{title: string, url: string, text: string, chars: number, matched: number, total: number, fault?: string}>} the passages.
1180
+ */
1181
+ export async function readRelevant(page, query, options = {}) {
1182
+ const budget = Math.max(400, Math.min(24_000, Number(options.maxChars ?? 4000)))
1183
+ try {
1184
+ const raw = String((await evaluateIn(page, blocks(budget * 8))) ?? '{}')
1185
+ const said = JSON.parse(raw)
1186
+ const all = Array.isArray(said?.blocks) ? said.blocks : []
1187
+ const ranked = rank(all, query)
1188
+ if (ranked.length === 0) {
1189
+ return { title: String(said?.title ?? page.title ?? ''), url: String(page.url ?? ''), text: '', chars: 0, matched: 0, total: all.length }
1190
+ }
1191
+ const limit = Math.max(1, Math.min(20, Number(options.limit ?? 8)))
1192
+ const lines = []
1193
+ let used = 0
1194
+ let matched = 0
1195
+ let lastHeading = ''
1196
+ for (const row of ranked.slice(0, limit)) {
1197
+ const body = String(row.block.t ?? '')
1198
+ if (used + body.length > budget) continue
1199
+ const heading = String(row.block.h ?? '')
1200
+ if (heading !== '' && heading !== lastHeading) { lines.push(`## ${heading}`); lastHeading = heading }
1201
+ lines.push(body)
1202
+ used += body.length
1203
+ matched += 1
1204
+ if (used >= budget) break
1205
+ }
1206
+ return {
1207
+ title: String(said?.title ?? page.title ?? ''),
1208
+ url: String(page.url ?? ''),
1209
+ text: lines.join('\n\n'),
1210
+ chars: used,
1211
+ matched,
1212
+ total: all.length,
1213
+ }
1214
+ } catch (error) {
1215
+ return { title: '', url: String(page.url ?? ''), text: '', chars: 0, matched: 0, total: 0, fault: String(error?.message ?? error) }
1216
+ }
1217
+ }
1218
+
1219
+ /**
1220
+ * Read the page as Markdown, through the same deadline as a snapshot.
1221
+ * @param {any} page - sidecar page.
1222
+ * @param {{maxChars?: number}} [options] - character ceiling.
1223
+ * @returns {Promise<{url: string, title: string, text: string, chars: number, linkCount: number, truncated: boolean, fault?: string}>} what it says.
1224
+ */
1225
+ export async function readArticle(page, options = {}) {
1226
+ const budget = Math.max(400, Math.min(24_000, Number(options.maxChars ?? 6000)))
1227
+ try {
1228
+ const raw = String((await evaluateIn(page, article(budget))) ?? '{}')
1229
+ const said = JSON.parse(raw)
1230
+ return {
1231
+ url: String(page.url ?? ''),
1232
+ title: String(await evaluateIn(page, 'document.title').catch(() => page.title ?? '') ?? ''),
1233
+ text: String(said?.text ?? ''),
1234
+ chars: Number(said?.chars ?? 0),
1235
+ linkCount: Number(said?.linkCount ?? 0),
1236
+ truncated: said?.truncated === true,
1237
+ }
1238
+ } catch (error) {
1239
+ return { url: String(page.url ?? ''), title: String(page.title ?? ''), text: '', chars: 0, linkCount: 0, truncated: false, fault: String(error?.message ?? error) }
1240
+ }
1241
+ }
1242
+
731
1243
  export async function readPage(page, options = {}) {
732
1244
  const mode = String(options.mode ?? 'text')
733
1245
  const maxChars = Number.isFinite(Number(options.maxChars)) ? Math.max(400, Math.min(24_000, Number(options.maxChars))) : 6000
734
1246
  const max = Number.isFinite(Number(options.max)) ? Math.max(5, Math.min(200, Number(options.max))) : 90
735
- await seedPage(page)
736
- let read = await evaluateSnapshot(page, mode, maxChars, max)
737
- if (read?.broken === true) {
738
- await seedPage(page)
739
- read = await evaluateSnapshot(page, mode, maxChars, max)
1247
+ /* A page that refuses to run the reader gets two tries and then one line
1248
+ saying why; an empty page must not be confused with a broken one. */
1249
+ let fault = ''
1250
+ let read = {}
1251
+ for (let attempt = 0; attempt < 2 && fault === ''; attempt += 1) {
1252
+ try {
1253
+ await seedPage(page)
1254
+ read = await evaluateSnapshot(page, mode, maxChars, max)
1255
+ if (read?.broken === true) fault = attempt === 1 ? 'the page replied with something that was not a read' : ''
1256
+ } catch (error) {
1257
+ fault = String(error?.message ?? error)
1258
+ }
740
1259
  }
1260
+ if (fault !== '') return faultRead(page, fault)
741
1261
  const framed = await readFrames(page, mode, Math.min(maxChars, 1200))
742
1262
  if (framed.controls.length > 0) read = { ...read, controls: [...(read.controls ?? []), ...framed.controls] }
743
1263
  if (framed.text !== '') read = { ...read, text: `${String(read.text ?? '')}${String(read.text ?? '') === '' ? '' : '\n'}${framed.text}` }
@@ -750,7 +1270,11 @@ export async function readPage(page, options = {}) {
750
1270
  scroll: read?.scroll,
751
1271
  focused: read?.focused ?? null,
752
1272
  controls: Array.isArray(read?.controls) ? read.controls : [],
753
- blind: blindSpots(read?.blind, framed.frames),
1273
+ blind: (() => {
1274
+ const own = blindSpots(read?.blind, framed.frames)
1275
+ for (const key of Object.keys(own)) own[key] += Number(framed.blind?.[key] ?? 0)
1276
+ return own
1277
+ })(),
754
1278
  }
755
1279
  }
756
1280
 
@@ -758,7 +1282,7 @@ export async function readPage(page, options = {}) {
758
1282
  * Merge what the page counted with what the frame reader managed to see.
759
1283
  * @param {any} said - counts from the page.
760
1284
  * @param {number} [read] - frames that answered.
761
- * @returns {{pictures: number, canvases: number, videos: number, frames: number}} what text cannot show.
1285
+ * @returns {{pictures: number, canvases: number, videos: number, frames: number, flash: number}} what text cannot show.
762
1286
  */
763
1287
  export function blindSpots(said, read) {
764
1288
  const n = (value) => (Number.isFinite(Number(value)) ? Math.max(0, Math.round(Number(value))) : 0)
@@ -766,7 +1290,7 @@ export function blindSpots(said, read) {
766
1290
  many exist, tracking pixels included. The screen is what the reader is
767
1291
  missing, so its count wins whenever the page could count at all. */
768
1292
  const frames = said !== undefined && said !== null && said.frames !== undefined ? n(said.frames) : n(read)
769
- return { pictures: n(said?.pictures), canvases: n(said?.canvases), videos: n(said?.videos), frames }
1293
+ return { pictures: n(said?.pictures), canvases: n(said?.canvases), videos: n(said?.videos), frames, flash: n(said?.flash) }
770
1294
  }
771
1295
 
772
1296
  /**
@@ -833,15 +1357,69 @@ function sigilOf(control) {
833
1357
  export function renderPage(read, options = {}) {
834
1358
  const mode = String(options.mode ?? 'text')
835
1359
  const maxChars = Number(options.maxChars ?? 6000)
1360
+ const maxControls = Number(options.maxControls ?? 140)
836
1361
  const lines = [read.url]
837
1362
  /* A browser error page is not a site with nothing on it: it is a site that
838
1363
  never arrived. Saying so is the only way the reader stops looking for the
839
1364
  content that would have been there. */
840
- if (String(read.url).startsWith('chrome-error://')) lines.push('the page did not load — sidecar_nav to open the address again, or sidecar_open if the browser is gone')
1365
+ if (String(read.url).startsWith('chrome-error://')) lines.push('the page did not load — browser_nav to open the address again, or browser_open if the browser is gone')
1366
+ if (String(read.fault ?? '') !== '') lines.push(`the page did not answer when read — ${String(read.fault).slice(0, 200)}`)
841
1367
  if (read.title !== '') lines.push(`# ${read.title}`)
1368
+ /* A map is for a page too big to read: what it is made of and where the
1369
+ things worth clicking are, without the words. One screen of landmarks
1370
+ instead of forty thousand characters of page. */
1371
+ if (mode === 'map') {
1372
+ const kinds = new Map()
1373
+ for (const control of read.controls) kinds.set(sigilOf(control), Number(kinds.get(sigilOf(control)) ?? 0) + 1)
1374
+ const kindsText = [...kinds].map(([kind, count]) => `${kind}×${count}`).join(', ')
1375
+ lines.push(`map: ${String(read.controls.length)} controls${kindsText === '' ? '' : ` — ${kindsText}`}`)
1376
+ const seenNames = new Set()
1377
+ let listedNames = 0
1378
+ for (const control of read.controls) {
1379
+ const key = `${sigilOf(control)}|${control.name}`
1380
+ if (seenNames.has(key)) continue
1381
+ seenNames.add(key)
1382
+ lines.push(`${String(control.ref)} [${sigilOf(control)}] ${control.name === '' ? '(unnamed)' : control.name}`)
1383
+ listedNames += 1
1384
+ if (listedNames >= 24) break
1385
+ }
1386
+ const rest = read.controls.length - listedNames
1387
+ if (rest > 0) lines.push(`… ${String(rest)} more, counted above — browser_view mode links or forms lists every one`)
1388
+ const words = Number(read.textChars ?? String(read.text ?? '').length)
1389
+ if (words > 0) lines.push(`words: ${String(words)} characters — browser_read for the text, or browser_read with query for one answer`)
1390
+ if (read.focused) lines.push(`focus ${String(read.focused.ref)} [${String(read.focused.kind)}] ${String(read.focused.name)}`)
1391
+ const out = lines.join('\n')
1392
+ return out.length > maxChars ? `${out.slice(0, Math.max(0, maxChars - 40))}\n… (cut at ${String(maxChars)} characters)` : out
1393
+ }
842
1394
  const shown = read.controls.filter((control) => control.view !== false || mode === 'full')
843
1395
  const hidden = read.controls.length - shown.length
844
- for (const control of shown) {
1396
+ /* A page with hundreds of rows would spend the whole budget on numbers and
1397
+ leave no room for the words that say what the page is; the rest keep their
1398
+ refs and come back with a narrower mode or after a scroll. */
1399
+ const listed = shown.length > maxControls ? shown.slice(0, maxControls) : shown
1400
+ /* A listing page repeats the same word down the page: thirty "Add to cart",
1401
+ a header menu repeated in the footer. A person reading it sees one idea,
1402
+ so it gets one line with a count; the first ref still clicks, and mode
1403
+ links or forms lists every one when a caller needs a particular row. */
1404
+ const tally = new Map()
1405
+ if (mode === 'text') {
1406
+ for (const control of listed) {
1407
+ const key = `${sigilOf(control)}|${control.name}|${control.checked ? 1 : 0}`
1408
+ const seen = tally.get(key)
1409
+ if (seen === undefined) tally.set(key, { first: control, count: 1 })
1410
+ else seen.count += 1
1411
+ }
1412
+ }
1413
+ const repeated = (control) => tally.get(`${sigilOf(control)}|${control.name}|${control.checked ? 1 : 0}`)
1414
+ const done = new Set()
1415
+ for (const control of listed) {
1416
+ const group = mode === 'text' ? repeated(control) : undefined
1417
+ if (group !== undefined && group.count > 2) {
1418
+ if (done.has(group)) continue
1419
+ done.add(group)
1420
+ lines.push(`${control.ref} [${sigilOf(control)}] ${control.name === '' ? '(unnamed)' : control.name} ×${group.count}`)
1421
+ continue
1422
+ }
845
1423
  let line = `${control.ref} [${sigilOf(control)}] ${control.name === '' ? '(unnamed)' : control.name}`
846
1424
  if (control.kind === 'checkbox' || control.kind === 'radio') line += control.checked ? ' (checked)' : ' (unchecked)'
847
1425
  else if (control.value !== '' && control.value !== control.name) line += ` = ${control.value}`
@@ -850,12 +1428,32 @@ export function renderPage(read, options = {}) {
850
1428
  lines.push(line)
851
1429
  }
852
1430
  if (hidden > 0 && mode !== 'links' && mode !== 'forms') lines.push(`… ${hidden} more controls off screen`)
1431
+ if (shown.length > listed.length) lines.push(`… ${shown.length - listed.length} more controls on this screen — browser_scroll, or ask for mode links or forms`)
853
1432
  if (read.focused) lines.push(`focus ${read.focused.ref} [${read.focused.kind}] ${read.focused.name}`)
854
- const body = read.text
1433
+ let body = read.text
1434
+ /* A listing page's words are its button labels again: printing them twice
1435
+ doubles the cost of the read. Only when most of the screen's words came
1436
+ from controls — an article's prose stays. */
1437
+ if (mode === 'text' && body.length > 200 && listed.length > 1) {
1438
+ let covered = 0
1439
+ for (const control of listed) {
1440
+ const name = String(control.name ?? '')
1441
+ if (name.length < 3) continue
1442
+ let at = body.indexOf(name)
1443
+ while (at !== -1 && covered < body.length) {
1444
+ covered += name.length
1445
+ at = body.indexOf(name, at + name.length)
1446
+ }
1447
+ }
1448
+ if (covered / body.length > 0.55) body = ''
1449
+ }
855
1450
  if (body !== '') {
856
1451
  lines.push('')
857
1452
  lines.push(body)
858
1453
  if (read.textChars > body.length) lines.push(`… ${read.textChars - body.length} more characters`)
1454
+ } else if (read.text !== '') {
1455
+ lines.push('')
1456
+ lines.push(`the words here are the ${listed.length} controls above — browser_view {mode:'full'} for the page's own wording`)
859
1457
  }
860
1458
  if (read.blind !== undefined && read.blind !== null) {
861
1459
  const bits = []
@@ -863,9 +1461,15 @@ export function renderPage(read, options = {}) {
863
1461
  if (read.blind.canvases > 0) bits.push(`${read.blind.canvases} drawing surface(s)`)
864
1462
  if (read.blind.videos > 0) bits.push(`${read.blind.videos} video(s)`)
865
1463
  if (read.blind.frames > 0) bits.push(`${read.blind.frames} embedded frame(s)`)
1464
+ if (read.blind.flash > 0) bits.push(`${read.blind.flash} Flash movie(s) this browser cannot play — look for the site's own H5 or micro-client version`)
866
1465
  /* Only when something is actually out of reach: a line saying "nothing is
867
1466
  hidden" on every read is a line the reader learns to skip. */
868
- if (bits.length > 0) lines.push(`text cannot show: ${bits.join(', ')} — sidecar_grab takes the picture, sidecar_shot shows the screen`)
1467
+ if (bits.length > 0) lines.push(`text cannot show: ${bits.join(', ')} — browser_grab takes the picture, browser_shot shows the screen`)
1468
+ /* Almost no words but frames on screen is the shape of a plugin game. Saying
1469
+ it here is the difference between "blank page" and "this needs Flash". */
1470
+ if (read.blind.flash === 0 && read.blind.frames > 0 && Number(read.textChars ?? 0) < 400) {
1471
+ lines.push(`${read.blind.frames} frame(s) and almost no text: the content is inside them — if it stays blank it may be a plugin game this browser cannot play; look for the site's H5 or micro-client version`)
1472
+ }
869
1473
  }
870
1474
  if (read.picture && Array.isArray(read.picture.missing)) {
871
1475
  /* Something was meant to be here and is not: say that instead of nothing. */
@@ -877,7 +1481,7 @@ export function renderPage(read, options = {}) {
877
1481
  const pic = read.picture
878
1482
  const look = Number(pic.flat) >= 0.9 ? 'mostly one colour' : Number(pic.colors) <= 3 ? 'a few colours' : 'a picture with detail'
879
1483
  const size = `${Math.round(Number(pic.width) || 0)}x${Math.round(Number(pic.height) || 0)}`
880
- lines.push(`picture ${size} ${look}${pic.name === '' ? '' : ` "${pic.name}"`} — sidecar_grab saves it as a file`)
1484
+ lines.push(`picture ${size} ${look}${pic.name === '' ? '' : ` "${pic.name}"`} — browser_grab saves it as a file`)
881
1485
  }
882
1486
  if (read.scroll && read.scroll.height > read.scroll.viewport + 40) {
883
1487
  lines.push(`scroll ${read.scroll.y}/${read.scroll.height - read.scroll.viewport}`)
@@ -886,7 +1490,7 @@ export function renderPage(read, options = {}) {
886
1490
  }
887
1491
 
888
1492
  /** A ref that was valid on the previous read and is no longer here. */
889
- export const REF_GONE = 'That control is gone: the page changed since the read. Call sidecar_view and use the new number.'
1493
+ export const REF_GONE = 'That control is gone: the page changed since the read. Call browser_view and use the new number.'
890
1494
 
891
1495
  /**
892
1496
  * The control itself, not its number: what it is, what it is called, and where