// ==UserScript==
// @name StreamWatch - 流媒体监控
// @name:zh-CN StreamWatch - 流媒体监控
// @namespace https://github.com/MissChina/StreamWatch
// @version 2.7.0
// @description Monitor and detect streaming media loading on web pages
// @description:zh-CN 监控和检测网页中的流媒体加载情况
// @author MissChina
// @match *://*/*
// @grant none
// @icon https://github.com/MissChina/StreamWatch/raw/main/streamwatch.png
// @license Custom License - No Commercial Use, Attribution Required
// @homepageURL https://github.com/MissChina/StreamWatch
// @supportURL https://github.com/MissChina/StreamWatch/issues
// @downloadURL none
// ==/UserScript==
(function() {
'use strict';
/**
* StreamWatch Pro - 高级M3U8/HLS流媒体检测器
* 版本: 2.7.0
* 作者: MissChina
*
* 版本 2.7.0 更新内容:
* 1. 修复版本号不一致问题
* 2. 优化全局变量命名规范
* 3. 改进代码逻辑和错误处理
* 4. 统一API接口命名
* 5. 优化UI交互体验
*/
// 配置常量 - Configuration constants
const CONFIG = {
VERSION: '2.7.0',
AUTHOR: 'MissChina',
GITHUB: 'https://github.com/MissChina/StreamWatch',
THEME: {
PRIMARY: '#00ff88',
SECONDARY: '#6c5ce7',
BACKGROUND: 'rgba(18, 18, 24, 0.85)',
PANEL: 'rgba(28, 28, 36, 0.8)',
TEXT: '#ffffff',
ERROR: '#ff4757',
WARNING: '#ffa502',
INFO: '#70a1ff',
SUCCESS: '#2ed573'
},
// 监控间隔和最大缓存数量
SCAN_INTERVAL: 3000,
MAX_STREAMS: 100,
TOAST_DURATION: 3000,
// 初始位置设置
POSITION: {
RIGHT: '20px',
TOP: '20px'
}
};
// M3U8/HLS检测模式 - Stream detection patterns
const STREAM_PATTERNS = {
// 高优先级M3U8模式 - High priority M3U8 patterns
m3u8: [
/\.m3u8([?#].*)?$/i,
/\/[^/]*m3u8[^/]*$/i,
/master\.m3u8/i,
/index\.m3u8/i,
/playlist\.m3u8/i,
/manifest\.m3u8/i,
/live\.m3u8/i
],
// HLS流模式 - HLS stream patterns
hls: [
/\/hls\//i,
/\/live\//i,
/\/playlist\//i,
/type=m3u8/i,
/application\/x-mpegURL/i,
/application\/vnd\.apple\.mpegurl/i,
/content-type=[^&]*m3u8/i
],
// 其他支持的视频格式 - Other supported video formats
video: [
/\.mp4([?#].*)?$/i,
/\.webm([?#].*)?$/i,
/\.mov([?#].*)?$/i,
/\.m4v([?#].*)?$/i,
/\.mkv([?#].*)?$/i,
/\.mpd([?#].*)?$/i,
/\/dash\//i
]
};
// 严格屏蔽的模式 - Strictly blocked patterns
const BLOCKED_PATTERNS = [
/\.ts([?#].*)?$/i,
/segment[-_]?\d+/i,
/chunk[-_]?\d+/i,
/frag[-_]?\d+/i,
/\.aac([?#].*)?$/i,
/\.vtt([?#].*)?$/i,
/\.srt([?#].*)?$/i,
/\.key([?#].*)?$/i, // 屏蔽key文件
/key\.key/i, // 屏蔽key.key文件
/subtitle/i,
/caption/i,
/blob:/i,
/^data:/i,
/\/ts\//i,
/init-/i,
/\.json([?#].*)?$/i,
/\.png([?#].*)?$/i,
/\.jpg([?#].*)?$/i,
/\.jpeg([?#].*)?$/i,
/\.gif([?#].*)?$/i,
/\.css([?#].*)?$/i,
/\.js([?#].*)?$/i,
/\.html([?#].*)?$/i,
/\.svg([?#].*)?$/i,
/\.ico([?#].*)?$/i,
/\.woff([?#].*)?$/i,
/\.woff2([?#].*)?$/i,
/\.ttf([?#].*)?$/i,
/\.eot([?#].*)?$/i,
/favicon/i
];
// SVG图标 - SVG Icons (简化版本,减少干扰)
const ICONS = {
LOGO: '',
COPY: '',
FFMPEG: '',
OPEN: '',
CLEAR: '',
EXPORT: '',
MINIMIZE: '',
EXPAND: '',
CLOSE: '',
STOP: '',
START: '',
SETTINGS: ''
};
/**
* StreamWatchPro 主类 - 实现流媒体监控核心功能
*/
class StreamWatchPro {
constructor() {
// 状态管理
this.isActive = false; // 监控状态
this.isMinimized = false; // 最小化状态
this.isVisible = true; // 可见状态(用于关闭功能)
this.streams = new Map(); // 检测到的流
this.scanTimer = null; // 扫描定时器
this.isDragging = false; // 拖拽状态
this.isMobile = this.detectMobile(); // 移动设备检测
this.position = { // 面板位置
x: null,
y: null
};
// 初始化
this.initialize();
}
/**
* 检测是否为移动设备
* @returns {boolean} 是否为移动设备
*/
detectMobile() {
return /Android|webOS|iPhone|iPad|iPod|BlackBerry|IEMobile|Opera Mini/i.test(navigator.userAgent)
|| window.innerWidth <= 768;
}
/**
* 初始化监控系统
*/
initialize() {
// 创建UI界面
this.createUI();
// 设置监听器
this.setupEventListeners();
// 拦截网络请求
this.interceptNetworkRequests();
// 延迟启动监控
setTimeout(() => {
this.toggleMonitoring();
}, 1000);
// 控制台输出初始化信息
console.log(
`%c🎬 StreamWatch Pro v${CONFIG.VERSION} 已初始化`,
`color: ${CONFIG.THEME.PRIMARY}; font-weight: bold; font-size: 14px;`
);
}
/**
* 创建用户界面
*/
createUI() {
// 注入样式
this.injectStyles();
// 创建主容器
const container = document.createElement('div');
container.id = 'sw-container';
container.innerHTML = `
`;
// 添加到页面
document.body.appendChild(container);
// 设置初始位置
if (this.isMobile) {
container.style.bottom = CONFIG.POSITION.TOP;
container.style.right = CONFIG.POSITION.RIGHT;
} else {
container.style.top = CONFIG.POSITION.TOP;
container.style.right = CONFIG.POSITION.RIGHT;
}
// 绑定事件
this.bindUIEvents();
// 使面板可拖拽
this.makeDraggable();
}
/**
* 注入CSS样式
*/
injectStyles() {
const style = document.createElement('style');
style.id = 'sw-styles';
// 根据设备类型调整样式
const styles = `
/* 基础样式 */
#sw-container {
position: fixed;
z-index: 2147483647;
font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", "Microsoft YaHei", Roboto, Helvetica, Arial, sans-serif;
font-size: 13px;
color: ${CONFIG.THEME.TEXT};
width: ${this.isMobile ? '300px' : '340px'};
line-height: 1.4;
transition: transform 0.3s ease, opacity 0.3s ease;
user-select: none;
box-shadow: 0 8px 32px rgba(0, 0, 0, 0.1);
}
/* 主容器 */
.sw-wrapper {
background: ${CONFIG.THEME.BACKGROUND};
backdrop-filter: blur(10px);
-webkit-backdrop-filter: blur(10px);
border-radius: 12px;
box-shadow: 0 8px 32px rgba(0, 0, 0, 0.2);
border: 1px solid rgba(255, 255, 255, 0.1);
overflow: hidden;
max-height: 85vh;
display: flex;
flex-direction: column;
opacity: 0.95;
transition: opacity 0.2s ease;
}
.sw-wrapper:hover {
opacity: 1;
}
/* 标题栏 */
.sw-header {
padding: 12px 16px;
display: flex;
justify-content: space-between;
align-items: center;
background: ${CONFIG.THEME.PANEL};
border-bottom: 1px solid rgba(255, 255, 255, 0.05);
cursor: move;
transition: background 0.2s ease;
}
.sw-header:hover {
background: rgba(44, 44, 56, 0.85);
}
.sw-title {
display: flex;
align-items: center;
gap: 8px;
}
.sw-logo {
display: flex;
align-items: center;
color: ${CONFIG.THEME.PRIMARY};
}
.sw-name {
font-weight: 600;
font-size: 14px;
color: ${CONFIG.THEME.PRIMARY};
}
.sw-badge {
background: rgba(0, 255, 136, 0.1);
color: ${CONFIG.THEME.PRIMARY};
font-size: 10px;
padding: 2px 6px;
border-radius: 10px;
font-weight: 500;
}
.sw-controls {
display: flex;
gap: 8px;
align-items: center;
}
/* 按钮样式 */
.sw-btn {
border: none;
border-radius: 6px;
padding: 6px 12px;
font-size: 12px;
font-weight: 500;
cursor: pointer;
background: ${CONFIG.THEME.PRIMARY};
color: #000;
display: flex;
align-items: center;
gap: 4px;
transition: all 0.2s ease;
}
.sw-btn:hover {
opacity: 0.9;
transform: translateY(-1px);
}
.sw-btn-small {
padding: 6px 8px;
background: rgba(255, 255, 255, 0.08);
color: ${CONFIG.THEME.TEXT};
}
.sw-btn-small:hover {
background: rgba(255, 255, 255, 0.15);
}
#sw-close:hover {
background: ${CONFIG.THEME.ERROR};
color: white;
}
.sw-btn-icon {
padding: 4px;
background: rgba(255, 255, 255, 0.08);
color: ${CONFIG.THEME.TEXT};
}
.sw-btn-icon:hover {
background: rgba(255, 255, 255, 0.15);
}
.sw-btn-danger {
background: ${CONFIG.THEME.ERROR};
color: white;
}
/* 内容区域 */
.sw-content {
padding: 16px;
overflow-y: auto;
max-height: 65vh;
}
/* 最小化状态 */
#sw-container.sw-minimized .sw-content {
display: none;
}
/* 部分标题 */
.sw-section {
margin-bottom: 16px;
}
.sw-section-header {
display: flex;
justify-content: space-between;
align-items: center;
margin-bottom: 12px;
padding-bottom: 8px;
border-bottom: 1px solid rgba(255, 255, 255, 0.05);
}
.sw-section-title {
font-weight: 600;
font-size: 13px;
display: flex;
align-items: center;
gap: 8px;
}
.sw-counter {
background: rgba(0, 255, 136, 0.1);
color: ${CONFIG.THEME.PRIMARY};
padding: 2px 8px;
border-radius: 10px;
font-size: 11px;
}
.sw-actions {
display: flex;
gap: 6px;
}
/* 流媒体列表 */
.sw-stream-list {
max-height: 50vh;
overflow-y: auto;
padding-right: 4px;
}
/* 流媒体项目 */
.sw-stream-item {
background: ${CONFIG.THEME.PANEL};
border-radius: 8px;
margin-bottom: 10px;
padding: 12px;
border-left: 3px solid;
transition: all 0.2s ease;
}
.sw-stream-item:hover {
background: rgba(44, 44, 56, 0.85);
transform: translateX(2px);
}
.sw-stream-item.m3u8 { border-left-color: #ff7675; }
.sw-stream-item.hls { border-left-color: #00b894; }
.sw-stream-item.video { border-left-color: #0984e3; }
.sw-stream-header {
display: flex;
justify-content: space-between;
margin-bottom: 8px;
}
.sw-stream-title {
font-weight: 600;
font-size: 12px;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
max-width: 200px;
}
.sw-stream-type {
font-size: 10px;
padding: 2px 6px;
border-radius: 4px;
text-transform: uppercase;
font-weight: 600;
}
.sw-stream-type.m3u8 {
background: rgba(255, 118, 117, 0.2);
color: #ff7675;
}
.sw-stream-type.hls {
background: rgba(0, 184, 148, 0.2);
color: #00b894;
}
.sw-stream-type.video {
background: rgba(9, 132, 227, 0.2);
color: #0984e3;
}
.sw-stream-url {
background: rgba(0, 0, 0, 0.25);
padding: 8px 10px;
border-radius: 6px;
font-family: 'SFMono-Regular', Consolas, 'Liberation Mono', Menlo, Courier, monospace;
font-size: 11px;
word-break: break-all;
line-height: 1.4;
margin-bottom: 10px;
color: #ddd;
position: relative;
border: 1px solid rgba(255, 255, 255, 0.05);
cursor: pointer;
transition: all 0.2s ease;
}
.sw-stream-url:hover {
border-color: rgba(0, 255, 136, 0.3);
color: ${CONFIG.THEME.PRIMARY};
}
.sw-stream-url:hover::after {
content: "点击复制";
position: absolute;
right: 8px;
top: 8px;
background: rgba(0, 0, 0, 0.7);
padding: 2px 6px;
border-radius: 4px;
font-size: 9px;
color: #fff;
}
.sw-stream-actions {
display: flex;
gap: 6px;
}
.sw-stream-btn {
flex: 1;
padding: 6px 10px;
border: none;
border-radius: 4px;
font-size: 11px;
font-weight: 500;
cursor: pointer;
display: flex;
align-items: center;
justify-content: center;
gap: 4px;
transition: all 0.2s ease;
}
.sw-stream-btn:hover {
transform: translateY(-1px);
filter: brightness(1.1);
}
.sw-stream-btn.copy { background: #0984e3; color: white; }
.sw-stream-btn.ffmpeg { background: #6c5ce7; color: white; }
.sw-stream-btn.open { background: #00b894; color: white; }
/* 空状态 */
.sw-empty-state {
text-align: center;
padding: 30px 20px;
color: rgba(255, 255, 255, 0.5);
}
.sw-empty-icon {
font-size: 32px;
margin-bottom: 10px;
opacity: 0.8;
}
/* 滚动条样式 */
.sw-content::-webkit-scrollbar,
.sw-stream-list::-webkit-scrollbar {
width: 4px;
}
.sw-content::-webkit-scrollbar-track,
.sw-stream-list::-webkit-scrollbar-track {
background: rgba(255, 255, 255, 0.05);
}
.sw-content::-webkit-scrollbar-thumb,
.sw-stream-list::-webkit-scrollbar-thumb {
background: rgba(0, 255, 136, 0.3);
border-radius: 2px;
}
/* 提示样式 */
.sw-toast {
position: fixed;
${this.isMobile ? 'bottom: 80px; left: 50%; transform: translateX(-50%);' : 'top: 80px; right: 20px;'}
background: ${CONFIG.THEME.BACKGROUND};
border: 1px solid rgba(0, 255, 136, 0.3);
color: white;
padding: 10px 16px;
border-radius: 8px;
font-size: 13px;
font-weight: 500;
z-index: 2147483648;
box-shadow: 0 4px 20px rgba(0, 0, 0, 0.15);
max-width: 300px;
text-align: center;
animation: swFadeIn 0.3s ease;
backdrop-filter: blur(10px);
-webkit-backdrop-filter: blur(10px);
}
.sw-toast.success { border-color: ${CONFIG.THEME.SUCCESS}; }
.sw-toast.error { border-color: ${CONFIG.THEME.ERROR}; }
.sw-toast.info { border-color: ${CONFIG.THEME.INFO}; }
.sw-toast.warning { border-color: ${CONFIG.THEME.WARNING}; }
/* 重新打开按钮 */
.sw-reopen {
position: fixed;
bottom: 20px;
right: 20px;
background: ${CONFIG.THEME.BACKGROUND};
border-radius: 50%;
width: 48px;
height: 48px;
display: flex;
align-items: center;
justify-content: center;
cursor: pointer;
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.2);
z-index: 2147483647;
border: 1px solid rgba(0, 255, 136, 0.3);
transition: all 0.2s ease;
backdrop-filter: blur(10px);
-webkit-backdrop-filter: blur(10px);
}
.sw-reopen:hover {
transform: scale(1.1);
box-shadow: 0 6px 16px rgba(0, 0, 0, 0.3);
}
.sw-reopen .sw-logo {
transform: scale(1.5);
}
/* 动画 */
@keyframes swFadeIn {
from { opacity: 0; transform: ${this.isMobile ? 'translateX(-50%) translateY(20px)' : 'translateY(-20px)'}; }
to { opacity: 1; transform: ${this.isMobile ? 'translateX(-50%) translateY(0)' : 'translateY(0)'}; }
}
@keyframes swPulse {
0%, 100% { opacity: 1; }
50% { opacity: 0.8; }
}
.sw-stream-item {
animation: swFadeIn 0.3s ease;
}
/* 加载状态 */
.sw-scanning {
display: inline-block;
width: 16px;
height: 16px;
border: 2px solid rgba(0, 255, 136, 0.3);
border-radius: 50%;
border-top-color: ${CONFIG.THEME.PRIMARY};
animation: swSpin 1s linear infinite;
margin-left: 8px;
}
@keyframes swSpin {
to { transform: rotate(360deg); }
}
/* 拖拽指示器 */
.sw-header:active {
cursor: grabbing;
}
.sw-dragging {
opacity: 0.8;
transition: none;
}
/* 活动状态指示器 */
.sw-active-indicator {
display: inline-block;
width: 8px;
height: 8px;
border-radius: 50%;
margin-right: 6px;
background: ${CONFIG.THEME.SUCCESS};
animation: swPulse 1.5s ease infinite;
}
`;
style.textContent = styles;
document.head.appendChild(style);
}
/**
* 绑定UI事件
*/
bindUIEvents() {
// 获取DOM元素
const toggleBtn = document.getElementById('sw-toggle');
const minimizeBtn = document.getElementById('sw-minimize');
const clearBtn = document.getElementById('sw-clear');
const exportBtn = document.getElementById('sw-export');
const closeBtn = document.getElementById('sw-close');
const reopenBtn = document.getElementById('sw-reopen');
const container = document.getElementById('sw-container');
// 监控开关按钮
toggleBtn.addEventListener('click', () => this.toggleMonitoring());
// 最小化按钮
minimizeBtn.addEventListener('click', () => this.toggleMinimize());
// 清空按钮
clearBtn.addEventListener('click', () => this.clearStreams());
// 导出按钮
exportBtn.addEventListener('click', () => this.exportData());
// 关闭按钮
closeBtn.addEventListener('click', () => this.toggleVisibility(false));
// 重新打开按钮
reopenBtn.addEventListener('click', () => this.toggleVisibility(true));
// 窗口大小变化适应
window.addEventListener('resize', () => {
this.isMobile = this.detectMobile();
// 仅当未手动定位时重置位置
if (!this.position.x && !this.position.y) {
container.style.left = '';
container.style.top = '';
container.style.right = CONFIG.POSITION.RIGHT;
container.style.bottom = this.isMobile ? CONFIG.POSITION.TOP : '';
container.style.top = this.isMobile ? '' : CONFIG.POSITION.TOP;
}
// 重新注入样式
document.getElementById('sw-styles')?.remove();
this.injectStyles();
});
}
/**
* 使面板可拖拽
*/
makeDraggable() {
const container = document.getElementById('sw-container');
const header = container.querySelector('.sw-header');
let startX, startY, startLeft, startTop;
const handleStart = (e) => {
// 避免在按钮上拖动
if (e.target.closest('.sw-btn')) return;
e.preventDefault();
this.isDragging = true;
container.classList.add('sw-dragging');
// 获取起始位置(支持触摸和鼠标)
const clientX = e.touches ? e.touches[0].clientX : e.clientX;
const clientY = e.touches ? e.touches[0].clientY : e.clientY;
// 记录起始位置
startX = clientX;
startY = clientY;
const rect = container.getBoundingClientRect();
startLeft = rect.left;
startTop = rect.top;
// 移除所有自动定位
container.style.right = '';
container.style.bottom = '';
};
const handleMove = (e) => {
if (!this.isDragging) return;
// 获取当前位置
const clientX = e.touches ? e.touches[0].clientX : e.clientX;
const clientY = e.touches ? e.touches[0].clientY : e.clientY;
// 计算位移
const deltaX = clientX - startX;
const deltaY = clientY - startY;
// 计算新位置
let newLeft = startLeft + deltaX;
let newTop = startTop + deltaY;
// 边界检查
const rect = container.getBoundingClientRect();
newLeft = Math.max(0, Math.min(newLeft, window.innerWidth - rect.width));
newTop = Math.max(0, Math.min(newTop, window.innerHeight - rect.height));
// 应用新位置
container.style.left = `${newLeft}px`;
container.style.top = `${newTop}px`;
// 更新保存的位置
this.position.x = newLeft;
this.position.y = newTop;
};
const handleEnd = () => {
this.isDragging = false;
container.classList.remove('sw-dragging');
// 保存位置到localStorage以便页面刷新后恢复
try {
localStorage.setItem('sw-position', JSON.stringify(this.position));
} catch (e) {
console.warn('[StreamWatch] 无法保存位置到localStorage', e);
}
};
// 鼠标事件
header.addEventListener('mousedown', handleStart);
document.addEventListener('mousemove', handleMove);
document.addEventListener('mouseup', handleEnd);
// 触摸事件
header.addEventListener('touchstart', handleStart, { passive: false });
document.addEventListener('touchmove', handleMove, { passive: false });
document.addEventListener('touchend', handleEnd);
// 尝试从localStorage恢复位置
try {
const savedPosition = localStorage.getItem('sw-position');
if (savedPosition) {
const pos = JSON.parse(savedPosition);
// 确保位置在有效范围内
if (pos.x >= 0 && pos.x <= window.innerWidth - 340 &&
pos.y >= 0 && pos.y <= window.innerHeight - 100) {
container.style.left = `${pos.x}px`;
container.style.top = `${pos.y}px`;
container.style.right = '';
container.style.bottom = '';
this.position = pos;
}
}
} catch (e) {
console.warn('[StreamWatch] 无法从localStorage恢复位置', e);
}
}
/**
* 设置事件监听器
*/
setupEventListeners() {
// DOM变化监听
const observer = new MutationObserver((mutations) => {
if (!this.isActive) return;
for (const mutation of mutations) {
for (const node of mutation.addedNodes) {
if (node.nodeType === 1) { // Element节点
this.scanElement(node);
}
}
}
});
observer.observe(document.body, {
childList: true,
subtree: true
});
// 媒体元素事件监听
const mediaEvents = ['loadstart', 'loadedmetadata', 'playing', 'canplay'];
mediaEvents.forEach(eventName => {
document.addEventListener(eventName, (e) => {
if (!this.isActive) return;
if (e.target.tagName === 'VIDEO' || e.target.tagName === 'AUDIO') {
this.handleMediaEvent(e);
}
}, true);
});
}
/**
* 拦截网络请求
*/
interceptNetworkRequests() {
// 拦截Fetch请求
const originalFetch = window.fetch;
window.fetch = (...args) => {
if (this.isActive) {
const request = args[0];
if (typeof request === 'string') {
this.analyzeUrl(request);
} else if (request instanceof Request) {
this.analyzeUrl(request.url);
}
}
return originalFetch.apply(window, args);
};
// 拦截XMLHttpRequest
const originalOpen = XMLHttpRequest.prototype.open;
XMLHttpRequest.prototype.open = function(method, url, ...args) {
if (window.streamWatchPro?.isActive) {
window.streamWatchPro.analyzeUrl(url);
}
return originalOpen.apply(this, [method, url, ...args]);
};
}
/**
* 分析URL是否为流媒体
* @param {string} url - 要分析的URL
*/
analyzeUrl(url) {
if (!url || typeof url !== 'string') return;
try {
const streamType = this.detectStreamType(url);
if (streamType) {
this.addStream(url, streamType);
}
} catch (error) {
console.error('[StreamWatch] 分析URL失败:', error);
}
}
/**
* 检测流媒体类型
* @param {string} url - 要检测的URL
* @returns {string|null} - 流媒体类型或null
*/
detectStreamType(url) {
// 忽略无效URL
if (!url || typeof url !== 'string') return null;
// 检查是否被屏蔽
if (this.isBlockedUrl(url)) return null;
// 检查是否为M3U8
for (const pattern of STREAM_PATTERNS.m3u8) {
if (pattern.test(url)) return 'm3u8';
}
// 检查是否为HLS
for (const pattern of STREAM_PATTERNS.hls) {
if (pattern.test(url)) return 'hls';
}
// 检查是否为视频
for (const pattern of STREAM_PATTERNS.video) {
if (pattern.test(url)) return 'video';
}
return null;
}
/**
* 检查URL是否被屏蔽
* @param {string} url - 要检查的URL
* @returns {boolean} - 是否被屏蔽
*/
isBlockedUrl(url) {
for (const pattern of BLOCKED_PATTERNS) {
if (pattern.test(url)) return true;
}
return false;
}
/**
* 扫描DOM元素
* @param {Element} element - 要扫描的DOM元素
*/
scanElement(element) {
try {
// 检查媒体元素
if (element.tagName === 'VIDEO' || element.tagName === 'AUDIO' || element.tagName === 'SOURCE') {
this.scanMediaElement(element);
}
// 检查链接元素
if (element.tagName === 'A' && element.href) {
this.analyzeUrl(element.href);
}
// 检查带src属性的元素
if (element.hasAttribute('src')) {
this.analyzeUrl(element.getAttribute('src'));
}
// 检查自定义数据属性
const dataAttributes = ['data-src', 'data-url', 'data-hls', 'data-m3u8'];
for (const attr of dataAttributes) {
if (element.hasAttribute(attr)) {
this.analyzeUrl(element.getAttribute(attr));
}
}
// 递归检查子元素
if (element.children && element.children.length > 0) {
for (const child of element.children) {
this.scanElement(child);
}
}
} catch (error) {
console.error('[StreamWatch] 扫描元素失败:', error);
}
}
/**
* 扫描媒体元素
* @param {Element} element - 要扫描的媒体元素
*/
scanMediaElement(element) {
// 检查src属性
if (element.src) {
this.analyzeUrl(element.src);
}
// 检查currentSrc属性
if (element.currentSrc) {
this.analyzeUrl(element.currentSrc);
}
// 检查所有source子元素
if (element.tagName === 'VIDEO' || element.tagName === 'AUDIO') {
const sources = element.querySelectorAll('source');
for (const source of sources) {
if (source.src) {
this.analyzeUrl(source.src);
}
}
}
}
/**
* 处理媒体事件
* @param {Event} event - 媒体事件
*/
handleMediaEvent(event) {
const element = event.target;
// 检查当前播放源
if (element.currentSrc) {
this.analyzeUrl(element.currentSrc);
}
// 检查源属性
if (element.src) {
this.analyzeUrl(element.src);
}
}
/**
* 添加流媒体
* @param {string} url - 流媒体URL
* @param {string} type - 流媒体类型
*/
addStream(url, type) {
// 检查是否已存在
if (this.streams.has(url)) return;
// 检查数量限制
if (this.streams.size >= CONFIG.MAX_STREAMS) {
this.log('已达到最大流媒体数量限制', 'warning');
return;
}
// 创建流媒体对象
const stream = {
url: url,
type: type,
title: this.generateTitle(url),
timestamp: new Date().toLocaleTimeString(),
id: Date.now() + Math.random().toString(36).slice(2, 11)
};
// 添加到集合
this.streams.set(url, stream);
// 渲染到UI
this.renderStream(stream);
// 更新计数
this.updateCounter();
// 确保面板可见(如果检测到新流)
if (this.isMinimized) {
this.showToast(`检测到新的${type.toUpperCase()}流`, 'info');
}
// 记录日志
this.log(`检测到${type.toUpperCase()}流: ${this.truncateUrl(url)}`, 'info');
}
/**
* 截断URL用于显示
* @param {string} url - 要截断的URL
* @returns {string} - 截断后的URL
*/
truncateUrl(url) {
return url.length > 100 ? url.substring(0, 100) + '...' : url;
}
/**
* 生成流媒体标题
* @param {string} url - 流媒体URL
* @returns {string} - 生成的标题
*/
generateTitle(url) {
try {
const urlObj = new URL(url);
// 尝试从路径中提取文件名
let pathname = urlObj.pathname;
let filename = pathname.split('/').pop() || '';
// 移除查询参数和哈希
filename = filename.split('?')[0].split('#')[0];
// 移除文件扩展名
filename = filename.replace(/\.(m3u8|mp4|webm|mpd|mov)$/i, '');
// 如果没有有效文件名,使用域名
if (!filename || filename.length < 2) {
filename = urlObj.hostname.replace(/^www\./i, '');
}
// 替换特殊字符
filename = filename.replace(/[_-]/g, ' ');
// 首字母大写
filename = filename.split(' ').map(word =>
word.charAt(0).toUpperCase() + word.slice(1)
).join(' ');
// 限制长度
if (filename.length > 30) {
filename = filename.substring(0, 30) + '...';
}
return filename;
} catch (error) {
// 如果解析失败,返回URL的一部分
return url.substring(0, 30) + '...';
}
}
/**
* 渲染流媒体到UI
* @param {Object} stream - 流媒体对象
*/
renderStream(stream) {
// 获取列表容器
const streamList = document.getElementById('sw-stream-list');
if (!streamList) return;
// 移除空状态
const emptyState = streamList.querySelector('.sw-empty-state');
if (emptyState) {
emptyState.remove();
}
// 创建流媒体项
const streamItem = document.createElement('div');
streamItem.className = `sw-stream-item ${stream.type}`;
streamItem.setAttribute('data-id', stream.id);
// 设置HTML内容
streamItem.innerHTML = `
${stream.url}
`;
// 添加事件监听
streamItem.querySelector('.sw-stream-url').addEventListener('click', () => {
this.copyToClipboard(stream.url);
});
streamItem.querySelector('.copy').addEventListener('click', () => {
this.copyToClipboard(stream.url);
});
streamItem.querySelector('.ffmpeg').addEventListener('click', () => {
this.copyFFmpegCommand(stream.url, stream.type);
});
streamItem.querySelector('.open').addEventListener('click', () => {
this.openInNewTab(stream.url);
});
// 添加到列表
streamList.appendChild(streamItem);
// 滚动到底部
streamList.scrollTop = streamList.scrollHeight;
}
/**
* 更新流媒体计数
*/
updateCounter() {
const counter = document.getElementById('sw-counter');
if (counter) {
counter.textContent = this.streams.size;
}
}
/**
* 复制文本到剪贴板
* @param {string} text - 要复制的文本
*/
copyToClipboard(text) {
const copyFn = async () => {
try {
// 使用现代API
if (navigator.clipboard && navigator.clipboard.writeText) {
await navigator.clipboard.writeText(text);
return true;
}
// 备用方法
const textArea = document.createElement('textarea');
textArea.value = text;
textArea.style.position = 'fixed';
textArea.style.opacity = '0';
textArea.style.left = '-999999px';
document.body.appendChild(textArea);
textArea.select();
const success = document.execCommand('copy');
document.body.removeChild(textArea);
return success;
} catch (error) {
console.error('[StreamWatch] 复制失败:', error);
return false;
}
};
copyFn().then(success => {
if (success) {
this.showToast('已复制到剪贴板', 'success');
} else {
this.showToast('复制失败,请手动复制', 'error');
}
});
}
/**
* 复制FFmpeg命令
* @param {string} url - 流媒体URL
* @param {string} type - 流媒体类型
*/
copyFFmpegCommand(url, type) {
let command = this.getFFmpegCommand(url, type);
// 复制命令
this.copyToClipboard(command);
// 额外记录到控制台
console.log(
`%c[StreamWatch] FFmpeg下载命令:`,
`color: ${CONFIG.THEME.PRIMARY}; font-weight: bold;`
);
console.log(`%c${command}`, 'background: #282c34; color: #abb2bf; padding: 4px 8px; border-radius: 4px;');
}
/**
* 在新标签页打开URL
* @param {string} url - 要打开的URL
*/
openInNewTab(url) {
try {
window.open(url, '_blank');
this.showToast('已在新标签页打开', 'info');
} catch (error) {
this.showToast('无法打开链接', 'error');
console.error('[StreamWatch] 打开链接失败:', error);
}
}
/**
* 显示提示信息
* @param {string} message - 提示消息
* @param {string} type - 提示类型 (success, error, info, warning)
*/
showToast(message, type = 'info') {
// 移除现有提示
const existingToast = document.querySelector('.sw-toast');
if (existingToast) {
existingToast.remove();
}
// 创建新提示
const toast = document.createElement('div');
toast.className = `sw-toast ${type}`;
toast.textContent = message;
// 添加到页面
document.body.appendChild(toast);
// 自动移除
setTimeout(() => {
if (toast.parentNode) {
toast.style.opacity = '0';
toast.style.transform = this.isMobile ?
'translateX(-50%) translateY(20px)' : 'translateY(-20px)';
setTimeout(() => {
if (toast.parentNode) toast.remove();
}, 300);
}
}, CONFIG.TOAST_DURATION);
}
/**
* 切换监控状态
*/
toggleMonitoring() {
this.isActive = !this.isActive;
// 获取按钮
const toggleBtn = document.getElementById('sw-toggle');
if (this.isActive) {
// 启动监控
toggleBtn.innerHTML = ICONS.STOP;
toggleBtn.style.background = CONFIG.THEME.ERROR;
toggleBtn.style.color = '#fff';
toggleBtn.title = '停止监控';
// 添加活动指示器到标题
const title = document.querySelector('.sw-title');
if (!title.querySelector('.sw-active-indicator')) {
const indicator = document.createElement('span');
indicator.className = 'sw-active-indicator';
title.insertBefore(indicator, title.firstChild);
}
this.startPeriodicScan();
this.showToast('已开始监控流媒体', 'success');
this.log('开始监控流媒体', 'success');
} else {
// 停止监控
toggleBtn.innerHTML = ICONS.START;
toggleBtn.style.background = '';
toggleBtn.style.color = '';
toggleBtn.title = '开始监控';
// 移除活动指示器
const indicator = document.querySelector('.sw-active-indicator');
if (indicator) indicator.remove();
this.stopPeriodicScan();
this.showToast('已停止监控', 'warning');
this.log('停止监控流媒体', 'warning');
}
}
/**
* 切换最小化状态
*/
toggleMinimize() {
this.isMinimized = !this.isMinimized;
// 获取元素
const container = document.getElementById('sw-container');
const minimizeBtn = document.getElementById('sw-minimize');
if (this.isMinimized) {
// 最小化
container.classList.add('sw-minimized');
minimizeBtn.innerHTML = ICONS.EXPAND;
minimizeBtn.title = '展开';
} else {
// 展开
container.classList.remove('sw-minimized');
minimizeBtn.innerHTML = ICONS.MINIMIZE;
minimizeBtn.title = '最小化';
}
}
/**
* 切换可见性状态
* @param {boolean} visible - 是否可见
*/
toggleVisibility(visible) {
this.isVisible = visible;
const container = document.getElementById('sw-container');
const reopenBtn = document.getElementById('sw-reopen');
if (visible) {
// 显示面板
container.style.display = 'block';
reopenBtn.style.display = 'none';
} else {
// 隐藏面板
container.style.display = 'none';
reopenBtn.style.display = 'flex';
// 如果正在监控,显示提示
if (this.isActive) {
this.showToast('StreamWatch 已最小化到右下角但仍在监控', 'info');
}
}
}
/**
* 开始周期性扫描
*/
startPeriodicScan() {
// 先停止现有扫描
this.stopPeriodicScan();
// 立即执行一次扫描
this.scanPage();
// 开始定时扫描
this.scanTimer = setInterval(() => {
this.scanPage();
}, CONFIG.SCAN_INTERVAL);
}
/**
* 停止周期性扫描
*/
stopPeriodicScan() {
if (this.scanTimer) {
clearInterval(this.scanTimer);
this.scanTimer = null;
}
}
/**
* 扫描整个页面
*/
scanPage() {
if (!this.isActive) return;
try {
// 扫描所有媒体元素
const mediaElements = document.querySelectorAll('video, audio, source');
mediaElements.forEach(element => {
this.scanMediaElement(element);
});
// 扫描所有链接
const links = document.querySelectorAll('a[href]');
links.forEach(link => {
this.analyzeUrl(link.href);
});
// 扫描带有数据属性的元素
const dataElements = document.querySelectorAll('[data-src], [data-url], [data-hls], [data-m3u8]');
dataElements.forEach(element => {
['data-src', 'data-url', 'data-hls', 'data-m3u8'].forEach(attr => {
if (element.hasAttribute(attr)) {
this.analyzeUrl(element.getAttribute(attr));
}
});
});
// 扫描所有iframe
const iframes = document.querySelectorAll('iframe[src]');
iframes.forEach(iframe => {
this.analyzeUrl(iframe.src);
});
// 扫描所有script标签的src属性
const scripts = document.querySelectorAll('script[src]');
scripts.forEach(script => {
this.analyzeUrl(script.src);
});
// 扫描所有视频播放器相关元素
const playerElements = document.querySelectorAll('[class*="player"], [class*="video"], [class*="media"]');
playerElements.forEach(element => {
// 检查元素的所有属性
Array.from(element.attributes).forEach(attr => {
if (attr.value && typeof attr.value === 'string' && attr.value.includes('http')) {
this.analyzeUrl(attr.value);
}
});
});
} catch (error) {
console.error('[StreamWatch] 扫描页面失败:', error);
}
}
/**
* 清空流媒体列表
*/
clearStreams() {
// 清空数据
this.streams.clear();
// 清空UI
const streamList = document.getElementById('sw-stream-list');
if (streamList) {
streamList.innerHTML = `
`;
}
// 更新计数
this.updateCounter();
// 显示提示
this.showToast('已清空流媒体列表', 'info');
}
/**
* 导出数据
*/
exportData() {
// 检查是否有数据
if (this.streams.size === 0) {
this.showToast('没有可导出的数据', 'warning');
return;
}
try {
// 构建导出数据
const exportData = {
version: CONFIG.VERSION,
timestamp: new Date().toISOString(),
pageUrl: window.location.href,
pageTitle: document.title,
streams: Array.from(this.streams.values()).map(stream => ({
url: stream.url,
type: stream.type,
title: stream.title,
timestamp: stream.timestamp,
ffmpegCommand: this.getFFmpegCommand(stream.url, stream.type)
}))
};
// 创建JSON文件
const blob = new Blob([JSON.stringify(exportData, null, 2)], { type: 'application/json' });
const url = URL.createObjectURL(blob);
// 触发下载
const a = document.createElement('a');
a.href = url;
a.download = `streamwatch_${new Date().toISOString().replace(/[:.]/g, '-')}.json`;
a.click();
// 释放URL
URL.revokeObjectURL(url);
// 显示提示
this.showToast(`已导出${exportData.streams.length}个流媒体数据`, 'success');
} catch (error) {
this.showToast('导出失败', 'error');
console.error('[StreamWatch] 导出数据失败:', error);
}
}
/**
* 获取FFmpeg命令
* @param {string} url - 流媒体URL
* @param {string} type - 流媒体类型
* @returns {string} - FFmpeg命令
*/
getFFmpegCommand(url, type) {
switch (type) {
case 'm3u8':
case 'hls':
return `ffmpeg -i "${url}" -c copy -bsf:a aac_adtstoasc output.mp4`;
case 'video':
const ext = url.match(/\.([^.?#]+)(?:\?|#|$)/) ? url.match(/\.([^.?#]+)(?:\?|#|$)/)[1] : 'mp4';
return `ffmpeg -i "${url}" -c copy output.${ext}`;
default:
return `ffmpeg -i "${url}" -c copy output.mp4`;
}
}
/**
* 获取监控报告
* @returns {Object} - 详细的监控报告
*/
getReport() {
const streams = Array.from(this.streams.values());
const report = {
version: CONFIG.VERSION,
timestamp: new Date().toISOString(),
pageUrl: window.location.href,
pageTitle: document.title,
isActive: this.isActive,
statistics: {
totalStreams: streams.length,
streamTypes: {},
detectedFormats: new Set()
},
streams: streams.map(stream => ({
url: stream.url,
type: stream.type,
title: stream.title,
timestamp: stream.timestamp
}))
};
// 统计流媒体类型
streams.forEach(stream => {
report.statistics.streamTypes[stream.type] = (report.statistics.streamTypes[stream.type] || 0) + 1;
report.statistics.detectedFormats.add(stream.type);
});
report.statistics.detectedFormats = Array.from(report.statistics.detectedFormats);
return report;
}
/**
* 简化的切换方法(向后兼容)
*/
toggle() {
return this.toggleMonitoring();
}
/**
* 简化的分析方法(向后兼容)
*/
analyze(url) {
return this.analyzeUrl(url);
}
/**
* 记录日志
* @param {string} message - 日志消息
* @param {string} type - 日志类型
*/
log(message, type = 'info') {
const colors = {
info: CONFIG.THEME.INFO,
success: CONFIG.THEME.SUCCESS,
warning: CONFIG.THEME.WARNING,
error: CONFIG.THEME.ERROR
};
console.log(
`%c[StreamWatch] ${message}`,
`color: ${colors[type] || colors.info}; font-weight: 500;`
);
}
/**
* 销毁实例并清理资源
*/
destroy() {
// 停止监控
this.stopPeriodicScan();
// 移除DOM元素
const container = document.getElementById('sw-container');
if (container) container.remove();
const reopenBtn = document.getElementById('sw-reopen');
if (reopenBtn) reopenBtn.remove();
const styles = document.getElementById('sw-styles');
if (styles) styles.remove();
// 移除所有toast
const toast = document.querySelector('.sw-toast');
if (toast) toast.remove();
// 移除实例引用
window.streamWatchPro = null;
window.streamWatch = null;
this.log('StreamWatch已完全卸载', 'warning');
}
}
/**
* 初始化StreamWatchPro
*/
function initStreamWatchPro() {
// 避免重复初始化
if (window.streamWatchPro) {
console.log('[StreamWatch] 已经初始化,跳过');
return;
}
// 创建实例并绑定到全局
window.streamWatchPro = new StreamWatchPro();
// 提供向后兼容的全局变量
window.streamWatch = window.streamWatchPro;
// 设置便捷控制台命令
window.swToggle = () => window.streamWatchPro.toggleMonitoring();
window.swClear = () => window.streamWatchPro.clearStreams();
window.swExport = () => window.streamWatchPro.exportData();
window.swReport = () => {
const streams = Array.from(window.streamWatchPro.streams.values());
console.table(streams.map(s => ({
类型: s.type.toUpperCase(),
标题: s.title,
URL: s.url.substring(0, 50) + '...',
时间: s.timestamp
})));
return `检测到${streams.length}个流媒体`;
};
window.swDestroy = () => window.streamWatchPro.destroy();
// 提供向后兼容的全局函数
window.streamWatchReport = window.swReport;
window.streamWatchToggle = window.swToggle;
// 控制台信息
console.log(
`%c📺 StreamWatch Pro v${CONFIG.VERSION}`,
`color: ${CONFIG.THEME.PRIMARY}; font-size: 14px; font-weight: bold;`
);
console.log(
`%c可用控制台命令:\n`+
`swToggle() - 切换监控状态\n`+
`swClear() - 清空列表\n`+
`swExport() - 导出数据\n`+
`swReport() - 显示统计\n`+
`swDestroy() - 卸载工具`,
`color: #ddd; font-size: 12px;`
);
}
// 根据文档加载状态执行初始化
if (document.readyState === 'loading') {
document.addEventListener('DOMContentLoaded', initStreamWatchPro);
} else {
// 延迟执行以确保页面已加载
setTimeout(initStreamWatchPro, 100);
}
// 备用初始化逻辑,确保脚本一定会运行
setTimeout(() => {
if (!window.streamWatchPro) {
console.log('[StreamWatch] 备用初始化触发');
initStreamWatchPro();
}
}, 2000);
})();