dsh-inner-browser-use 0.27.0 → 0.34.95

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 ?? '') : ''
@@ -257,13 +257,20 @@ function snapshot(mode, budget, max) {
257
257
  const missing = []
258
258
  for (const el of document.querySelectorAll('img,canvas')) {
259
259
  const b = box(el)
260
- if (b.width < 64 || b.height < 64 || b.width * b.height < 8000) continue
260
+ /* A picture that failed to arrive shrinks to an error icon, so its own
261
+ declared size is what the page meant to show. */
262
+ const askW = Number(el.getAttribute?.('width')) || 0
263
+ const askH = Number(el.getAttribute?.('height')) || 0
264
+ const wide = Math.max(b.width, askW)
265
+ const high = Math.max(b.height, askH)
266
+ if (wide < 64 || high < 64 || wide * high < 8000) continue
261
267
  const s = getComputedStyle(el)
262
268
  if (s.visibility === 'hidden' || s.display === 'none') continue
263
269
  /* A picture slot that never got its picture is the commonest way a
264
270
  generated image goes missing, and it is silent by default. */
265
271
  if (el.tagName === 'IMG' && el.complete && el.naturalWidth === 0) {
266
- missing.push({ width: Math.round(b.width), height: Math.round(b.height), name: String(el.getAttribute('alt') ?? '').replace(/\s+/g, ' ').trim().slice(0, 60) })
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)
267
274
  continue
268
275
  }
269
276
  const w = el.tagName === 'CANVAS' ? el.width : el.naturalWidth || Math.round(b.width)
@@ -272,7 +279,7 @@ function snapshot(mode, budget, max) {
272
279
  }
273
280
  if (best === null) return missing.length === 0 ? null : { missing }
274
281
  /* What the read hands back about one picture: not a screen of fields. */
275
- 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) }
276
283
  said.kind = best.kind
277
284
  said.width = best.w
278
285
  said.height = best.h
@@ -300,6 +307,76 @@ function snapshot(mode, budget, max) {
300
307
  }
301
308
  return said
302
309
  })()
310
+ /* What no amount of text will tell the reader: the screen shows something
311
+ that has no words in the tree. Counted here so every read can say it. */
312
+ const blind = (() => {
313
+ const out = { pictures: 0, canvases: 0, videos: 0, frames: 0, flash: 0 }
314
+ const speaks = (el) => {
315
+ const said = String(el.getAttribute?.('alt') ?? '').trim() || String(el.getAttribute?.('aria-label') ?? '').trim() || String(el.getAttribute?.('title') ?? '').trim()
316
+ if (said.length > 1) return true
317
+ let near = el.closest?.('figure, picture')
318
+ if (near) {
319
+ const words = String(near.querySelector?.('figcaption')?.textContent ?? '').trim()
320
+ if (words.length > 1) return true
321
+ }
322
+ return false
323
+ }
324
+ const shows = (el) => {
325
+ const b = box(el)
326
+ if (b.width < 48 || b.height < 48) return false
327
+ if (!inView(b)) return false
328
+ const st = getComputedStyle(el)
329
+ return st.visibility !== 'hidden' && st.display !== 'none' && Number(st.opacity) > 0.05
330
+ }
331
+ for (const el of document.querySelectorAll('img')) {
332
+ if (!shows(el) || speaks(el)) continue
333
+ out.pictures += 1
334
+ }
335
+ for (const el of document.querySelectorAll('canvas')) if (shows(el)) out.canvases += 1
336
+ for (const el of document.querySelectorAll('video')) if (shows(el)) out.videos += 1
337
+ /* A tracking pixel of a frame is not something the reader is missing: only
338
+ frames that occupy the screen are worth a sentence. */
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
+ }
378
+ return out
379
+ })()
303
380
  return JSON.stringify({
304
381
  url: location.href, // ui-copy-ignore
305
382
  title: document.title, // ui-copy-ignore
@@ -313,6 +390,7 @@ function snapshot(mode, budget, max) {
313
390
  })(),
314
391
  controls: picked,
315
392
  picture,
393
+ blind,
316
394
  })
317
395
  })()`
318
396
  }
@@ -332,13 +410,102 @@ export function resolve(ref) {
332
410
  const el = hit.el
333
411
  const b = el.getBoundingClientRect()
334
412
  if (b.width < 2 || b.height < 2) return JSON.stringify({ gone: true, off: true })
335
- 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' })
336
429
  const after = el.getBoundingClientRect()
337
430
  const s = getComputedStyle(el)
338
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 })
339
432
  })()`
