☰
油猴脚本实现网页音频嗅探与本地离线留存
2026/10/1 5:45:42 网站建设 项目流程

前阵子在整理自己的本地音乐库,翻出来一堆几年前从网页上随手存下来的音频文件,文件名乱七八糟,歌手和歌名全挤在一起,还有不少下到一半的残废文件。那次清理让我重新把tampermonkey(也就是大家常说的油猴)捡了起来,顺手写了一个针对网页版播放器的脚本,专门处理"页面上正在放的东西,怎么落到本地磁盘"这件事。这篇文章就是那次折腾的完整复盘。

这套东西解决的核心问题很朴素:网页播放器天生不给你留文件,它只负责把声音送进声卡;而很多时候我们确实需要一个能离线听、能放进自己播放器里的文件。本文适合三类人看——刚装上油猴不知道从哪下手的新手,会写点 JavaScript 但没做过页面注入的开发者,以及纯粹想把这件事搞明白的技术好奇者。需要先把话说在前面:下面的所有内容都只针对你有权获取的内容做个人离线留存,不要拿去做传播或者绕过付费,这条线我自己守得很死,也建议你守住。

1. 先把需求边界划清楚,再动手写脚本

1.1 QQ音乐网页版把音频送进浏览器的完整链路

很多人写脚本失败,根源不在代码,而在没搞清楚数据是怎么流动的。网页版播放器播放一首歌,大致会经历这么几步:页面路由切到某首歌,前端拿着这首歌的标识去后端换一个播放地址,拿到地址后用fetch或者XMLHttpRequest把音频数据拉回来,最后塞给<audio>元素或者MediaSource播放。这几步里,唯一值得脚本介入的环节是"拿到播放地址"那一步,因为地址才是真正的文件入口。

这里有个实操中最容易忽略的细节:那个地址通常不是裸奔的直链,而是带了一串签名参数的临时链接,并且服务端会校验请求头里的Referer。我第一次写的时候直接把抓到的地址丢进浏览器新标签页打开,返回的是 403,还以为是地址抓错了,折腾了半小时才发现是防盗链。后来在脚本里通过GM_xmlhttpRequest手动带上Referer才通。这个坑非常典型,后面第 4 章还会展开。

另外一个必须知道的概念是MediaSource。有些音源不是一次性给你一个完整文件,而是切成一小段一小段地推过来,前端再拼起来播放。这种情况在开发者工具的 Network 面板里看到的是一堆小请求,而不是一个几 MB 的文件。遇到这种形态,脚本能做的事情非常有限,因为你拿到的是碎片,需要自己按顺序拼接并处理封装格式,成本陡增。我的态度是:认怂,放弃这条路,改用别的方式。

1.2 油猴脚本能做什么、不该做什么

把能力边界说清楚,比堆代码重要得多。油猴脚本本质上就是一个被浏览器扩展注入到指定页面的 JavaScript 文件,它拥有和页面脚本几乎相同的能力,再加上几个扩展提供的特权 API。这意味着它能做三件事:读页面上已经存在的信息、监听页面发出的网络请求、在页面上加自己的按钮和面板。这三件事组合起来,就足够完成"嗅探 + 下载"的闭环。

它做不到的事情同样明确。它不能凭空变出一个你没权限访问的地址,不能伪造服务端的授权判定,也不能把已经加密过的音频流解密。换句话说,脚本只是一个更顺手的搬运工,它搬运的是页面本来就能拿到的东西。我在自己的脚本注释里专门写了一行:只处理当前页面已经正常播放、且我有权离线留存的内容。这不是写给谁看的,是写给我自己看的,免得哪天手一滑把边界越过去。

还有一个现实问题:网页版的 DOM 结构和接口是会变的。我那个脚本前前后后修过四五次,最长的一次稳定了大概半年。所以别把脚本当成一次性工程,把它当成一个需要偶尔维护的小工具,心里预期对了,修起来就不烦躁。

2. 环境准备与抓请求的正确姿势

