M3U8 下载助手油猴脚本 - 技术实现解析
M3U8 下载助手油猴脚本 - 技术实现解析
概述
本文深入解析 M3U8 下载助手油猴脚本的技术实现,包括 M3U8 链接检测机制、iframe 跨域通信、DOM 监听等核心技术。
架构设计
整体架构
┌─────────────────────────────────────────────────────────────┐
│ 浏览器页面 │
│ ┌───────────────────────────────────────────────────────┐ │
│ │ 注入的拦截脚本 │ │
│ │ ┌─────────┐ ┌─────────┐ ┌─────────┐ ┌─────────┐ │ │
│ │ │ XHR │ │ fetch │ │WebSocket│ │ DOM │ │ │
│ │ │ 劫持 │ │ 劫持 │ │ 劫持 │ │ 监听 │ │ │
│ │ └────┬────┘ └────┬────┘ └────┬────┘ └────┬────┘ │ │
│ │ │ │ │ │ │ │
│ │ └────────────┴─────┬──────┴────────────┘ │ │
│ │ │ │ │
│ │ 收集 M3U8 链接 │ │
│ │ │ │ │
│ │ ┌───────────┴───────────┐ │ │
│ │ │ window.__m3u8Links │ │ │
│ │ │ (Set 集合) │ │ │
│ │ └───────────┬───────────┘ │ │
│ └──────────────────────────┼────────────────────────────┘ │
│ │ │
│ postMessage (如果是 iframe) │
│ │ │
│ ┌──────────────────────────┴────────────────────────────┐ │
│ │ 顶层窗口 (油猴脚本) │ │
│ │ ┌─────────────────────────────────────────────────┐ │ │
│ │ │ 监听 message 事件,收集来自所有 iframe 的链接 │ │ │
│ │ └─────────────────────────────────────────────────┘ │ │
│ │ ┌─────────────────────────────────────────────────┐ │ │
│ │ │ 创建浮动按钮、弹窗等 UI 组件 │ │ │
│ │ └─────────────────────────────────────────────────┘ │ │
│ │ ┌─────────────────────────────────────────────────┐ │ │
│ │ │ GM_xmlhttpRequest 调用后端 API │ │ │
│ │ └─────────────────────────────────────────────────┘ │ │
│ └───────────────────────────────────────────────────────┘ │
└─────────────────────────────────────────────────────────────┘
│
│ HTTP/WebSocket
▼
┌─────────────────────────────────────────────────────────────┐
│ M3U8 下载服务 (Rust) │
└─────────────────────────────────────────────────────────────┘
核心技术详解
1. 脚本注入机制
油猴脚本使用 @run-at document-start 在页面最早期执行,但为了不阻塞页面加载,只注入一个轻量级的 script 元素:
const injectScript = `
(function() {
// 拦截逻辑
})();
`;
const s = document.createElement('script');
s.textContent = injectScript;
(document.head || document.documentElement).appendChild(s);
s.remove();
为什么要注入到页面上下文?
油猴脚本运行在隔离的沙箱环境中,无法直接访问页面的 window 对象。通过注入 script 元素,可以让拦截代码在页面上下文中执行,从而劫持页面的原生 API。
2. 网络请求拦截
2.1 XMLHttpRequest 劫持
// 保存原始方法
const xhrOpen = XMLHttpRequest.prototype.open;
const xhrSend = XMLHttpRequest.prototype.send;
// 劫持 open 方法,保存 URL
XMLHttpRequest.prototype.open = function(method, url) {
this._url = url;
return xhrOpen.apply(this, arguments);
};
// 劫持 send 方法,检测 URL
XMLHttpRequest.prototype.send = function(body) {
if (this._url) {
add(this._url); // 添加到检测集合
}
return xhrSend.apply(this, arguments);
};
为什么要在 send 时检测?
因为 open 只是设置参数,send 才真正发起请求。而且有些代码会在 open 之后修改 URL。
2.2 fetch 劫持
const origFetch = window.fetch;
window.fetch = function(input) {
// input 可以是字符串或 Request 对象
add(typeof input === 'string' ? input : input.url);
return origFetch.apply(this, arguments);
};
2.3 WebSocket 劫持
const origWS = window.WebSocket;
window.WebSocket = function(url) {
add(url);
return new origWS(url);
};
3. DOM 变化监听
使用 MutationObserver 监听 DOM 变化,捕获动态添加的视频元素:
new MutationObserver(mutations => {
mutations.forEach(mutation => {
// 属性变化
if (mutation.type === 'attributes') {
const attrName = mutation.attributeName;
if (attrName === 'src' || attrName === 'href') {
add(mutation.target[attrName]);
}
}
// 子节点变化
else if (mutation.type === 'childList') {
mutation.addedNodes.forEach(node => {
if (node.nodeType === 1) { // Element 节点
// 检查各种可能包含 URL 的属性
['src', 'href', 'data-src', 'data-href'].forEach(attr => {
const value = node.getAttribute(attr);
if (value) add(value);
});
// 特别处理 video/audio 元素
if (node.tagName === 'VIDEO' || node.tagName === 'AUDIO') {
if (node.src) add(node.src);
if (node.currentSrc) add(node.currentSrc);
}
}
});
}
});
}).observe(document.documentElement, {
childList: true,
subtree: true,
attributes: true,
attributeFilter: ['src', 'href']
});
优化点:
- 使用
attributeFilter只监听src和href属性,减少回调次数 - 只检查
nodeType === 1的元素节点
4. iframe 跨域通信
当视频在 iframe 中加载时,需要跨 iframe 传递检测到的链接。
4.1 iframe 内部:发送消息
// 在 iframe 中检测到链接后
if (top === self) {
// 是顶层窗口,直接添加
window.__m3u8Links.add(url);
} else {
// 是 iframe,发送消息给顶层
top.postMessage({
type: 'M3U8_LINK_FOUND',
url: url
}, '*');
}
4.2 顶层窗口:接收消息
if (realWin.top === realWin.self) {
realWin.addEventListener('message', e => {
if (e.data?.type === 'M3U8_LINK_FOUND' && e.data.url) {
if (!realWin.__m3u8Links.has(e.data.url)) {
realWin.__m3u8Links.add(e.data.url);
// 更新 UI
if (realWin.__m3u8Links.size === 1) {
ensureButton();
}
}
}
});
}
5. URL 标准化
检测到的 URL 需要标准化处理:
const add = (url) => {
if (!url) return;
try {
// 转换为绝对 URL
let abs = new URL(url, location.href).href;
// 验证是否是 M3U8 链接
if (abs.includes('.m3u8') && /^https?:/.test(abs)) {
// 只在顶层窗口存储
if (top === self) {
window.__m3u8Links.add(abs);
} else {
top.postMessage({ type: 'M3U8_LINK_FOUND', url: abs }, '*');
}
}
} catch(e) {
// URL 解析失败,忽略
}
};
关键点:
- 使用
new URL(url, location.href)处理相对路径 - 正则验证必须是 HTTP(S) 协议
- 使用 Set 自动去重
6. 文件名处理
自动清理文件名中的非法字符:
const sanitize = name => {
if (!name) return '';
return name
.replace(/[\\/:*?"<>|]/g, '_') // 替换非法字符为下划线
.trim();
};
智能默认文件名生成:
const defaultFileName = (link) => {
// 优先使用网页标题
let title = document.title;
if (title?.trim()) return sanitize(title);
// 其次使用 URL 中的文件名
try {
let last = new URL(link).pathname
.split('/')
.pop()
.replace(/\.m3u8$/i, '');
return sanitize(decodeURIComponent(last)) || 'video';
} catch (e) {
return 'm3u8_video';
}
};
7. GM_xmlhttpRequest
使用油猴提供的 GM_xmlhttpRequest 发送跨域请求:
const startDownload = (url, name) => new Promise((resolve, reject) => {
GM_xmlhttpRequest({
method: 'POST',
url: `${BACKEND_URL}/api/download`,
headers: {
'Content-Type': 'application/json'
},
data: JSON.stringify({ url, name }),
onload: resp => {
if (resp.status === 200 || resp.status === 201) {
try {
resolve(JSON.parse(resp.responseText).id);
} catch (e) {
reject('解析失败');
}
} else {
reject(`状态码 ${resp.status}`);
}
},
onerror: err => reject(`网络错误: ${err}`)
});
});
为什么不用原生 fetch?
因为油猴脚本运行在隔离环境中,原生 fetch 会受到同源策略限制。GM_xmlhttpRequest 是油猴提供的特权 API,可以绕过这些限制。
8. WebSocket 进度监控
下载任务创建后,通过 WebSocket 实时监控进度:
const monitorTask = (taskId, taskName, url) => {
const ui = createProgressModal(taskId, taskName, url);
const connect = () => {
const ws = new WebSocket(
`${BACKEND_URL.replace('http', 'ws')}/api/tasks/${taskId}/ws`
);
ws.onmessage = e => {
const task = JSON.parse(e.data);
// 更新进度
if (task.progress !== undefined) {
ui.updateProgress(task.progress);
}
// 更新状态
if (task.status) {
ui.updateStatus(task.status, task.error);
}
// 完成或失败时关闭
if (task.status === 'completed') {
finalize(true);
} else if (task.status === 'failed') {
finalize(false, task.error);
}
};
};
connect();
};
性能优化
1. 防抖处理
对于频繁触发的事件,使用防抖减少处理次数:
let updateTimeout = null;
const debouncedUpdate = () => {
clearTimeout(updateTimeout);
updateTimeout = setTimeout(() => {
// 实际处理逻辑
}, 100);
};
2. Set 去重
使用 Set 自动去重,避免重复存储相同链接:
window.__m3u8Links = new Set();
3. 定时扫描优化
定时扫描只在检测到链接后才创建浮动按钮,避免空操作:
setInterval(() => {
if (realWin.__m3u8Links?.size && !document.getElementById('tm-m3u8-btn')) {
ensureButton();
}
}, 1500);
4. 条件注入
检查是否已注入,避免重复注入:
if (realWin.__m3u8InterceptorInstalled) return;
realWin.__m3u8InterceptorInstalled = true;
安全考虑
1. URL 验证
只处理 HTTP(S) 协议的 URL:
if (!/^https?:/.test(abs)) return;
2. XSS 防护
用户输入的文件名需要转义:
const escapeHtml = (text) => {
const div = document.createElement('div');
div.textContent = text;
return div.innerHTML;
};
3. 作用域隔离
使用 IIFE 避免污染全局作用域:
(function() {
'use strict';
// 脚本代码
})();
调试技巧
1. 查看检测到的链接
在浏览器控制台中执行:
// 在页面上下文中执行
console.log(Array.from(window.__m3u8Links));
2. 手动触发按钮显示
// 强制显示按钮
ensureButton();
3. 查看注入是否成功
console.log(window.__m3u8InterceptorInstalled); // 应该是 true
总结
M3U8 下载助手油猴脚本综合运用了多种浏览器技术:
| 技术 | 用途 |
|---|---|
| XHR/fetch 劫持 | 拦截网络请求 |
| MutationObserver | 监听 DOM 变化 |
| postMessage | iframe 跨域通信 |
| GM_xmlhttpRequest | 跨域 API 调用 |
| WebSocket | 实时进度监控 |
| URL API | URL 标准化 |
这些技术的组合使得脚本能够全面、高效地检测网页中的 M3U8 链接,并提供良好的用户体验。