This commit is contained in:
ChuXun
2025-10-19 20:55:27 +08:00
parent e879ccefb3
commit 53f9554f38
99 changed files with 22308 additions and 2 deletions

921
catch-script/catch.js Normal file
View File

@@ -0,0 +1,921 @@
(function () {
class CatCatcher {
constructor() {
console.log("catch.js Start");
// 初始化属性
this.enable = true; // 捕获开关
this.language = navigator.language; // 语言设置
this.isComplete = false; // 捕获完成标志
this.catchMedia = []; // 捕获的媒体数据
this.mediaSize = 0; // 捕获的媒体数据大小
this.setFileName = null; // 文件名
this.catCatch = null; // UI元素
// 移动面板相关属性
this.x = 0;
this.y = 0;
// 初始化语言
if (window.CatCatchI18n) {
if (!window.CatCatchI18n.languages.includes(this.language)) {
this.language = this.language.split("-")[0];
if (!window.CatCatchI18n.languages.includes(this.language)) {
this.language = "en";
}
}
}
// 初始化组件
// 删除iframe sandbox属性 避免 issues #576
this.setupIframeProcessing();
// 初始化 Trusted Types
this.initTrustedTypes();
// 创建和设置UI
this.createUI();
// 代理MediaSource方法
this.proxyMediaSourceMethods();
// 自动跳转到缓冲尾
if (localStorage.getItem("CatCatchCatch_autoToBuffered") == "checked") {
const autoToBufferedInterval = setInterval(() => {
const videos = document.querySelectorAll('video');
if (videos.length > 0 && Array.from(videos).some(video => !video.paused && video.readyState > 2)) {
const autoToBufferedElement = this.catCatch.querySelector("#autoToBuffered");
if (autoToBufferedElement) {
autoToBufferedElement.click();
clearInterval(autoToBufferedInterval);
}
}
}, 1000);
}
}
/**
* 设置iframe处理删除sandbox属性
* 解决 issues #576
*/
setupIframeProcessing() {
document.addEventListener('DOMContentLoaded', () => {
const processIframe = (iframe) => {
if (iframe && iframe.hasAttribute && iframe.hasAttribute('sandbox')) {
const clonedIframe = iframe.cloneNode(true);
clonedIframe.removeAttribute('sandbox');
if (iframe.parentNode) {
iframe.parentNode.replaceChild(clonedIframe, iframe);
}
}
};
document.querySelectorAll('iframe').forEach(processIframe);
const observer = new MutationObserver((mutationsList) => {
for (const mutation of mutationsList) {
if (mutation.type === 'childList') {
mutation.addedNodes.forEach(node => {
if (node.nodeName === 'IFRAME') {
processIframe(node);
} else if (node.querySelectorAll) {
node.querySelectorAll('iframe').forEach(processIframe);
}
});
}
}
});
observer.observe(document.body || document.documentElement, { childList: true, subtree: true });
});
}
/**
* 初始化 Trusted Types
*/
initTrustedTypes() {
let createHTML = (string) => {
try {
const fakeDiv = document.createElement('div');
fakeDiv.innerHTML = string;
createHTML = (string) => string;
} catch (e) {
if (typeof trustedTypes !== 'undefined') {
const policy = trustedTypes.createPolicy('catCatchPolicy', { createHTML: (s) => s });
createHTML = (string) => policy.createHTML(string);
const _innerHTML = Object.getOwnPropertyDescriptor(Element.prototype, 'innerHTML');
Object.defineProperty(Element.prototype, 'innerHTML', {
set: function (value) {
_innerHTML.set.call(this, createHTML(value));
}
});
} else {
console.warn("trustedTypes不可用跳过安全策略设置");
}
}
};
createHTML("<div></div>");
}
/**
* 创建UI元素
*/
createUI() {
const buttonStyle = 'style="border:solid 1px #000;margin:2px;padding:2px;background:#fff;border-radius:4px;border:solid 1px #c7c7c780;color:#000;"';
const checkboxStyle = 'style="-webkit-appearance: auto;"';
this.catCatch = document.createElement("div");
this.catCatch.setAttribute("id", "CatCatchCatch");
const style = `
display: flex;
flex-direction: column;
align-items: flex-start;`;
this.catCatch.innerHTML = `<img src="data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAABgAAAAYBAMAAAASWSDLAAAAKlBMVEUAAADLlROxbBlRAD16GS5oAjWWQiOCIytgADidUx/95gHqwwTx0gDZqwT6kfLuAAAACnRSTlMA/vUejV7kuzi8za0PswAAANpJREFUGNNjwA1YSxkYTEqhnKZLLi6F1w0gnKA1shdvHYNxdq1atWobjLMKCOAyC3etlVrUAOH4HtNZmLgoAMKpXX37zO1FwcZAwMDguGq1zKpFmTNnzqx0Bpp2WvrU7ttn9py+I8JgLn1R8Pad22vurNkjwsBReHv33junzuyRnOnMwNCSeFH27K5dq1SNgcZxFMnuWrNq1W5VkNntihdv7ToteGcT0C7mIkE1qbWCYjJnM4CqEoWKdoslChXuUgXJqIcLebiphSgCZRhaPDhcDFhdmUMCGIgEAFA+Uc02aZg9AAAAAElFTkSuQmCC" style="-webkit-user-drag: none;width: 20px;">
<div id="catCatch" style="${style}">
<div id="tips"></div>
<button id="download" ${buttonStyle} data-i18n="downloadCapturedData">下载已捕获的数据</button>
<button id="clean" ${buttonStyle} data-i18n="deleteCapturedData">删除已捕获数据</button>
<div><button id="hide" ${buttonStyle} data-i18n="hide">隐藏</button><button id="close" ${buttonStyle} data-i18n="close">关闭</button></div>
<label><input type="checkbox" id="autoDown" ${localStorage.getItem("CatCatchCatch_autoDown") || ""} ${checkboxStyle}><span data-i18n="automaticDownload">完成捕获自动下载</span></label>
<label><input type="checkbox" id="ffmpeg" ${localStorage.getItem("CatCatchCatch_ffmpeg") || ""} ${checkboxStyle}><span data-i18n="ffmpeg">使用ffmpeg合并</span></label>
<label><input type="checkbox" id="autoToBuffered" ${checkboxStyle}><span data-i18n="autoToBuffered">自动跳转缓冲尾</span></label>
<label><input type="checkbox" id="checkHead" ${checkboxStyle}>清理多余头部数据</label>
<label><input type="checkbox" id="completeClearCache" ${localStorage.getItem("CatCatchCatch_completeClearCache") || ""} ${checkboxStyle}>下载完成后清空数据</label>
<details>
<summary data-i18n="fileName" id="summary">文件名设置</summary>
<div style="font-weight:bold;"><span data-i18n="fileName">文件名</span>: </div><div id="fileName"></div>
<div style="font-weight:bold;"><span data-i18n="selector">表达式</span>: </div><div id="selector">Null</div>
<div style="font-weight:bold;"><span data-i18n="regular">正则</span>: </div><div id="regular">Null</div>
<button id="setSelector" ${buttonStyle} data-i18n="usingSelector">表达式提取</button>
<button id="setRegular" ${buttonStyle} data-i18n="usingRegular">正则提取</button>
<button id="setFileName" ${buttonStyle} data-i18n="customize">手动填写</button>
</details>
<details>
<summary>test</summary>
<button id="test" ${buttonStyle}>test</button>
<button id="restart" ${buttonStyle} data-i18n="capturedBeginning">从头捕获</button>
<label><input type="checkbox" id="restartAlways" ${localStorage.getItem("CatCatchCatch_restart") || ""} ${checkboxStyle}><span data-i18n="alwaysCapturedBeginning">始终从头捕获</span>(beta)</label>
</details>
</div>`;
this.catCatch.style = `
position: fixed;
z-index: 999999;
top: 10%;
left: 90%;
background: rgb(255 255 255 / 85%);
border: solid 1px #c7c7c7;
border-radius: 4px;
color: rgb(26, 115, 232);
padding: 5px 5px 5px 5px;
font-size: 12px;
font-family: "Microsoft YaHei", "Helvetica", "Arial", sans-serif;
user-select: none;`;
// 创建 Shadow DOM
this.createShadowRoot();
// 初始化UI元素引用
this.tips = this.catCatch.querySelector("#tips");
this.fileName = this.catCatch.querySelector("#fileName");
this.selector = this.catCatch.querySelector("#selector");
this.regular = this.catCatch.querySelector("#regular");
if (!this.tips || !this.fileName || !this.selector || !this.regular) {
console.error("UI元素初始化失败找不到必要的DOM元素");
}
// 初始化显示
this.tips.innerHTML = this.i18n("waiting", "等待视频播放");
this.selector.innerHTML = localStorage.getItem("CatCatchCatch_selector") ?? "Null";
this.regular.innerHTML = localStorage.getItem("CatCatchCatch_regular") ?? "Null";
// 绑定事件
this.bindEvents();
// 自动从头捕获设置
if (localStorage.getItem("CatCatchCatch_restart") == "checked") {
this.setupAutoRestart();
}
}
/**
* 创建 Shadow DOM
* 解决 issues #693 安全使用attachShadow 从iframe中获取原生方法
*/
createShadowRoot() {
try {
// 解决 issues #693 安全使用attachShadow 从iframe中获取原生方法
const createSecureShadowRoot = (element, mode = 'closed') => {
const getPristineAttachShadow = () => {
try {
const iframe = document.createElement('iframe');
const parentNode = document.body || document.documentElement;
parentNode.appendChild(iframe);
const pristineMethod = iframe.contentDocument.createElement('div').attachShadow;
iframe.remove();
if (pristineMethod) return pristineMethod;
} catch (e) {
console.log("获取原生attachShadow方法失败:", e);
}
return Element.prototype.attachShadow;
};
const executor = Element.prototype.attachShadow.toString().includes('[native code]')
? Element.prototype.attachShadow.bind(element)
: getPristineAttachShadow().bind(element);
try {
return executor({ mode });
} catch (e) {
console.error('Shadow DOM 创建失败:', e);
// 应急处理:降级方案
return document.createElement('div');
}
};
// 创建 Shadow DOM 放入CatCatch
const divShadow = document.createElement('div');
const shadowRoot = createSecureShadowRoot(divShadow);
shadowRoot.appendChild(this.catCatch);
// 页面插入Shadow DOM
const htmlElement = document.getElementsByTagName('html')[0];
if (htmlElement) {
htmlElement.appendChild(divShadow);
} else {
document.appendChild(divShadow);
}
} catch (error) {
console.error("创建Shadow DOM失败:", error);
// 降级方案直接添加到body
try {
const body = document.body || document.documentElement;
body.appendChild(this.catCatch);
} catch (e) {
console.error("降级添加UI也失败:", e);
}
}
}
/**
* 绑定事件处理函数
*/
bindEvents() {
// 移动面板相关事件
this.catCatch.addEventListener('mousedown', this.handleDragStart.bind(this));
// 设置选项相关事件
const autoDown = this.catCatch.querySelector("#autoDown");
if (autoDown) autoDown.addEventListener('change', this.handleAutoDownChange.bind(this));
const ffmpeg = this.catCatch.querySelector("#ffmpeg");
if (ffmpeg) ffmpeg.addEventListener('change', this.handleFfmpegChange.bind(this));
const restartAlways = this.catCatch.querySelector("#restartAlways");
if (restartAlways) restartAlways.addEventListener('change', this.handleRestartAlwaysChange.bind(this));
// 按钮相关事件
const clean = this.catCatch.querySelector("#clean");
if (clean) clean.addEventListener('click', this.handleClean.bind(this));
const download = this.catCatch.querySelector("#download");
if (download) download.addEventListener('click', this.handleDownload.bind(this));
const hide = this.catCatch.querySelector("#hide");
if (hide) hide.addEventListener('click', this.handleHide.bind(this));
const img = this.catCatch.querySelector("img");
if (img) img.addEventListener('click', this.handleHide.bind(this));
const close = this.catCatch.querySelector("#close");
if (close) close.addEventListener('click', this.handleClose.bind(this));
const restart = this.catCatch.querySelector("#restart");
if (restart) restart.addEventListener('click', this.handleRestart.bind(this));
const setFileName = this.catCatch.querySelector("#setFileName");
if (setFileName) setFileName.addEventListener('click', this.handleSetFileName.bind(this));
const test = this.catCatch.querySelector("#test");
if (test) test.addEventListener('click', this.handleTest.bind(this));
const summary = this.catCatch.querySelector("#summary");
if (summary) summary.addEventListener('click', this.getFileName.bind(this));
const completeClearCache = this.catCatch.querySelector("#completeClearCache");
if (completeClearCache) completeClearCache.addEventListener('click', this.handleCompleteClearCache.bind(this));
// 自动跳转到缓冲节点
// this.autoToBufferedFlag = true;
const autoToBuffered = this.catCatch.querySelector("#autoToBuffered");
if (autoToBuffered) autoToBuffered.addEventListener('click', this.handleAutoToBuffered.bind(this));
// 文件名设置相关事件
const setSelector = this.catCatch.querySelector("#setSelector");
if (setSelector) setSelector.addEventListener('click', this.handleSetSelector.bind(this));
const setRegular = this.catCatch.querySelector("#setRegular");
if (setRegular) setRegular.addEventListener('click', this.handleSetRegular.bind(this));
// i18n 处理
this.applyI18n();
}
/**
* 应用国际化文本
*/
applyI18n() {
if (window.CatCatchI18n) {
this.catCatch.querySelectorAll('[data-i18n]').forEach((element) => {
if (element && element.dataset && element.dataset.i18n) {
element.innerHTML = window.CatCatchI18n[element.dataset.i18n][this.language] || element.innerHTML;
}
});
this.catCatch.querySelectorAll('[data-i18n-outer]').forEach((element) => {
if (element && element.dataset && element.dataset.i18nOuter) {
element.outerHTML = window.CatCatchI18n[element.dataset.i18nOuter][this.language] || element.outerHTML;
}
});
}
}
/**
* 翻译函数
* @param {String} key
* @param {String|null} original 原始文本
* @returns 翻译后的文本
*/
i18n(key, original = "") {
if (!window.CatCatchI18n || !key || !window.CatCatchI18n[key]) { return original; }
return window.CatCatchI18n[key][this.language] || original;
}
/**
* 处理面板拖动事件
* @param {MouseEvent} event
*/
handleDragStart(event) {
this.x = event.pageX - this.catCatch.offsetLeft;
this.y = event.pageY - this.catCatch.offsetTop;
const moveHandler = this.handleMove.bind(this);
document.addEventListener('mousemove', moveHandler);
document.addEventListener('mouseup', () => {
document.removeEventListener('mousemove', moveHandler);
}, { once: true });
}
/**
* 处理面板移动事件
* 通过鼠标事件更新面板位置
* @param {MouseEvent} event
*/
handleMove(event) {
if (!this.catCatch) return;
this.catCatch.style.left = (event.pageX - this.x) + 'px';
this.catCatch.style.top = (event.pageY - this.y) + 'px';
}
handleAutoDownChange(event) {
localStorage.setItem("CatCatchCatch_autoDown", event.target.checked ? "checked" : "");
}
handleFfmpegChange(event) {
localStorage.setItem("CatCatchCatch_ffmpeg", event.target.checked ? "checked" : "");
}
handleRestartAlwaysChange(event) {
localStorage.setItem("CatCatchCatch_restart", event.target.checked ? "checked" : "");
}
/**
* 处理清理缓存事件
* @param {MouseEvent} event
*/
handleClean(event) {
if (window.confirm(this.i18n("clearCacheConfirmation", "确认清除缓存?"))) {
this.clearCache();
const $clean = this.catCatch.querySelector("#clean");
if (!$clean) return;
$clean.innerHTML = this.i18n("cleanupCompleted", "清理完成!");
setTimeout(() => {
if ($clean) $clean.innerHTML = this.i18n("clearCache", "清理缓存");
}, 1000);
}
}
/**
* 处理下载事件
* @param {MouseEvent} event
*/
handleDownload(event) {
try {
if (this.isComplete || window.confirm(this.i18n("downloadConfirmation", "提前下载可能会造成数据混乱.确认?"))) {
this.catchDownload();
}
} catch (error) {
console.error("下载处理失败:", error);
alert(this.i18n("downloadError", "下载过程中出错,请查看控制台"));
}
}
handleHide(event) {
const catCatchElement = this.catCatch.querySelector('#catCatch');
if (catCatchElement.style.display === "none") {
catCatchElement.style.display = "flex";
this.catCatch.style.opacity = "";
} else {
catCatchElement.style.display = "none";
this.catCatch.style.opacity = "0.5";
}
}
handleClose(event) {
if (this.isComplete || window.confirm(this.i18n("closeConfirmation", "确认关闭?"))) {
this.clearCache();
this.enable = false;
this.catCatch.style.display = "none";
window.postMessage({ action: "catCatchToBackground", Message: "script", script: "catch.js", refresh: false });
}
}
/**
* 从头捕获
* @param {MouseEvent} event
*/
handleRestart(event) {
const checkHead = this.catCatch.querySelector("#checkHead");
if (checkHead) checkHead.checked = true;
this.clearCache();
document.querySelectorAll("video").forEach((element) => {
element.currentTime = 0;
element.play();
});
}
handleSetFileName(event) {
this.setFileName = window.prompt(this.i18n("fileName", "输入文件名, 不包含扩展名"), this.setFileName ?? "");
this.getFileName();
}
handleTest(event) {
console.log("捕获的媒体数据:", this.catchMedia);
}
handleCompleteClearCache(event) {
localStorage.setItem("CatCatchCatch_completeClearCache", event.target.checked ? "checked" : "");
}
/**
* 自动缓冲尾
* @param {MouseEvent} event
*/
handleAutoToBuffered(event) {
// if (!this.autoToBufferedFlag) return;
// this.autoToBufferedFlag = false;
const $autoToBuffered = this.catCatch.querySelector("#autoToBuffered");
if (!$autoToBuffered) return;
localStorage.setItem("CatCatchCatch_autoToBuffered", event.target.checked ? "checked" : "");
const videos = document.querySelectorAll("video");
for (let video of videos) {
video.addEventListener("progress", (event) => {
try {
if (video.buffered && video.buffered.length > 0) {
const bufferedEnd = video.buffered.end(0);
if ($autoToBuffered.checked && bufferedEnd < video.duration) {
video.currentTime = bufferedEnd - 5;
}
}
} catch (error) {
console.error("处理缓冲进度失败:", error);
}
});
video.addEventListener("ended", () => {
$autoToBuffered.checked = false;
});
}
}
/**
* CSS选择器 提取文件名
* @param {MouseEvent} event
*/
handleSetSelector(event) {
const result = window.prompt("Selector", localStorage.getItem("CatCatchCatch_selector") ?? "");
if (result == null) return;
if (result == "") {
this.clearFileName("selector");
return;
}
let title;
try {
title = document.querySelector(result);
} catch (e) {
this.clearFileName("selector", this.i18n("fileNameError", "选择器语法错误!"));
return;
}
if (title && title.innerHTML) {
this.selector.innerHTML = this.stringModify(result);
localStorage.setItem("CatCatchCatch_selector", result);
this.getFileName();
} else {
this.clearFileName("selector", this.i18n("fileNameError", "表达式错误, 无法获取或内容为空!"));
}
}
/**
* 正则 提取文件名
* @param {MouseEvent} event
*/
handleSetRegular(event) {
let result = window.prompt(this.i18n("regular", "文件名获取正则"), localStorage.getItem("CatCatchCatch_regular") ?? "");
if (result == null) return;
if (result == "") {
this.clearFileName("regular");
return;
}
try {
new RegExp(result);
this.regular.innerHTML = this.stringModify(result);
localStorage.setItem("CatCatchCatch_regular", result);
this.getFileName();
} catch (e) {
this.clearFileName("regular", this.i18n("fileNameError", "正则表达式错误"));
console.log(e);
}
}
/**
* 核心函数 代理MediaSource方法
*/
proxyMediaSourceMethods() {
// 代理 addSourceBuffer 方法
window.MediaSource.prototype.addSourceBuffer = new Proxy(window.MediaSource.prototype.addSourceBuffer, {
apply: (target, thisArg, argumentsList) => {
try {
const result = Reflect.apply(target, thisArg, argumentsList);
// 标题获取
setTimeout(() => { this.getFileName(); }, 2000);
this.tips.innerHTML = this.i18n("capturingData", "捕获数据中...");
this.catchMedia.push({ mimeType: argumentsList[0], bufferList: [] });
const index = this.catchMedia.length - 1;
// 代理 appendBuffer 方法
result.appendBuffer = new Proxy(result.appendBuffer, {
apply: (target, thisArg, argumentsList) => {
Reflect.apply(target, thisArg, argumentsList);
if (this.enable && argumentsList[0]) {
this.mediaSize += argumentsList[0].byteLength || 0;
if (this.tips) {
this.tips.innerHTML = this.i18n("capturingData", "捕获数据中...") + ": " + this.byteToSize(this.mediaSize);
}
this.catchMedia[index].bufferList.push(argumentsList[0]);
}
}
});
return result;
} catch (error) {
console.error("addSourceBuffer 代理错误:", error);
return Reflect.apply(target, thisArg, argumentsList);
}
}
});
// 代理 endOfStream 方法
window.MediaSource.prototype.endOfStream = new Proxy(window.MediaSource.prototype.endOfStream, {
apply: (target, thisArg, argumentsList) => {
try {
Reflect.apply(target, thisArg, argumentsList);
if (this.enable) {
this.isComplete = true;
if (this.tips) {
this.tips.innerHTML = this.i18n("captureCompleted", "捕获完成");
}
if (localStorage.getItem("CatCatchCatch_autoDown") == "checked") {
setTimeout(() => this.catchDownload(), 500);
}
}
} catch (error) {
console.error("endOfStream 代理错误:", error);
return Reflect.apply(target, thisArg, argumentsList);
}
}
});
}
/**
* 自动从头捕获
* 监控DOM变化自动重置视频播放位置
*/
setupAutoRestart() {
document.addEventListener('DOMContentLoaded', () => {
document.querySelectorAll('video').forEach((video) => this.resetVideoPlayback(video));
// 监控 DOM
const observer = new MutationObserver(mutations => {
mutations.forEach(mutation => {
mutation.addedNodes.forEach(node => {
try {
if (node.tagName === 'VIDEO') {
this.resetVideoPlayback(node);
} else if (node.querySelectorAll) {
node.querySelectorAll('video').forEach(video => this.resetVideoPlayback(video));
}
} catch (error) {
console.error("处理新添加的视频节点失败:", error);
}
});
});
});
observer.observe(document.body || document.documentElement, { childList: true, subtree: true });
});
}
/**
* 重置视频播放位置
* @param {Object} video
*/
resetVideoPlayback(video) {
if (!video) return;
const timer = setInterval(() => {
if (!video.paused) {
video.currentTime = 0;
const checkHead = this.catCatch.querySelector("#checkHead");
if (checkHead) checkHead.checked = true;
this.clearCache();
clearInterval(timer);
}
}, 500);
// 5秒后如果还没有检测到播放就清除定时器
setTimeout(() => clearInterval(timer), 5000);
video.addEventListener('play', () => {
if (!video.isResetCatCatch) {
video.isResetCatCatch = true;
video.currentTime = 0;
const checkHead = this.catCatch.querySelector("#checkHead");
if (checkHead) checkHead.checked = true;
this.clearCache();
}
}, { once: true });
}
/**
* 下载捕获的数据
*/
catchDownload() {
if (this.catchMedia.length == 0) {
alert(this.i18n("noData", "没抓到有效数据"));
return;
}
let downloadWithFFmpeg = this.catchMedia.length >= 2 && localStorage.getItem("CatCatchCatch_ffmpeg") == "checked";
/**
* 检查文件
* 检查是否有头部文件 没有头部文件则提示 不使用ffmpeg合并
* 检查是否有多个头部文件 根据用户选项 是否清理多于头部数据
*/
const checkHead = this.catCatch.querySelector("#checkHead");
// 仅确认一次是否清除多余头部数据
let userConfirmedHeadChoice = false;
for (let key in this.catchMedia) {
if (!this.catchMedia[key]?.bufferList || this.catchMedia[key].bufferList.length <= 1) continue;
let lastHeaderIndex = -1;
// 遍历所有 buffer 寻找最后一个头部
for (let i = 0; i < this.catchMedia[key].bufferList.length; i++) {
const data = new Uint8Array(this.catchMedia[key].bufferList[i]);
// 检查MP4格式的头部 (ftyp)
if (data.length > 8 &&
data[4] === 0x66 && // 'f'
data[5] === 0x74 && // 't'
data[6] === 0x79 && // 'y'
data[7] === 0x70) // 'p'
{
lastHeaderIndex = i; // 持续更新直到找到最后一个头部
}
// 检查WebM格式的头部 (1A 45 DF A3)
else if (data.length > 4 &&
data[0] === 0x1A &&
data[1] === 0x45 &&
data[2] === 0xDF &&
data[3] === 0xA3) {
lastHeaderIndex = i; // 持续更新直到找到最后一个WebM头部
}
}
if (lastHeaderIndex == -1) {
alert(this.i18n("noHead", "没有检测到视频头部数据, 请使用本地工具处理"));
downloadWithFFmpeg = false; // 没有头部数据则不使用ffmpeg合并
}
if (lastHeaderIndex > 0) {
// 只有第一次遇到多余头部且用户尚未选择时才提示
if (!userConfirmedHeadChoice && !checkHead.checked) {
checkHead.checked = window.confirm(this.i18n("headData", "检测到多余头部数据, 是否清除?"));
userConfirmedHeadChoice = true; // 标记已经询问过用户
}
if (checkHead.checked) {
this.catchMedia[key].bufferList.splice(0, lastHeaderIndex); // 移除最后一个头部之前的所有元素
}
}
}
downloadWithFFmpeg ? this.downloadWithFFmpeg() : this.downloadDirect();
if (this.isComplete) {
if (localStorage.getItem("CatCatchCatch_completeClearCache") == "checked") { this.clearCache(); }
if (this.tips) {
this.tips.innerHTML = this.i18n("downloadCompleted", "下载完毕...");
}
}
}
/**
* 使用FFmpeg合并下载捕获的数据
*/
downloadWithFFmpeg() {
const media = [];
for (let item of this.catchMedia) {
if (!item || !item.bufferList || item.bufferList.length === 0) continue;
const mime = (item.mimeType && item.mimeType.split(';')[0]) || 'video/mp4';
const fileBlob = new Blob(item.bufferList, { type: mime });
const type = mime.split('/')[0] || 'video';
media.push({
data: (typeof chrome == "object") ? URL.createObjectURL(fileBlob) : fileBlob,
type: type
});
}
if (media.length === 0) {
alert(this.i18n("noData", "没有有效数据可下载"));
return;
}
const title = this.fileName ? this.fileName.innerHTML.trim() : document.title;
window.postMessage({
action: "catCatchFFmpeg",
use: "catchMerge",
files: media,
title: title,
output: title,
quantity: media.length
});
}
/**
* 直接下载捕获的数据
*/
downloadDirect() {
const a = document.createElement('a');
let downloadCount = 0;
for (let item of this.catchMedia) {
if (!item || !item.bufferList || item.bufferList.length === 0) continue;
const mime = (item.mimeType && item.mimeType.split(';')[0]) || 'video/mp4';
const type = mime.split('/')[0] == "video" ? "mp4" : "mp3";
const fileBlob = new Blob(item.bufferList, { type: mime });
a.href = URL.createObjectURL(fileBlob);
a.download = `${this.fileName ? this.fileName.innerHTML.trim() : document.title}.${type}`;
a.click();
// 释放URL对象以避免内存泄漏
setTimeout(() => URL.revokeObjectURL(a.href), 100);
downloadCount++;
}
a.remove();
if (downloadCount === 0) {
alert(this.i18n("noData", "没有有效数据可下载"));
}
}
clearFileName(obj = "selector", warning = "") {
localStorage.removeItem("CatCatchCatch_" + obj);
const element = obj == "selector" ? this.selector : this.regular;
if (element) element.innerHTML = this.i18n("notSet", "未设置");
this.getFileName();
if (warning) alert(warning);
}
/**
* 清理缓存
*/
clearCache() {
this.mediaSize = 0;
if (this.isComplete) {
this.catchMedia = [];
this.isComplete = false;
return;
}
for (let key in this.catchMedia) {
const media = this.catchMedia[key];
if (media && media.bufferList && media.bufferList.length > 0) {
// 保留第一个buffer块清除其余的
const firstBuffer = media.bufferList[0];
media.bufferList = [firstBuffer];
this.mediaSize += firstBuffer ? (firstBuffer.byteLength || 0) : 0;
}
}
}
byteToSize(byte) {
if (!byte || byte < 1024) return "0KB";
if (byte < 1024 * 1024) {
return (byte / 1024).toFixed(1) + "KB";
} else if (byte < 1024 * 1024 * 1024) {
return (byte / 1024 / 1024).toFixed(1) + "MB";
} else {
return (byte / 1024 / 1024 / 1024).toFixed(1) + "GB";
}
}
/**
* 获取文件名
*/
getFileName() {
try {
if (!this.fileName) return;
if (this.setFileName) {
this.fileName.innerHTML = this.stringModify(this.setFileName);
return;
}
let name = "";
const selectorKey = localStorage.getItem("CatCatchCatch_selector");
if (selectorKey) {
const title = document.querySelector(selectorKey);
if (title && title.innerHTML) {
name = title.innerHTML;
}
}
const regularKey = localStorage.getItem("CatCatchCatch_regular");
if (regularKey) {
const str = name == "" ? document.documentElement.outerHTML : name;
const reg = new RegExp(regularKey, "g");
let result = str.match(reg);
if (result) {
result = result.filter((item) => item !== "");
name = result.join("_");
}
}
this.fileName.innerHTML = name ? this.stringModify(name) : this.stringModify(document.title);
} catch (error) {
console.error("获取文件名失败:", error);
if (this.fileName) this.fileName.innerHTML = this.stringModify(document.title);
}
}
stringModify(str) {
if (!str) return "untitled";
return str.replace(/['\\:\*\?"<\/>\|~]/g, function (m) {
return {
"'": '&#39;',
'\\': '&#92;',
'/': '&#47;',
':': '&#58;',
'*': '&#42;',
'?': '&#63;',
'"': '&quot;',
'<': '&lt;',
'>': '&gt;',
'|': '&#124;',
'~': '_'
}[m];
});
}
}
// 创建并启动CatCatcher实例
const catCatcher = new CatCatcher();
})();

