1
This commit is contained in:
921
catch-script/catch.js
Normal file
921
catch-script/catch.js
Normal 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 {
|
||||
"'": ''',
|
||||
'\\': '\',
|
||||
'/': '/',
|
||||
':': ':',
|
||||
'*': '*',
|
||||
'?': '?',
|
||||
'"': '"',
|
||||
'<': '<',
|
||||
'>': '>',
|
||||
'|': '|',
|
||||
'~': '_'
|
||||
}[m];
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
// 创建并启动CatCatcher实例
|
||||
const catCatcher = new CatCatcher();
|
||||
})();
|
||||
230
catch-script/i18n.js
Normal file
230
catch-script/i18n.js
Normal 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
266
catch-script/recorder.js
Normal 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
257
catch-script/recorder2.js
Normal 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
751
catch-script/search.js
Normal 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
320
catch-script/webrtc.js
Normal 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];
|
||||
}
|
||||
})();
|
||||
Reference in New Issue
Block a user