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/README.md +53 -18
- package/README.zh.md +26 -14
- package/client.js +262 -28
- package/docs/ARCHITECTURE.md +15 -12
- package/docs/ARTIFACTS.md +19 -0
- package/docs/BENCH.md +568 -17
- package/docs/FLASH.md +84 -0
- package/docs/ROADMAP.md +107 -13
- package/docs/VERIFY.md +20 -10
- package/index.js +456 -45
- package/package.json +20 -3
- package/read.js +734 -29
- package/review.js +213 -0
- 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 +1763 -106
- package/tools.js +1667 -191
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 ?? '') : ''
|
|
@@ -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
|
-
|
|
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(
|
|
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(
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
535
|
-
|
|
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
|
-
|
|
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
|
|
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
|
-
|
|
685
|
-
|
|
686
|
-
|
|
687
|
-
|
|
688
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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}"`} —
|
|
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
|
|
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
|