dsh-inner-browser-use 0.35.2 → 0.36.0

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 CHANGED
@@ -20,6 +20,7 @@ live picture of that browser, and your own cursor keeps working on the picture.
20
20
  | `browser_key` | Presses one key: Enter, Tab, Escape, Backspace, Delete, Insert, arrows, Home, End, PageUp, PageDown, F1-F12, the numeric keypad, letters, digits, punctuation and every shifted symbol, plus ContextMenu, CapsLock, PrintScreen and media keys. `Ctrl`/`Alt`/`Shift`/`Meta`/`Mod` modifiers, chords with `+`, and `Ctrl+C/X/V/A/Z/Y` run the real editing commands. |
21
21
  | `browser_scroll` | Scrolls the wheel at the centre of the page, or at `x`/`y`. `dy: 600` is about a screen down. |
22
22
  | `browser_shot` | Saves a full-size PNG into `~/.dsh-inner-browser-use/screenshots` and returns the path. |
23
+ | `browser_media` | Lists the video and sound the page already fetched, with sizes; `save: <row>` or part of the address keeps one. A stream is followed to its pieces and joined, so nothing has to be installed. |
23
24
  | `browser_state` | Reports whether the browser runs, which profile it uses, its address and title, and how many columns are showing it. |
24
25
 
25
26
  `browser_read` on the same page is the words only, in Markdown (measured: 4,520
package/README.zh.md CHANGED
@@ -19,6 +19,7 @@ profile,不是产品自带的那个浏览页签:右栏放的是它的画面
19
19
  | `browser_key` | 按一个键:Enter、Tab、Escape、Backspace、Delete、上下左右、Home、End、PageUp、PageDown。 |
20
20
  | `browser_scroll` | 在页面中心滚轮,也可以指定 `x`/`y`。`dy: 600` 约一屏。 |
21
21
  | `browser_shot` | 存一张 PNG 到 `~/.dsh/inner-browser-use/screenshots`,返回路径;`region: [x, y, w, h]` 只截一块(游戏、图表、看局部)。 |
22
+ | `browser_media` | 列出页面已经请求过的视频和声音(带大小),`save: <行号>` 或地址片段存下来;流会顺着播放列表把片段取回拼好,不用装任何东西。 |
22
23
  | `browser_state` | 报浏览器在不在跑、用的哪个 profile、地址、标题,以及有几个右栏在看它。 |
23
24
 
24
25
  点、输入、按键之后的那一行会多一句结果:`· navigated to …`(换页了)、`· content changed`(页内有变化)、`· no visible change`(点了没动)、Flash 页则是 `· screen updated` / `· no frame change`;`read: true` 时不再多说。
package/docs/ARTIFACTS.md CHANGED
@@ -8,7 +8,7 @@
8
8
 
9
9
  - **项目**:会话工作目录的文件夹名,加路径哈希前 6 位,例如 `dsh-inner-browser-use-a1b2c3`。同名文件夹、不同路径不会混。
10
10
  - **会话**:会话 id 去掉 `session-` 后的前 8 位,例如 `72557d6e`。同一项目里两个会话各存各的。
11
- - **种类**:`shots/` 是 `browser_shot` 的截图,`pictures/` 是 `browser_grab` 抓的图,`pdf/` 是 `browser_pdf` 打印的文档。
11
+ - **种类**:`shots/` 是 `browser_shot` 的截图,`pictures/` 是 `browser_grab` 抓的图,`pdf/` 是 `browser_pdf` 打印的文档,`media/` 是 `browser_media` 存下的视频、音频和流。
12
12
 
13
13
  文件名默认带时间戳(`shot-20261028-124500.png`),给了 `name` 就用它;名字里的分隔符、连续点、开头点都会被去掉,跳不出这个目录。
14
14
 
package/index.js CHANGED
@@ -944,7 +944,8 @@ export function apply(ctx) {
944
944
  '先接着用已有的页:`browser_view`(拿编号控件与可见文字)→ `browser_click` / `browser_type` → 再 `browser_view` 直到出现要的结果;页面要等就重复读,不 sleep。`browser_open` 只用于没有页、要换新地址、或要明确刷新;同一个地址不要反复重开。',
945
945
  '页面很长、只想确认某句话/按钮在哪时用 `browser_find`,别整页读。要读长文(文章、文档、issue、论坛帖)用 `browser_read`:它回 Markdown,只花几十分之一的 token,链接可点;要动手点、填表才用 `browser_view` 拿编号。',
946
946
  '要保存页面样子用 `browser_shot`,它会给出 PNG 绝对路径;要交给人看的文件,把绝对路径写进回复。',
947
- '游戏、canvas、Flash:画面自己画的地方,`browser_view` 和 `browser_read` 读不出里面是什么。先 `browser_prepare` 拿画面位置、帧率、按键是否到达和下一步;之后看 `browser_shot` 的图定位置,按坐标点、按住、按组合键,再拍一张看画面变了没有——变了就是这一步生效了。当年用 Flash 做的游戏用 `browser_open` 带 `flash:true` 在栏内跑,不装播放器、不开窗口。',
947
+ '页面放视频或音乐、人想留下它时用 `browser_media`:它列的是页面自己已经请求过的媒体(带大小),用行号或地址片段存一个;视频是流的话会顺着播放列表把片段取回拼成整段,不用装 yt-dlp 或 ffmpeg。先让页面播一下再列,播放请求才是文件。',
948
+ '游戏、canvas、Flash:画面自己画的地方,`browser_view` 和 `browser_read` 读不出里面是什么。先 `browser_prepare` 拿画面位置、帧率、按键是否到达和下一步;之后看 `browser_shot` 的图定位置,按坐标点、按住、按组合键,再拍一张看画面变了没有——变了就是这一步生效了。当年用 Flash 做的游戏用 `browser_open` 带 `flash:true` 在栏内跑,不装播放器、不开窗口;Flash 默认关,`flash:true` 开、`flash:false` 回到普通 Chrome,两边都只在本会话右栏。',
948
949
  ].join('\n'),
949
950
  }), 'dsh-inner-browser-use: system prompt section')
