提示

返回博客列表

我是怎么写了一个浏览器插件来嗅探网页上的视频地址的

我是怎么写了一个浏览器插件来嗅探网页上的视频地址的

你在网页上看到一个视频,想下载它。右键 → 检查元素 → Network 面板 → 在一堆 XHR 请求里找 .mp4.m3u8……每次都要重复这个流程,烦不烦?如果有一个浏览器扩展,点一下按钮就自动列出当前页面上的所有视频地址,一键复制到下载器,该多好。这就是浏览器扩展里的"视频嗅探器"——听起来很高端,实际上核心逻辑不到 200 行。这篇文章从零讲清楚一个视频嗅探扩展的实现原理:如何拦截网络请求、如何过滤视频 URL、以及如何绕过平台的防护措施。

TL;DR:浏览器扩展嗅探视频的核心是利用 chrome.webRequest API 拦截所有网络请求,然后根据 URL 模式(.mp4/.m3u8/.ts/videoplayback)和响应头(Content-Type: video/mp4)筛选出视频资源。进阶技巧:监听 MediaSource API 的 addSourceBuffer 调用捕获 MSE 推流地址,注入 XMLHttpRequest.prototype.open 拦截 JS 发起的视频请求。

目录

一、浏览器扩展的基础架构

一个 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 了解更多。

想亲手试试?用 VidDown 一键解析下载

粘贴视频链接即可解析,多平台支持、网页端即用;下载桌面客户端解锁海外平台本地解析,开通会员更享不限次下载。

顶部