230
catch-script/i18n.js Normal file
View File

@@ -0,0 +1,230 @@
(function () {
if (window.CatCatchI18n) { return; }
window.CatCatchI18n = {
languages: ["en", "zh"],
downloadCapturedData: {
en: "Download the captured data",
zh: "下载已捕获的数据"
},
deleteCapturedData: {
en: "Delete the captured data",
zh: "删除已捕获数据"
},
capturedBeginning: {
en: "Capture from the beginning",
zh: "从头捕获"
},
alwaysCapturedBeginning: {
en: "Always Capture from the beginning",
zh: "始终从头捕获"
},
hide: {
en: "Hide",
zh: "隐藏"
},
close: {
en: "Close",
zh: "关闭"
},
save: {
en: "Save",
zh: "保存"
},
automaticDownload: {
en: "Automatic download",
zh: "完成捕获自动下载"
},
ffmpeg: {
en: "using ffmpeg",
zh: "使用ffmpeg"
},
fileName: {
en: "File name",
zh: "文件名"
},
selector: {
en: "Selector",
zh: "表达式"
},
regular: {
en: "Regular",
zh: "正则"
},
notSet: {
en: "Not set",
zh: "未设置"
},
usingSelector: {
en: "selector",
zh: "表达式提取"
},
usingRegular: {
en: "regular",
zh: "正则提取"
},
customize: {
en: "Customize",
zh: "自定义"
},
cleanHeader: {
en: "Clean up redundant header data",
zh: "清理多余头部数据"
},
clearCache: {
en: "Clear cache",
zh: "清理缓存"
},
cleanupCompleted: {
en: "Cleanup completed",
zh: "清理完成"
},
downloadConfirmation: {
en: "Downloading in advance may cause data confusion. Confirm?",
zh: "提前下载可能会造成数据混乱.确认?"
},
fileNameError: {
en: "Unable to fetch or the content is empty!",
zh: "无法获取或内容为空!"
},
noData: {
en: "No data",
zh: "没抓到有效数据!"
},
waiting: {
en: "Waiting for video to play",
zh: "等待视频播放"
},
capturingData: {
en: "Capturing data",
zh: "捕获数据中"
},
captureCompleted: {
en: "Capture completed",
zh: "捕获完成"
},
downloadCompleted: {
en: "Download completed",
zh: "下载完毕"
},
selectVideo: {
en: "Select Video",
zh: "选择视频"
},
selectAudio: {
en: "Select Audio",
zh: "选择音频"
},
recordEncoding: {
en: "Record Encoding",
zh: "录制编码"
},
readVideo: {
en: "Read Video",
zh: "读取视频"
},
startRecording: {
en: "Start Recording",
zh: "开始录制"
},
stopRecording: {
en: "Stop Recording",
zh: "停止录制"
},
noVideoDetected: {
en: "No video detected, Please read again",
zh: "没有检测到视频, 请重新读取"
},
recording: {
en: "Recording",
zh: "视频录制中"
},
recordingNotSupported: {
en: "recording Not Supported",
zh: "不支持录制"
},
formatNotSupported: {
en: "Format not supported",
zh: "不支持此格式"
},
clickToStartRecording: {
en: "Click to start recording",
zh: "请点击开始录制"
},
sentToFfmpeg: {
en: "Sent to ffmpeg",
zh: "发送到ffmpeg"
},
recordingFailed: {
en: "Recording failed",
zh: "录制失败"
},
scriptNotSupported: {
en: "This script is not supported",
zh: "当前网页不支持此脚本"
},
dragWindow: {
en: "Drag window",
zh: "拖动窗口"
},
autoToBuffered: {
en: "Automatically jump to buffer",
zh: "自动跳转到缓冲尾"
},
save1hour: {
en: "Save once every hour",
zh: "1小时保存一次"
},
recordingChangeEncoding: {
en: "Cannot change encoding during recording",
zh: "录制中不能更改编码"
},
streamEmpty: {
en: "Media stream is empty",
zh: "媒体流为空"
},
notStream: {
en: "Not a media stream object",
zh: "非媒体流对象"
},
notStream: {
en: "Not a media stream object",
zh: "非媒体流对象"
},
streamAdded: {
en: "Stream added",
zh: "流已添加"
},
videoAndAudio: {
en: "Includes both audio and video streams",
zh: "已包含音频和视频流"
},
audioBits: {
en: "Audio bit",
zh: "音频码率"
},
videoBits: {
en: "Video bits",
zh: "视频码率"
},
frameRate: {
en: "frame Rate",
zh: "帧率"
},
noHeader: {
en: "No header data detected, please process with local tools",
zh: "没有检测到视频头部数据, 请使用本地工具处理"
},
headData: {
en: "Multiple header data found in media file, Clear it?",
zh: "检测到多余头部数据, 是否清除?"
},
clearCacheConfirmation: {
en: "Are you sure you want to clear the cache?",
zh: "确定要清除缓存吗?"
},
closeConfirmation: {
en: "Are you sure you want to close?",
zh: "确定要关闭吗?"
}
};
})();