2.1 Tampermonkey 安装与新建脚本的头信息怎么写

安装环节没什么技术含量,浏览器扩展商店搜一下就有,装完地址栏右边会多一个图标。真正需要认真对待的是脚本头部那段注释块,它决定了脚本在什么页面生效、拥有哪些权限。这一段写错,脚本要么不执行,要么执行了但拿不到关键 API,排查起来很费时间。

// ==UserScript== // @name 页面音频嗅探与本地留存(个人学习用) // @namespace local.demo.audio // @version 0.3 // @description 监听页面媒体请求,记录音频地址与曲目信息,便于个人离线留存 // @match https://y.qq.com/* // @grant GM_xmlhttpRequest // @grant GM_download // @grant GM_setValue // @grant GM_getValue // @grant GM_registerMenuCommand // @connect qq.com // @connect qqmusic.qq.com // @run-at document-start // ==/UserScript==

几个参数的选择理由值得说明。@match我坚持写具体域名而不是*://*/*,因为脚本要 hook 全局的fetch,注入到所有页面既拖慢别人也容易冲突。@run-at document-start是关键,必须在页面自己的脚本执行之前就把 hook 挂上去,否则前几个请求就漏了。@grant每多写一个,脚本的运行环境就多一层隔离,unsafeWindow的可用性也会受影响,所以只申请真正用得到的。@connect是GM_xmlhttpRequest的跨域白名单,不写的话请求会被扩展拦下来,报错信息还挺隐晦。

2.2 定位音频请求的三种手段对比

写脚本前一定要先手工把请求找出来,这一步不能跳。我用过三种手段,各有适用场景,整理成表格方便你按情况选。

手段操作方式优势局限
Network 面板过滤打开开发者工具,切 Network,筛选 Media 或输入关键词最直观,能看到请求头、响应头、体积页面刷新会丢记录,时序不好复现
控制台 hook 打印在 Console 里重写fetch和XMLHttpRequest.prototype.open能带上下文,看到调用栈只在当前标签页有效,刷新即失效
直接读媒体面板开发者工具的 Media 面板看<audio>的 src一眼看到当前播放源只反映当前这一首,批量场景不够用

我个人的固定流程是:先开 Network 过滤,播一首歌,看有没有一个体积明显大于其它请求的音频类请求。如果有,把地址复制出来看参数结构;如果没有,说明走的是分片,直接放弃这条路。确认之后再用 Console hook 的方式验证一遍,确保我 hook 的位置能覆盖到那个请求,然后才动手写正式脚本。

顺便说一个提速技巧:在 Network 面板里对着音频请求右键,选"Copy as fetch",粘贴到控制台里改一改就能复现请求,验证Referer到底是不是必须的。这一步能省掉大量盲猜。

2.3 必背的五个 GM_ API 速查

油猴提供的特权 API 不少,但真正高频的就这么几个,把它们的行为差异记熟,写脚本会顺很多。

API用途关键注意点
GM_xmlhttpRequest跨域发请求,可自定义请求头需要@connect白名单;大文件建议responseType: 'blob'
GM_download直接触发浏览器下载并指定文件名同样受@connect限制;失败回调要写,否则问题无声无息
GM_setValue/GM_getValue跨页面持久化小数据只适合存配置和索引,别拿来存音频数据
GM_registerMenuCommand在油猴菜单里加自定义入口适合放"清空记录""导出清单"这类操作
unsafeWindow访问页面真实 window部分站点会检测,能不用就不用

这里有个我踩过的坑:GM_download在文件较大或者网络波动时,失败回调里给出的信息非常有限,我一度以为是脚本写错了,后来发现是目标地址的签名过期了。所以现在我都会在失败回调里把时间和地址打出来,方便判断到底是哪一类问题。

3. 脚本核心实现:从页面里拿信息、拿地址、落盘

3.1 歌曲元信息的三条获取路径