340
433
  }
341
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
+
342
509
  /**
343
510
  * How big the control is and where it sits, so a picture can be of it alone.
344
511
  * @param {number} ref - ref from a read.
@@ -378,8 +545,8 @@ export function pictures(ref, count) {
378
545
  if (el === null || el === undefined || seen.has(el)) return
379
546
  seen.add(el)
380
547
  const b = el.getBoundingClientRect()
381
- const w = kind === 'canvas' ? el.width : el.naturalWidth || b.width
382
- const h = kind === 'canvas' ? el.height : el.naturalHeight || b.height
548
+ const w = kind === 'canvas' ? el.width : kind === 'video' ? el.videoWidth || b.width : el.naturalWidth || b.width
549
+ const h = kind === 'canvas' ? el.height : kind === 'video' ? el.videoHeight || b.height : el.naturalHeight || b.height
383
550
  if (w < 48 || h < 48) return
384
551
  if (b.width < 8 || b.height < 8) return
385
552
  const s = getComputedStyle(el)
@@ -392,9 +559,13 @@ export function pictures(ref, count) {
392
559
  const tag = node.tagName
393
560
  if (tag === 'IMG') add(node, 'img')
394
561
  else if (tag === 'CANVAS') add(node, 'canvas')
562
+ /* A paused video is the frame the person is looking at; it never arrives as
563
+ an image file, so it has to be drawn out of the element. */
564
+ else if (tag === 'VIDEO' && (node.videoWidth || 0) > 0) add(node, 'video')
395
565
  }
396
566
  if (where && where.tagName === 'IMG') add(where, 'img')
397
567
  if (where && where.tagName === 'CANVAS') add(where, 'canvas')
568
+ if (where && where.tagName === 'VIDEO' && (where.videoWidth || 0) > 0) add(where, 'video')
398
569
  picked.sort((a, b) => b.area - a.area)
399
570
  /* A picture nobody can look at still has to be describable: how much of it is
400
571
  one colour tells a blank chart from a chart. Sampled at 32x32, which is
@@ -428,8 +599,15 @@ export function pictures(ref, count) {
428
599
  let data = ''
429
600
  let type = 'image/png'
430
601
  try {
431
- if (item.kind === 'canvas') {
432
- data = item.el.toDataURL('image/png')
602
+ if (item.kind === 'canvas' || item.kind === 'video') {
603
+ /* Same path for both: a video frame is copied onto a board and read as
604
+ a picture. Nothing here needs the video to be playing. */
605
+ const board = document.createElement('canvas')
606
+ board.width = Math.max(1, Math.round(item.w))
607
+ board.height = Math.max(1, Math.round(item.h))
608
+ const pen = board.getContext('2d')
609
+ pen.drawImage(item.el, 0, 0, board.width, board.height)
610
+ data = board.toDataURL('image/png')
433
611
  } else {
434
612
  const url = item.el.currentSrc || item.el.src || ''
435
613
  const answer = await fetch(url, { credentials: 'include' })
@@ -527,12 +705,57 @@ function asRoute(route) {
527
705
  * @param {number|{contextId?: number, sessionId?: string}} [route] - where to run it.
528
706
  * @returns {Promise<any>} the JSON-safe value.
529
707
  */
530
- 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) {
531
740
  const where = asRoute(route)
532
741
  const params = { expression, returnByValue: true }
533
742
  if (where.contextId !== undefined) params.contextId = where.contextId
534
- const { result } = await page.cdp.send('Runtime.evaluate', params, where.sessionId)
535
- 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
536
759
  }
537
760
 
538
761
  /** @param {any} page - sidecar page. @param {any} [route] - world to seed. */
@@ -612,18 +835,20 @@ function routeOf(page, frame, slot) {
612
835
  * @param {any} page - sidecar page.
613
836
  * @param {string} mode - read mode.
614
837
  * @param {number} budget - characters of text to keep.
615
- * @returns {Promise<{text: string, controls: any[]}>} what the frames add.
838
+ * @returns {Promise<{text: string, controls: any[], frames: number}>} what the frames add.
616
839
  */
617
840
  async function readFrames(page, mode, budget) {
618
- if (typeof page.frameList !== 'function') return { text: '', controls: [] }
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
619
843
  let frames = []
620
844
  try {
621
845
  frames = await page.frameList()
622
846
  } catch (error) {
623
- return { text: '', controls: [] }
847
+ return none
624
848
  }
625
849
  const children = frames.slice(1)
626
850
  const controls = []
851
+ const blind = { ...none.blind }
627
852
  let text = ''
628
853
  for (let slot = 0; slot < children.length && slot < 8; slot += 1) {
629
854
  const route = routeOf(page, children[slot], slot + 1)
@@ -638,11 +863,14 @@ async function readFrames(page, mode, budget) {
638
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 })
639
864
  }
640
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)))
641
869
  } catch (error) {
642
870
  continue
643
871
  }