266
catch-script/recorder.js Normal file
View File

@@ -0,0 +1,266 @@
(function () {
console.log("recorder.js Start");
if (document.getElementById("catCatchRecorder")) { return; }
// let language = "en";
let language = navigator.language.replace("-", "_");
if (window.CatCatchI18n) {
if (!window.CatCatchI18n.languages.includes(language)) {
language = language.split("_")[0];
if (!window.CatCatchI18n.languages.includes(language)) {
language = "en";
}
}
}
const buttonStyle = 'style="border:solid 1px #000;margin:2px;padding:2px;background:#fff;border-radius:4px;border:solid 1px #c7c7c780;color:#000;"';
const checkboxStyle = 'style="-webkit-appearance: auto;"';
const CatCatch = document.createElement("div");
CatCatch.setAttribute("id", "catCatchRecorder");
CatCatch.innerHTML = `<img src="data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAABgAAAAYBAMAAAASWSDLAAAAKlBMVEUAAADLlROxbBlRAD16GS5oAjWWQiOCIytgADidUx/95gHqwwTx0gDZqwT6kfLuAAAACnRSTlMA/vUejV7kuzi8za0PswAAANpJREFUGNNjwA1YSxkYTEqhnKZLLi6F1w0gnKA1shdvHYNxdq1atWobjLMKCOAyC3etlVrUAOH4HtNZmLgoAMKpXX37zO1FwcZAwMDguGq1zKpFmTNnzqx0Bpp2WvrU7ttn9py+I8JgLn1R8Pad22vurNkjwsBReHv33junzuyRnOnMwNCSeFH27K5dq1SNgcZxFMnuWrNq1W5VkNntihdv7ToteGcT0C7mIkE1qbWCYjJnM4CqEoWKdoslChXuUgXJqIcLebiphSgCZRhaPDhcDFhdmUMCGIgEAFA+Uc02aZg9AAAAAElFTkSuQmCC" style="-webkit-user-drag: none;width: 20px;">
<div id="tips"></div>
<span data-i18n="selectVideo">选择视频</span> <select id="videoList" style="max-width: 200px;"></select>
<span data-i18n="recordEncoding">录制编码</span> <select id="mimeTypeList" style="max-width: 200px;"></select>
<label><input type="checkbox" id="ffmpeg" ${checkboxStyle}><span data-i18n="ffmpeg">使用ffmpeg转码</span></label>
<label>
<select id="videoBits">
<option value="2500000" data-i18n="videoBits">视频码率</option>
<option value="2500000">2.5 Mbps</option>
<option value="5000000">5 Mbps</option>
<option value="8000000">8 Mbps</option>
<option value="16000000">16 Mbps</option>
</select>
<select id="audioBits">
<option value="128000" data-i18n="audioBits">视频码率</option>
<option value="128000">128 kbps</option>
<option value="256000">256 kbps</option>
</select>
<select id="frameRate">
<option value="0" data-i18n="frameRate">帧率</option>
<option value="25">25 FPS</option>
<option value="30">30 FPS</option>
<option value="60">60 FPS</option>
<option value="120">120 FPS</option>
</select>
</label>
<div>
<button id="getVideo" ${buttonStyle} data-i18n="readVideo">读取视频</button>
<button id="start" ${buttonStyle} data-i18n="startRecording">开始录制</button>
<button id="stop" ${buttonStyle} data-i18n="stopRecording">停止录制</button>
<button id="hide" ${buttonStyle} data-i18n="hide">隐藏</button>
<button id="close" ${buttonStyle} data-i18n="close">关闭</button>
</div>`;
CatCatch.style = `
position: fixed;
z-index: 999999;
top: 10%;
left: 80%;
background: rgb(255 255 255 / 85%);
border: solid 1px #c7c7c7;
border-radius: 4px;
color: rgb(26, 115, 232);
padding: 5px 5px 5px 5px;
font-size: 12px;
font-family: "Microsoft YaHei", "Helvetica", "Arial", sans-serif;
user-select: none;
display: flex;
align-items: flex-start;
justify-content: space-evenly;
flex-direction: column;
line-height: 20px;`;
// 创建 Shadow DOM 放入CatCatch
const divShadow = document.createElement('div');
const shadowRoot = divShadow.attachShadow({ mode: 'closed' });
shadowRoot.appendChild(CatCatch);
// 页面插入Shadow DOM
document.getElementsByTagName('html')[0].appendChild(divShadow);
const $tips = CatCatch.querySelector("#tips");
const $videoList = CatCatch.querySelector("#videoList");
const $mimeTypeList = CatCatch.querySelector("#mimeTypeList");
const $start = CatCatch.querySelector("#start");
const $stop = CatCatch.querySelector("#stop");
let videoList = [];
$tips.innerHTML = i18n("noVideoDetected", "没有检测到视频, 请重新读取");
let recorder = {};
let option = { mimeType: 'video/webm;codecs=vp9,opus' };
CatCatch.querySelector("#hide").addEventListener('click', function (event) {
CatCatch.style.display = "none";
});
CatCatch.querySelector("#close").addEventListener('click', function (event) {
recorder?.state && recorder.stop();
CatCatch.style.display = "none";
window.postMessage({ action: "catCatchToBackground", Message: "script", script: "recorder.js", refresh: false });
});
function init() {
getVideo();
$start.style.display = 'inline';
$stop.style.display = 'none';
}
setTimeout(init, 500);
// #region 视频编码选择
function setMimeType() {
function getSupportedMimeTypes(media, types, codecs) {
const supported = [];
types.forEach((type) => {
const mimeType = `${media}/${type}`;
codecs.forEach((codec) => [`${mimeType};codecs=${codec}`].forEach(variation => {
if (MediaRecorder.isTypeSupported(variation)) {
supported.push(variation);
}
}));
if (MediaRecorder.isTypeSupported(mimeType)) {
supported.push(mimeType);
}
});
return supported;
};
const videoTypes = ["webm", "ogg", "mp4", "x-matroska"];
const codecs = ["should-not-be-supported", "vp9", "vp8", "avc1", "av1", "h265", "h.265", "h264", "h.264", "opus", "pcm", "aac", "mpeg", "mp4a"];
const supportedVideos = getSupportedMimeTypes("video", videoTypes, codecs);
supportedVideos.forEach(function (type) {
$mimeTypeList.options.add(new Option(type, type));
});
option.mimeType = supportedVideos[0];
$mimeTypeList.addEventListener('change', function (event) {
if (recorder && recorder.state && recorder.state === 'recording') {
$tips.innerHTML = i18n("recordingChangeEncoding", "录制中不能更改编码");
return;
}
if (MediaRecorder.isTypeSupported(event.target.value)) {
option.mimeType = event.target.value;
$tips.innerHTML = event.target.value;
} else {
$tips.innerHTML = i18n("formatNotSupported", "不支持此格式");
}
});
}
setMimeType();
// #endregion 视频编码选择
// #region 获取视频列表
function getVideo() {
videoList = [];
$videoList.options.length = 0;
document.querySelectorAll("video, audio").forEach(function (video, index) {
if (video.currentSrc) {
const src = video.currentSrc.split("/").pop();
videoList.push(video);
$videoList.options.add(new Option(src, index));
}
});
$tips.innerHTML = videoList.length ? i18n("clickToStartRecording", "请点击开始录制") : i18n("noVideoDetected", "没有检测到视频, 请重新读取");
}
CatCatch.querySelector("#getVideo").addEventListener('click', getVideo);
CatCatch.querySelector("#stop").addEventListener('click', function () {
recorder.stop();
});
// #endregion 获取视频列表
CatCatch.querySelector("#start").addEventListener('click', function (event) {
if (!MediaRecorder.isTypeSupported(option.mimeType)) {
$tips.innerHTML = i18n("formatNotSupported", "不支持此格式");
return;
}
init();
const index = $videoList.value;
if (index && videoList[index]) {
let stream = null;
try {
const frameRate = +CatCatch.querySelector("#frameRate").value;
if (frameRate) {
stream = videoList[index].captureStream(frameRate);
} else {
stream = videoList[index].captureStream();
}
} catch (e) {
$tips.innerHTML = i18n("recordingNotSupported", "不支持录制");
return;
}
// 码率
option.audioBitsPerSecond = +CatCatch.querySelector("#audioBits").value;
option.videoBitsPerSecond = +CatCatch.querySelector("#videoBits").value;
recorder = new MediaRecorder(stream, option);
recorder.ondataavailable = function (event) {
if (CatCatch.querySelector("#ffmpeg").checked) {
window.postMessage({
action: "catCatchFFmpeg",
use: "transcode",
files: [{ data: URL.createObjectURL(event.data), type: option.mimeType }],
title: document.title.trim()
});
$tips.innerHTML = i18n("clickToStartRecording", "请点击开始录制");
return;
}
const a = document.createElement('a');
a.href = URL.createObjectURL(event.data);
a.download = `${document.title}`;
a.click();
a.remove();
$tips.innerHTML = i18n("downloadCompleted", "下载完成");;
}
recorder.onstart = function (event) {
$stop.style.display = 'inline';
$start.style.display = 'none';
$tips.innerHTML = i18n("recording", "视频录制中");
}
recorder.onstop = function (event) {
$tips.innerHTML = i18n("stopRecording", "停止录制");
init();
}
recorder.onerror = function (event) {
init();
$tips.innerHTML = i18n("recordingFailed", "录制失败");;
console.log(event);
};
recorder.start();
videoList[index].play();
setTimeout(() => {
if (recorder.state === 'recording') {
$stop.style.display = 'inline';
$start.style.display = 'none';
$tips.innerHTML = i18n("recording", "视频录制中");
}
}, 500);
} else {
$tips.innerHTML = i18n("noVideoDetected", "请确认视频是否存在");
}
});
// #region 移动逻辑
let x, y;
function move(event) {
CatCatch.style.left = event.pageX - x + 'px';
CatCatch.style.top = event.pageY - y + 'px';
}
CatCatch.addEventListener('mousedown', function (event) {
x = event.pageX - CatCatch.offsetLeft;
y = event.pageY - CatCatch.offsetTop;
document.addEventListener('mousemove', move);
document.addEventListener('mouseup', function () {
document.removeEventListener('mousemove', move);
});
});
// #endregion 移动逻辑
// i18n
if (window.CatCatchI18n) {
CatCatch.querySelectorAll('[data-i18n]').forEach(function (element) {
element.innerHTML = window.CatCatchI18n[element.dataset.i18n][language];
});
CatCatch.querySelectorAll('[data-i18n-outer]').forEach(function (element) {
element.outerHTML = window.CatCatchI18n[element.dataset.i18nOuter][language];
});
}
function i18n(key, original = "") {
if (!window.CatCatchI18n) { return original };
return window.CatCatchI18n[key][language];
}
})();

