dsh-inner-browser-use 0.35.3 → 0.37.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 +1 -0
- package/README.zh.md +1 -0
- package/docs/ARTIFACTS.md +15 -1
- package/docs/BENCH.md +17 -0
- package/index.js +2 -1
- package/media.js +309 -0
- package/package.json +3 -2
- package/sidecar.js +58 -0
- package/tools.js +140 -1
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, the one its own player shows first and marked `main`; `save: <row>`, `save: main` or part of the address keeps one. Ads and beacons stay out unless `all: true`; `watch: false` stops remembering the page. 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` | 列出页面已经请求过的视频和声音(带大小),页面自己播放器里那个排第一、标 `main`;`save: <行号>`、`save: main` 或地址片段存下来。广告和统计默认不出现(`all: true` 才看),`watch: false` 关掉这个页面的记录。流会顺着播放列表把片段取回拼好,不用装任何东西。 |
|
|
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
|
|
|
@@ -16,4 +16,18 @@
|
|
|
16
16
|
|
|
17
17
|
调用环境里没有项目或会话信息时,落到 `no-project/no-chat/<种类>/`,不猜。
|
|
18
18
|
|
|
19
|
+
## 整个目录
|
|
20
|
+
|
|
21
|
+
```
|
|
22
|
+
~/.dsh-inner-browser-use/
|
|
23
|
+
artifacts/<项目>/<会话>/<种类>/ 给人看的产出,只在这里(见上)
|
|
24
|
+
chrome/ 浏览器自己的数据:登录态、Cookie、缓存
|
|
25
|
+
flash/ Flash 播放器运行时,第一次用 flash:true 才装
|
|
26
|
+
browser.log 最近一次浏览器启动和页面记录
|
|
27
|
+
asks.log 人问过什么、答了什么
|
|
28
|
+
tabs.json / mirror.json / flash.json / watcher.json 上次开着哪些页
|
|
29
|
+
```
|
|
30
|
+
|
|
31
|
+
`chrome/` 是浏览器进程自己在读写,不要手动改;要清登录态,用 `browser_open` 的档位或插件自己的入口,别删目录里的某个文件。上面这些都在一个根目录下,卸载插件前想留产出就拷 `artifacts/`。
|
|
32
|
+
|
|
19
33
|
测试里用 `DSH_INNER_BROWSER_ARTIFACTS` 指向临时目录,不写真实 home。
|
package/docs/BENCH.md
CHANGED
|
@@ -1,3 +1,20 @@
|
|
|
1
|
+
## 只列该看的那一个(0.37.0,同一台本地页面,加了两个广告请求)
|
|
2
|
+
|
|
3
|
+
页面自己放一个 `<video src="/movie.mp4">`、一个 `<audio>`、fetch 一个 master 播放列表,再 fetch 两条广告(`/ads/preroll.mp4` 9 MB、`/px/beacon.mp4` 2 B)。不筛选时 5 条都在;默认列表剩 3 条,页面播放器那个标 `main` 排第一,`save: main` 存的就是它。
|
|
4
|
+
|
|
5
|
+
| 项 | 0.36.0 | 0.37.0 |
|
|
6
|
+
|---|---|---|
|
|
7
|
+
| 列表行数(同一页面 5 条请求) | 5 | 3,广告单独一行说数量 |
|
|
8
|
+
| 首行 | `1. video 20 B · /movie.mp4` | `1. main video 156 B · /movie.mp4` |
|
|
9
|
+
| 找人要的那个 | 数行号 / 猜地址 | `save: main` |
|
|
10
|
+
| 列表耗时 | 0 ms(纯内存) | 1 ms(多一次页面查询拿播放器与时长) |
|
|
11
|
+
|
|
12
|
+
`media-pick` 1 ms,预算 `media-pick: 150 ms`;`all: true` 回到 5 行。`media-sniff` 仍 0 ms(记录仍是网络日志附带的),`media-save` 4 ms、96 B、`6 parts joined`,`budget checks 17 ok`。
|
|
13
|
+
|
|
14
|
+
## 媒体嗅探与流保存(0.36.0,本地 http 页面,`scripts/bench.mjs`)
|
|
15
|
+
|
|
16
|
+
页面自己放 `<video>`、`<audio>` 并 fetch 一个 master 播放列表:加载完的那一刻网络记录里已经有 3 条,`media-sniff` 0 ms(没有额外请求,嗅探就是已有的网络记录)。保存 master → 1080 → 6 个片段,`media-save` 5 ms、96 B,报 `6 parts joined`;列表首行 59 字符。预算 `media-sniff: 400 ms`、`media-save: 500 ms` 均通过,`budget checks 16 ok`。
|
|
17
|
+
|
|
1
18
|
## Flash 镜像按栏改窗口(0.34.48,Electron Flash 运行时,右栏 747×1196)
|
|
2
19
|
|
|
3
20
|
Flash 路径原来采集一个固定的 1440×900 窗口,竖高右栏里上下留黑边。现在 `setViewport` 直接改窗口大小,采集源与栏同比例。
|
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
|
-
'
|
|
947
|
+
'页面放视频或音乐、人想留下它时用 `browser_media`:它列的是页面自己已经请求过的媒体(带大小),页面自己播放器里那个标 `main` 排第一,广告和统计请求默认不出现(`all:true` 才看),`save: main` 存第一个;视频是流的话会顺着播放列表把片段取回拼成整段,不用装 yt-dlp 或 ffmpeg。先让页面播一下再列,播放请求才是文件。不想要这个页面的记录就 `watch:false`。',
|
|
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,309 @@
|
|
|
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
|
+
}
|
|
240
|
+
|
|
241
|
+
/**
|
|
242
|
+
* Ad servers and ad slots, recognised by themselves rather than by size: an ad
|
|
243
|
+
* is often the biggest file on the page, and a real clip can be small.
|
|
244
|
+
*/
|
|
245
|
+
const AD_HOST = /^(?:(?:[\w-]+\.)*(?:doubleclick\.net|googlesyndication\.com|googleadservices\.com|amazon-adsystem\.com|adsrvr\.org|taboola\.com|outbrain\.com|criteo\.(?:net|com)|scorecardresearch\.com|moatads\.com|adnxs\.com|pubmatic\.com|rubiconproject\.com|openx\.net)$)$/i;
|
|
246
|
+
const AD_SLOT = /^(?:ad|ads|adv|adserver|advert(?:isement|ising)?|pre-?roll|mid-?roll|post-?roll|banner|sponsor|promo|tracking|tracker|pixel|beacon|analytics|telemetry)\d*$/i
|
|
247
|
+
|
|
248
|
+
/**
|
|
249
|
+
* Whether an address is an ad or a measurement call.
|
|
250
|
+
* @param {string} url - the address.
|
|
251
|
+
* @returns {boolean} true when it is worth hiding from a person picking a file.
|
|
252
|
+
*/
|
|
253
|
+
export function adLike(url) {
|
|
254
|
+
let parsed
|
|
255
|
+
try {
|
|
256
|
+
parsed = new URL(String(url))
|
|
257
|
+
} catch {
|
|
258
|
+
return false
|
|
259
|
+
}
|
|
260
|
+
if (AD_HOST.test(parsed.hostname)) return true
|
|
261
|
+
const pieces = parsed.pathname.split('/').filter((piece) => piece !== '')
|
|
262
|
+
const named = pieces.slice(0, -1).some((piece) => AD_SLOT.test(piece))
|
|
263
|
+
if (named) return true
|
|
264
|
+
const file = pieces.length === 0 ? '' : pieces[pieces.length - 1]
|
|
265
|
+
return /^(?:ad|ads|adv|preroll|banner|pixel|beacon|track|tracking)[-_.]/i.test(file)
|
|
266
|
+
}
|
|
267
|
+
|
|
268
|
+
/**
|
|
269
|
+
* How much a row looks like the thing a person came for: the player on the page
|
|
270
|
+
* first, then a real file, and ads or beacons last.
|
|
271
|
+
* @param {{url: string, mime?: string, kind?: string, bytes?: number}} row - one media request.
|
|
272
|
+
* @param {Array<{url: string, dur?: number}>} [players] - what the page's own `<video>` / `<audio>` plays.
|
|
273
|
+
* @returns {number} the score; higher is more likely the point of the page.
|
|
274
|
+
*/
|
|
275
|
+
export function coreScore(row, players = []) {
|
|
276
|
+
const url = String(row?.url ?? '')
|
|
277
|
+
const bytes = Number(row?.bytes ?? 0)
|
|
278
|
+
const kind = String(row?.kind ?? '')
|
|
279
|
+
const mime = String(row?.mime ?? '')
|
|
280
|
+
let score = 0
|
|
281
|
+
const mine = players.some((one) => {
|
|
282
|
+
const said = String(one?.url ?? '')
|
|
283
|
+
return said !== '' && (said === url || url.startsWith(said) || said.startsWith(url))
|
|
284
|
+
})
|
|
285
|
+
if (mine) score += 100
|
|
286
|
+
if (adLike(url)) score -= 80
|
|
287
|
+
if (bytes >= 1_000_000) score += 25
|
|
288
|
+
else if (bytes >= 200_000) score += 10
|
|
289
|
+
else if (bytes > 0 && bytes < 20_000 && kind !== 'stream') score -= 20
|
|
290
|
+
if (kind === 'stream') score += 15
|
|
291
|
+
if (/^video\//i.test(mime)) score += 5
|
|
292
|
+
if (/^audio\//i.test(mime)) score += 3
|
|
293
|
+
return score
|
|
294
|
+
}
|
|
295
|
+
|
|
296
|
+
/**
|
|
297
|
+
* The rows in the order a person would want them, with ads gone unless asked.
|
|
298
|
+
* @param {Array<object>} rows - media rows, any order.
|
|
299
|
+
* @param {Array<{url: string, dur?: number}>} [players] - the page's own players.
|
|
300
|
+
* @param {boolean} [all] - keep the ads and the beacons too.
|
|
301
|
+
* @returns {Array<object>} the rows to show.
|
|
302
|
+
*/
|
|
303
|
+
export function coreRows(rows, players = [], all = false) {
|
|
304
|
+
const ranked = rows.map((row) => ({ row, score: coreScore(row, players) }))
|
|
305
|
+
return ranked
|
|
306
|
+
.filter((one) => all || one.score >= 0)
|
|
307
|
+
.sort((left, right) => right.score - left.score || Number(right.row?.seq ?? 0) - Number(left.row?.seq ?? 0))
|
|
308
|
+
.map((one) => one.row)
|
|
309
|
+
}
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "dsh-inner-browser-use",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.37.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/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,10 @@ 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()
|
|
582
|
+
this.mediaOn = true
|
|
578
583
|
/* A PDF is a document the mirror cannot draw: Chrome leaves the page blank
|
|
579
584
|
and a model looking at it would think the address was empty. What it is
|
|
580
585
|
and where the bytes went is state, not a guess. */
|
|
@@ -713,6 +718,7 @@ export class SidecarPage {
|
|
|
713
718
|
it navigates the same tab to its own page, and the PDF is still what
|
|
714
719
|
the tab shows. */
|
|
715
720
|
if (String(params.type ?? '') === 'Document' && !String(request.url ?? '').startsWith('chrome-extension://')) this.pdf = null
|
|
721
|
+
if (String(params.type ?? '') === 'Document' && !String(request.url ?? '').startsWith('chrome-extension://')) this.media = new Map()
|
|
716
722
|
this.requests.set(id, {
|
|
717
723
|
method: String(request.method ?? 'GET'),
|
|
718
724
|
url: String(request.url ?? '').slice(0, 300), // ui-copy-ignore
|
|
@@ -732,6 +738,8 @@ export class SidecarPage {
|
|
|
732
738
|
bytes: Number(params.response?.encodedDataLength ?? 0),
|
|
733
739
|
}
|
|
734
740
|
}
|
|
741
|
+
if (known !== undefined) known.status = Number(params.response?.status ?? 0)
|
|
742
|
+
this.noteMedia(params)
|
|
735
743
|
if (known === undefined) return
|
|
736
744
|
known.status = Number(params.response?.status ?? 0)
|
|
737
745
|
this.troubleSeq += 1
|
|
@@ -749,6 +757,56 @@ export class SidecarPage {
|
|
|
749
757
|
}
|
|
750
758
|
}
|
|
751
759
|
|
|
760
|
+
/**
|
|
761
|
+
* Keep the requests that hold a movie or a song, so asking "what can be saved
|
|
762
|
+
* here" is a memory read instead of a network sweep.
|
|
763
|
+
* @param {{requestId?: string, type?: string, response?: object}} params - a Network.responseReceived payload.
|
|
764
|
+
* @returns {void} nothing.
|
|
765
|
+
*/
|
|
766
|
+
noteMedia(params) {
|
|
767
|
+
if (this.mediaOn === false) return
|
|
768
|
+
const response = params?.response ?? {}
|
|
769
|
+
const url = String(response.url ?? '')
|
|
770
|
+
const mime = String(response.mimeType ?? '')
|
|
771
|
+
if (!isMedia(url, mime, String(params?.type ?? ''))) return
|
|
772
|
+
const id = String(params?.requestId ?? '')
|
|
773
|
+
if (id === '') return
|
|
774
|
+
const headers = response.headers ?? {}
|
|
775
|
+
const length = Number(headers['content-length'] ?? headers['Content-Length'] ?? response.encodedDataLength ?? 0)
|
|
776
|
+
const existing = this.media.get(id)
|
|
777
|
+
this.media.set(id, {
|
|
778
|
+
url,
|
|
779
|
+
mime,
|
|
780
|
+
kind: kindOf(url, mime),
|
|
781
|
+
bytes: Number.isFinite(length) ? length : 0,
|
|
782
|
+
status: Number(response.status ?? 0),
|
|
783
|
+
seq: existing?.seq ?? this.troubleSeq,
|
|
784
|
+
})
|
|
785
|
+
if (this.media.size > 60) this.media.delete(this.media.keys().next().value)
|
|
786
|
+
}
|
|
787
|
+
|
|
788
|
+
/**
|
|
789
|
+
* Stop or resume remembering media on this page; stopping empties the list so
|
|
790
|
+
* nothing is kept from before the switch.
|
|
791
|
+
* @param {boolean} on - false stops, true resumes.
|
|
792
|
+
* @returns {boolean} what it is set to.
|
|
793
|
+
*/
|
|
794
|
+
mediaWatch(on) {
|
|
795
|
+
this.mediaOn = on !== false
|
|
796
|
+
if (!this.mediaOn) this.media = new Map()
|
|
797
|
+
return this.mediaOn
|
|
798
|
+
}
|
|
799
|
+
|
|
800
|
+
/**
|
|
801
|
+
* What on this page is worth saving, newest first.
|
|
802
|
+
* @returns {Array<{url: string, mime: string, kind: string, bytes: number, status: number}>} the media.
|
|
803
|
+
*/
|
|
804
|
+
mediaList() {
|
|
805
|
+
const byUrl = new Map()
|
|
806
|
+
for (const each of this.media.values()) byUrl.set(each.url, each)
|
|
807
|
+
return [...byUrl.values()].sort((left, right) => right.seq - left.seq)
|
|
808
|
+
}
|
|
809
|
+
|
|
752
810
|
/**
|
|
753
811
|
* Follow a tab the page just opened: the mirror shows the new page and the
|
|
754
812
|
* popup is closed, so there is still one page to drive.
|
package/tools.js
CHANGED
|
@@ -14,10 +14,47 @@ 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'
|
|
17
|
+
import { VIEW, DESKTOP_UA } from './sidecar.js?v=1'
|
|
18
|
+
import { planOf, gather, sizeText, coreRows } from './media.js?m=2'
|
|
18
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
|
+
|
|
40
|
+
/** What the page's own players are showing, in one look: the address, the length, whether it runs. */
|
|
41
|
+
async function playersOf(page) {
|
|
42
|
+
const answer = await page.cdp.send('Runtime.evaluate', {
|
|
43
|
+
expression: `(() => [...document.querySelectorAll('video,audio')].map((one) => ({ url: one.currentSrc || one.src || '', playing: !one.paused, dur: Number.isFinite(one.duration) ? Math.round(one.duration) : 0 })))()`,
|
|
44
|
+
returnByValue: true,
|
|
45
|
+
}).catch(() => null)
|
|
46
|
+
const seen = answer?.result?.value
|
|
47
|
+
return Array.isArray(seen) ? seen.filter((one) => String(one?.url ?? '') !== '') : []
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
/** Seconds as a person writes them: 2:13, 1:02:00. */
|
|
51
|
+
function clockOf(seconds) {
|
|
52
|
+
const whole = Math.max(0, Math.round(Number(seconds) || 0))
|
|
53
|
+
const rest = whole % 3600
|
|
54
|
+
const pad = (value) => String(value).padStart(2, '0')
|
|
55
|
+
return whole >= 3600 ? `${String(Math.floor(whole / 3600))}:${pad(Math.floor(rest / 60))}:${pad(rest % 60)}` : `${String(Math.floor(whole / 60))}:${pad(rest % 60)}`
|
|
56
|
+
}
|
|
57
|
+
|
|
21
58
|
/** Which chat the running call belongs to, so each gets its own tab. */
|
|
22
59
|
export const sessionScope = new AsyncLocalStorage()
|
|
23
60
|
|
|
@@ -3313,6 +3350,108 @@ export function sidecarTools(sidecar, refs, host = {}) { // ui-copy-ignore
|
|
|
3313
3350
|
}),
|
|
3314
3351
|
},
|
|
3315
3352
|
|
|
3353
|
+
{
|
|
3354
|
+
name: 'browser_media', // ui-copy-ignore
|
|
3355
|
+
description: 'What this page played, and how to keep it: lists the video and sound requests the page already made, the one its own player shows first, with sizes; save one by row number, by part of its address, or save: main. Ads and measurement beacons stay out of the list unless all: true. A stream playlist is followed to its pieces and joined, so nothing has to be installed. watch: false stops remembering this page.',
|
|
3356
|
+
parameters: {
|
|
3357
|
+
type: 'object',
|
|
3358
|
+
properties: {
|
|
3359
|
+
find: { type: 'string', description: 'Only rows whose address contains this.' },
|
|
3360
|
+
save: { type: ['integer', 'string'], description: 'Row number from the list, part of its address, or main for the first row.' },
|
|
3361
|
+
all: { type: 'boolean', description: 'Include ads and beacons too (default false).' },
|
|
3362
|
+
watch: { type: 'boolean', description: 'false stops remembering what this page plays and empties the list; true resumes.' },
|
|
3363
|
+
height: { type: 'number', description: 'Tallest version to take when one address offers several (default 1080).' },
|
|
3364
|
+
name: { type: 'string', description: 'File name without extension.' },
|
|
3365
|
+
},
|
|
3366
|
+
additionalProperties: false,
|
|
3367
|
+
},
|
|
3368
|
+
output: TEXT_OUTPUT, // ui-copy-ignore
|
|
3369
|
+
timeoutMs: 180_000,
|
|
3370
|
+
isConcurrencySafe: () => false,
|
|
3371
|
+
/** @returns {Promise<{text: string}>} the list, or where the file went. */
|
|
3372
|
+
execute: async (args) => withRawPage(async (page) => {
|
|
3373
|
+
if (typeof args?.watch === 'boolean') {
|
|
3374
|
+
const on = typeof page.mediaWatch === 'function' ? page.mediaWatch(args.watch) : false
|
|
3375
|
+
if (!on) return 'Off: this page keeps nothing from now on. watch: true puts it back.'
|
|
3376
|
+
if (args.save === undefined || args.save === null || args.save === '') return 'On: ask again once it has played something.'
|
|
3377
|
+
}
|
|
3378
|
+
const known = typeof page.mediaList === 'function' ? page.mediaList() : []
|
|
3379
|
+
const players = known.length === 0 ? [] : await playersOf(page)
|
|
3380
|
+
const ranked = coreRows(known, players, args?.all === true)
|
|
3381
|
+
const needle = String(args?.find ?? '').toLowerCase()
|
|
3382
|
+
const rows = needle === '' ? ranked : ranked.filter((each) => `${String(each.url)} ${String(each.mime)}`.toLowerCase().includes(needle))
|
|
3383
|
+
const save = args?.save
|
|
3384
|
+
if (save === undefined || save === null || save === '') {
|
|
3385
|
+
if (rows.length === 0) {
|
|
3386
|
+
if (known.length === 0) return 'Nothing to save yet: play the video or sound once, then ask again — the request it makes is the file.'
|
|
3387
|
+
if (needle !== '') return `Nothing matches ${String(args?.find)}; ${String(known.length)} played here, the ones worth keeping are gone.`
|
|
3388
|
+
return `Only ads and beacons played here (${String(known.length)}); all: true shows them.`
|
|
3389
|
+
}
|
|
3390
|
+
const hidden = known.length - ranked.length
|
|
3391
|
+
const lines = rows.slice(0, 12).map((each, at) => {
|
|
3392
|
+
const mine = players.find((one) => one.url === each.url || String(one.url).startsWith(String(each.url)) || String(each.url).startsWith(String(one.url)))
|
|
3393
|
+
const mark = at === 0 && mine !== undefined ? 'main ' : ''
|
|
3394
|
+
const length = mine !== undefined && mine.dur > 0 ? ` ${clockOf(mine.dur)}` : ''
|
|
3395
|
+
return `${String(at + 1)}. ${mark}${String(each.kind)}${length} ${sizeText(each.bytes)} · ${String(each.url).slice(0, 160)}`
|
|
3396
|
+
})
|
|
3397
|
+
if (rows.length > 12) lines.push(`… ${String(rows.length - 12)} more; narrow it with find`)
|
|
3398
|
+
if (hidden > 0) lines.push(`… ${String(hidden)} ad or beacon kept out; all: true shows them`)
|
|
3399
|
+
lines.push('save: <row> or main')
|
|
3400
|
+
return lines.join('\n')
|
|
3401
|
+
}
|
|
3402
|
+
const named = typeof save === 'string' && save.toLowerCase() === 'main'
|
|
3403
|
+
const wanted = named ? rows[0] : typeof save === 'number' ? rows[save - 1] : rows.find((each) => String(each.url).includes(String(save)))
|
|
3404
|
+
if (wanted === undefined) {
|
|
3405
|
+
return `No media row ${String(save)}. ${rows.length === 0 ? 'Nothing to save yet.' : 'Call browser_media without save to see the rows; all: true includes ads.'}`
|
|
3406
|
+
}
|
|
3407
|
+
const cookieAnswer = await page.cdp.send('Network.getCookies', { urls: [String(wanted.url)] }).catch(() => ({ cookies: [] }))
|
|
3408
|
+
const cookie = (cookieAnswer?.cookies ?? []).map((each) => `${String(each.name)}=${String(each.value)}`).join('; ')
|
|
3409
|
+
const headers = { 'user-agent': DESKTOP_UA, accept: '*/*' }
|
|
3410
|
+
if (cookie !== '') headers.cookie = cookie
|
|
3411
|
+
if (/^https?:/i.test(String(page.url ?? ''))) headers.referer = String(page.url)
|
|
3412
|
+
const started = Date.now()
|
|
3413
|
+
const getBinary = async (address) => {
|
|
3414
|
+
const answer = await fetch(address, { headers, redirect: 'follow', signal: AbortSignal.timeout(60_000) })
|
|
3415
|
+
if (!answer.ok) throw new Error(`${String(answer.status)} for ${address.slice(0, 120)}`)
|
|
3416
|
+
return Buffer.from(await answer.arrayBuffer())
|
|
3417
|
+
}
|
|
3418
|
+
const cap = Number(args?.height ?? 1080)
|
|
3419
|
+
const wantedSize = cap > 0 ? { height: cap } : {}
|
|
3420
|
+
let parts
|
|
3421
|
+
let ext = extensionOf(wanted.url, wanted.mime)
|
|
3422
|
+
try {
|
|
3423
|
+
let body = await getBinary(String(wanted.url))
|
|
3424
|
+
parts = [body]
|
|
3425
|
+
const playlist = /\.m3u8(\?|#|$)/i.test(String(wanted.url)) || /mpegurl/i.test(String(wanted.mime ?? ''))
|
|
3426
|
+
if (playlist) {
|
|
3427
|
+
let plan = null
|
|
3428
|
+
let from = String(wanted.url)
|
|
3429
|
+
for (let hop = 0; hop < 2; hop += 1) {
|
|
3430
|
+
plan = planOf(from, body.toString('utf8'), wantedSize)
|
|
3431
|
+
if (plan.kind !== 'hls' || plan.variant === null) break
|
|
3432
|
+
from = plan.variant.url
|
|
3433
|
+
body = await getBinary(from)
|
|
3434
|
+
}
|
|
3435
|
+
if (plan.encrypted === true) return 'That stream is encrypted: the browser can play it, but the pieces are not a file to keep.'
|
|
3436
|
+
if (plan.parts.length === 0) return 'That playlist names no pieces. Play the movie first, then ask again.'
|
|
3437
|
+
const init = plan.init === '' ? [] : [await getBinary(plan.init)]
|
|
3438
|
+
parts = [...init, ...(await gather(plan.parts, getBinary, 6))]
|
|
3439
|
+
ext = plan.init !== '' ? '.mp4' : (/\.ts(\?|#|$)/i.test(plan.parts[0]) ? '.ts' : '.m4s')
|
|
3440
|
+
}
|
|
3441
|
+
} catch (error) {
|
|
3442
|
+
return `Could not fetch it: ${String(error?.message ?? error)}`
|
|
3443
|
+
}
|
|
3444
|
+
const body = Buffer.concat(parts)
|
|
3445
|
+
const dir = artifactDir('media')
|
|
3446
|
+
await mkdir(dir, { recursive: true })
|
|
3447
|
+
const stamp = new Date().toISOString().replace(/[-:]/g, '').replace('T', '-').slice(0, 15)
|
|
3448
|
+
const safe = String(args?.name ?? '').replace(/[^\w.-]/g, '').replace(/\.{2,}/g, '.').replace(/^\.+/, '')
|
|
3449
|
+
const file = join(dir, `${safe === '' ? `media-${stamp}` : safe}${ext}`)
|
|
3450
|
+
await writeFile(file, body)
|
|
3451
|
+
return `${file} — ${sizeText(body.length)} in ${String(Date.now() - started)} ms${parts.length > 1 ? ` (${String(parts.length)} parts joined)` : ''}`
|
|
3452
|
+
}),
|
|
3453
|
+
},
|
|
3454
|
+
|
|
3316
3455
|
{
|
|
3317
3456
|
name: 'browser_pdf', // ui-copy-ignore
|
|
3318
3457
|
description: 'Print the page to a PDF and return its path. Useful when the person wants the document rather than a picture of it.',
|