644
872
  }
645
- return { text, controls }
873
+ return { text, controls, frames: children.length, blind }
646
874
  }
647
875
 
648
876
  /** Decode a ref into the frame it lives in and the number that frame knows. */
@@ -677,16 +905,359 @@ export async function contextFor(page, ref) {
677
905
  * @param {{mode?: string, maxChars?: number, max?: number}} [options] - how much to read.
678
906
  * @returns {Promise<{url: string, title: string, text: string, textChars: number, scroll?: any, focused?: any, controls: any[]}>} the read.
679
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
+
680
1243
  export async function readPage(page, options = {}) {
681
1244
  const mode = String(options.mode ?? 'text')
682
1245
  const maxChars = Number.isFinite(Number(options.maxChars)) ? Math.max(400, Math.min(24_000, Number(options.maxChars))) : 6000
683
1246
  const max = Number.isFinite(Number(options.max)) ? Math.max(5, Math.min(200, Number(options.max))) : 90
684
- await seedPage(page)
685
- let read = await evaluateSnapshot(page, mode, maxChars, max)
686
- if (read?.broken === true) {
687
- await seedPage(page)
688
- 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
+ }
689
1259
  }
1260
+ if (fault !== '') return faultRead(page, fault)
690
1261
  const framed = await readFrames(page, mode, Math.min(maxChars, 1200))
691
1262
  if (framed.controls.length > 0) read = { ...read, controls: [...(read.controls ?? []), ...framed.controls] }
692
1263
  if (framed.text !== '') read = { ...read, text: `${String(read.text ?? '')}${String(read.text ?? '') === '' ? '' : '\n'}${framed.text}` }
@@ -699,15 +1270,55 @@ export async function readPage(page, options = {}) {
699
1270
  scroll: read?.scroll,
700
1271
  focused: read?.focused ?? null,
701
1272
  controls: Array.isArray(read?.controls) ? read.controls : [],
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
+ })(),
702
1278
  }
703
1279
  }
704
1280
 
1281
+ /**
1282
+ * Merge what the page counted with what the frame reader managed to see.
1283
+ * @param {any} said - counts from the page.
1284
+ * @param {number} [read] - frames that answered.
1285
+ * @returns {{pictures: number, canvases: number, videos: number, frames: number, flash: number}} what text cannot show.
1286
+ */
1287
+ export function blindSpots(said, read) {
1288
+ const n = (value) => (Number.isFinite(Number(value)) ? Math.max(0, Math.round(Number(value))) : 0)
1289
+ /* The page knows which frames take up the screen; the frame reader knows how
1290
+ many exist, tracking pixels included. The screen is what the reader is
1291
+ missing, so its count wins whenever the page could count at all. */
1292
+ const frames = said !== undefined && said !== null && said.frames !== undefined ? n(said.frames) : n(read)
1293
+ return { pictures: n(said?.pictures), canvases: n(said?.canvases), videos: n(said?.videos), frames, flash: n(said?.flash) }
1294
+ }
1295
+
705
1296
  /**
706
1297
  * Resolve a ref into a point, at the moment of the action.
707
1298
  * @param {any} page - sidecar page.
708
1299
  * @param {number} ref - ref from a read.
709
1300
  * @returns {Promise<{gone: boolean, x?: number, y?: number, name?: string, kind?: string, off?: boolean, hidden?: boolean, disabled?: boolean}>} where it is now.
710
1301
  */