257
catch-script/recorder2.js Normal file
View File

@@ -0,0 +1,257 @@
(function () {
console.log("recorder2.js Start");
if (document.getElementById("catCatchRecorder2")) {
return;
}
if (!navigator.mediaDevices) {
alert("当前网页不支持屏幕分享");
return;
}
let language = navigator.language.replace("-", "_");
if (window.CatCatchI18n) {
if (!window.CatCatchI18n.languages.includes(language)) {
language = language.split("_")[0];
if (!window.CatCatchI18n.languages.includes(language)) {
language = "en";
}
}
}
// 添加style
const style = document.createElement("style");
style.innerHTML = `
@keyframes color-change{
0% { outline: 4px solid rgb(26, 115, 232); }
50% { outline: 4px solid red; }
100% { outline: 4px solid rgb(26, 115, 232); }
}
#catCatchRecorder2 {
font-weight: bold;
position: absolute;
cursor: move;
z-index: 999999999;
outline: 4px solid rgb(26, 115, 232);
resize: both;
overflow: hidden;
height: 720px;
width: 1024px;
top: 30%;
left: 30%;
pointer-events: none;
font-size: 10px;
}
#catCatchRecorderHeader {
background: rgb(26, 115, 232);
color: white;
text-align: center;
height: 20px;
cursor: pointer;
display: flex;
justify-content: space-evenly;
align-items: center;
pointer-events: auto;
}
#catCatchRecorderTitle {
cursor: move;
user-select: none;
width: 45%;
}
#catCatchRecorderinnerCropArea {
height: calc(100% - 20px);
width: 100%;
}
.animation {
animation: color-change 5s infinite;
}
.input-group {
display: flex;
align-items: center;
}
.input-group label {
margin-right: 5px;
}
#videoBitrate, #audioBitrate {
width: 4rem;
}
.input-group label{
width: 5rem;
}`;
// 添加div
let cat = document.createElement("div");
cat.setAttribute("id", "catCatchRecorder2");
cat.innerHTML = `<div id="catCatchRecorderinnerCropArea"></div>
<div id="catCatchRecorderHeader">
<div class="input-group">
<select id="videoBits">
<option value="2500000" data-i18n="videoBits">视频码率</option>
<option value="2500000">2.5 Mbps</option>
<option value="5000000">5 Mbps</option>
<option value="8000000">8 Mbps</option>
<option value="16000000">16 Mbps</option>
</select>
</div>
<div class="input-group">
<select id="audioBits">
<option value="128000" data-i18n="audioBits">视频码率</option>
<option value="128000">128 kbps</option>
<option value="256000">256 kbps</option>
</select>
</div>
<div id="catCatchRecorderStart" data-i18n="startRecording">开始录制</div>
<div id="catCatchRecorderTitle" data-i18n="dragWindow">拖动窗口</div>
<div id="catCatchRecorderClose" data-i18n="close">关闭</div>
</div>`;
// 创建 Shadow DOM 放入CatCatch
const divShadow = document.createElement('div');
const shadowRoot = divShadow.attachShadow({ mode: 'closed' });
shadowRoot.appendChild(cat);
shadowRoot.appendChild(style);
document.getElementsByTagName('html')[0].appendChild(divShadow);
// 事件绑定
const catCatchRecorderStart = cat.querySelector("#catCatchRecorderStart");
catCatchRecorderStart.onclick = function () {
if (recorder) {
recorder.stop();
return;
}
try { startRecording(); } catch (e) { console.log(e); return; }
}
cat.querySelector("#catCatchRecorderClose").onclick = function () {
recorder && recorder.stop();
cat.remove();
}
// 拖动div
const catCatchRecorderinnerCropArea = cat.querySelector("#catCatchRecorderinnerCropArea");
cat.querySelector("#catCatchRecorderTitle").onpointerdown = (e) => {
let pos1, pos2, pos3, pos4;
pos3 = e.clientX;
pos4 = e.clientY;
if (pos3 - cat.offsetWidth - cat.offsetLeft > - 20 &&
pos4 - cat.offsetHeight - cat.offsetTop > - 20) {
return;
}
document.onpointermove = (e) => {
pos1 = pos3 - e.clientX;
pos2 = pos4 - e.clientY;
pos3 = e.clientX;
pos4 = e.clientY;
cat.style.top = cat.offsetTop - pos2 + "px";
cat.style.left = cat.offsetLeft - pos1 + "px";
}
document.onpointerup = () => {
document.onpointerup = null;
document.onpointermove = null;
}
}
// document.getElementsByTagName('html')[0].appendChild(cat);
// 初始化位置
const video = document.querySelector("video");
if (video) {
// 调整和video一样大小
if (video.clientHeight >= 0 && video.clientWidth >= 0) {
cat.style.height = video.clientHeight + 20 + "px";
cat.style.width = video.clientWidth + "px";
}
// 调整到video的位置
const videoOffset = getElementOffset(video);
if (videoOffset.top >= 0 && videoOffset.left >= 0) {
cat.style.top = videoOffset.top + "px";
cat.style.left = videoOffset.left + "px";
}
// 防止遮挡菜单
let catAttr = cat.getBoundingClientRect();
if (document.documentElement.scrollTop + catAttr.bottom > document.documentElement.scrollTop + window.innerHeight) {
cat.style.top = document.documentElement.scrollTop + window.innerHeight - catAttr.height + "px";
}
}
// 录制
var recorder;
async function startRecording() {
const buffer = [];
let option = {
mimeType: 'video/webm;codecs=vp8,opus',
videoBitsPerSecond: +cat.querySelector("#videoBits").value,
audioBitsPerSecond: +cat.querySelector("#audioBits").value
};
if (MediaRecorder.isTypeSupported('video/webm;codecs=vp9,opus')) {
option.mimeType = 'video/webm;codecs=vp9,opus';
} else if (MediaRecorder.isTypeSupported('video/webm;codecs=h264')) {
option.mimeType = 'video/webm;codecs=h264';
}
const cropTarget = await CropTarget.fromElement(catCatchRecorderinnerCropArea);
const stream = await navigator.mediaDevices
.getDisplayMedia({
preferCurrentTab: true,
video: {
cursor: "never"
},
audio: {
sampleRate: 48000,
sampleSize: 16,
channelCount: 2
}
});
const [track] = stream.getVideoTracks();
await track.cropTo(cropTarget);
recorder = new MediaRecorder(stream, option);
recorder.start();
recorder.onstart = function (e) {
buffer.slice(0);
catCatchRecorderStart.innerHTML = i18n("stopRecording", "停止录制");
cat.classList.add("animation");
}
recorder.ondataavailable = function (e) {
buffer.push(e.data);
}
recorder.onstop = function () {
const fileBlob = new Blob(buffer, { type: option });
const a = document.createElement('a');
a.href = URL.createObjectURL(fileBlob);
a.download = `${document.title}.webm`;
a.click();
a.remove();
buffer.slice(0);
stream.getTracks().forEach(track => track.stop());
recorder = undefined;
catCatchRecorderStart.innerHTML = i18n("startRecording", "开始录制");
cat.classList.remove("animation");
}
}
function getElementOffset(el) {
const rect = el.getBoundingClientRect();
const scrollTop = window.pageYOffset || document.documentElement.scrollTop;
const scrollLeft = window.pageXOffset || document.documentElement.scrollLeft;
return {
top: rect.top + scrollTop,
left: rect.left + scrollLeft
};
}
// i18n
if (window.CatCatchI18n) {
CatCatch.querySelectorAll('[data-i18n]').forEach(function (element) {
const translation = window.CatCatchI18n[element.dataset.i18n]?.[language];
if (translation) {
element.innerHTML = translation;
}
});
CatCatch.querySelectorAll('[data-i18n-outer]').forEach(function (element) {
const outerTranslation = window.CatCatchI18n[element.dataset.i18nOuter]?.[language];
if (outerTranslation) {
element.outerHTML = outerTranslation;
}
});
}
function i18n(key, original = "") {
if (!window.CatCatchI18n) { return original };
return window.CatCatchI18n[key][language];
}
})();