下载下来的文件如果没有正确的文件名,等于白下。元信息我一般从三个地方取,按可靠性排序是:页面注入的初始数据、DOM 文本、地址栏参数。初始数据最全,但结构可能变;DOM 最直观,但容易受样式调整影响;地址栏参数最稳定,但信息量最少,通常只有一个曲目标识。

实际做法是三者互相兜底。先用地址栏里的标识作为主键,再去 DOM 里找歌名和歌手文本,如果 DOM 取不到就退回用document.title做兜底。document.title通常形如"歌名 - 歌手 - 平台名",用简单切分就能得到可用的文件名。我在代码里把这三条路径写成了一个pickMeta()函数,返回一个对象,任何一条路径失败都不影响整体流程。

这里提醒一个细节:DOM 里的文本经常带有额外的空白、零宽字符和后缀标记,比如"高音质""试听"之类的角标文字。直接拿来当文件名会出现一堆奇怪符号。我的做法是统一做一次清洗,去掉首尾空白、连续空白压缩成一个空格、过滤掉文件系统不接受的字符。文件名里只保留中文、英文、数字、空格、短横线和下划线,其它一律替换掉。

3.2 音频地址的三种形态与对应打法

抓到地址只是开始,能不能拿到一个能播的文件,取决于地址是哪一种形态。

第一种是带签名的临时直链,最常见。特征是 URL 里有m4a或mp3之类的后缀,后面跟一串看起来很长的参数。这种最好处理,带上Referer请求头就能完整下载。签名的有效期通常是几十分钟,过期后再请求会返回错误,所以别把地址存起来第二天再用。

