返回提交历史
Modified
SKILL.md
+4
-1
Modified
package-lock.json
+2
-2
Modified
package.json
+1
-1
Added
references/audio-transcription.md
+33
-0
Modified
scripts/media-evidence.mjs
+5
-2
Modified
scripts/self-test.mjs
+7
-4
XFEstudio/BrowserDebugger
Handle media placeholders and document local audio transcription
7d74d9e
代码差异
6 个文件
+52
-10
@@ -1,6 +1,6 @@
1
1
---
2
2
name: browser-debugger
3
description: 操作和调试 Edge、Chrome 等 Chromium 浏览器,完成网页交互、真实文件上传/下载,以及类似 DevTools Network 的请求、响应、WebSocket 和 API 捕获。适用于用户要求浏览器操作、抓包、查接口、检查页面错误或通过 CDP 调试 DOM、脚本和性能;单纯搜索网页或编写离线 API 客户端无需使用。
3
description: 操作和调试 Edge、Chrome 等 Chromium 浏览器,完成网页交互、真实文件上传/下载、视频与音轨取证,以及类似 DevTools Network 的请求、响应、WebSocket 和 API 捕获。适用于用户要求浏览器操作、抓包、查接口、检查页面错误或通过 CDP 调试 DOM、脚本和性能;单纯搜索网页或编写离线 API 客户端无需使用。
4
4
---
5
5
6
6
# 浏览器调试与网络捕获
@@ -35,11 +35,14 @@ var page = debugSession.page;
35
35
await page.goto('https://example.com', { waitUntil: 'domcontentloaded' });
36
36
```
37
37
38
持久工具若禁止 `process` 或原生 Node 模块,不能假设其隔离环境可运行 helper;改用下面的 CLI,在实际 Node.js 进程中保持会话。
39
38
40
`debugSession.page`、`context`、`browser` 是完整 Playwright 对象,直接使用实际存在的 Playwright API。`debugSession.protocol` 是独立 CDP 连接;`debugSession.cdp(method, params, options)` 可发送当前浏览器支持的协议命令,不局限于内置示例。默认对当前 page 发送命令,`Browser`、`Target`、`SystemInfo` 域默认走浏览器根连接;用 `{ sessionId }` 可指定 Worker/iframe 等目标。当前浏览器的完整协议保存在捕获目录的 `protocol.json`。
39
41
40
42
- 网页操作、文件选择、下载、弹窗和权限:按需读 [references/browser-actions.md](references/browser-actions.md)。
41
43
- 网络捕获、接口分析、请求重放和 DevTools 调试:按需读 [references/network-and-devtools.md](references/network-and-devtools.md)。
42
44
- 视频画面/字幕采集、嵌套 iframe、自定义字体、现有扩展会话事件分页和 C# WebView2:按需读 [references/media-and-embedded.md](references/media-and-embedded.md)。
45
- 音轨提取、本地语音识别与真实 Agent 联调:按需读 [references/audio-transcription.md](references/audio-transcription.md)。
43
46
44
47
先观察实际页面(截图、ARIA/DOM、当前 URL、标签页),再定位并操作。对 SPA、WebSocket、SSE 页面,以目标元素、响应或状态变化判断完成,避免把 `networkidle` 当成通用完成条件。监听响应、弹窗、下载和文件选择事件应放在触发动作之前。
45
48
@@ -1,12 +1,12 @@
1
1
{
2
2
"name": "browser-debugger-skill",
3
"version": "1.1.0",
3
"version": "1.2.0",
4
4
"lockfileVersion": 3,
5
5
"requires": true,
6
6
"packages": {
7
7
"": {
8
8
"name": "browser-debugger-skill",
9
"version": "1.1.0",
9
"version": "1.2.0",
10
10
"dependencies": {
11
11
"playwright-core": "1.64.1"
12
12
},
@@ -1,6 +1,6 @@
1
1
{
2
2
"name": "browser-debugger-skill",
3
"version": "1.1.0",
3
"version": "1.2.0",
4
4
"private": true,
5
5
"type": "module",
6
6
"engines": { "node": ">=22" },
@@ -0,0 +1,33 @@
1
# 音轨转写与真实媒体联调
2
3
## 定位播放失败
4
5
枚举所有 iframe 的 HTMLMediaElement 时,区分实际课程媒体和隐藏占位元素。`<audio src="">` 的 `.src` 属性可能被解析为当前文档 URL,不能用 `Boolean(audio.src)` 判断音源。检查原始 `getAttribute('src').trim()`、有效 `<source>` 和实际 `currentSrc`;空音频占位节点不进入播放队列。`inspectMedia` 保留这类节点并标记 `placeholder`,取证采样会跳过它们。
6
7
保留 `play()` 拒绝时的异常名称与原因:无媒体来源、尚未就绪、权限策略和解码失败需要不同处理。按实际观察的播放器结构点击可见播放入口(例如 Video.js 的 `.vjs-big-play-button`),随后等待 readyState、paused 和 currentTime 的实际变化。按钮点击成功或 play Promise resolved 都不等于持续播放成功。不要为排错而改速、伪造完成或直接重放心跳。
8
9
## 音轨获取
10
11
1. 从真实播放器取得已观察到的 HTTP/HTTPS currentSrc;只在任务授权的会话中使用必要 Cookie、Referer、User-Agent。
12
2. 流式下载到任务本地目录,限制单文件大小,支持取消。不要将带签名的媒体 URL、Cookie 写到日志或命令行示例。blob、MSE、HLS、DRM 需要对应适配,不能将 blob 地址当 HTTP 下载链接。
13
3. MP4/AAC 可通过 Windows Media Foundation 解码,使用 NAudio `MediaFoundationReader` 和 `MediaFoundationResampler` 转成 16 kHz、16 bit、单声道 PCM WAV。NAudio 3.x 的 `Read` 接收 Span,不能套用旧版本的三参数重载。
14
4. 直接解码媒体文件不依赖扬声器音量;无需录制系统声音或麦克风。避免把用户其它应用的音频混入课程转写。
15
16
## 本地识别
17
18
- Whisper.net:主包与本机 CPU/GPU Runtime 均要随软件部署;例如经过验证的 1.9.1 组合。输入必须为真实、非空、格式正确的 WAV。通过 `WithLanguage("zh")` 和 `ProcessAsync(stream, cancellationToken)` 得到时间段,保存 TXT/SRT/JSON。模型下载用唯一临时文件,成功校验后原子移动,避免并发下载争用同一 `.download` 文件。
19
- 模型以维护者官方 Hugging Face 仓库为来源。网络受限时可用镜像,但必须核对官方 SHA-256;base:`60ed5bc3dd14eea856493d334349b405782ddcaf0028d4b5df4088345fba2efe`,small:`1be3a9b2063867b937e64e2ec7483364a79917e157fa98c5d94b5c1fffea987b`。用户指定的其它模型要明确其来源与适用运行时。
20
- Windows:若 `SpeechRecognitionEngine.InstalledRecognizers()` 有目标语言,可使用 `System.Speech`、`DictationGrammar`、`SetInputToWaveFile` 和 `RecognizeAsync(RecognizeMode.Multiple)` 离线识别。通过 SpeechRecognized 的音频位置保存时间戳;停止时取消识别并等待完成事件。质量与语言引擎有关,保留原始转写供核对。
21
- 自动模式可先用已缓存 Whisper,再用已安装 Windows 引擎,两者都没有时下载模型。界面应允许选择引擎、语言和本地模型路径。
22
23
## Agent 与确认结果
24
25
转写可以与正常播放并行。正文分析和后续回答要等待需要的音轨任务完成;停止时取消下载、解码和识别,不能在转写失败后悄悄当作已分析完整视频。保留来源和时间标记,自动识别可能有同音错字,不让 Agent 将纠正推断伪称逐字原文。
26
27
学科背景知识与课程原文分开注明。是否允许借助背景知识应由软件的证据模式明确配置,不能把模型的不确定回答变成“高置信度”来推进测试。
28
29
真实 API 联调除了 GET 模型列表,还要验证图片、转写文本和 JSON 答案请求。DeepSeek 当前思考模式会消耗输出预算;若固定输出太短而 finish_reason=length,按官方文档配置 `thinking.type="disabled"` 或给足推理/输出预算,不用截断的 JSON 执行答案。
30
31
最终区分:实际媒体播放结束、转写完成、Agent 返回成功、控件已填写、提交请求成功、页面显示提交结果/分数。只通过本地 mock 或 HTTP 200 不能宣称真实流程已完成。对真实账号仅执行用户授权的课程和次数;联调不自动扩展为整门课无限运行。
32
33
参考:[Whisper.net](https://github.com/sandrohanea/whisper.net)、[官方模型](https://huggingface.co/ggerganov/whisper.cpp)、[NAudio](https://github.com/naudio/NAudio)、[DeepSeek API](https://api-docs.deepseek.com/)。
@@ -17,6 +17,8 @@ export async function inspectMedia(page) {
17
17
duration: Number.isFinite(v.duration) ? v.duration : null,
18
18
paused: v.paused, ended: v.ended, seeking: v.seeking, readyState: v.readyState,
19
19
playbackRate: v.playbackRate, source: v.currentSrc || v.src,
20
placeholder: v.tagName === 'AUDIO' && !(v.getAttribute('src') || '').trim() &&
21
!Array.from(v.querySelectorAll('source')).some(s => (s.getAttribute('src') || '').trim()),
20
22
width: v.getBoundingClientRect().width, height: v.getBoundingClientRect().height,
21
23
tracks: Array.from(v.textTracks || []).map(t => ({ kind: t.kind, language: t.language, mode: t.mode,
22
24
activeCues: Array.from(t.activeCues || []).map(c => ({ startTime: c.startTime, endTime: c.endTime, text: c.text || '' })) })),
@@ -55,7 +57,7 @@ export async function captureMediaEvidence(page, options) {
55
57
}
56
58
if (!frameIds.has(frame)) frameIds.set(frame, ++nextFrame);
57
59
const key = `${frameIds.get(frame)}:${item.index}:${item.source}`;
58
if (item.seeking || item.readyState < 2 || item.currentTime <= 0 ||
60
if (item.placeholder || item.seeking || item.readyState < 2 || item.currentTime <= 0 ||
59
61
(last.has(key) && item.currentTime - last.get(key) < intervalSeconds)) continue;
60
62
const record = { ...item, image: null };
61
63
if (item.tag === 'video' && item.width > 0 && item.height > 0) {
@@ -74,7 +76,8 @@ export async function captureMediaEvidence(page, options) {
74
76
await appendFile(manifestPath, `${JSON.stringify(sample)}\n`, 'utf8'); samples++;
75
77
if (onSample) await onSample(sample);
76
78
}
77
if (snapshot.media.length && snapshot.media.every(item => item.ended)) break;
79
const playable = snapshot.media.filter(item => !item.placeholder);
80
if (playable.length && playable.every(item => item.ended)) break;
78
81
const remaining = seconds * 1000 - (Date.now() - started);
79
82
if (remaining > 0) await delay(Math.min(pollMs, remaining), undefined, { signal });
80
83
}
@@ -146,6 +146,7 @@ try {
146
146
const mediaFrame = page.frames().find(frame => frame.url().endsWith('/frame'));
147
147
await page.locator('iframe').evaluate(frame => { frame.style.height = '300px'; frame.style.width = '500px'; });
148
148
await mediaFrame.evaluate(async () => {
149
const audition = document.createElement('audio'); audition.id = 'auditionAudio'; audition.src = ''; audition.hidden = true; document.body.append(audition);
149
150
const canvas = document.createElement('canvas'); canvas.width = 320; canvas.height = 180;
150
151
const context = canvas.getContext('2d'); let tick = 0;
151
152
const paint = setInterval(() => { context.fillStyle = '#007e75'; context.fillRect(0, 0, 320, 180);
@@ -162,9 +163,11 @@ try {
162
163
track.addCue(new VTTCue(0, 10, 'media-evidence-caption')); await video.play();
163
164
});
164
165
const mediaState = await inspectMedia(page);
165
assert.equal(mediaState.media.length, 1);
166
assert.ok(mediaState.media[0].frameUrl.endsWith('/frame'));
167
assert.equal(mediaState.media[0].playbackRate, 1);
166
assert.equal(mediaState.media.length, 2);
167
assert.equal(mediaState.media.filter(item => item.placeholder).length, 1);
168
const actualVideo = mediaState.media.find(item => item.tag === 'video');
169
assert.ok(actualVideo.frameUrl.endsWith('/frame'));
170
assert.equal(actualVideo.playbackRate, 1);
168
171
const evidence = await captureMediaEvidence(page, { outputDir: path.join(root, 'media'), seconds: 1.4, intervalSeconds: .3, pollMs: 100 });
169
172
assert.ok(evidence.images >= 2, `Expected progressing media frames, got ${evidence.images}`);
170
173
const manifest = await readFile(evidence.manifestPath, 'utf8');
@@ -225,7 +228,7 @@ try {
225
228
const report = {
226
229
passed: true, testedAt: new Date().toISOString(), browser: session.version.Browser,
227
230
outputDir: root, requests: requests.length, targets: [...new Set(requests.map(r => r.targetType))],
228
checks: ['multiple files', 'file chooser', 'directory upload', 'server upload response', 'download', 'screenshot', 'DOM', 'Debugger', 'raw CDP', 'existing browser attach/disconnect', 'fetch', 'XHR', 'redirect hops', 'network failure', 'response body', 'ExtraInfo headers', 'large-body omission', 'dedicated worker', 'shared worker', 'service worker', 'cross-origin iframe', 'cross-target request completion', 'popup', 'WebSocket sent/received', 'SSE', 'console', 'all-target HAR', 'native HAR', 'live protocol schema', 'nested-frame media state', 'video frames and active captions', 'media cancellation'],
231
checks: ['multiple files', 'file chooser', 'directory upload', 'server upload response', 'download', 'screenshot', 'DOM', 'Debugger', 'raw CDP', 'existing browser attach/disconnect', 'fetch', 'XHR', 'redirect hops', 'network failure', 'response body', 'ExtraInfo headers', 'large-body omission', 'dedicated worker', 'shared worker', 'service worker', 'cross-origin iframe', 'cross-target request completion', 'popup', 'WebSocket sent/received', 'SSE', 'console', 'all-target HAR', 'native HAR', 'live protocol schema', 'nested-frame media state', 'video frames and active captions', 'media cancellation', 'empty audio placeholder filtering'],
229
232
};
230
233
await writeFile(path.join(root, 'test-result.json'), `${JSON.stringify(report, null, 2)}\n`, 'utf8');
231
234
console.log(JSON.stringify(report, null, 2));