751
catch-script/search.js Normal file
View File

@@ -0,0 +1,751 @@
// const CATCH_SEARCH_ONLY = true;
(function __CAT_CATCH_CATCH_SCRIPT__() {
const isRunningInWorker = typeof WorkerGlobalScope !== 'undefined' && self instanceof WorkerGlobalScope;
const CATCH_SEARCH_DEBUG = false; // 开发调试日志
// 防止 console.log 被劫持
if (!isRunningInWorker && CATCH_SEARCH_DEBUG && console.log.toString() != 'function log() { [native code] }') {
const newIframe = top.document.createElement("iframe");
newIframe.style.display = "none";
top.document.body.appendChild(newIframe);
window.console.log = newIframe.contentWindow.console.log;
}
// 防止 window.postMessage 被劫持
const _postMessage = self.postMessage;
// console.log("start search.js");
const filter = new Set();
const reKeyURL = /URI="(.*)"/;
const dataRE = /^data:(application|video|audio)\//i;
const joinBaseUrlTask = [];
const baseUrl = new Set();
const regexVimeo = /^https:\/\/[^\.]*\.vimeocdn\.com\/exp=.*\/playlist\.json\?/i;
const videoSet = new Set();
const base64Regex = /^[A-Za-z0-9+/]+={0,2}$/;
extractBaseUrl(location.href);
// Worker
const _Worker = Worker;
self.Worker = function (scriptURL, options) {
try {
const xhr = new XMLHttpRequest();
xhr.open('GET', scriptURL, false);
xhr.send();
if (xhr.status === 200) {
const blob = new Blob([`(${__CAT_CATCH_CATCH_SCRIPT__.toString()})();`, xhr.response], { type: 'text/javascript' });
const newWorker = new _Worker(URL.createObjectURL(blob), options);
newWorker.addEventListener("message", function (event) {
if (event.data?.action == "catCatchAddKey" || event.data?.action == "catCatchAddMedia") {
postData(event.data);
}
});
return newWorker;
}
} catch (error) {
return new _Worker(scriptURL, options);
}
return new _Worker(scriptURL, options);
}
self.Worker.toString = function () {
return _Worker.toString();
}
// JSON.parse
const _JSONparse = JSON.parse;
JSON.parse = function () {
let data = _JSONparse.apply(this, arguments);
findMedia(data);
return data;
}
JSON.parse.toString = function () {
return _JSONparse.toString();
}
async function findMedia(data, depth = 0) {
CATCH_SEARCH_DEBUG && console.log(data);
let index = 0;
if (!data) { return; }
if (data instanceof Array && data.length == 16) {
const isKey = data.every(function (value) {
return typeof value == 'number' && value <= 256
});
if (isKey) {
postData({ action: "catCatchAddKey", key: data, href: location.href, ext: "key" });
return;
}
}
if (data instanceof ArrayBuffer && data.byteLength == 16) {
postData({ action: "catCatchAddKey", key: data, href: location.href, ext: "key" });
return;
}
for (let key in data) {
if (index != 0) { depth = 0; } index++;
if (typeof data[key] == "object") {
// 查找疑似key
if (data[key] instanceof Array && data[key].length == 16) {
const isKey = data[key].every(function (value) {
return typeof value == 'number' && value <= 256
});
isKey && postData({ action: "catCatchAddKey", key: data[key], href: location.href, ext: "key" });
continue;
}
if (depth > 10) { continue; } // 防止死循环 最大深度
findMedia(data[key], ++depth);
continue;
}
if (typeof data[key] == "string") {
if (isUrl(data[key])) {
const ext = getExtension(data[key]);
if (ext) {
const url = data[key].startsWith("//") ? (location.protocol + data[key]) : data[key];
extractBaseUrl(url);
postData({ action: "catCatchAddMedia", url: url, href: location.href, ext: ext });
}
continue;
}
if (data[key].substring(0, 7).toUpperCase() == "#EXTM3U") {
toUrl(data[key]);
continue;
}
if (dataRE.test(data[key].substring(0, 17))) {
const text = getDataM3U8(data[key]);
text && toUrl(text);
continue;
}
if (data[key].toLowerCase().includes("urn:mpeg:dash:schema:mpd")) {
toUrl(data[key], "mpd");
continue;
}
if (CATCH_SEARCH_DEBUG && data[key].includes("manifest")) {
console.log(data);
}
}
}
}
// XHR
const _xhrOpen = XMLHttpRequest.prototype.open;
XMLHttpRequest.prototype.open = function (method) {
method = method.toUpperCase();
CATCH_SEARCH_DEBUG && console.log(this);
this.addEventListener("readystatechange", function (event) {
CATCH_SEARCH_DEBUG && console.log(this);
if (this.status != 200) { return; }
// 处理viemo
this.responseURL.includes("vimeocdn.com") && vimeo(this.responseURL, this.response);
// 查找疑似key
if (this.responseType === "arraybuffer" && this.response?.byteLength) {
if (this.response.byteLength === 16 || this.response.byteLength === 32) {
postData({ action: "catCatchAddKey", key: this.response, href: location.href, ext: "key" });
}
if (this.responseURL.includes(".ts")) {
extractBaseUrl(this.responseURL);
}
}
if (typeof this.response == "object") {
findMedia(this.response);
return;
}
if (this.response == "" || typeof this.response != "string") { return; }
if (dataRE.test(this.response)) {
const text = getDataM3U8(this.response);
text && toUrl(text);
return;
}
if (dataRE.test(this.responseURL)) {
const text = getDataM3U8(this.responseURL);
text && toUrl(text);
return;
}
if (isUrl(this.response)) {
const ext = getExtension(this.response);
ext && postData({ action: "catCatchAddMedia", url: this.response, href: location.href, ext: ext });
return;
}
const responseUpper = this.response.toUpperCase();
if (responseUpper.includes("#EXTM3U")) {
if (responseUpper.substring(0, 7) == "#EXTM3U") {
if (method == "GET") {
toUrl(addBaseUrl(getBaseUrl(this.responseURL), this.response));
postData({ action: "catCatchAddMedia", url: this.responseURL, href: location.href, ext: "m3u8" });
return;
}
toUrl(this.response);
return;
}
if (isJSON(this.response)) {
if (method == "GET") {
postData({ action: "catCatchAddMedia", url: this.responseURL, href: location.href, ext: "json" });
return;
}
toUrl(this.response, "json");
return;
}
}
// dash DRM
if (responseUpper.includes("<MPD") && responseUpper.includes("</MPD>")) {
_postMessage({
action: "catCatchDashDRMMedia",
url: this.responseURL,
data: this.response,
href: location.href
});
return;
}
const isJson = isJSON(this.response);
if (isJson) {
findMedia(isJson);
return;
}
});
_xhrOpen.apply(this, arguments);
}
XMLHttpRequest.prototype.open.toString = function () {
return _xhrOpen.toString();
}
// fetch
const _fetch = fetch;
fetch = async function (input, init) {
let response;
try {
response = await _fetch.apply(this, arguments);
} catch (error) {
console.error("Fetch error:", error);
throw error; // Re-throw the error if necessary
}
const clone = response.clone();
CATCH_SEARCH_DEBUG && console.log(response);
response.arrayBuffer()
.then(arrayBuffer => {
CATCH_SEARCH_DEBUG && console.log({ arrayBuffer, input });
if (arrayBuffer.byteLength == 16) {
postData({ action: "catCatchAddKey", key: arrayBuffer, href: location.href, ext: "key" });
return;
}
let text = new TextDecoder().decode(arrayBuffer);
if (text == "") { return; }
if (typeof input == "object") { input = input.url; }
let isJson = isJSON(text);
if (isJson) {
findMedia(isJson);
return;
}
if (text.substring(0, 7).toUpperCase() == "#EXTM3U") {
if (init?.method == undefined || (init.method && init.method.toUpperCase() == "GET")) {
toUrl(addBaseUrl(getBaseUrl(input), text));
postData({ action: "catCatchAddMedia", url: input, href: location.href, ext: "m3u8" });
return;
}
toUrl(text);
return;
}
if (dataRE.test(text.substring(0, 17))) {
const data = getDataM3U8(text);
data && toUrl(data);
return;
}
});
return clone;
}
fetch.toString = function () {
return _fetch.toString();
}
// Array.prototype.slice
const _slice = Array.prototype.slice;
Array.prototype.slice = function (start, end) {
const data = _slice.apply(this, arguments);
if (end == 16 && this.length == 32) {
CATCH_SEARCH_DEBUG && console.log(this, start, end, data);
for (let item of data) {
if (typeof item != "number" || item > 255) { return data; }
}
postData({ action: "catCatchAddKey", key: data, href: location.href, ext: "key" });
}
return data;
}
Array.prototype.slice.toString = function () {
return _slice.toString();
}
//#region TypedArray.prototype.subarray
const createSubarrayWrapper = (originalSubarray) => {
return function (start, end) {
const data = originalSubarray.apply(this, arguments);
CATCH_SEARCH_DEBUG && console.log(this, start, end, data);
if (data.byteLength == 16) {
const uint8 = new _Uint8Array(data);
const isValid = Array.from(uint8).every(item => typeof item == "number" && item <= 255);
isValid && postData({ action: "catCatchAddKey", key: uint8.buffer, href: location.href, ext: "key" });
}
return data;
}
}
// Int8Array.prototype.subarray
const _Int8ArraySubarray = Int8Array.prototype.subarray;
Int8Array.prototype.subarray = createSubarrayWrapper(_Int8ArraySubarray);
Int8Array.prototype.subarray.toString = function () {
return _Int8ArraySubarray.toString();
}
// Uint8Array.prototype.subarray
const _Uint8ArraySubarray = Uint8Array.prototype.subarray;
Uint8Array.prototype.subarray = createSubarrayWrapper(_Uint8ArraySubarray);
Uint8Array.prototype.subarray.toString = function () {
return _Uint8ArraySubarray.toString();
}
//#endregion
// window.btoa / window.atob
const _btoa = btoa;
btoa = function (data) {
const base64 = _btoa.apply(this, arguments);
CATCH_SEARCH_DEBUG && console.log(base64, data, base64.length);
if (base64.length == 24 && base64.substring(22, 24) == "==") {
postData({ action: "catCatchAddKey", key: base64, href: location.href, ext: "base64Key" });
}
if (data.substring(0, 7).toUpperCase() == "#EXTM3U") {
toUrl(data);
}
return base64;
}
btoa.toString = function () {
return _btoa.toString();
}
const _atob = atob;
atob = function (base64) {
const data = _atob.apply(this, arguments);
CATCH_SEARCH_DEBUG && console.log(base64, data, base64.length);
if (base64.length == 24 && base64.substring(22, 24) == "==") {
postData({ action: "catCatchAddKey", key: base64, href: location.href, ext: "base64Key" });
}
if (data.substring(0, 7).toUpperCase() == "#EXTM3U") {
toUrl(data);
}
if (data.endsWith("</MPD>")) {
toUrl(data, "mpd");
}
return data;
}
atob.toString = function () {
return _atob.toString();
}
// fromCharCode
const _fromCharCode = String.fromCharCode;
let m3u8Text = '';
String.fromCharCode = function () {
const data = _fromCharCode.apply(this, arguments);
if (data.length < 7) { return data; }
CATCH_SEARCH_DEBUG && console.log(data, this, arguments);
if (data.substring(0, 7) == "#EXTM3U" || data.includes("#EXTINF:")) {
m3u8Text += data;
if (m3u8Text.includes("#EXT-X-ENDLIST")) {
toUrl(m3u8Text.split("#EXT-X-ENDLIST")[0] + "#EXT-X-ENDLIST");
m3u8Text = '';
}
return data;
}
const key = data.replaceAll("\u0010", "");
if (key.length == 32) {
postData({ action: "catCatchAddKey", key: key, href: location.href, ext: "key" });
}
return data;
}
String.fromCharCode.toString = function () {
return _fromCharCode.toString();
}
// DataView
const _DataView = DataView;
DataView = new Proxy(_DataView, {
construct(target, args) {
let instance = new target(...args);
// 劫持常用的set方法
for (const methodName of ['setInt8', 'setUint8', 'setInt16', 'setUint16', 'setInt32', 'setUint32']) {
if (typeof instance[methodName] !== 'function') {
continue;
}
instance[methodName] = new Proxy(instance[methodName], {
apply(target, thisArg, argArray) {
const result = Reflect.apply(target, thisArg, argArray);
if (thisArg.byteLength == 16) {
postData({ action: "catCatchAddKey", key: thisArg.buffer, href: location.href, ext: "key" });
}
return result;
}
});
}
CATCH_SEARCH_DEBUG && console.log(target.name, args, instance);
if (instance.byteLength == 16 && instance.buffer.byteLength == 16) {
postData({ action: "catCatchAddKey", key: instance.buffer, href: location.href, ext: "key" });
}
if (instance.byteLength == 256 || instance.byteLength == 128 || instance.byteLength == 32) {
const _buffer = isRepeatedExpansion(instance.buffer, 16);
if (_buffer) {
postData({ action: "catCatchAddKey", key: _buffer, href: location.href, ext: "key" });
}
}
if (instance.byteLength == 32) {
const key = instance.buffer.slice(0, 16);
postData({ action: "catCatchAddKey", key: key, href: location.href, ext: "key" });
}
return instance;
}
});
// escape
const _escape = escape;
escape = function (str) {
CATCH_SEARCH_DEBUG && console.log(str);
if (str?.length && str.length == 24 && str.substring(22, 24) == "==") {
postData({ action: "catCatchAddKey", key: str, href: location.href, ext: "base64Key" });
}
return _escape(str);
}
escape.toString = function () {
return _escape.toString();
}
// indexOf
const _indexOf = String.prototype.indexOf;
String.prototype.indexOf = function (searchValue, fromIndex) {
const out = _indexOf.apply(this, arguments);
// CATCH_SEARCH_DEBUG && console.log(this, searchValue, fromIndex, out);
if (searchValue === '#EXTM3U' && out !== -1) {
const data = this.substring(fromIndex);
toUrl(data);
}
return out;
}
String.prototype.indexOf.toString = function () {
return _indexOf.toString();
}
const uint32ArrayToUint8Array_ = (array) => {
const newArray = new Uint8Array(16);
for (let i = 0; i < 4; i++) {
newArray[i * 4] = (array[i] >> 24) & 0xff;
newArray[i * 4 + 1] = (array[i] >> 16) & 0xff;
newArray[i * 4 + 2] = (array[i] >> 8) & 0xff;
newArray[i * 4 + 3] = array[i] & 0xff;
}
return newArray;
}
const uint16ArrayToUint8Array_ = (array) => {
const newArray = new Uint8Array(16);
for (let i = 0; i < 8; i++) {
newArray[i * 2] = (array[i] >> 8) & 0xff;
newArray[i * 2 + 1] = array[i] & 0xff;
}
return newArray;
}
// findTypedArray
const findTypedArray = (target, args) => {
const isArray = Array.isArray(args[0]) && args[0].length === 16;
const isArrayBuffer = args[0] instanceof ArrayBuffer && args[0].byteLength === 16;
const instance = new target(...args);
CATCH_SEARCH_DEBUG && console.log(target.name, args, instance);
if (isArray || isArrayBuffer) {
postData({ action: "catCatchAddKey", key: args[0], href: location.href, ext: "key" });
} else if (instance.buffer.byteLength === 16) {
if (target.name === 'Uint32Array') {
postData({ action: "catCatchAddKey", key: uint32ArrayToUint8Array_(instance).buffer, href: location.href, ext: "key" });
} else if (target.name === 'Uint16Array') {
postData({ action: "catCatchAddKey", key: uint16ArrayToUint8Array_(instance).buffer, href: location.href, ext: "key" });
} else {
postData({ action: "catCatchAddKey", key: instance.buffer, href: location.href, ext: "key" });
}
}
return instance;
}
// Uint8Array
const _Uint8Array = Uint8Array;
Uint8Array = new Proxy(_Uint8Array, {
construct(target, args) {
return findTypedArray(target, args);
}
});
// Uint16Array
const _Uint16Array = Uint16Array;
Uint16Array = new Proxy(_Uint16Array, {
construct(target, args) {
return findTypedArray(target, args);
}
});
// Uint32Array
const _Uint32Array = Uint32Array;
Uint32Array = new Proxy(_Uint32Array, {
construct(target, args) {
return findTypedArray(target, args);
}
});
// join
const _arrayJoin = Array.prototype.join;
Array.prototype.join = function () {
const data = _arrayJoin.apply(this, arguments);
// CATCH_SEARCH_DEBUG && console.log(data, this, arguments);
if (data.substring(0, 7).toUpperCase() == "#EXTM3U") {
toUrl(data);
}
if (data.length == 24) {
// 判断是否是base64
CATCH_SEARCH_DEBUG && console.log(data, this, arguments);
base64Regex.test(data) && postData({ action: "catCatchAddKey", key: data, href: location.href, ext: "base64Key" });
}
return data;
}
Array.prototype.join.toString = function () {
return _arrayJoin.toString();
}
function isUrl(str) {
return (str.startsWith("http://") || str.startsWith("https://") || str.startsWith("//"));
}
function isFullM3u8(text) {
let tsLists = text.split("\n");
for (let ts of tsLists) {
if (ts[0] == "#") { continue; }
if (isUrl(ts)) { return true; }
return false;
}
return false;
}
function TsProtocol(text) {
let tsLists = text.split("\n");
for (let i in tsLists) {
if (tsLists[i][0] == "#") { continue; }
if (tsLists[i].startsWith("//")) {
tsLists[i] = location.protocol + tsLists[i];
}
}
// return tsLists.join("\n");
return _arrayJoin.call(tsLists, "\n");
}
function getBaseUrl(url) {
let bashUrl = url.split("/");
bashUrl.pop();
// return baseUrl.join("/") + "/";
return _arrayJoin.call(bashUrl, "/") + "/";
}
function addBaseUrl(baseUrl, m3u8Text) {
let m3u8_split = m3u8Text.split("\n");
m3u8Text = "";
for (let ts of m3u8_split) {
if (ts == "" || ts == " " || ts == "\n") { continue; }
if (ts.includes("URI=")) {
let KeyURL = reKeyURL.exec(ts);
if (KeyURL && KeyURL[1] && !isUrl(KeyURL[1])) {
ts = ts.replace(reKeyURL, 'URI="' + baseUrl + KeyURL[1] + '"');
}
}
if (ts[0] != "#" && !isUrl(ts)) {
if (ts.startsWith("/")) {
// url根目录
const urlSplit = baseUrl.split("/");
ts = urlSplit[0] + "//" + urlSplit[2] + ts;
} else {
ts = baseUrl + ts;
}
}
m3u8Text += ts + "\n";
}
return m3u8Text;
}
function isJSON(str) {
if (typeof str == "object") {
return str;
}
if (typeof str == "string") {
try {
return _JSONparse(str);
} catch (e) { return false; }
}
return false;
}
function getExtension(str) {
let ext;
try {
if (str.startsWith("//")) {
str = location.protocol + str;
}
ext = new URL(str);
} catch (e) { return undefined; }
ext = ext.pathname.split(".");
if (ext.length == 1) { return undefined; }
ext = ext[ext.length - 1].toLowerCase();
if (ext == "m3u8" ||
ext == "m3u" ||
ext == "mpd" ||
ext == "mp4" ||
ext == "mp3" ||
ext == "flv" ||
ext == "key"
) { return ext; }
return false;
}
function toUrl(text, ext = "m3u8") {
if (!text) { return; }
// 处理ts地址无protocol
text = TsProtocol(text);
if (isFullM3u8(text)) {
let url = URL.createObjectURL(new Blob([new TextEncoder("utf-8").encode(text)]));
postData({ action: "catCatchAddMedia", url: url, href: location.href, ext: ext });
return;
}
baseUrl.forEach((url) => {
url = URL.createObjectURL(new Blob([new TextEncoder("utf-8").encode(addBaseUrl(url, text))]));
postData({ action: "catCatchAddMedia", url: url, href: location.href, ext: ext });
});
joinBaseUrlTask.push((url) => {
url = URL.createObjectURL(new Blob([new TextEncoder("utf-8").encode(addBaseUrl(url, text))]));
postData({ action: "catCatchAddMedia", url: url, href: location.href, ext: ext });
});
}
function getDataM3U8(text) {
text = text.substring(text.indexOf('/') + 1);
const mimeTypes = ["vnd.apple.mpegurl", "x-mpegurl", "mpegurl"];
const matchedType = mimeTypes.find(type =>
text.toLowerCase().startsWith(type)
);
if (!matchedType) return false;
const remainingText = text.slice(matchedType.length + 1);
const [prefix, data] = remainingText.split(/,(.+)/);
return prefix.toLowerCase() === 'base64'
? _atob(data)
: remainingText;
}
function postData(data) {
let value = data.url ? data.url : data.key;
if (value instanceof ArrayBuffer || value instanceof Array) {
if (value.byteLength == 0) { return; }
if (data.action == "catCatchAddKey") {
// 判断是否ftyp
const uint8 = new _Uint8Array(value);
if ((uint8[4] === 0x73 || uint8[4] === 0x66) && uint8[5] == 0x74 && uint8[6] == 0x79 && uint8[7] == 0x70) {
return;
}
}
data.key = ArrayToBase64(value);
value = data.key;
}
/**
* AAAAAAAA... 空数据
*/
if (data.action == "catCatchAddKey" && (data.key.startsWith("AAAAAAAAAAAAAAAAAAAA"))) {
return;
}
if (filter.has(value)) { return false; }
filter.add(value);
data.requestId = Date.now().toString() + filter.size;
_postMessage(data);
}
function ArrayToBase64(data) {
try {
let bytes = new _Uint8Array(data);
let binary = "";
for (let i = 0; i < bytes.byteLength; i++) {
binary += _fromCharCode(bytes[i]);
}
if (typeof _btoa == "function") {
return _btoa(binary);
}
return _btoa(binary);
} catch (e) {
return false;
}
}
function isRepeatedExpansion(array, expansionLength) {
let _buffer = new _Uint8Array(expansionLength);
array = new _Uint8Array(array);
for (let i = 0; i < expansionLength; i++) {
_buffer[i] = array[i];
for (let j = i + expansionLength; j < array.byteLength; j += expansionLength) {
if (array[i] !== array[j]) {
return false;
}
}
}
return _buffer.buffer;
}
function extractBaseUrl(url) {
let urlSplit = url.split("/");
urlSplit.pop();
urlSplit = urlSplit.join("/") + "/";
if (!baseUrl.has(urlSplit)) {
joinBaseUrlTask.forEach(fn => fn(urlSplit));
baseUrl.add(urlSplit);
}
}
// vimeo json 翻译为 m3u8
async function vimeo(originalUrl, json) {
if (!json || !regexVimeo.test(originalUrl) || videoSet.has(originalUrl)) return;
const data = isJSON(json);
if (!data?.base_url || !data?.video) return;
videoSet.add(originalUrl);
try {
const url = new URL(originalUrl);
const pathBase = url.pathname.substring(0, url.pathname.lastIndexOf('/')) + "/";
const baseURL = new URL(url.origin + pathBase + data.base_url).href;
let M3U8List = ["#EXTM3U", "#EXT-X-INDEPENDENT-SEGMENTS", "#EXT-X-VERSION:3"];
const toM3U8 = (stream) => {
if (!stream.segments || stream.segments.length == 0) return null;
let M3U8 = [
"#EXTM3U",
"#EXT-X-VERSION:3",
`#EXT-X-TARGETDURATION:${stream.duration}`,
"#EXT-X-MEDIA-SEQUENCE:0",
"#EXT-X-PLAYLIST-TYPE:VOD"
];
if (stream.init_segment) {
M3U8.push(`#EXT-X-MAP:URI="data:application/octet-stream;base64,${stream.init_segment}"`);
} else if (stream.init_segment_url) {
M3U8.push(`#EXT-X-MAP:URI="${baseURL}${stream.base_url}${stream.init_segment_url}"`);
}
for (const segment of stream.segments) {
M3U8.push(`#EXTINF:${segment.end - segment.start},`);
M3U8.push(`${baseURL}${stream.base_url}${segment.url}`);
}
M3U8.push("#EXT-X-ENDLIST");
return URL.createObjectURL(
new Blob([new TextEncoder("utf-8").encode(_arrayJoin.call(M3U8, "\n"))])
);
}
if (data.video) {
for (const stream of data.video) {
const blobUrl = toM3U8(stream);
if (!blobUrl) continue;
M3U8List.push(`#EXT-X-STREAM-INF:BANDWIDTH=${stream.bitrate},RESOLUTION=${stream.width}x${stream.height},CODECS="${stream.codecs}"`);
M3U8List.push(blobUrl);
}
}
if (data.audio) {
for (const stream of data.audio) {
const blobUrl = toM3U8(stream);
if (!blobUrl) continue;
M3U8List.push(`#EXT-X-MEDIA:TYPE=AUDIO,GROUP-ID="${stream.id}",NAME="${stream.bitrate}",URI="${blobUrl}"`);
}
}
const blobUrl = URL.createObjectURL(
new Blob([new TextEncoder("utf-8").encode(_arrayJoin.call(M3U8List, "\n"))])
);
postData({ action: "catCatchAddMedia", url: blobUrl, href: location.href, ext: "m3u8" });
} catch (e) {
CATCH_SEARCH_DEBUG && console.error("Error processing Vimeo stream:", e);
}
}
})();