第二种是无后缀的媒体接口地址。特征是路径里看不出文件类型,但响应头的Content-Type是audio/*。这种也能下载,只是要自己根据Content-Type推断扩展名,否则下出来的文件双击没反应。

第三种是分片流。特征是短时间内出现大量体积相近的小请求,或者在 Media 面板里看到blob:开头的地址。这种我前面说了,直接放弃,因为拼接分片还要处理初始化段和编码格式,投入产出比太低。

判断方法很简单:在 Network 面板里看响应的Content-Length。如果单次响应体积和整首歌的时长大致匹配,就是完整文件;如果只有几百 KB 甚至几十 KB,那就是分片。

3.3 一份可跑的脚本骨架

下面这份骨架是我自己脚本的简化版,去掉了业务定制的部分,保留可复用的核心逻辑。它的工作方式是:在页面脚本执行前 hook 网络请求,把所有看起来像音频的地址记录下来,同时在页面右下角加一个小面板,把抓到的候选地址列出来,点一下触发下载。

(function () { 'use strict'; const seen = new Set(); const candidates = []; // 粗筛:只保留像音频资源的地址,减少噪音 const AUDIO_RE = /\.(m4a|mp3|flac|aac|ogg)(\?|$)/i; function record(url, extra) { if (!url || typeof url !== 'string') return; if (!AUDIO_RE.test(url)) return; if (seen.has(url)) return; seen.add(url); candidates.push({ url, extra: extra || {}, ts: Date.now() }); render(); console.log('[audio-candidate]', url, extra); } // hook fetch const rawFetch = window.fetch; window.fetch = function (input, init) { const url = typeof input === 'string' ? input : (input && input.url); record(url, { via: 'fetch' }); return rawFetch.apply(this, arguments); }; // hook XHR const rawOpen = XMLHttpRequest.prototype.open; XMLHttpRequest.prototype.open = function (method, url) { record(url, { via: 'xhr', method: method }); return rawOpen.apply(this, arguments); }; // 清理文件名里的非法字符 function safeName(s) { return String(s || 'unknown') .replace(/[\u200b-\u200d\ufeff]/g, '') .replace(/[\\/:*?"<>|]/g, '_') .replace(/\s+/g, ' ') .trim() .slice(0, 120); } // 从页面里尽力拼一个像样的名字 function pickMeta() { const title = safeName(document.title.split('-')[0]); const el = document.querySelector('.song_name, .data__name_txt'); const name = el ? safeName(el.textContent) : title; return name || 'audio_' + Date.now(); } function render() { let box = document.getElementById('qm-audio-box'); if (!box) { box = document.createElement('div'); box.id = 'qm-audio-box'; box.style.cssText = [ 'position:fixed', 'right:16px', 'bottom:16px', 'z-index:99999', 'max-width:320px', 'max-height:240px', 'overflow:auto', 'background:#fff', 'border:1px solid #ddd', 'border-radius:8px', 'padding:10px', 'font-size:12px', 'line-height:1.6', 'box-shadow:0 4px 16px rgba(0,0,0,.12)' ].join(';'); document.body.appendChild(box); } box.innerHTML = '<b>已捕获 ' + candidates.length + ' 个候选地址</b>'; candidates.slice(-8).forEach(function (item) { const row = document.createElement('div'); const btn = document.createElement('button'); btn.textContent = '下载'; btn.style.cssText = 'margin-left:6px;cursor:pointer'; btn.onclick = function () { startDownload(item.url); }; row.textContent = item.url.slice(0, 60) + '...'; row.appendChild(btn); box.appendChild(row); }); } function startDownload(url) { const name = pickMeta() + '.m4a'; GM_download({ url: url, name: name, headers: { // 关键一步:补上防盗链校验需要的来源标识 Referer: location.origin + '/' }, onload: function () { console.log('下载完成', name); }, onerror: function (e) { console.error('下载失败', e); }, ontimeout: function () { console.warn('下载超时'); } }); } GM_registerMenuCommand('清空候选记录', function () { seen.clear(); candidates.length = 0; render(); }); window.addEventListener('load', render); })();

这份代码里有几处是刻意设计的。AUDIO_RE只匹配带明确后缀的地址,把分片请求和图片、字体之类的噪音全部挡在外面。seen这个 Set 保证同一个地址只记录一次,避免循环播放时反复触发。hook 只包一层不做复杂逻辑,是为了把性能影响降到最低,我实测过包一层fetch对页面加载速度的影响基本感知不到。面板用固定定位挂在右下角,不干扰页面原有布局。

3.4 命名规范与批量下载的节奏控制

单个下载跑通之后,很自然就会想批量。这里有两个坑必须提前说。

第一个是节奏。批量下载时如果一次性把几十个地址丢出去,轻则触发限流,重则地址签名批量失效。我的做法是加一个间隔,每次请求之间间隔一秒到两秒,并且在失败之后退避三秒再试。代码上用一个简单的队列就能实现:

const queue = []; let running = false; function enqueue(task) { queue.push(task); if (!running) drain(); } async function drain() { running = true; while (queue.length) { const task = queue.shift(); try { await task(); } catch (e) { console.warn('任务失败,退避后继续', e); await new Promise(function (r) { setTimeout(r, 3000); }); } await new Promise(function (r) { setTimeout(r, 1500); }); } running = false; }

第二个是文件名冲突。同一首歌可能有多个版本,同名会互相覆盖。我在文件名模板里固定加了序号前缀,格式是序号_歌名_歌手.扩展名,序号用三位补零,这样在文件管理器里天然按顺序排列。扩展名不要写死,根据 URL 后缀或响应头推断,写死m4a遇到 mp3 源就尴尬了。命名这块看起来是小事,等你下了两百首再回头改,就知道有多痛苦了。

4. 踩坑实录与排查手册

4.1 文件只有几KB或者根本播不了

这是最高频的问题,没有之一。我统计过自己遇到的情况,大概分成四类原因。

第一类是防盗链没带。表现是下载得到一个几百字节的文件,用文本编辑器打开一看是一段错误提示。解决方式就是请求头里补Referer,值填页面自己的来源即可。

第二类是签名过期。表现是第一次成功,隔一会儿再下就失败。解决方式是缩短从抓取到下载之间的时间差,别攒着地址慢慢下。

第三类是Range 请求不完整。有些服务端对音频请求要求带Range头,不带就只返回一小段。这种情况需要显式指定下载区间,或者在响应出现异常体积时自动重试一次完整请求。

第四类是下载到了分片。表现是每个文件体积都很小,播放器能识别时长但没声音。这就属于我前面说的形态判断失误,正确做法是回到 Network 面板重新确认一次请求形态。

提示:判断"文件是否完整"有一个很土但很准的办法,把文件拖进浏览器播放器,看进度条能不能走完。走不完就是有问题。

4.2 重复抓、漏抓、切歌之后串名

这个问题比下载失败更隐蔽,因为它是"看起来成功了但结果是错的"。重复抓的成因是我的 hook 记录的是地址,而同一首歌在不同音质下地址不同,切几次音质就出现多条记录。解决办法是在记录时把曲目标识也带上,去重时按曲目标识 + 音质级别做键,而不是按地址。

漏抓的成因几乎都是注入时机太晚。网页播放器是单页应用,路由切换不会刷新页面,但脚本如果只在页面首次加载时初始化,后面的请求就漏了。我的处理方式是监听路由变化事件,并配合MutationObserver观察播放区域的 DOM 变化,一旦发现当前曲目变了就重置去重键的上下文。

串名的成因是元信息读取的时机不对。我遇到过一次很典型的情况:点击下载时,页面已经切到了下一首,但下载任务还在用上一首的元信息,结果文件名标着 A 歌曲,内容是 B。修复办法是在捕获地址的那一刻就把元信息一起快照下来,存进候选记录里,下载时直接读快照,不要再实时读 DOM。这个改动只有十几行代码,但彻底消灭了串名问题。

4.3 常见问题速查表

把我这几年遇到的问题整理成表,遇到状况先查表,能省不少时间。

现象大概率原因处理方式
脚本完全没反应@match写错或@run-at时机不对检查域名匹配,改成document-start
控制台能看到日志但下不了@connect未加目标域名补齐白名单,重装脚本
下载文件几百字节防盗链校验未通过请求头补Referer
文件体积远远小于预期命中了分片流放弃该源,或换音质再试
文件名出现乱码元信息含零宽字符或非法符号统一走清洗函数
文件名正确内容不对元信息实时读取导致错位捕获时快照元信息
批量跑到一半全失败触发限流或并发过高降并发,加退避重试
切歌之后脚本失效SPA 路由变化未监听监听路由 + DOM 变化

4.4 脚本在系统层面跑不起来的那类报错

写这篇文章的时候顺手搜了一圈相关话题,发现很多人的卡点其实不在油猴脚本本身,而在本地辅助脚本的执行环境上。这些报错非常典型,顺手一并说了。

最常见的是在 Windows 上执行本地脚本时提示"因为在此系统上禁止运行脚本"。这是执行策略在拦,不是脚本写错了。可以在当前用户范围内放开,而不是直接改全局设置:

Set-ExecutionPolicy -Scope CurrentUser -ExecutionPolicy RemoteSigned

这条命令的作用是允许本地自己写的脚本运行,但从网络下载的脚本仍然需要签名。改完之后用Get-ExecutionPolicy -List确认比改之前宽松即可。我自己是长期保持这个设置,用了几年没出过问题。

第二类高频报错是命令行工具找不到,提示"无法将 xxx 项识别为 cmdlet、函数、脚本文件或可运行程序的名称"。这种九成是环境变量没配好,或者安装完之后没有重开终端。重开终端这个动作看起来很蠢,但确实能解决一半以上的问题。

第三类是.bat脚本双击一闪而过。原因是脚本执行完了窗口就关了,看不到报错。在脚本最后加一行pause就能把窗口留住,或者干脆从命令行里调用它。

第四类是编码问题。Windows 上某些终端默认编码和脚本文件编码不一致,中文输出会变成乱码。稳妥的做法是把脚本文件保存成带 BOM 的 UTF-8,或者在脚本开头显式设置编码。

5. 我更推荐的几条替代路线

5.1 官方渠道其实够用

说句实话,折腾完这一圈之后,我现在的第一选择反而是官方途径。客户端本身就有下载功能,音质和元信息都是最规范的,省去了自己清洗文件名的功夫。网页端在某些场景下也提供了缓存能力,用来做短期离线收听是够的。写脚本这件事对我而言更像是一次技术练习,真实使用频率并不高。

所以如果你的目标只是"路上没网的时候能听",先把手上的官方工具摸熟,很可能根本不需要写脚本。只有当你的需求变成"我自己合法拥有的素材要做批量归档"这种高度个性化的场景,脚本的价值才体现出来。这个判断很重要,能帮你省下大量时间。

5.2 用一段循环脚本把散落的文件归位

不管文件是怎么来的,积累到一定数量后,整理是绕不开的。我最后是用一段 shell 循环把几百个文件统一重命名了,逻辑简单但很好用:

#!/usr/bin/env bash set -euo pipefail TARGET_DIR="$HOME/Music/inbox" index=0 for f in "$TARGET_DIR"/*.m4a "$TARGET_DIR"/*.mp3; do [ -e "$f" ] || continue index=$((index + 1)) base=$(basename "$f") ext="${f##*.}" # 去掉原有的各种前缀,只保留主体名称 name=$(echo "$base" | sed -E 's/^[0-9]+[_-]//; s/\.[a-zA-Z0-9]+$//') printf -v seq '%03d' "$index" mv -- "$f" "$TARGET_DIR/${seq}_${name}.${ext}" done echo "整理完成,共处理 $index 个文件"

这段脚本里有两个细节值得说。一是printf -v做三位补零,保证文件在文件管理器里按序号排序;二是mv --里的--,防止文件名以短横线开头时被当成参数解析。这些细节平时不注意,遇到一次就要查半天资料。

如果是在 Windows 上做同样的事,用 PowerShell 也能写,循环语法稍微啰嗦一点:

$dir = "$env:USERPROFILE\Music\inbox" $i = 0 Get-ChildItem -Path $dir -Include *.m4a, *.mp3 -File | ForEach-Object { $i++ $seq = "{0:D3}" -f $i $newName = "$seq" + "_" + $_.BaseName + $_.Extension Rename-Item -Path $_.FullName -NewName $newName } Write-Host "整理完成,共处理 $i 个文件"

注意:批量重命名之前一定要先备份,或者先在几十个文件的小样本上跑一遍。我第三次用的时候就因为 sed 表达式写错,把文件名里的日期段吃掉了,好在有备份。

5.3 几个让我少走弯路的习惯

最后分享几个我自己的习惯,都是踩过坑之后养成的。

第一,永远先在单曲上验证完整闭环,再去想批量。单曲能稳定跑通、文件能正常播放、文件名正确,这三件事都满足之后,再考虑队列和并发。跳过这一步直接上批量,出问题的时候你连是哪一层错了都不知道。

第二,日志要打到控制台,而且要带时间戳。我前期的日志只有一句"下载失败",排查时完全靠猜。后来改成把时间、地址前缀、失败原因一起打出来,定位速度快了不止一倍。

第三,脚本头部写清楚用途和边界。这既是对自己负责,也是万一脚本流传出去时的一点约束。

第四,保留一份历史版本的备份。页面结构变了之后,改坏的脚本会让你怀念上一版。我一般用@version递增,改之前先把旧版导出存一份。

第五,别追求一次做到完美。这套东西我从最开始只会打印日志,到后来能做到自动命名和队列下载,前后改了好几版,每版解决一个问题就够了。想着一口气写出一个万能脚本,通常的结局是写到一半放弃。

我自己现在的状态是,脚本放在那儿,偶尔页面改版了就修一修,平时基本不动它。真正用得多的反而是最后那段文件整理脚本,每次往库里丢新东西都会跑一遍。工具这东西,能稳定解决你真实存在的那一两个具体问题就够了,剩下的复杂度都是自己给自己找的。

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询