950
951
  }
package/media.js ADDED
@@ -0,0 +1,239 @@
1
+ /**
2
+ * Media a page plays, and the way to the file behind it.
3
+ *
4
+ * yt-dlp solves this by teaching itself every site. The page already knows
5
+ * better than any list: it asked for the file, and this browser saw the
6
+ * request. So the sniffing half is free — the network log this plugin already
7
+ * keeps says which URL is the movie — and the rest is playlist arithmetic:
8
+ * a master playlist names its variants, a media playlist names its segments,
9
+ * and the segments are plain HTTP with the cookies the page already has.
10
+ *
11
+ * Nothing here touches the disk or the network: parsing and planning are pure,
12
+ * so they are the part that can be tested without a page.
13
+ */
14
+
15
+ /** A mime that means "this is the movie itself", not a page about it. */
16
+ const FILE_MIME = /^(video|audio)\/|^application\/(vnd\.apple\.mpegurl|x-mpegurl|dash\+xml|octet-stream)$/i
17
+
18
+ /** A URL that ends like a file a player would stream. */
19
+ const FILE_PATH = /\.(m3u8|mpd|mp4|m4v|webm|mkv|mov|flv|ts|m4s|mp3|m4a|aac|ogg|opus|wav)(\?|#|$)/i
20
+
21
+ /** Same-origin noise that sniffing must not report as a movie. */
22
+ const NOT_MEDIA = /\.(m3u8|mpd|mp4|webm|mov|flv|ts|m4s|mp3|m4a|aac|ogg|opus|wav)\b|^data:|^blob:/i
23
+
24
+ /**
25
+ * Is this request something a person would call a video or a song?
26
+ * @param {string} url - request URL.
27
+ * @param {string} [mime] - response mime, when known.
28
+ * @param {string} [type] - CDP resource type, when known.
29
+ * @returns {boolean} true when it is worth listing.
30
+ */
31
+ export function isMedia(url, mime = '', type = '') {
32
+ const at = String(url ?? '')
33
+ const said = String(mime ?? '').toLowerCase()
34
+ if (at === '' || at.startsWith('data:') || at.startsWith('blob:')) return false
35
+ if (said.startsWith('image/') || said.startsWith('font/') || said.startsWith('text/html') || said.includes('javascript') || said.includes('css')) return false
36
+ if (said === 'application/json' || said.startsWith('application/json')) return false
37
+ if (FILE_MIME.test(said)) return true
38
+ if (String(type ?? '') === 'Media') return true
39
+ return FILE_PATH.test(at) && !/\.(html?|php|aspx?)$/i.test(at)
40
+ }
41
+
42
+ /**
43
+ * Which word a person would use for it.
44
+ * @param {string} url - request URL.
45
+ * @param {string} [mime] - response mime.
46
+ * @returns {'video'|'audio'|'stream'|'file'} the kind.
47
+ */
48
+ export function kindOf(url, mime = '') {
49
+ const said = String(mime ?? '').toLowerCase()
50
+ const at = String(url ?? '')
51
+ if (/mpegurl|dash\+xml/.test(said) || /\.(m3u8|mpd)(\?|#|$)/i.test(at)) return 'stream'
52
+ if (said.startsWith('audio/')) return 'audio'
53
+ if (/\.(mp3|m4a|aac|ogg|opus|wav)(\?|#|$)/i.test(at)) return 'audio'
54
+ if (said.startsWith('video/') || /\.(mp4|m4v|webm|mkv|mov|flv|ts|m4s)(\?|#|$)/i.test(at)) return 'video'
55
+ return 'file'
56
+ }
57
+
58
+ /**
59
+ * Turn the relative parts of a playlist into addresses that can be fetched.
60
+ * @param {string} base - the playlist's own URL.
61
+ * @param {string} href - a line from it.
62
+ * @returns {string} absolute URL, or '' when it is a comment.
63
+ */
64
+ export function absoluteUrl(base, href) {
65
+ const line = String(href ?? '').trim()
66
+ if (line === '' || line.startsWith('#')) return ''
67
+ try {
68
+ return new URL(line, base).toString()
69
+ } catch {
70
+ return ''
71
+ }
72
+ }
73
+
74
+ /**
75
+ * Read the attributes on one `#EXT-X-STREAM-INF:` line.
76
+ * @param {string} line - the tag line.
77
+ * @returns {{bandwidth: number, width: number, height: number, codecs: string}} what it says.
78
+ */
79
+ export function streamInfo(line) {
80
+ const text = String(line ?? '')
81
+ const number = (name) => {
82
+ const found = new RegExp(`${name}=(\\d+)`, 'i').exec(text)
83
+ return found === null ? 0 : Number(found[1])
84
+ }
85
+ const size = /RESOLUTION=(\d+)x(\d+)/i.exec(text)
86
+ const codecs = /CODECS="([^"]*)"/i.exec(text)
87
+ return {
88
+ bandwidth: number('BANDWIDTH') || number('AVERAGE-BANDWIDTH'),
89
+ width: size === null ? 0 : Number(size[1]),
90
+ height: size === null ? 0 : Number(size[2]),
91
+ codecs: codecs === null ? '' : String(codecs[1]).slice(0, 40),
92
+ }
93
+ }
94
+
95
+ /**
96
+ * A master playlist is a menu of the same movie at different sizes.
97
+ * @param {string} text - playlist body.
98
+ * @param {string} base - playlist URL.
99
+ * @returns {Array<{bandwidth: number, width: number, height: number, codecs: string, url: string}>} variants, best first.
100
+ */
101
+ export function parseMaster(text, base) {
102
+ const lines = String(text ?? '').split(/\r?\n/)
103
+ const variants = []
104
+ for (let i = 0; i < lines.length; i += 1) {
105
+ const line = lines[i].trim()
106
+ if (!line.toUpperCase().startsWith('#EXT-X-STREAM-INF')) continue
107
+ const info = streamInfo(line)
108
+ let at = i + 1
109
+ while (at < lines.length && (lines[at].trim() === '' || lines[at].trim().startsWith('#'))) at += 1
110
+ const url = at < lines.length ? absoluteUrl(base, lines[at]) : ''
111
+ if (url === '') continue
112
+ variants.push({ ...info, url })
113
+ i = at
114
+ }
115
+ variants.sort((left, right) => (right.bandwidth || right.height) - (left.bandwidth || left.height))
116
+ return variants
117
+ }
118
+
119
+ /**
120
+ * A media playlist is the movie cut into pieces.
121
+ * @param {string} text - playlist body.
122
+ * @param {string} base - playlist URL.
123
+ * @returns {{segments: string[], init: string, duration: number, encrypted: boolean}} the pieces and what they need first.
124
+ */
125
+ export function parsePlaylist(text, base) {
126
+ const lines = String(text ?? '').split(/\r?\n/)
127
+ const segments = []
128
+ let init = ''
129
+ let duration = 0
130
+ let encrypted = false
131
+ for (const raw of lines) {
132
+ const line = raw.trim()
133
+ if (line === '') continue
134
+ if (line.startsWith('#EXTINF:')) duration += Number.parseFloat(line.slice(8)) || 0
135
+ else if (line.toUpperCase().startsWith('#EXT-X-MAP')) {
136
+ const found = /URI="([^"]+)"/i.exec(line)
137
+ if (found !== null) init = absoluteUrl(base, found[1])
138
+ } else if (line.toUpperCase().startsWith('#EXT-X-KEY')) {
139
+ if (!/METHOD=NONE/i.test(line)) encrypted = true
140
+ } else if (!line.startsWith('#')) {
141
+ const url = absoluteUrl(base, line)
142
+ if (url !== '') segments.push(url)
143
+ }
144
+ }
145
+ return { segments, init, duration: Math.round(duration), encrypted }
146
+ }
147
+
148
+ /**
149
+ * Which playlist this body is.
150
+ * @param {string} text - playlist body.
151
+ * @returns {'master'|'media'|null} the kind.
152
+ */
153
+ export function playlistKind(text) {
154
+ const body = String(text ?? '')
155
+ if (!body.trimStart().startsWith('#EXTM3U')) return null
156
+ if (/#EXT-X-STREAM-INF/i.test(body)) return 'master'
157
+ return 'media'
158
+ }
159
+
160
+ /**
161
+ * The one variant a person would want: tall enough to be worth watching,
162
+ * cheap enough to fetch. A high one is never the answer to "save this".
163
+ * @param {Array<{bandwidth: number, width: number, height: number, url: string}>} variants - from parseMaster.
164
+ * @param {{height?: number, bandwidth?: number}} [wanted] - caps.
165
+ * @returns {{bandwidth: number, width: number, height: number, url: string}|null} the pick.
166
+ */
167
+ export function pickVariant(variants, wanted = {}) {
168
+ const list = (variants ?? []).filter((each) => each !== null && typeof each === 'object' && String(each.url ?? '') !== '')
169
+ if (list.length === 0) return null
170
+ const capHeight = Number(wanted.height ?? 0)
171
+ const capBandwidth = Number(wanted.bandwidth ?? 0)
172
+ let allowed = list
173
+ if (capHeight > 0) allowed = list.filter((each) => (each.height || 0) <= capHeight)
174
+ if (capBandwidth > 0) allowed = allowed.filter((each) => (each.bandwidth || 0) <= capBandwidth)
175
+ /* A cap nobody fits is not an empty answer: the smallest is still better
176
+ than nothing. */
177
+ if (allowed.length === 0) allowed = [...list].sort((left, right) => (left.bandwidth || left.height) - (right.bandwidth || right.height)).slice(0, 1)
178
+ return allowed.slice().sort((left, right) => (right.bandwidth || right.height) - (left.bandwidth || left.height))[0]
179
+ }
180
+
181
+ /**
182
+ * Follow a manifest down to the URLs that hold bytes.
183
+ * @param {string} url - what the page asked for.
184
+ * @param {string} body - what came back.
185
+ * @param {{height?: number, bandwidth?: number}} [wanted] - size cap.
186
+ * @returns {{kind: 'file'|'hls', parts: string[], init: string, duration: number, variant: object|null, encrypted: boolean}} what to fetch.
187
+ */
188
+ export function planOf(url, body, wanted = {}) {
189
+ const kind = playlistKind(body)
190
+ if (kind === 'master') {
191
+ const variant = pickVariant(parseMaster(body, url), wanted)
192
+ return { kind: 'hls', parts: [], init: '', duration: 0, variant, encrypted: false }
193
+ }
194
+ if (kind === 'media') {
195
+ const list = parsePlaylist(body, url)
196
+ return { kind: 'hls', parts: list.segments, init: list.init, duration: list.duration, variant: null, encrypted: list.encrypted }
197
+ }
198
+ return { kind: 'file', parts: [url], init: '', duration: 0, variant: null, encrypted: false }
199
+ }
200
+
201
+ /**
202
+ * Fetch many URLs, but only a few at a time: a hundred segments asked for at
203
+ * once is a hundred sockets and a server that starts refusing.
204
+ * @template T
205
+ * @param {string[]} urls - in order.
206
+ * @param {(url: string) => Promise<T>} get - one URL.
207
+ * @param {number} [width] - how many at once.
208
+ * @returns {Promise<T[]>} in the same order as the input.
209
+ */
210
+ export async function gather(urls, get, width = 6) {
211
+ const out = new Array(urls.length)
212
+ let next = 0
213
+ const take = async () => {
214
+ for (;;) {
215
+ const at = next
216
+ next += 1
217
+ if (at >= urls.length) return
218
+ out[at] = await get(urls[at])
219
+ }
220
+ }
221
+ const taking = []
222
+ for (let i = 0; i < Math.max(1, Math.min(width, urls.length)); i += 1) taking.push(take())
223
+ await Promise.all(taking)
224
+ return out
225
+ }
226
+
227
+ /**
228
+ * How a person would see the size of a pile of bytes.
229
+ * @param {number} bytes - byte count.
230
+ * @returns {string} "12 MB" and the like.
231
+ */
232
+ export function sizeText(bytes) {
233
+ const n = Number(bytes ?? 0)
234
+ if (!(n > 0)) return '?'
235
+ if (n >= 1_000_000_000) return `${(n / 1_000_000_000).toFixed(1)} GB`
236
+ if (n >= 1_000_000) return `${(n / 1_000_000).toFixed(1)} MB`
237
+ if (n >= 1000) return `${Math.round(n / 1000)} kB`
238
+ return `${String(n)} B`
239
+ }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "dsh-inner-browser-use",
3
- "version": "0.35.2",
3
+ "version": "0.36.0",
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",
@@ -17,6 +17,7 @@
17
17
  "tools.js",
18
18
  "read.js",
19
19
  "review.js",
20
+ "media.js",
20
21
  "client.js",
21
22
  "locale/*.json",
22
23
  "cordis.patch.yml",
@@ -48,7 +49,7 @@
48
49
  "dsh": ">=0.2.0"
49
50
  },
50
51
  "scripts": {
51
- "test": "node --test test/artifacts.test.mjs test/client.test.mjs test/cookies.test.mjs test/diff.test.mjs test/fresh.test.mjs test/host.test.mjs test/input.test.mjs test/isolation.test.mjs test/keyboard.test.mjs test/leak.test.mjs test/login.test.mjs test/no-duplicate.test.mjs test/package.test.mjs test/readfault.test.mjs test/refs.test.mjs test/review.test.mjs test/session.test.mjs test/settle.test.mjs test/sidecar.test.mjs test/swf.test.mjs test/tabs.test.mjs test/trouble.test.mjs",
52
+ "test": "node --test test/artifacts.test.mjs test/client.test.mjs test/cookies.test.mjs test/diff.test.mjs test/fresh.test.mjs test/host.test.mjs test/input.test.mjs test/isolation.test.mjs test/keyboard.test.mjs test/leak.test.mjs test/login.test.mjs test/media.test.mjs test/no-duplicate.test.mjs test/package.test.mjs test/readfault.test.mjs test/refs.test.mjs test/review.test.mjs test/session.test.mjs test/settle.test.mjs test/sidecar.test.mjs test/swf.test.mjs test/tabs.test.mjs test/trouble.test.mjs",
52
53
  "doctor": "node scripts/doctor.mjs",
53
54
  "breadth": "node scripts/breadth.mjs",
54
55
  "live": "node scripts/live.mjs",
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/sidecar.js CHANGED
@@ -11,6 +11,7 @@ import { homedir } from 'node:os'
11
11
  import { join } from 'node:path'
12
12
  import { fileURLToPath } from 'node:url'
13
13
  import { readyFlash } from './runtime/flash-setup.mjs'
14
+ import { isMedia, kindOf } from './media.js?m=1'
14
15
 
15
16
  /**
16
17
  * Windows keeps a browser under Program Files (machine-wide) or LocalAppData
@@ -575,6 +576,9 @@ export class SidecarPage {
575
576
  be understood without a screenshot or a retry. */
576
577
  this.network = []
577
578
  this.requests = new Map()
579
+ /* The movie a page plays is one of the requests it already made, so the
580
+ file behind it costs no second look: the network log is the sniff. */
581
+ this.media = new Map()
578
582
  /* A PDF is a document the mirror cannot draw: Chrome leaves the page blank
579
583
  and a model looking at it would think the address was empty. What it is
580
584
  and where the bytes went is state, not a guess. */
@@ -713,6 +717,7 @@ export class SidecarPage {
713
717
  it navigates the same tab to its own page, and the PDF is still what
714
718
  the tab shows. */
715
719
  if (String(params.type ?? '') === 'Document' && !String(request.url ?? '').startsWith('chrome-extension://')) this.pdf = null
720
+ if (String(params.type ?? '') === 'Document' && !String(request.url ?? '').startsWith('chrome-extension://')) this.media = new Map()
716
721
  this.requests.set(id, {
717
722
  method: String(request.method ?? 'GET'),
718
723
  url: String(request.url ?? '').slice(0, 300), // ui-copy-ignore
@@ -732,6 +737,8 @@ export class SidecarPage {
732
737
  bytes: Number(params.response?.encodedDataLength ?? 0),
733
738
  }
734
739
  }
740
+ if (known !== undefined) known.status = Number(params.response?.status ?? 0)
741
+ this.noteMedia(params)
735
742
  if (known === undefined) return
736
743
  known.status = Number(params.response?.status ?? 0)
737
744
  this.troubleSeq += 1
@@ -749,6 +756,43 @@ export class SidecarPage {
749
756
  }
750
757
  }
751
758
 
759
+ /**
760
+ * Keep the requests that hold a movie or a song, so asking "what can be saved
761
+ * here" is a memory read instead of a network sweep.
762
+ * @param {{requestId?: string, type?: string, response?: object}} params - a Network.responseReceived payload.
763
+ * @returns {void} nothing.
764
+ */
765
+ noteMedia(params) {
766
+ const response = params?.response ?? {}
767
+ const url = String(response.url ?? '')
768
+ const mime = String(response.mimeType ?? '')
769
+ if (!isMedia(url, mime, String(params?.type ?? ''))) return
770
+ const id = String(params?.requestId ?? '')
771
+ if (id === '') return
772
+ const headers = response.headers ?? {}
773
+ const length = Number(headers['content-length'] ?? headers['Content-Length'] ?? response.encodedDataLength ?? 0)
774
+ const existing = this.media.get(id)
775
+ this.media.set(id, {
776
+ url,
777
+ mime,
778
+ kind: kindOf(url, mime),
779
+ bytes: Number.isFinite(length) ? length : 0,
780
+ status: Number(response.status ?? 0),
781
+ seq: existing?.seq ?? this.troubleSeq,
782
+ })
783
+ if (this.media.size > 60) this.media.delete(this.media.keys().next().value)
784
+ }
785
+
786
+ /**
787
+ * What on this page is worth saving, newest first.
788
+ * @returns {Array<{url: string, mime: string, kind: string, bytes: number, status: number}>} the media.
789
+ */
790
+ mediaList() {
791
+ const byUrl = new Map()
792
+ for (const each of this.media.values()) byUrl.set(each.url, each)
793
+ return [...byUrl.values()].sort((left, right) => right.seq - left.seq)
794
+ }
795
+
752
796
  /**
753
797
  * Follow a tab the page just opened: the mirror shows the new page and the
754
798
  * popup is closed, so there is still one page to drive.
package/tools.js CHANGED
@@ -14,10 +14,29 @@ import { createHash } from 'node:crypto'
14
14
  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
- 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'
17
+ import { VIEW, DESKTOP_UA } from './sidecar.js?v=1'
18
+ import { planOf, gather, sizeText } from './media.js?m=1'
19
+ 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
20
  import { brief as askBrief, recordRound, lastRoundNote, lastRound, markRound, MARK_STATUS } from './review.js?v=1'
20
21
 
22
+ /**
23
+ * What a saved file should be called, from the address when it says so and the
24
+ * type when it does not.
25
+ * @param {string} url - the media's address.
26
+ * @param {string} [mime] - what the server called it.
27
+ * @returns {string} an extension with the dot.
28
+ */
29
+ function extensionOf(url, mime = '') {
30
+ const known = /\.(mp4|m4v|webm|mkv|mov|flv|ts|m4s|mp3|m4a|aac|ogg|opus|wav)(\?|#|$)/i.exec(String(url ?? ''))
31
+ if (known !== null) return `.${known[1].toLowerCase()}`
32
+ const said = String(mime ?? '').toLowerCase()
33
+ if (said.includes('mp4') || said.includes('m4v')) return '.mp4'
34
+ if (said.includes('webm')) return '.webm'
35
+ if (said.includes('mpegurl')) return '.ts'
36
+ if (said.startsWith('audio/')) return '.m4a'
37
+ return '.bin'
38
+ }
39
+
21
40
  /** Which chat the running call belongs to, so each gets its own tab. */
22
41
  export const sessionScope = new AsyncLocalStorage()
23
42
 
@@ -1050,6 +1069,49 @@ async function until(page, expression, timeout) {
1050
1069
  * @returns {Promise<string>} a compact report.
1051
1070
  */
1052
1071
  /** The first control whose words match, scrolled into view. Text can beat a number: it costs no read. */
1072
+ /** Words a label can lose without changing what it asks for. */
1073
+ 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'])
1074
+
1075
+ /**
1076
+ * The significant words of a label, lowercased.
1077
+ * @param {unknown} text - a label.
1078
+ * @returns {string[]} words worth comparing.
1079
+ */
1080
+ export function tokensOf(text) {
1081
+ return String(text ?? '').toLowerCase().split(/[^a-z0-9\u4e00-\u9fff]+/).filter((word) => word !== '' && !SMALL_WORDS.has(word))
1082
+ }
1083
+
1084
+ /**
1085
+ * The one label that means what the caller said when no label repeats it.
1086
+ * Only an unambiguous winner is offered, so a paraphrase cannot silently click
1087
+ * a different control than the one asked for.
1088
+ * @param {Array<{ref: number, name?: string, view?: boolean}>} controls - controls from a read.
1089
+ * @param {string} words - what the caller asked for.
1090
+ * @returns {any} the best control, or null when nothing stands out.
1091
+ */
1092
+ export function nearestByWords(controls, words) {
1093
+ const want = tokensOf(words)
1094
+ if (want.length === 0 || !Array.isArray(controls)) return null
1095
+ const scored = []
1096
+ for (const control of controls) {
1097
+ if (control?.view === false) continue
1098
+ const have = new Set(tokensOf(control?.name))
1099
+ if (have.size === 0) continue
1100
+ let shared = 0
1101
+ for (const word of want) if (have.has(word)) shared += 1
1102
+ const cover = shared / want.length
1103
+ if (shared === 0 || cover < 0.5) continue
1104
+ scored.push({ control, score: cover * 0.7 + (shared / have.size) * 0.3 })
1105
+ }
1106
+ if (scored.length === 0) return null
1107
+ scored.sort((a, b) => b.score - a.score)
1108
+ const best = scored[0]
1109
+ const second = scored[1]
1110
+ if (second !== undefined && second.score > best.score - 0.05) return null
1111
+ if (String(best.control.name ?? '').length > String(words).length * 3 + 12) return null
1112
+ return { ...best.control, ref: Number(best.control.ref) }
1113
+ }
1114
+
1053
1115
  export async function findWords(page, words, known) {
1054
1116
  /* The words a caller used usually came from a read that already happened, so
1055
1117
  look among those numbers first: one position check is a single evaluate,
@@ -1066,7 +1128,19 @@ export async function findWords(page, words, known) {
1066
1128
  end is only the fallback. */
1067
1129
  const firstInOrder = remembered.filter((control) => control.view !== false).sort((a, b) => Number(a.ref) - Number(b.ref))
1068
1130
  const nearEnd = remembered.slice(-8).reverse()
1069
- for (const control of [...firstInOrder.slice(0, 4), ...nearEnd]) {
1131
+ const order = [...firstInOrder.slice(0, 4), ...nearEnd]
1132
+ /* One round trip answers which of these the page still has: a dozen numbers
1133
+ left over from a read that a redraw invalidated used to cost a dozen
1134
+ checks. Numbers inside a frame keep the per-number route below. */
1135
+ const plain = order.map((control) => Number(control.ref)).filter((ref) => Number.isFinite(ref) && ref < FRAME_BASE)
1136
+ if (plain.length > 0) {
1137
+ const any = await locateFirst(page, plain, wantedFirst)
1138
+ if (any?.gone !== true && Number.isFinite(Number(any?.x))) {
1139
+ const byRef = order.find((control) => Number(control.ref) === Number(any.ref)) ?? { ref: Number(any.ref) }
1140
+ return { ...byRef, ref: Number(any.ref), name: String(any.name ?? byRef.name ?? ''), x: Number(any.x), y: Number(any.y) }
1141
+ }
1142
+ }
1143
+ for (const control of order.filter((control) => Number(control.ref) >= FRAME_BASE)) {
1070
1144
  const spot = await locate(page, Number(control.ref))
1071
1145
  if (spot.gone === true) continue
1072
1146
  const name = String(spot.name ?? control.name ?? '')
@@ -1080,7 +1154,17 @@ export async function findWords(page, words, known) {
1080
1154
  const squeeze = (text) => String(text).toLowerCase().replace(/\s+/g, '')
1081
1155
  const wanted = squeeze(words)
1082
1156
  const found = shown.find((control) => squeeze(control.name) === wanted) ?? shown.find((control) => squeeze(control.name).includes(wanted))
1083
- if (found === undefined) return null
1157
+ if (found === undefined) {
1158
+ /* A button the page redrew often only swaps one word, and a person says
1159
+ "submit order" for "Place order". Accept only an unambiguous winner. */
1160
+ const near = nearestByWords(shown, words)
1161
+ if (near !== null) {
1162
+ const spot = await locate(page, near.ref)
1163
+ if (spot.gone === true) return null
1164
+ return { ...near, x: Number(spot.x), y: Number(spot.y) }
1165
+ }
1166
+ return null
1167
+ }
1084
1168
  /* When only the squeezed form matched, say the words the caller used: the
1085
1169
  page spread them over nodes and "Signin" is not what anyone wrote. */
1086
1170
  const hit = squeeze(found.name) === wanted && found.name.toLowerCase() !== words.toLowerCase() ? { ...found, name: words } : found
@@ -3248,6 +3332,91 @@ export function sidecarTools(sidecar, refs, host = {}) { // ui-copy-ignore
3248
3332
  }),
3249
3333
  },
3250
3334
 
3335
+ {
3336
+ name: 'browser_media', // ui-copy-ignore
3337
+ description: 'What this page played, and how to keep it: lists the video and sound requests the page already made, with their sizes; save one by row number or part of its address. A stream playlist is followed to its pieces and joined, so nothing has to be installed.',
3338
+ parameters: {
3339
+ type: 'object',
3340
+ properties: {
3341
+ find: { type: 'string', description: 'Only rows whose address contains this.' },
3342
+ save: { type: ['integer', 'string'], description: 'Row number from the list, or part of its address.' },
3343
+ height: { type: 'number', description: 'Tallest version to take when one address offers several (default 1080).' },
3344
+ name: { type: 'string', description: 'File name without extension.' },
3345
+ },
3346
+ additionalProperties: false,
3347
+ },
3348
+ output: TEXT_OUTPUT, // ui-copy-ignore
3349
+ timeoutMs: 180_000,
3350
+ isConcurrencySafe: () => false,
3351
+ /** @returns {Promise<{text: string}>} the list, or where the file went. */
3352
+ execute: async (args) => withRawPage(async (page) => {
3353
+ const all = typeof page.mediaList === 'function' ? page.mediaList() : []
3354
+ const needle = String(args?.find ?? '').toLowerCase()
3355
+ const rows = needle === '' ? all : all.filter((each) => `${String(each.url)} ${String(each.mime)}`.toLowerCase().includes(needle))
3356
+ const save = args?.save
3357
+ if (save === undefined || save === null || save === '') {
3358
+ if (rows.length === 0) {
3359
+ return all.length === 0
3360
+ ? 'Nothing to save yet: play the video or sound once, then ask again — the request it makes is the file.'
3361
+ : `Nothing matches ${String(args?.find)}; this page has ${String(all.length)} other media.`
3362
+ }
3363
+ const lines = rows.slice(0, 12).map((each, at) => `${String(at + 1)}. ${String(each.kind)} ${sizeText(each.bytes)} · ${String(each.url).slice(0, 160)}`)
3364
+ if (rows.length > 12) lines.push(`… ${String(rows.length - 12)} more; narrow it with find`)
3365
+ lines.push('save: <row> keeps one')
3366
+ return lines.join('\n')
3367
+ }
3368
+ const wanted = typeof save === 'number' ? rows[save - 1] : rows.find((each) => String(each.url).includes(String(save)))
3369
+ if (wanted === undefined) {
3370
+ return `No media row ${String(save)}. ${rows.length === 0 ? 'Nothing to save yet.' : 'Call browser_media without save to see the rows.'}`
3371
+ }
3372
+ const cookieAnswer = await page.cdp.send('Network.getCookies', { urls: [String(wanted.url)] }).catch(() => ({ cookies: [] }))
3373
+ const cookie = (cookieAnswer?.cookies ?? []).map((each) => `${String(each.name)}=${String(each.value)}`).join('; ')
3374
+ const headers = { 'user-agent': DESKTOP_UA, accept: '*/*' }
3375
+ if (cookie !== '') headers.cookie = cookie
3376
+ if (/^https?:/i.test(String(page.url ?? ''))) headers.referer = String(page.url)
3377
+ const started = Date.now()
3378
+ const getBinary = async (address) => {
3379
+ const answer = await fetch(address, { headers, redirect: 'follow', signal: AbortSignal.timeout(60_000) })
3380
+ if (!answer.ok) throw new Error(`${String(answer.status)} for ${address.slice(0, 120)}`)
3381
+ return Buffer.from(await answer.arrayBuffer())
3382
+ }
3383
+ const cap = Number(args?.height ?? 1080)
3384
+ const wantedSize = cap > 0 ? { height: cap } : {}
3385
+ let parts
3386
+ let ext = extensionOf(wanted.url, wanted.mime)
3387
+ try {
3388
+ let body = await getBinary(String(wanted.url))
3389
+ parts = [body]
3390
+ const playlist = /\.m3u8(\?|#|$)/i.test(String(wanted.url)) || /mpegurl/i.test(String(wanted.mime ?? ''))
3391
+ if (playlist) {
3392
+ let plan = null
3393
+ let from = String(wanted.url)
3394
+ for (let hop = 0; hop < 2; hop += 1) {
3395
+ plan = planOf(from, body.toString('utf8'), wantedSize)
3396
+ if (plan.kind !== 'hls' || plan.variant === null) break
3397
+ from = plan.variant.url
3398
+ body = await getBinary(from)
3399
+ }
3400
+ if (plan.encrypted === true) return 'That stream is encrypted: the browser can play it, but the pieces are not a file to keep.'
3401
+ if (plan.parts.length === 0) return 'That playlist names no pieces. Play the movie first, then ask again.'
3402
+ const init = plan.init === '' ? [] : [await getBinary(plan.init)]
3403
+ parts = [...init, ...(await gather(plan.parts, getBinary, 6))]
3404
+ ext = plan.init !== '' ? '.mp4' : (/\.ts(\?|#|$)/i.test(plan.parts[0]) ? '.ts' : '.m4s')
3405
+ }
3406
+ } catch (error) {
3407
+ return `Could not fetch it: ${String(error?.message ?? error)}`
3408
+ }
3409
+ const body = Buffer.concat(parts)
3410
+ const dir = artifactDir('media')
3411
+ await mkdir(dir, { recursive: true })
3412
+ const stamp = new Date().toISOString().replace(/[-:]/g, '').replace('T', '-').slice(0, 15)
3413
+ const safe = String(args?.name ?? '').replace(/[^\w.-]/g, '').replace(/\.{2,}/g, '.').replace(/^\.+/, '')
3414
+ const file = join(dir, `${safe === '' ? `media-${stamp}` : safe}${ext}`)
3415
+ await writeFile(file, body)
3416
+ return `${file} — ${sizeText(body.length)} in ${String(Date.now() - started)} ms${parts.length > 1 ? ` (${String(parts.length)} parts joined)` : ''}`
3417
+ }),
3418
+ },
3419
+
3251
3420
  {
3252
3421
  name: 'browser_pdf', // ui-copy-ignore
3253
3422
  description: 'Print the page to a PDF and return its path. Useful when the person wants the document rather than a picture of it.',