// ==UserScript==
// @name M3U8视频链接检测助手
// @namespace http://tampermonkey.net/
// @version 1.0.0
// @description 自动检测页面中的M3U8视频链接,智能验证可用性,支持一键复制
// @author MissChina
// @license 仅限个人非商业用途,禁止商业使用
// @match *://*/*
// @run-at document-start
// @grant GM_setClipboard
// @grant GM_notification
// @icon data:image/svg+xml,
// @downloadURL https://update.greasyfork.icu/scripts/557172/M3U8%E8%A7%86%E9%A2%91%E9%93%BE%E6%8E%A5%E6%A3%80%E6%B5%8B%E5%8A%A9%E6%89%8B.user.js
// @updateURL https://update.greasyfork.icu/scripts/557172/M3U8%E8%A7%86%E9%A2%91%E9%93%BE%E6%8E%A5%E6%A3%80%E6%B5%8B%E5%8A%A9%E6%89%8B.meta.js
// ==/UserScript==
(function() {
'use strict';
// -------------------- 配置与全局状态 --------------------
const validLinks = new Set(); // 已确认有效的 M3U8 链接
const pendingLinks = new Map(); // 待确认的 M3U8 链接
const logs = []; // 日志记录
let panel = null; // 面板根节点
let activeTab = 'links'; // 当前激活的 Tab
let tsDetectedCount = 0; // .ts 检测计数(限制次数)
// ========================================================
// 日志系统
// ========================================================
function log(msg, type = 'info') {
const timestamp = new Date().toLocaleTimeString('zh-CN', { hour12: false });
logs.push({ time: timestamp, msg, type });
if (logs.length > 100) logs.shift();
updatePanel();
}
// ========================================================
// 工具函数
// ========================================================
// 判断是否为 M3U8 链接
function isM3U8(url) {
if (!url || typeof url !== 'string') return false;
return /\.m3u8(?:\?|#|$)/.test(url) && !url.includes('.ts');
}
// 获取 URL 的目录路径
function getPathOnly(url) {
try {
const u = new URL(url);
return u.pathname.substring(0, u.pathname.lastIndexOf('/'));
} catch(e) {
return '';
}
}
// 获取 URL 的 origin
function getOrigin(url) {
try {
return new URL(url).origin;
} catch(e) {
return '';
}
}
// ========================================================
// 面板刷新
// ========================================================
function updatePanel() {
if (!panel) return;
const cntEl = document.getElementById('cnt');
const lcntEl = document.getElementById('lcnt');
const cont = document.getElementById('cont');
if (cntEl) cntEl.textContent = validLinks.size;
if (lcntEl) lcntEl.textContent = logs.length;
if (!cont) return;
if (activeTab === 'links') {
// 链接列表
if (validLinks.size === 0) {
cont.innerHTML = '
当前页面暂无 M3U8 链接
';
} else {
cont.innerHTML = Array.from(validLinks).map(url => `
`).join('');
cont.querySelectorAll('.m3u8-btn-pri').forEach(btn => {
btn.onclick = () => window.m3u8Copy(btn.dataset.url);
});
}
} else if (activeTab === 'logs') {
// 日志列表
if (logs.length === 0) {
cont.innerHTML = '暂无日志
';
} else {
const typeColors = {
info: '#4b5563',
success: '#16a34a',
warning: '#d97706',
error: '#dc2626'
};
cont.innerHTML = `
` + logs.slice().reverse().map(l => `
${l.time}
${l.msg}
`).join('');
const clearBtn = document.getElementById('clear-logs');
if (clearBtn) {
clearBtn.onclick = () => {
logs.length = 0;
updatePanel();
};
}
}
}
}
// ========================================================
// 复制 M3U8 链接
// ========================================================
window.m3u8Copy = function(url) {
try {
GM_setClipboard(url);
GM_notification({ title: '✅ 复制成功', text: '链接已复制到剪贴板', timeout: 2000 });
log('📋 已复制链接: ' + url, 'success');
} catch(e) {
log('❌ 复制失败: ' + e.toString(), 'error');
}
};
// ========================================================
// 链接状态管理
// ========================================================
function addValid(url) {
if (!validLinks.has(url)) {
log('✅ 发现 M3U8: ' + url, 'success');
validLinks.add(url);
pendingLinks.delete(url);
if (validLinks.size === 1) {
setTimeout(showPanel, 120);
} else {
updatePanel();
}
}
}
function addPending(url) {
if (!validLinks.has(url) && !pendingLinks.has(url)) {
pendingLinks.set(url, { tsCount: 0 });
}
}
// 处理 .ts 资源侦测,用来辅助确认真实 m3u8 地址
function onTS(tsUrl) {
if (tsDetectedCount >= 3) return;
tsDetectedCount++;
const tsPath = getPathOnly(tsUrl);
const tsOrigin = getOrigin(tsUrl);
for (const [m3u8Url, info] of pendingLinks) {
const m3u8Path = getPathOnly(m3u8Url);
const m3u8Origin = getOrigin(m3u8Url);
if (m3u8Path === tsPath) {
info.tsCount++;
if (info.tsCount >= 1) {
if (tsOrigin !== m3u8Origin) {
try {
const m3u8UrlObj = new URL(m3u8Url);
const tsUrlObj = new URL(tsUrl);
const realUrl = tsUrlObj.origin + m3u8UrlObj.pathname + m3u8UrlObj.search;
log('🔧 检测到重定向,尝试构造真实 M3U8 URL', 'warning');
pendingLinks.delete(m3u8Url);
addValid(realUrl);
} catch(e) {
addValid(m3u8Url);
}
} else {
addValid(m3u8Url);
}
}
}
}
}
// ========================================================
// Hook fetch / XHR / PerformanceObserver
// ========================================================
// 重写 fetch
const _fetch = window.fetch;
window.fetch = function(...args) {
const url = typeof args[0] === 'string' ? args[0] : args[0]?.url;
if (url && isM3U8(url)) {
addPending(url);
return _fetch.apply(this, args).then(res => {
if (res.status === 200) {
res.clone().text().then(txt => {
if (txt && txt.includes('#EXTM3U')) addValid(url);
}).catch(() => {});
}
return res;
});
} else if (url && url.includes('.ts')) {
onTS(url);
}
return _fetch.apply(this, args);
};
// 重写 XMLHttpRequest
const _open = XMLHttpRequest.prototype.open;
const _send = XMLHttpRequest.prototype.send;
XMLHttpRequest.prototype.open = function(method, url, ...rest) {
this.__url = url;
return _open.call(this, method, url, ...rest);
};
XMLHttpRequest.prototype.send = function(...args) {
const url = this.__url;
if (url && isM3U8(url)) {
addPending(url);
this.addEventListener('load', function() {
if (this.status === 200) {
try {
const txt = this.responseText;
if (txt && txt.includes('#EXTM3U')) addValid(url);
} catch(e) {}
}
});
} else if (url && url.includes('.ts')) {
onTS(url);
}
return _send.apply(this, args);
};
// PerformanceObserver 监听资源加载
try {
const observer = new PerformanceObserver((list) => {
for (const entry of list.getEntries()) {
const url = entry.name;
if (isM3U8(url)) {
addPending(url);
} else if (url.includes('.ts')) {
onTS(url);
}
}
});
observer.observe({ entryTypes: ['resource'] });
} catch(e) {}
// ========================================================
// 面板创建 & 拖动
// ========================================================
function showPanel() {
if (!panel) {
if (document.body) {
createPanel();
} else {
setTimeout(showPanel, 100);
}
} else {
panel.style.display = 'block';
}
}
function createPanel() {
if (panel) return;
panel = document.createElement('div');
panel.setAttribute('data-m3u8-panel', 'true');
panel.innerHTML = `
`;
document.body.appendChild(panel);
const root = document.getElementById('m3u8-root');
const bodyEl = document.getElementById('m3u8-body');
const minBtn = document.getElementById('m3u8-min');
const closeBtn = document.getElementById('m3u8-close');
// 折叠
if (minBtn) {
minBtn.onclick = () => {
const hidden = bodyEl.style.display === 'none';
bodyEl.style.display = hidden ? 'block' : 'none';
minBtn.textContent = hidden ? '−' : '+';
};
}
// 关闭
if (closeBtn) {
closeBtn.onclick = () => {
panel.style.display = 'none';
};
}
// Tab 切换
panel.querySelectorAll('.m3u8-tab').forEach(tab => {
tab.onclick = () => {
panel.querySelectorAll('.m3u8-tab').forEach(t => t.classList.remove('active'));
tab.classList.add('active');
activeTab = tab.dataset.tab;
updatePanel();
};
});
// 拖动:拖动根容器 #m3u8-root
const dragEl = document.getElementById('m3u8-drag');
let dragging = false;
let startX = 0, startY = 0;
let startLeft = 0, startTop = 0;
const onMouseMove = (e) => {
if (!dragging) return;
const dx = e.clientX - startX;
const dy = e.clientY - startY;
root.style.left = startLeft + dx + 'px';
root.style.top = startTop + dy + 'px';
root.style.right = 'auto';
root.style.bottom = 'auto';
};
const endDrag = () => {
if (!dragging) return;
dragging = false;
document.removeEventListener('mousemove', onMouseMove);
document.removeEventListener('mouseup', endDrag);
};
if (dragEl) {
dragEl.addEventListener('mousedown', (e) => {
if (e.target.closest('.m3u8-btn-hdr')) return;
e.preventDefault();
const rect = root.getBoundingClientRect();
dragging = true;
startX = e.clientX;
startY = e.clientY;
startLeft = rect.left;
startTop = rect.top;
document.addEventListener('mousemove', onMouseMove);
document.addEventListener('mouseup', endDrag);
});
}
updatePanel();
}
})();