320
catch-script/webrtc.js Normal file
View File

@@ -0,0 +1,320 @@
(function () {
console.log("webrtc.js Start");
if (document.getElementById("catCatchWebRTC")) { return; }
// 多语言
let language = navigator.language.replace("-", "_");
if (window.CatCatchI18n) {
if (!window.CatCatchI18n.languages.includes(language)) {
language = language.split("_")[0];
if (!window.CatCatchI18n.languages.includes(language)) {
language = "en";
}
}
}
const buttonStyle = 'style="border:solid 1px #000;margin:2px;padding:2px;background:#fff;border-radius:4px;border:solid 1px #c7c7c780;color:#000;"';
const checkboxStyle = 'style="-webkit-appearance: auto;"';
const CatCatch = document.createElement("div");
CatCatch.innerHTML = `<img src="data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAABgAAAAYBAMAAAASWSDLAAAAKlBMVEUAAADLlROxbBlRAD16GS5oAjWWQiOCIytgADidUx/95gHqwwTx0gDZqwT6kfLuAAAACnRSTlMA/vUejV7kuzi8za0PswAAANpJREFUGNNjwA1YSxkYTEqhnKZLLi6F1w0gnKA1shdvHYNxdq1atWobjLMKCOAyC3etlVrUAOH4HtNZmLgoAMKpXX37zO1FwcZAwMDguGq1zKpFmTNnzqx0Bpp2WvrU7ttn9py+I8JgLn1R8Pad22vurNkjwsBReHv33junzuyRnOnMwNCSeFH27K5dq1SNgcZxFMnuWrNq1W5VkNntihdv7ToteGcT0C7mIkE1qbWCYjJnM4CqEoWKdoslChXuUgXJqIcLebiphSgCZRhaPDhcDFhdmUMCGIgEAFA+Uc02aZg9AAAAAElFTkSuQmCC" style="-webkit-user-drag: none;width: 20px;">
<div id="tips" data-i18n="waiting">正在等待视频流..."</div>
<div id="time"></div>
${i18n("selectVideo", "选择视频")}:
<select id="videoTrack">
<option value="-1">${i18n("selectVideo", "选择视频")}</option>
</select>
${i18n("selectAudio", "选择音频")}:
<select id="audioTrack">
<option value="-1">${i18n("selectVideo", "选择视频")}</option>
</select>
${i18n("recordEncoding", "录制编码")}: <select id="mimeTypeList" style="max-width: 200px;"></select>
<label><input type="checkbox" id="autoSave1"} ${checkboxStyle} data-i18n="save1hour">1小时保存一次</label>
<label>
<select id="videoBits">
<option value="2500000" data-i18n="videoBits">视频码率</option>
<option value="2500000">2.5 Mbps</option>
<option value="5000000">5 Mbps</option>
<option value="8000000">8 Mbps</option>
<option value="16000000">16 Mbps</option>
</select>
<select id="audioBits">
<option value="128000" data-i18n="audioBits">音频码率</option>
<option value="128000">128 kbps</option>
<option value="256000">256 kbps</option>
</select>
</label>
<div>
<button id="start" ${buttonStyle} data-i18n="startRecording">开始录制</button>
<button id="stop" ${buttonStyle} data-i18n="stopRecording">停止录制</button>
<button id="save" ${buttonStyle} data-i18n="save">保存</button>
<button id="hide" ${buttonStyle} data-i18n="hide">隐藏</button>
<button id="close" ${buttonStyle} data-i18n="close">关闭</button>
</div>`;
CatCatch.style = `
position: fixed;
z-index: 999999;
top: 10%;
left: 80%;
background: rgb(255 255 255 / 85%);
border: solid 1px #c7c7c7;
border-radius: 4px;
color: rgb(26, 115, 232);
padding: 5px 5px 5px 5px;
font-size: 12px;
font-family: "Microsoft YaHei", "Helvetica", "Arial", sans-serif;
user-select: none;
display: flex;
align-items: flex-start;
justify-content: space-evenly;
flex-direction: column;
line-height: 20px;`;
// 创建 Shadow DOM 放入CatCatch
const divShadow = document.createElement('div');
const shadowRoot = divShadow.attachShadow({ mode: 'closed' });
shadowRoot.appendChild(CatCatch);
// 页面插入Shadow DOM
document.getElementsByTagName('html')[0].appendChild(divShadow);
// 提示
const $tips = CatCatch.querySelector("#tips");
const tips = (text) => {
$tips.innerHTML = text;
}
// 开始 结束 按钮切换
const $start = CatCatch.querySelector("#start");
const $stop = CatCatch.querySelector("#stop");
const buttonState = (state = true) => {
$start.style.display = state ? 'inline' : 'none';
$stop.style.display = state ? 'none' : 'inline';
}
$start.style.display = 'inline';
$stop.style.display = 'none';
// 关闭
CatCatch.querySelector("#close").addEventListener('click', function (event) {
recorder?.state && recorder.stop();
CatCatch.style.display = "none";
window.postMessage({ action: "catCatchToBackground", Message: "script", script: "webrtc.js", refresh: true });
});
// 隐藏
CatCatch.querySelector("#hide").addEventListener('click', function (event) {
CatCatch.style.display = "none";
});
const tracks = { video: [], audio: [] };
const $tracks = { video: CatCatch.querySelector('#videoTrack'), audio: CatCatch.querySelector('#audioTrack') };
/* 核心变量 */
let recorder = null; // 录制器
let autoSave1Timer = null; // 1小时保存一次
// #region 编码选择
let option = { mimeType: 'video/webm;codecs=vp9,opus' };
function getSupportedMimeTypes(media, types, codecs) {
const supported = [];
types.forEach((type) => {
const mimeType = `${media}/${type}`;
codecs.forEach((codec) => [`${mimeType};codecs=${codec}`].forEach(variation => {
if (MediaRecorder.isTypeSupported(variation)) {
supported.push(variation);
}
}));
if (MediaRecorder.isTypeSupported(mimeType)) {
supported.push(mimeType);
}
});
return supported;
};
const $mimeTypeList = CatCatch.querySelector("#mimeTypeList");
const videoTypes = ["webm", "ogg", "mp4", "x-matroska"];
const codecs = ["should-not-be-supported", "vp9", "vp8", "avc1", "av1", "h265", "h.265", "h264", "h.264", "opus", "pcm", "aac", "mpeg", "mp4a"];
const supportedVideos = getSupportedMimeTypes("video", videoTypes, codecs);
supportedVideos.forEach(function (type) {
$mimeTypeList.options.add(new Option(type, type));
});
option.mimeType = supportedVideos[0];
$mimeTypeList.addEventListener('change', function (event) {
if (recorder && recorder.state && recorder.state === 'recording') {
tips(i18n("recordingChangeEncoding", "录制中不能更改编码"));
return;
}
if (MediaRecorder.isTypeSupported(event.target.value)) {
option.mimeType = event.target.value;
tips(`${i18n("recordEncoding", "录制编码")}:` + event.target.value);
} else {
tips(i18n("formatNotSupported", "不支持此格式"));
}
});
// #endregion 编码选择
// 录制
$time = CatCatch.querySelector("#time");
CatCatch.querySelector("#start").addEventListener('click', function () {
if (!tracks.video.length && !tracks.audio.length) {
tips(i18n("streamEmpty", "媒体流为空"));
return;
}
let recorderTime = 0;
let recorderTimeer = undefined;
let chunks = [];
// 音频 视频 选择
const videoTrack = +CatCatch.querySelector("#videoTrack").value;
const audioTrack = +CatCatch.querySelector("#audioTrack").value;
const streamTrack = [];
if (videoTrack !== -1 && tracks.video[videoTrack]) {
streamTrack.push(tracks.video[videoTrack]);
}
if (audioTrack !== -1 && tracks.audio[audioTrack]) {
streamTrack.push(tracks.audio[audioTrack]);
}
// 码率
option.audioBitsPerSecond = +CatCatch.querySelector("#audioBits").value;
option.videoBitsPerSecond = +CatCatch.querySelector("#videoBits").value;
const mediaStream = new MediaStream(streamTrack);
recorder = new MediaRecorder(mediaStream, option);
recorder.ondataavailable = event => {
chunks.push(event.data)
};
recorder.onstop = () => {
recorderTime = 0;
clearInterval(recorderTimeer);
clearInterval(autoSave1Timer);
$time.innerHTML = "";
tips(i18n("stopRecording", "已停止录制!"));
download(chunks);
buttonState();
}
recorder.onstart = () => {
chunks = [];
tips(i18n("recording", "视频录制中"));
$time.innerHTML = "00:00";
recorderTimeer = setInterval(function () {
recorderTime++;
$time.innerHTML = secToTime(recorderTime);
}, 1000);
buttonState(false);
}
recorder.onerror = (msg) => {
console.error(msg);
}
recorder.start(60000);
});
// 停止录制
CatCatch.querySelector("#stop").addEventListener('click', function () {
if (recorder) {
recorder.stop();
recorder = undefined;
}
});
// 保存
CatCatch.querySelector("#save").addEventListener('click', function () {
if (recorder) {
recorder.stop();
recorder.start();
}
});
// 每1小时 保存一次
CatCatch.querySelector("#autoSave1").addEventListener('click', function () {
clearInterval(autoSave1Timer);
if (CatCatch.querySelector("#autoSave1").checked) {
autoSave1Timer = setInterval(function () {
if (recorder) {
recorder.stop();
recorder.start();
}
}, 3600000);
}
});
// 获取webRTC流
window.RTCPeerConnection = new Proxy(window.RTCPeerConnection, {
construct(target, args) {
const pc = new target(...args);
pc.addEventListener('track', (event) => {
const track = event.track;
if (track.kind === 'video' || track.kind === 'audio') {
tips(`${track.kind} ${i18n("streamAdded", "流已添加")}`);
$tracks[track.kind].appendChild(new Option(track.label, tracks[track.kind].length));
$tracks[track.kind].value = tracks[track.kind].length;
tracks[track.kind].push(track);
if (tracks.video.length && tracks.audio.length) {
tips(i18n("videoAndAudio", "已包含音频和视频流"));
}
}
});
pc.addEventListener('iceconnectionstatechange', (event) => {
if (pc.iceConnectionState === 'disconnected' && recorder?.state === 'recording') {
recorder.stop();
tips(i18n("stopRecording", "连接已断开,录制已停止"));
}
});
return pc;
}
});
// #region 移动逻辑
let x, y;
const move = (event) => {
CatCatch.style.left = event.pageX - x + 'px';
CatCatch.style.top = event.pageY - y + 'px';
}
CatCatch.addEventListener('mousedown', function (event) {
x = event.pageX - CatCatch.offsetLeft;
y = event.pageY - CatCatch.offsetTop;
document.addEventListener('mousemove', move);
document.addEventListener('mouseup', function () {
document.removeEventListener('mousemove', move);
});
});
// #endregion 移动逻辑
function download(chunks) {
const blob = new Blob(chunks, { type: option.mimeType });
const url = URL.createObjectURL(blob);
const a = document.createElement('a');
a.style.display = 'none';
a.href = url;
a.download = 'recorded-video.mp4';
document.body.appendChild(a);
a.click();
window.URL.revokeObjectURL(url);
document.body.removeChild(a);
}
// 秒转换成时间
function secToTime(sec) {
let hour = (sec / 3600) | 0;
let min = ((sec % 3600) / 60) | 0;
sec = (sec % 60) | 0;
let time = hour > 0 ? hour + ":" : "";
time += min.toString().padStart(2, '0') + ":";
time += sec.toString().padStart(2, '0');
return time;
}
// 防止网页意外关闭跳转
window.addEventListener('beforeunload', function (e) {
recorder && recorder.stop();
return true;
});
// i18n
if (window.CatCatchI18n) {
CatCatch.querySelectorAll('[data-i18n]').forEach(function (element) {
element.innerHTML = window.CatCatchI18n[element.dataset.i18n][language];
});
CatCatch.querySelectorAll('[data-i18n-outer]').forEach(function (element) {
element.outerHTML = window.CatCatchI18n[element.dataset.i18nOuter][language];
});
}
function i18n(key, original = "") {
if (!window.CatCatchI18n) { return original };
return window.CatCatchI18n[key][language];
}
})();