dsh-inner-browser-use 0.35.1 → 0.35.3
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.zh.md +1 -1
- package/index.js +72 -3
- package/package.json +1 -1
- package/read.js +61 -1
- package/tools.js +81 -4
package/README.zh.md
CHANGED
|
@@ -18,7 +18,7 @@ profile,不是产品自带的那个浏览页签:右栏放的是它的画面
|
|
|
18
18
|
| `browser_type` | 往焦点输入框输入文字,也可以先点一个编号控件。中文整段送进去。`submit: true` 回车提交。 |
|
|
19
19
|
| `browser_key` | 按一个键:Enter、Tab、Escape、Backspace、Delete、上下左右、Home、End、PageUp、PageDown。 |
|
|
20
20
|
| `browser_scroll` | 在页面中心滚轮,也可以指定 `x`/`y`。`dy: 600` 约一屏。 |
|
|
21
|
-
| `browser_shot` |
|
|
21
|
+
| `browser_shot` | 存一张 PNG 到 `~/.dsh/inner-browser-use/screenshots`,返回路径;`region: [x, y, w, h]` 只截一块(游戏、图表、看局部)。 |
|
|
22
22
|
| `browser_state` | 报浏览器在不在跑、用的哪个 profile、地址、标题,以及有几个右栏在看它。 |
|
|
23
23
|
|
|
24
24
|
点、输入、按键之后的那一行会多一句结果:`· navigated to …`(换页了)、`· content changed`(页内有变化)、`· no visible change`(点了没动)、Flash 页则是 `· screen updated` / `· no frame change`;`read: true` 时不再多说。
|
package/index.js
CHANGED
|
@@ -117,6 +117,11 @@ export class Bridge {
|
|
|
117
117
|
is not on screen has no stream, so a push at ask time is lost; remembering
|
|
118
118
|
the ask is what makes the mirror appear when the person returns. */
|
|
119
119
|
this.wanted = { session: '', at: 0 }
|
|
120
|
+
/* Frame streams by chat. A close can arrive while a stream is still
|
|
121
|
+
attaching, so closing the mirror hangs up what that chat started: a
|
|
122
|
+
browser nobody watches is what lets it go to rest. */
|
|
123
|
+
/** @type {Map<string, Set<() => void>>} */
|
|
124
|
+
this.viewers = new Map()
|
|
120
125
|
/* A running plugin keeps the note; a bare Bridge in a test does not. */
|
|
121
126
|
this.persist = options.persist === true
|
|
122
127
|
const kept = this.persist ? readMirror() : { owner: '', session: '', at: 0 }
|
|
@@ -208,6 +213,42 @@ export class Bridge {
|
|
|
208
213
|
return [...this.seats.keys()]
|
|
209
214
|
}
|
|
210
215
|
|
|
216
|
+
/**
|
|
217
|
+
* Remember one open frame stream so the chat can hang it up later.
|
|
218
|
+
* @param {string} sessionId - chat drawing the mirror.
|
|
219
|
+
* @param {() => void} off - ends that one stream.
|
|
220
|
+
*/
|
|
221
|
+
watch(sessionId, off) {
|
|
222
|
+
const id = this.canon(sessionId)
|
|
223
|
+
if (id === '' || typeof off !== 'function') return
|
|
224
|
+
const set = this.viewers.get(id) ?? new Set()
|
|
225
|
+
set.add(off)
|
|
226
|
+
/* A tab that reloads faster than its old socket closes must not pile up
|
|
227
|
+
streams: the oldest goes, which is the one no page is reading. */
|
|
228
|
+
while (set.size > 4) {
|
|
229
|
+
const oldest = set.values().next().value
|
|
230
|
+
set.delete(oldest)
|
|
231
|
+
try { oldest() } catch { /* a stream already gone is already gone */ }
|
|
232
|
+
}
|
|
233
|
+
this.viewers.set(id, set)
|
|
234
|
+
}
|
|
235
|
+
|
|
236
|
+
/**
|
|
237
|
+
* Hang up every frame stream one chat started.
|
|
238
|
+
* @param {string} sessionId - chat whose streams go away.
|
|
239
|
+
* @returns {number} how many streams were hanging up.
|
|
240
|
+
*/
|
|
241
|
+
unwatch(sessionId) {
|
|
242
|
+
const id = this.canon(sessionId)
|
|
243
|
+
const set = this.viewers.get(id)
|
|
244
|
+
if (set === undefined) return 0
|
|
245
|
+
this.viewers.delete(id)
|
|
246
|
+
for (const off of set) {
|
|
247
|
+
try { off() } catch { /* a stream already gone is already gone */ }
|
|
248
|
+
}
|
|
249
|
+
return set.size
|
|
250
|
+
}
|
|
251
|
+
|
|
211
252
|
/**
|
|
212
253
|
* Give the mirror up: the person closed the column, so the chat stops owning
|
|
213
254
|
* it and no keepalive may ask for it again. Ownership is what the column
|
|
@@ -219,6 +260,9 @@ export class Bridge {
|
|
|
219
260
|
const id = this.canon(sessionId)
|
|
220
261
|
if (id === '') return false
|
|
221
262
|
const held = this.owner === id || this.wanted.session === id
|
|
263
|
+
/* The picture goes with the ask: a stream left attached after the column
|
|
264
|
+
closed keeps the browser awake with nobody looking at it. */
|
|
265
|
+
this.unwatch(id)
|
|
222
266
|
this.wanted = { session: '', at: 0 }
|
|
223
267
|
if (this.owner === id) this.owner = ''
|
|
224
268
|
if (this.persist === true) writeMirror({ owner: this.owner, session: '', at: 0 })
|
|
@@ -229,6 +273,7 @@ export class Bridge {
|
|
|
229
273
|
dispose() {
|
|
230
274
|
for (const res of this.seats.values()) res.end()
|
|
231
275
|
this.seats.clear()
|
|
276
|
+
for (const id of [...this.viewers.keys()]) this.unwatch(id)
|
|
232
277
|
}
|
|
233
278
|
}
|
|
234
279
|
|
|
@@ -447,17 +492,33 @@ export function apply(ctx) {
|
|
|
447
492
|
res.socket?.setNoDelay?.(true)
|
|
448
493
|
res.write('retry: 2000\n\n')
|
|
449
494
|
/** @type {() => void} */
|
|
450
|
-
let
|
|
451
|
-
|
|
495
|
+
let off = () => {}
|
|
496
|
+
/* The stream can end while `watch` is still waiting for a browser to come
|
|
497
|
+
up: that close has to be remembered, or the sink added a second later is
|
|
498
|
+
never taken away and the browser is kept awake with nobody looking. */
|
|
499
|
+
let shut = false
|
|
500
|
+
let done = false
|
|
501
|
+
const hang = () => {
|
|
502
|
+
if (done) return
|
|
503
|
+
done = true
|
|
504
|
+
try { off() } catch { /* a stream already gone is already gone */ }
|
|
505
|
+
try { res.end() } catch { /* the socket may be the thing that closed */ }
|
|
506
|
+
}
|
|
507
|
+
const drop = () => {
|
|
508
|
+
shut = true
|
|
509
|
+
hang()
|
|
510
|
+
}
|
|
452
511
|
req.on('close', drop)
|
|
453
512
|
res.on('error', drop)
|
|
454
513
|
try {
|
|
455
|
-
|
|
514
|
+
off = await sidecar.watch((frame) => {
|
|
456
515
|
/* A viewer that cannot keep up loses frames instead of making the
|
|
457
516
|
page wait: the newest picture is the only one worth sending. */
|
|
458
517
|
if (res.writableLength > 3 * 1024 * 1024) return
|
|
459
518
|
res.write(`data: ${JSON.stringify(frame)}\n\n`)
|
|
460
519
|
}, streaming)
|
|
520
|
+
if (shut) hang()
|
|
521
|
+
else if (watching !== '') bridge.watch(watching, hang)
|
|
461
522
|
const info = await sidecar.state()
|
|
462
523
|
/* The pane needs one address to offer back when this chat's own page
|
|
463
524
|
went away; the state route answers the same question for the tab
|
|
@@ -725,6 +786,13 @@ export function apply(ctx) {
|
|
|
725
786
|
} else {
|
|
726
787
|
await sidecar.useTab(at, actor)
|
|
727
788
|
}
|
|
789
|
+
/* Closing the last page is this chat saying it is done with the mirror.
|
|
790
|
+
An owner left behind is what the next poll reads as "put my tab back":
|
|
791
|
+
the pane reappeared right after the person closed it. */
|
|
792
|
+
if (body.op === 'close') {
|
|
793
|
+
const left = await sidecar.chatState(actor)
|
|
794
|
+
if (left?.alive !== true && bridge.forget(actor) && sidecar.watched() === 0) sidecar.touch(REST_MS)
|
|
795
|
+
}
|
|
728
796
|
const tabs = await sidecar.windowsFor(actor)
|
|
729
797
|
json(200, { ok: true, tabs })
|
|
730
798
|
} catch (error) {
|
|
@@ -876,6 +944,7 @@ export function apply(ctx) {
|
|
|
876
944
|
'先接着用已有的页:`browser_view`(拿编号控件与可见文字)→ `browser_click` / `browser_type` → 再 `browser_view` 直到出现要的结果;页面要等就重复读,不 sleep。`browser_open` 只用于没有页、要换新地址、或要明确刷新;同一个地址不要反复重开。',
|
|
877
945
|
'页面很长、只想确认某句话/按钮在哪时用 `browser_find`,别整页读。要读长文(文章、文档、issue、论坛帖)用 `browser_read`:它回 Markdown,只花几十分之一的 token,链接可点;要动手点、填表才用 `browser_view` 拿编号。',
|
|
878
946
|
'要保存页面样子用 `browser_shot`,它会给出 PNG 绝对路径;要交给人看的文件,把绝对路径写进回复。',
|
|
947
|
+
'游戏、canvas、Flash:画面自己画的地方,`browser_view` 和 `browser_read` 读不出里面是什么。先 `browser_prepare` 拿画面位置、帧率、按键是否到达和下一步;之后看 `browser_shot` 的图定位置,按坐标点、按住、按组合键,再拍一张看画面变了没有——变了就是这一步生效了。当年用 Flash 做的游戏用 `browser_open` 带 `flash:true` 在栏内跑,不装播放器、不开窗口。',
|
|
879
948
|
].join('\n'),
|
|
880
949
|
}), 'dsh-inner-browser-use: system prompt section')
|
|
881
950
|
}
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "dsh-inner-browser-use",
|
|
3
|
-
"version": "0.35.
|
|
3
|
+
"version": "0.35.3",
|
|
4
4
|
"description": "An agent-owned Chromium sidecar mirrored into the DeepSeek Harness right column: the agent drives it, the person watches and can take the mouse, and logins survive restarts.",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"main": "index.js",
|
package/read.js
CHANGED
|
@@ -432,6 +432,66 @@ export function resolve(ref) {
|
|
|
432
432
|
})()`
|
|
433
433
|
}
|
|
434
434
|
|
|
435
|
+
/**
|
|
436
|
+
* The first of several controls that still says these words, found in one
|
|
437
|
+
* evaluate. A caller hunts through a dozen numbers after a redraw: one page
|
|
438
|
+
* round trip answers "which of them is still there", instead of a dozen.
|
|
439
|
+
* @param {number[]} refs - numbers to try, in the order a person would.
|
|
440
|
+
* @param {string} wanted - the words, compared with the spaces taken out.
|
|
441
|
+
* @returns {string} an expression that answers with the first that is live.
|
|
442
|
+
*/
|
|
443
|
+
export function resolveFirst(refs, wanted) {
|
|
444
|
+
const order = refs.map((ref) => Number(ref)).filter((ref) => Number.isFinite(ref))
|
|
445
|
+
return `(() => {
|
|
446
|
+
const reg = window.${REGISTRY}
|
|
447
|
+
if (!reg) return JSON.stringify({ gone: true })
|
|
448
|
+
const squeeze = (text) => String(text).toLowerCase().replace(/\\s+/g, '')
|
|
449
|
+
const want = ${JSON.stringify(String(wanted))}
|
|
450
|
+
const spotOf = (el) => {
|
|
451
|
+
const b = el.getBoundingClientRect()
|
|
452
|
+
if (b.width < 2 || b.height < 2) return null
|
|
453
|
+
const clipped = () => {
|
|
454
|
+
const me = el.getBoundingClientRect()
|
|
455
|
+
for (let up = el.parentElement; up; up = up.parentElement) {
|
|
456
|
+
const css = getComputedStyle(up)
|
|
457
|
+
if (!/auto|scroll|hidden|clip/.test(css.overflowX + ' ' + css.overflowY)) continue
|
|
458
|
+
const box = up.getBoundingClientRect()
|
|
459
|
+
if (me.top < box.top - 1 || me.bottom > box.bottom + 1 || me.left < box.left - 1 || me.right > box.right + 1) return true
|
|
460
|
+
}
|
|
461
|
+
return false
|
|
462
|
+
}
|
|
463
|
+
if (b.top < 0 || b.left < 0 || b.bottom > innerHeight || b.right > innerWidth || clipped()) el.scrollIntoView({ block: 'center', inline: 'center' })
|
|
464
|
+
const after = el.getBoundingClientRect()
|
|
465
|
+
return { x: Math.round(after.left + after.width / 2), y: Math.round(after.top + after.height / 2) }
|
|
466
|
+
}
|
|
467
|
+
for (const ref of ${JSON.stringify(order)}) {
|
|
468
|
+
const hit = reg.find(ref)
|
|
469
|
+
if (!hit) continue
|
|
470
|
+
const el = hit.el
|
|
471
|
+
const name = String(el.innerText ?? el.value ?? el.getAttribute?.('aria-label') ?? '').trim().slice(0, 80)
|
|
472
|
+
if (want !== '' && !squeeze(name).includes(want)) continue
|
|
473
|
+
const spot = spotOf(el)
|
|
474
|
+
if (spot === null) continue
|
|
475
|
+
return JSON.stringify({ gone: false, ref, name, x: spot.x, y: spot.y })
|
|
476
|
+
}
|
|
477
|
+
return JSON.stringify({ gone: true })
|
|
478
|
+
})()`
|
|
479
|
+
}
|
|
480
|
+
|
|
481
|
+
/**
|
|
482
|
+
* Ask the page which of these numbers still carries the words.
|
|
483
|
+
* @param {any} page - sidecar page.
|
|
484
|
+
* @param {number[]} refs - numbers to try, in order.
|
|
485
|
+
* @param {string} wanted - the words said by the caller.
|
|
486
|
+
* @returns {Promise<{gone: boolean, ref?: number, name?: string, x?: number, y?: number}>} the first live one.
|
|
487
|
+
*/
|
|
488
|
+
export async function locateFirst(page, refs, wanted) {
|
|
489
|
+
const said = await evaluateIn(page, resolveFirst(refs, wanted)).catch(() => undefined)
|
|
490
|
+
let spot = { gone: true }
|
|
491
|
+
try { spot = JSON.parse(String(said ?? '{"gone":true}')) } catch (error) { spot = { gone: true } }
|
|
492
|
+
return spot
|
|
493
|
+
}
|
|
494
|
+
|
|
435
495
|
/**
|
|
436
496
|
* Click the control the page itself registered, instead of moving the mouse to
|
|
437
497
|
* it. A page whose own handlers want a click, or a control an overlay covers,
|
|
@@ -685,7 +745,7 @@ export function covering(ref) {
|
|
|
685
745
|
}
|
|
686
746
|
|
|
687
747
|
/** Where refs of controls inside iframes start. Main-frame refs stay small. */
|
|
688
|
-
const FRAME_BASE = 900_000
|
|
748
|
+
export const FRAME_BASE = 900_000
|
|
689
749
|
|
|
690
750
|
/** Room for the refs of one frame between FRAME_BASE steps. */
|
|
691
751
|
const FRAME_SLOT = 100_000
|
package/tools.js
CHANGED
|
@@ -15,7 +15,7 @@ import { homedir } from 'node:os'
|
|
|
15
15
|
import { basename, join } from 'node:path'
|
|
16
16
|
import { mkdir, readdir, stat, unlink, writeFile } from 'node:fs/promises'
|
|
17
17
|
import { VIEW } from './sidecar.js?v=1'
|
|
18
|
-
import { readPage, renderPage, readArticle, readRelevant, findOnPage, locate, covering, contextFor, identityOf, sameThing, differs, labelSpot, selectSpot, bounds, pictures, clickInDom, writeInDom, REF_GONE } from './read.js?v=1'
|
|
18
|
+
import { readPage, renderPage, readArticle, readRelevant, findOnPage, locate, locateFirst, FRAME_BASE, covering, contextFor, identityOf, sameThing, differs, labelSpot, selectSpot, bounds, pictures, clickInDom, writeInDom, REF_GONE } from './read.js?v=1'
|
|
19
19
|
import { brief as askBrief, recordRound, lastRoundNote, lastRound, markRound, MARK_STATUS } from './review.js?v=1'
|
|
20
20
|
|
|
21
21
|
/** Which chat the running call belongs to, so each gets its own tab. */
|
|
@@ -1050,6 +1050,49 @@ async function until(page, expression, timeout) {
|
|
|
1050
1050
|
* @returns {Promise<string>} a compact report.
|
|
1051
1051
|
*/
|
|
1052
1052
|
/** The first control whose words match, scrolled into view. Text can beat a number: it costs no read. */
|
|
1053
|
+
/** Words a label can lose without changing what it asks for. */
|
|
1054
|
+
const SMALL_WORDS = new Set(['a', 'an', 'the', 'to', 'of', 'and', 'or', 'in', 'on', 'at', 'for', 'with', 'my', 'your', 'please', 'click', 'tap', 'button', 'btn', 'link', 'now'])
|
|
1055
|
+
|
|
1056
|
+
/**
|
|
1057
|
+
* The significant words of a label, lowercased.
|
|
1058
|
+
* @param {unknown} text - a label.
|
|
1059
|
+
* @returns {string[]} words worth comparing.
|
|
1060
|
+
*/
|
|
1061
|
+
export function tokensOf(text) {
|
|
1062
|
+
return String(text ?? '').toLowerCase().split(/[^a-z0-9\u4e00-\u9fff]+/).filter((word) => word !== '' && !SMALL_WORDS.has(word))
|
|
1063
|
+
}
|
|
1064
|
+
|
|
1065
|
+
/**
|
|
1066
|
+
* The one label that means what the caller said when no label repeats it.
|
|
1067
|
+
* Only an unambiguous winner is offered, so a paraphrase cannot silently click
|
|
1068
|
+
* a different control than the one asked for.
|
|
1069
|
+
* @param {Array<{ref: number, name?: string, view?: boolean}>} controls - controls from a read.
|
|
1070
|
+
* @param {string} words - what the caller asked for.
|
|
1071
|
+
* @returns {any} the best control, or null when nothing stands out.
|
|
1072
|
+
*/
|
|
1073
|
+
export function nearestByWords(controls, words) {
|
|
1074
|
+
const want = tokensOf(words)
|
|
1075
|
+
if (want.length === 0 || !Array.isArray(controls)) return null
|
|
1076
|
+
const scored = []
|
|
1077
|
+
for (const control of controls) {
|
|
1078
|
+
if (control?.view === false) continue
|
|
1079
|
+
const have = new Set(tokensOf(control?.name))
|
|
1080
|
+
if (have.size === 0) continue
|
|
1081
|
+
let shared = 0
|
|
1082
|
+
for (const word of want) if (have.has(word)) shared += 1
|
|
1083
|
+
const cover = shared / want.length
|
|
1084
|
+
if (shared === 0 || cover < 0.5) continue
|
|
1085
|
+
scored.push({ control, score: cover * 0.7 + (shared / have.size) * 0.3 })
|
|
1086
|
+
}
|
|
1087
|
+
if (scored.length === 0) return null
|
|
1088
|
+
scored.sort((a, b) => b.score - a.score)
|
|
1089
|
+
const best = scored[0]
|
|
1090
|
+
const second = scored[1]
|
|
1091
|
+
if (second !== undefined && second.score > best.score - 0.05) return null
|
|
1092
|
+
if (String(best.control.name ?? '').length > String(words).length * 3 + 12) return null
|
|
1093
|
+
return { ...best.control, ref: Number(best.control.ref) }
|
|
1094
|
+
}
|
|
1095
|
+
|
|
1053
1096
|
export async function findWords(page, words, known) {
|
|
1054
1097
|
/* The words a caller used usually came from a read that already happened, so
|
|
1055
1098
|
look among those numbers first: one position check is a single evaluate,
|
|
@@ -1066,7 +1109,19 @@ export async function findWords(page, words, known) {
|
|
|
1066
1109
|
end is only the fallback. */
|
|
1067
1110
|
const firstInOrder = remembered.filter((control) => control.view !== false).sort((a, b) => Number(a.ref) - Number(b.ref))
|
|
1068
1111
|
const nearEnd = remembered.slice(-8).reverse()
|
|
1069
|
-
|
|
1112
|
+
const order = [...firstInOrder.slice(0, 4), ...nearEnd]
|
|
1113
|
+
/* One round trip answers which of these the page still has: a dozen numbers
|
|
1114
|
+
left over from a read that a redraw invalidated used to cost a dozen
|
|
1115
|
+
checks. Numbers inside a frame keep the per-number route below. */
|
|
1116
|
+
const plain = order.map((control) => Number(control.ref)).filter((ref) => Number.isFinite(ref) && ref < FRAME_BASE)
|
|
1117
|
+
if (plain.length > 0) {
|
|
1118
|
+
const any = await locateFirst(page, plain, wantedFirst)
|
|
1119
|
+
if (any?.gone !== true && Number.isFinite(Number(any?.x))) {
|
|
1120
|
+
const byRef = order.find((control) => Number(control.ref) === Number(any.ref)) ?? { ref: Number(any.ref) }
|
|
1121
|
+
return { ...byRef, ref: Number(any.ref), name: String(any.name ?? byRef.name ?? ''), x: Number(any.x), y: Number(any.y) }
|
|
1122
|
+
}
|
|
1123
|
+
}
|
|
1124
|
+
for (const control of order.filter((control) => Number(control.ref) >= FRAME_BASE)) {
|
|
1070
1125
|
const spot = await locate(page, Number(control.ref))
|
|
1071
1126
|
if (spot.gone === true) continue
|
|
1072
1127
|
const name = String(spot.name ?? control.name ?? '')
|
|
@@ -1080,7 +1135,17 @@ export async function findWords(page, words, known) {
|
|
|
1080
1135
|
const squeeze = (text) => String(text).toLowerCase().replace(/\s+/g, '')
|
|
1081
1136
|
const wanted = squeeze(words)
|
|
1082
1137
|
const found = shown.find((control) => squeeze(control.name) === wanted) ?? shown.find((control) => squeeze(control.name).includes(wanted))
|
|
1083
|
-
if (found === undefined)
|
|
1138
|
+
if (found === undefined) {
|
|
1139
|
+
/* A button the page redrew often only swaps one word, and a person says
|
|
1140
|
+
"submit order" for "Place order". Accept only an unambiguous winner. */
|
|
1141
|
+
const near = nearestByWords(shown, words)
|
|
1142
|
+
if (near !== null) {
|
|
1143
|
+
const spot = await locate(page, near.ref)
|
|
1144
|
+
if (spot.gone === true) return null
|
|
1145
|
+
return { ...near, x: Number(spot.x), y: Number(spot.y) }
|
|
1146
|
+
}
|
|
1147
|
+
return null
|
|
1148
|
+
}
|
|
1084
1149
|
/* When only the squeezed form matched, say the words the caller used: the
|
|
1085
1150
|
page spread them over nodes and "Signin" is not what anyone wrote. */
|
|
1086
1151
|
const hit = squeeze(found.name) === wanted && found.name.toLowerCase() !== words.toLowerCase() ? { ...found, name: words } : found
|
|
@@ -2738,13 +2803,14 @@ export function sidecarTools(sidecar, refs, host = {}) { // ui-copy-ignore
|
|
|
2738
2803
|
},
|
|
2739
2804
|
{
|
|
2740
2805
|
name: 'browser_shot', // ui-copy-ignore
|
|
2741
|
-
description: 'Save a PNG of the sidecar browser into the session folder and return its absolute path; when the current model accepts images the picture comes back in the same call, so a captcha, a chart or a broken layout can be looked at instead of guessed. Default is the visible screen; full takes the whole page, ref takes one control.',
|
|
2806
|
+
description: 'Save a PNG of the sidecar browser into the session folder and return its absolute path; when the current model accepts images the picture comes back in the same call, so a captcha, a chart or a broken layout can be looked at instead of guessed. Default is the visible screen; full takes the whole page, ref takes one control, region takes one box of it.',
|
|
2742
2807
|
parameters: {
|
|
2743
2808
|
type: 'object', // ui-copy-ignore
|
|
2744
2809
|
properties: {
|
|
2745
2810
|
name: { type: 'string', description: 'File name without extension.' },
|
|
2746
2811
|
full: { type: 'boolean', description: 'The whole page, not just the screen.' },
|
|
2747
2812
|
ref: { type: 'integer', description: 'One control from browser_view.' },
|
|
2813
|
+
region: { type: 'array', items: { type: 'number' }, description: 'One box instead of the whole screen: [x, y, width, height] in page pixels, the same numbers browser_prepare and browser_find report.' },
|
|
2748
2814
|
scale: { type: 'integer', description: 'Pixel ratio. Default 1; 2 is sharper and heavier.' },
|
|
2749
2815
|
},
|
|
2750
2816
|
additionalProperties: false,
|
|
@@ -2766,6 +2832,17 @@ export function sidecarTools(sidecar, refs, host = {}) { // ui-copy-ignore
|
|
|
2766
2832
|
const said = await withRead(async (page) => {
|
|
2767
2833
|
const scale = Math.max(1, Math.min(3, Number(args?.scale ?? 1)))
|
|
2768
2834
|
let clip = undefined
|
|
2835
|
+
/* A game or a chart does not need the whole screen: one box costs a
|
|
2836
|
+
fraction of the picture, and a picture is billed by area. */
|
|
2837
|
+
if (args?.ref === undefined && Array.isArray(args?.region) && args.region.length === 4) {
|
|
2838
|
+
const box = args.region.map((part) => Number(part))
|
|
2839
|
+
clip = {
|
|
2840
|
+
x: Math.max(0, Math.round(Number.isFinite(box[0]) ? box[0] : 0)),
|
|
2841
|
+
y: Math.max(0, Math.round(Number.isFinite(box[1]) ? box[1] : 0)),
|
|
2842
|
+
width: Math.max(8, Math.min(4000, Math.round(Number.isFinite(box[2]) ? box[2] : 0) || 8)),
|
|
2843
|
+
height: Math.max(8, Math.min(4000, Math.round(Number.isFinite(box[3]) ? box[3] : 0) || 8)),
|
|
2844
|
+
}
|
|
2845
|
+
}
|
|
2769
2846
|
if (args?.ref !== undefined) {
|
|
2770
2847
|
const spot = await findAgain(page, Number(args.ref))
|
|
2771
2848
|
if (spot.gone === true) return spot.why ?? REF_GONE
|