dsh-inner-browser-use 0.34.28 → 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/README.md +53 -18
- package/README.zh.md +26 -14
- package/client.js +161 -22
- package/docs/ARCHITECTURE.md +15 -12
- package/docs/ARTIFACTS.md +1 -1
- package/docs/BENCH.md +209 -34
- package/docs/FLASH.md +84 -0
- package/docs/ROADMAP.md +21 -15
- package/docs/VERIFY.md +20 -10
- package/index.js +144 -24
- package/package.json +17 -3
- package/read.js +633 -29
- package/runtime/flash-app/main.js +232 -0
- package/runtime/flash-app/package.json +1 -0
- package/runtime/flash-setup.mjs +151 -0
- package/runtime/swf.mjs +233 -0
- package/scripts/check-flash-local.mjs +104 -0
- package/scripts/check-flash-mirror.mjs +73 -0
- package/scripts/check-flash-tools.mjs +74 -0
- package/scripts/check-flash-window.mjs +58 -0
- package/scripts/doctor.mjs +3 -1
- package/sidecar.js +1076 -96
- package/tools.js +1068 -144
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(
|
|
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(
|
|
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(
|
|
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(
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
586
|
-
|
|
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
|
-
|
|
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
|
|
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
|
-
|
|
736
|
-
|
|
737
|
-
|
|
738
|
-
|
|
739
|
-
|
|
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:
|
|
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 —
|
|
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
|
-
|
|
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
|
-
|
|
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(', ')} —
|
|
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}"`} —
|
|
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
|
|
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
|