XFE Git
XFE Studio Git
Git 首页 全局搜索
XFE 主站 文档 NuGet

BrowserDebugger

【Skill】浏览器调试工具

公开
关注 0 Fork 0 Star 0
返回提交历史

XFEstudio/BrowserDebugger

Handle media placeholders and document local audio transcription

7d74d9e
XFE工作室室长 <mail@xfegzs.com>
提交于

代码差异

6 个文件 +52 -10
Modified SKILL.md +4 -1
@@ -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
Modified package-lock.json +2 -2
@@ -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 },
Modified package.json +1 -1
@@ -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" },
Added references/audio-transcription.md +33 -0
@@ -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/)。
Modified scripts/media-evidence.mjs +5 -2
@@ -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 }
Modified scripts/self-test.mjs +7 -4
@@ -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));