1302
+ /**
1303
+ * Whether a number still means what it meant when it was read.
1304
+ *
1305
+ * A page can keep the same element and change its words: "Send" becomes
1306
+ * "Stop", a row is reused for another order. Pressing the old number then
1307
+ * does something the person never asked for.
1308
+ * @param {any} known - the control the read handed out.
1309
+ * @param {any} spot - what the number resolves to now.
1310
+ * @returns {string} what changed, or '' when it is the same control.
1311
+ */
1312
+ export function differs(known, spot) {
1313
+ if (known === undefined || known === null || spot === undefined || spot === null) return ''
1314
+ const before = String(known.name ?? '').replace(/\s+/g, ' ').trim().slice(0, 40)
1315
+ const now = String(spot.name ?? '').replace(/\s+/g, ' ').trim().slice(0, 40)
1316
+ if (before === '' || now === '' || before === now) return ''
1317
+ const head = Math.min(12, before.length, now.length)
1318
+ if (head >= 4 && (before.slice(0, head) === now.slice(0, head) || before.includes(now.slice(0, head)) || now.includes(before.slice(0, head)))) return ''
1319
+ return `that number now points at "${now}" instead of "${before}" — read the page again and use the new number.`
1320
+ }
1321
+
711
1322
  export async function locate(page, ref) {
712
1323
  const split = splitFrameRef(ref)
713
1324
  let route = null
@@ -746,11 +1357,69 @@ function sigilOf(control) {
746
1357
  export function renderPage(read, options = {}) {
747
1358
  const mode = String(options.mode ?? 'text')
748
1359
  const maxChars = Number(options.maxChars ?? 6000)
1360
+ const maxControls = Number(options.maxControls ?? 140)
749
1361
  const lines = [read.url]
1362
+ /* A browser error page is not a site with nothing on it: it is a site that
1363
+ never arrived. Saying so is the only way the reader stops looking for the
1364
+ content that would have been there. */
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)}`)
750
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
+ }
751
1394
  const shown = read.controls.filter((control) => control.view !== false || mode === 'full')
752
1395
  const hidden = read.controls.length - shown.length
753
- 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
+ }
754
1423
  let line = `${control.ref} [${sigilOf(control)}] ${control.name === '' ? '(unnamed)' : control.name}`
755
1424
  if (control.kind === 'checkbox' || control.kind === 'radio') line += control.checked ? ' (checked)' : ' (unchecked)'
756
1425
  else if (control.value !== '' && control.value !== control.name) line += ` = ${control.value}`
@@ -759,12 +1428,48 @@ export function renderPage(read, options = {}) {
759
1428
  lines.push(line)
760
1429
  }
761
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`)
762
1432
  if (read.focused) lines.push(`focus ${read.focused.ref} [${read.focused.kind}] ${read.focused.name}`)
763
- 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
+ }
764
1450
  if (body !== '') {
765
1451
  lines.push('')
766
1452
  lines.push(body)
767
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`)
1457
+ }
1458
+ if (read.blind !== undefined && read.blind !== null) {
1459
+ const bits = []
1460
+ if (read.blind.pictures > 0) bits.push(`${read.blind.pictures} picture(s) with no words`)
1461
+ if (read.blind.canvases > 0) bits.push(`${read.blind.canvases} drawing surface(s)`)
1462
+ if (read.blind.videos > 0) bits.push(`${read.blind.videos} video(s)`)
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`)
1465
+ /* Only when something is actually out of reach: a line saying "nothing is
1466
+ hidden" on every read is a line the reader learns to skip. */
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
+ }
768
1473
  }
769
1474
  if (read.picture && Array.isArray(read.picture.missing)) {
770
1475
  /* Something was meant to be here and is not: say that instead of nothing. */
@@ -776,7 +1481,7 @@ export function renderPage(read, options = {}) {
776
1481
  const pic = read.picture
777
1482
  const look = Number(pic.flat) >= 0.9 ? 'mostly one colour' : Number(pic.colors) <= 3 ? 'a few colours' : 'a picture with detail'
778
1483
  const size = `${Math.round(Number(pic.width) || 0)}x${Math.round(Number(pic.height) || 0)}`
779
- 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`)
780
1485
  }
781
1486
  if (read.scroll && read.scroll.height > read.scroll.viewport + 40) {
782
1487
  lines.push(`scroll ${read.scroll.y}/${read.scroll.height - read.scroll.viewport}`)
@@ -785,7 +1490,7 @@ export function renderPage(read, options = {}) {
785
1490
  }
786
1491
 
787
1492
  /** A ref that was valid on the previous read and is no longer here. */
788
- 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.'
789
1494
 
790
1495
  /**
791
1496
  * The control itself, not its number: what it is, what it is called, and where