我是怎么写了一个浏览器插件来嗅探网页上的视频地址的
你在网页上看到一个视频,想下载它。右键 → 检查元素 → Network 面板 → 在一堆 XHR 请求里找
.mp4或.m3u8……每次都要重复这个流程,烦不烦?如果有一个浏览器扩展,点一下按钮就自动列出当前页面上的所有视频地址,一键复制到下载器,该多好。这就是浏览器扩展里的"视频嗅探器"——听起来很高端,实际上核心逻辑不到 200 行。这篇文章从零讲清楚一个视频嗅探扩展的实现原理:如何拦截网络请求、如何过滤视频 URL、以及如何绕过平台的防护措施。TL;DR:浏览器扩展嗅探视频的核心是利用
chrome.webRequestAPI 拦截所有网络请求,然后根据 URL 模式(.mp4/.m3u8/.ts/videoplayback)和响应头(Content-Type: video/mp4)筛选出视频资源。进阶技巧:监听 MediaSource API 的addSourceBuffer调用捕获 MSE 推流地址,注入XMLHttpRequest.prototype.open拦截 JS 发起的视频请求。
目录
- 一、浏览器扩展的基础架构
- 二、核心嗅探逻辑:拦截网络请求
- 三、过滤规则:从海量请求中挑出视频
- 四、进阶技巧:MSE 推流与 XHR 拦截
- 五、绕过反嗅探:平台如何隐藏视频地址
- 六、完整扩展代码
- 七、打包与发布
- 八、合规与温馨提示
一、浏览器扩展的基础架构
一个 Chrome/Edge 扩展由以下几个文件组成:
video-sniffer/
├── manifest.json # 扩展配置(权限、入口、图标)
├── background.js # 后台脚本(持久运行,拦截请求)
├── popup.html # 弹出窗口(用户界面)
├── popup.js # 弹出窗口的逻辑
├── content.js # 注入到网页的脚本(可选)
└── icon.png # 扩展图标
manifest.json 是扩展的"身份证",告诉浏览器这个扩展需要什么权限:
{
"manifest_version": 3,
"name": "视频嗅探器",
"version": "1.0",
"description": "一键嗅探网页上的视频地址",
"permissions": [
"webRequest", // 拦截网络请求
"activeTab", // 访问当前标签页
"storage", // 本地存储
"downloads" // 下载文件
],
"host_permissions": [
"<all_urls>" // 监听所有 URL 的请求
],
"background": {
"service_worker": "background.js"
},
"action": {
"default_popup": "popup.html",
"default_icon": "icon.png"
},
"icons": {
"48": "icon.png"
}
}
关键权限:
- webRequest:允许拦截和查看浏览器的所有网络请求
- host_permissions: ["<all_urls>"]:允许监听所有域名的请求
- activeTab:允许操作当前激活的标签页
二、核心嗅探逻辑:拦截网络请求
2.1 监听请求
浏览器发出的每一个 HTTP 请求,都会经过 webRequest API。我们在 background.js 里注册一个监听器:
// background.js
// 存储嗅探到的视频地址
let detectedVideos = {};
// 监听所有完成的请求
chrome.webRequest.onCompleted.addListener(
function(details) {
const url = details.url;
const tabId = details.tabId;
// 检查这个 URL 是不是视频
if (isVideoUrl(url)) {
// 获取响应头信息
const contentLength = details.responseHeaders ?
getHeader(details.responseHeaders, 'content-length') : null;
const contentType = details.responseHeaders ?
getHeader(details.responseHeaders, 'content-type') : null;
// 存储
if (!detectedVideos[tabId]) {
detectedVideos[tabId] = [];
}
// 去重
if (!detectedVideos[tabId].find(v => v.url === url)) {
detectedVideos[tabId].push({
url: url,
size: contentLength ? parseInt(contentLength) : null,
type: contentType || guessType(url),
timestamp: Date.now(),
});
}
}
},
{
urls: ["<all_urls>"],
types: ["media", "xmlhttprequest", "other"]
},
["responseHeaders"]
);
// 标签页关闭时清理数据
chrome.tabs.onRemoved.addListener(function(tabId) {
delete detectedVideos[tabId];
});
// popup 请求视频列表时返回
chrome.runtime.onMessage.addListener(function(request, sender, sendResponse) {
if (request.action === 'getVideos') {
chrome.tabs.query({active: true, currentWindow: true}, function(tabs) {
const tabId = tabs[0].id;
sendResponse({
videos: detectedVideos[tabId] || []
});
});
return true; // 异步响应
}
});
2.2 监听请求头(捕获未完成的大文件请求)
有些视频文件很大,onCompleted 可能等很久才触发。用 onHeadersReceived 可以更早捕获:
// 在收到响应头时就判断,不用等下载完
chrome.webRequest.onHeadersReceived.addListener(
function(details) {
const contentType = getHeader(
details.responseHeaders, 'content-type'
);
if (contentType && isVideoContentType(contentType)) {
const tabId = details.tabId;
if (!detectedVideos[tabId]) {
detectedVideos[tabId] = [];
}
const contentLength = getHeader(
details.responseHeaders, 'content-length'
);
if (!detectedVideos[tabId].find(v => v.url === details.url)) {
detectedVideos[tabId].push({
url: details.url,
size: contentLength ? parseInt(contentLength) : null,
type: contentType,
timestamp: Date.now(),
});
}
}
},
{
urls: ["<all_urls>"],
types: ["media", "xmlhttprequest", "other"]
},
["responseHeaders"]
);
三、过滤规则:从海量请求中挑出视频
3.1 URL 模式匹配
function isVideoUrl(url) {
const lowerUrl = url.toLowerCase();
// 规则 1:扩展名匹配(最直接)
const videoExtensions = [
'.mp4', '.m4v', '.mkv', '.webm', '.flv', '.avi',
'.mov', '.wmv', '.ts', '.m3u8', '.mpd'
];
for (const ext of videoExtensions) {
if (lowerUrl.includes(ext)) return true;
}
// 规则 2:路径关键词(YouTube/Youku/Bilibili 等)
const pathPatterns = [
'videoplayback', // YouTube
'googlevideo.com', // YouTube CDN
'upos-sz', // Bilibili
'bilivideo.com', // Bilibili
'v1.pstatp.com', // 抖音
'douyinvod.com', // 抖音
'txmov2.a.yximgs.com', // 快手
'sns-video', // 小红书
];
for (const pattern of pathPatterns) {
if (lowerUrl.includes(pattern)) return true;
}
return false;
}
3.2 Content-Type 匹配
function isVideoContentType(contentType) {
if (!contentType) return false;
const ct = contentType.toLowerCase();
const videoTypes = [
'video/mp4',
'video/webm',
'video/x-flv',
'video/quicktime',
'video/x-msvideo',
'video/mpeg',
'application/vnd.apple.mpegurl', // m3u8
'application/x-mpegurl', // m3u8 别名
'application/dash+xml', // mpd
'video/mp2t', // ts 分片
'application/octet-stream', // 通用二进制(谨慎使用)
];
return videoTypes.some(t => ct.startsWith(t));
}
function getHeader(headers, name) {
if (!headers) return null;
const header = headers.find(
h => h.name.toLowerCase() === name.toLowerCase()
);
return header ? header.value : null;
}
function guessType(url) {
const lower = url.toLowerCase();
if (lower.includes('.mp4')) return 'video/mp4';
if (lower.includes('.webm')) return 'video/webm';
if (lower.includes('.m3u8')) return 'application/vnd.apple.mpegurl';
if (lower.includes('.ts')) return 'video/mp2t';
if (lower.includes('.flv')) return 'video/x-flv';
return 'unknown';
}
3.3 智能排序
嗅探到的 URL 可能很多(比如 M3U8 的几百个 TS 分片),需要排序把最可能是完整视频的排前面:
function scoreVideo(video) {
let score = 0;
// 完整视频优先于分片
if (video.type === 'video/mp4') score += 100;
if (video.type === 'video/webm') score += 100;
// 文件大的优先
if (video.size) {
score += Math.log10(video.size) * 10;
}
// m3u8 优先级(通常是完整视频的播放列表)
if (video.url.includes('.m3u8')) score += 80;
// ts 分片优先级低
if (video.url.includes('.ts')) score -= 50;
// 包含 videoplayback 关键词(YouTube 完整视频)
if (video.url.includes('videoplayback')) score += 50;
return score;
}
function sortVideos(videos) {
return videos.sort((a, b) => scoreVideo(b) - scoreVideo(a));
}
四、进阶技巧:MSE 推流与 XHR 拦截
4.1 MSE(Media Source Extensions)推流
B 站、YouTube 等平台使用 MSE 推流——JS 通过 MediaSource.addSourceBuffer() 把视频数据推给 <video> 元素,整个过程不触发普通的 HTTP 请求。常规的 webRequest API 无法捕获这种数据。
解决办法:在 content.js 中注入代码,劫持 addSourceBuffer:
// content.js - 注入到网页中
// 劫持 MediaSource.addSourceBuffer
const originalAddSourceBuffer = MediaSource.prototype.addSourceBuffer;
MediaSource.prototype.addSourceBuffer = function(mimeType) {
console.log('[嗅探器] MediaSource.addSourceBuffer:', mimeType);
const sourceBuffer = originalAddSourceBuffer.call(this, mimeType);
// 劫持 sourceBuffer 的 appendBuffer
const originalAppendBuffer = sourceBuffer.appendBuffer;
sourceBuffer.appendBuffer = function(data) {
// data 是 ArrayBuffer,这里可以提取视频数据
console.log('[嗅探器] appendBuffer:', data.byteLength, 'bytes');
return originalAppendBuffer.call(this, data);
};
return sourceBuffer;
};
不过这种方式只能拿到二进制数据,拿不到原始 URL。对于 MSE 推流,更实用的做法是拦截 JS 发起的数据请求。
4.2 XHR 与 Fetch 拦截
// content.js
// 拦截 XMLHttpRequest
const originalOpen = XMLHttpRequest.prototype.open;
XMLHttpRequest.prototype.open = function(method, url) {
this._sniffer_url = url;
this._sniffer_method = method;
return originalOpen.apply(this, arguments);
};
const originalSend = XMLHttpRequest.prototype.send;
XMLHttpRequest.prototype.send = function(body) {
this.addEventListener('load', function() {
const contentType = this.getResponseHeader('content-type');
const url = this._sniffer_url;
if (contentType && isVideoContentType(contentType)) {
// 通过消息传递给 background.js
window.postMessage({
type: 'VIDEO_SNIFFER_XHR',
url: url,
contentType: contentType,
size: parseInt(this.getResponseHeader('content-length')) || 0,
}, '*');
}
});
return originalSend.apply(this, arguments);
};
// 拦截 fetch
const originalFetch = window.fetch;
window.fetch = function(input, init) {
const url = typeof input === 'string' ? input : input.url;
return originalFetch.apply(this, arguments).then(response => {
const contentType = response.headers.get('content-type');
if (contentType && isVideoContentType(contentType)) {
window.postMessage({
type: 'VIDEO_SNIFFER_FETCH',
url: url,
contentType: contentType,
}, '*');
}
return response;
});
};
4.3 监听 video 元素的 src 变化
// 使用 MutationObserver 监听 video 标签
const observer = new MutationObserver(function(mutations) {
mutations.forEach(function(mutation) {
if (mutation.type === 'attributes' && mutation.attributeName === 'src') {
const video = mutation.target;
if (video.src && isVideoUrl(video.src)) {
window.postMessage({
type: 'VIDEO_SNIFFER_VIDEO_SRC',
url: video.src,
}, '*');
}
}
// 监听 source 子元素
if (mutation.type === 'childList') {
mutation.addedNodes.forEach(function(node) {
if (node.tagName === 'SOURCE' && node.src) {
window.postMessage({
type: 'VIDEO_SNIFFER_SOURCE',
url: node.src,
}, '*');
}
});
}
});
});
observer.observe(document.body, {
attributes: true,
attributeFilter: ['src'],
childList: true,
subtree: true,
});
五、绕过反嗅探:平台如何隐藏视频地址
5.1 Blob URL
有些网站把视频数据转成 Blob URL:
// 平台的代码
const blob = new Blob([videoData], {type: 'video/mp4'});
const blobUrl = URL.createObjectURL(blob);
video.src = blobUrl; // blob:https://example.com/uuid
Blob URL 只在当前页面内有效,无法直接传给下载器。破解方式:劫持 URL.createObjectURL,记录 Blob 的 MIME 类型,如果是视频则通过 FileReader 读取内容。
const originalCreateObjectURL = URL.createObjectURL;
URL.createObjectURL = function(blob) {
const url = originalCreateObjectURL.call(this, blob);
if (blob.type && isVideoContentType(blob.type)) {
console.log('[嗅探器] 检测到 Blob 视频:', blob.type, blob.size);
// 可以在这里把 Blob 转成可下载的形式
const reader = new FileReader();
reader.onload = function() {
window.postMessage({
type: 'VIDEO_SNIFFER_BLOB',
url: url,
blobType: blob.type,
blobSize: blob.size,
}, '*');
};
reader.readAsDataURL(blob);
}
return url;
};
5.2 WASM 解密
少数平台用 WebAssembly 在浏览器端解密视频数据。这种情况下,webRequest 能拦截到加密的二进制数据,但无法直接使用。对于这类场景,嗅探器只能标记"检测到加密视频流",提示用户需要专业工具处理。
5.3 Service Worker 拦截
有些网站注册了 Service Worker 来处理视频请求。Service Worker 的 fetch 事件可以拦截并修改请求,导致 webRequest API 看到的是"请求了一个数据接口"而不是"请求了视频"。
目前 Chrome 扩展 API 对 Service Worker 内部的请求拦截支持有限,这是嗅探器的一个已知盲区。
六、完整扩展代码
popup.html
<!DOCTYPE html>
<html>
<head>
<meta charset="utf-8">
<style>
body { width: 500px; padding: 12px; font-family: system-ui; }
.video-item { margin-bottom: 8px; padding: 8px; border: 1px solid #eee;
border-radius: 4px; cursor: pointer; }
.video-item:hover { background: #f5f5f5; }
.video-url { font-size: 12px; color: #666; word-break: break-all; }
.video-meta { font-size: 12px; color: #999; margin-top: 4px; }
.btn { padding: 4px 12px; margin-left: 8px; cursor: pointer; }
.empty { color: #999; text-align: center; padding: 40px; }
.refresh { margin-bottom: 12px; }
</style>
</head>
<body>
<button id="refreshBtn" class="refresh">刷新列表</button>
<button id="clearBtn" class="refresh">清空</button>
<div id="videoList"></div>
<script src="popup.js"></script>
</body>
</html>
popup.js
document.addEventListener('DOMContentLoaded', function() {
const videoList = document.getElementById('videoList');
const refreshBtn = document.getElementById('refreshBtn');
const clearBtn = document.getElementById('clearBtn');
function loadVideos() {
chrome.runtime.sendMessage({action: 'getVideos'}, function(response) {
const videos = response.videos || [];
if (videos.length === 0) {
videoList.innerHTML = '<div class="empty">当前页面未检测到视频<br>刷新页面试试</div>';
return;
}
const sorted = videos.sort((a, b) => {
const scoreA = (a.type?.includes('mp4') ? 100 : 0) + (a.size || 0) / 1024;
const scoreB = (b.type?.includes('mp4') ? 100 : 0) + (b.size || 0) / 1024;
return scoreB - scoreA;
});
videoList.innerHTML = sorted.map((v, i) => `
<div class="video-item" data-url="${escapeHtml(v.url)}">
<div style="font-weight:500;">视频 #${i + 1} (${v.type || 'unknown'})</div>
<div class="video-url">${escapeHtml(v.url)}</div>
<div class="video-meta">
${v.size ? (v.size / 1024 / 1024).toFixed(1) + ' MB' : '未知大小'}
<button class="btn copy-btn" data-url="${escapeHtml(v.url)}">复制</button>
<button class="btn download-btn" data-url="${escapeHtml(v.url)}">下载</button>
</div>
</div>
`).join('');
// 绑定按钮事件
document.querySelectorAll('.copy-btn').forEach(btn => {
btn.addEventListener('click', function(e) {
e.stopPropagation();
const url = this.dataset.url;
navigator.clipboard.writeText(url);
this.textContent = '已复制';
setTimeout(() => { this.textContent = '复制'; }, 2000);
});
});
document.querySelectorAll('.download-btn').forEach(btn => {
btn.addEventListener('click', function(e) {
e.stopPropagation();
const url = this.dataset.url;
chrome.runtime.sendMessage({action: 'download', url: url});
});
});
document.querySelectorAll('.video-item').forEach(item => {
item.addEventListener('click', function() {
const url = this.dataset.url;
navigator.clipboard.writeText(url);
this.style.background = '#e8f5e9';
setTimeout(() => { this.style.background = ''; }, 1000);
});
});
});
}
refreshBtn.addEventListener('click', loadVideos);
clearBtn.addEventListener('click', function() {
chrome.runtime.sendMessage({action: 'clearVideos'}, loadVideos);
});
loadVideos();
});
function escapeHtml(text) {
const div = document.createElement('div');
div.textContent = text;
return div.innerHTML;
}
background.js 补充:下载功能
// 在 background.js 中添加
chrome.runtime.onMessage.addListener(function(request, sender, sendResponse) {
if (request.action === 'getVideos') {
chrome.tabs.query({active: true, currentWindow: true}, function(tabs) {
const tabId = tabs[0].id;
sendResponse({videos: detectedVideos[tabId] || []});
});
return true;
}
if (request.action === 'download') {
chrome.downloads.download({
url: request.url,
saveAs: true,
});
return false;
}
if (request.action === 'clearVideos') {
chrome.tabs.query({active: true, currentWindow: true}, function(tabs) {
detectedVideos[tabs[0].id] = [];
sendResponse({success: true});
});
return true;
}
});
七、打包与发布
# 目录结构确认
video-sniffer/
├── manifest.json
├── background.js
├── popup.html
├── popup.js
└── icon.png
# 打包:Chrome 扩展管理 → 开发者模式 → 打包扩展 → 选择目录
# 发布到 Chrome Web Store:
# 1. 注册 Chrome 开发者账号($5 一次性费用)
# 2. 上传 .zip 包
# 3. 填写描述、截图、隐私政策
# 4. 等待审核(通常 1-3 天)
八、合规与温馨提示
- 浏览器扩展的
webRequest权限可以查看所有网络请求,包括 HTTPS 加密的内容(在浏览器解密后查看)。这意味着扩展开发者需要负责任地处理用户数据 - 视频嗅探器属于"内容发现工具",本身不侵权。但用它下载受版权保护的内容用于再分发,属于侵权
- 部分平台的服务条款明确禁止自动化工具访问其内容,使用嗅探器可能违反这些条款
- 如果发布到 Chrome Web Store,务必提供清晰的隐私政策,说明扩展不会收集或上传用户的浏览数据
- 更多讨论见 下载视频算侵权吗?聊聊个人备份与版权的那条线
写一个视频嗅探扩展,本质上就是在浏览器的网络层加一个"过滤器"——让所有请求都从你眼前过一遍,你只需要挑出那些是视频的。Chrome 的 webRequest API 把这件事简化到了几十行代码。真正花时间的反而不是写代码,而是理解各个平台是怎么把视频地址藏起来的——MSE 推流、Blob URL、Service Worker 拦截……每一个都是新的挑战。
本文由 VidDown 技术博客原创发布。VidDown 桌面客户端内置了比浏览器扩展更强大的视频嗅探引擎——支持 MSE 推流解析、Blob 拦截、以及 30+ 平台的专用解析器。访问 VidDown 了解更多。