媒体流捕获

捕获HTML视频流,并下载的实现。理论上可以捕获任意在线视频或直播的媒体流

Από την 20/10/2022. Δείτε την τελευταία έκδοση.

// ==UserScript==
// @name         媒体流捕获
// @namespace    https://github.com/Momo707577045/media-source-extract
// @version      0.2.5
// @description  捕获HTML视频流,并下载的实现。理论上可以捕获任意在线视频或直播的媒体流
// @license      AGPL-3.0
// @author       Momo707577045
// @match        *://*/*
// @exclude      http://blog.luckly-mjw.cn/tool-show/media-source-extract/player/player.html
// @grant        none
// @run-at       document-start
// ==/UserScript==

(() => {
    'use strict';
    if (document.getElementById('media-source-capture')) {
        return;
    }

    let isClose = false, isEndOfStream = false;
    let sourceBufferList = [];
    let $btnDownload = document.createElement('div');
    let $downloadNum = document.createElement('div');
    let $tenRate = document.createElement('div'); // 16倍速播放
    let $closeBtn = document.createElement('div'); // 关闭
    $closeBtn.innerHTML = `
        <div style="margin-top: 4px; height: 34px; width: 34px; line-height: 34px; display: inline-block; border-radius: 50px; background-color: rgba(0, 0, 0, 0.5);" id="m3u8-close">
            <img style="padding-top: 4px; width: 24px; cursor: pointer;" src="data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAADAAAAAwCAMAAABg3Am1AAAAk1BMVEUAAAD////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////ROyVeAAAAMHRSTlMA1Sq7gPribxkJx6Ey8onMsq+GTe10QF8kqJl5WEcvIBDc0sHAkkk1FgO2ZZ+dj1FHfPqwAAACNElEQVRIx6VW6ZqqMAwtFlEW2Rm3EXEfdZa+/9PdBEvbIVXu9835oW1yjiQlTWQE/iYPuTObOTzMNz4bQFRlY2FgnFXRC/o01mytiafP+BPvQZk56bcLSOXem1jpCy4QgXvRtlEVCARfUP65RM/hp29/+0R7eSbhoHlnffZ8h76e6x1tyw9mxXaJ3nfTVLd89hQr9NfGceJxfLIXmONh6eNNYftNSESRmgkHlEOjmhgBbYcEW08FFQN/ro6dvAczjhgXEdQP76xHEYxM+igQq259gLrCSlwbD3iDtTMy+A4Yuk0B6zV8c+BcO2OgFIp/UvJdG4o/Rp1JQYXeZFflPEFMfvugiFGFXN587YtgX7C8lRGFXPCGGYCCzlkoxJ4xqmi/jrIcdYYh5pwxiwI/gt7lDDFrcLiMKhBJ//W78ENsJgVUsV8wKpjZBXshM6cCW0jbRAilICFxIpgGMmmiWGHSIR6ViY+DPFaqSJCbQ5mbxoZLIlU0Al/cBj6N1uXfFI0okLppi69StmumSFQRP6oIKDedFi3vRDn3j6KozCZlu0DdJb3AupJXNLmqkk9+X9FEHLt1Jq8oi1H5n01AtRlvwQZQl9hmtPY4JEjMDs5ftWJN4Xr4lLrV2OHiUDHCPgvA/Tn/hP4zGUBfjZ3eLJ+NIOfHxi8CMoAQtYfmw93v01O0e7VlqqcCsXML3Vsu94cxnb4c7ML5chG8JIP9b38dENGaj3+x+TpiA/AL/fen8In7H8l3ZjdJQt2TAAAAAElFTkSuQmCC">
        </div>`;


    for (const property of Object.getOwnPropertyNames(window)) {
        if (typeof window[property] === "function" && Boolean(window[property].prototype) && typeof window[property].prototype.addSourceBuffer === "function" && typeof window[property].prototype.endOfStream === "function") {
            doMediaSource(window[property]);
        }
    }

    // 16倍速播放
    function tenRatePlay(rate) {
        setTimeout(() => {
            let $domList = document.querySelectorAll('*');
            for (const $dom of $domList) {
                try {
                    $dom.playbackRate = rate;
                    $dom.muted = rate !== 1;
                } catch (e) {
                    // console.error(e.message);
                }
            }
        });
    }

    // 下载资源
    function download() {
        setTimeout(() => {
            const date = new Date();
            for (const target of sourceBufferList) {
                const mime = target.mime.split(';');
                console.log(target);
                const type = mime[0].split("/");
                const fileBlob = new Blob(target.bufferList, {type: mime}); // 创建一个Blob对象,并设置文件的 MIME 类型
                const a = document.createElement('a');
                a.download = `${type[0]}_${date.getFullYear().toString().padStart(4, '0')}${date.getMonth().toString().padStart(2, '0')}${date.getDay().toString().padStart(2, '0')}_${date.getHours().toString().padStart(2, '0')}${date.getMinutes().toString().padStart(2, '0')}${date.getSeconds().toString().padStart(2, '0')}${mime[1]}.${type[1]}`;
                a.href = URL.createObjectURL(fileBlob);
                a.style.display = 'none';
                document.body.appendChild(a);
                a.click();
                a.remove();
                if (isEndOfStream === true) {
                    sourceBufferList = [];
                    $downloadNum.innerHTML = `已捕获 0 个片段`;
                    isEndOfStream = false;
                    showTip('视频已下载');
                }
            }
        });
    }

    function doMediaSource(MediaSource) {
        const $endOfStream = MediaSource.prototype.endOfStream;
        MediaSource.prototype.endOfStream = function endOfStream() {
            if (!isClose) {
                isEndOfStream = true;
                $downloadNum.innerHTML = `已捕获到终点,请下载`;
                showTip('已捕获到终点,请下载');
                $endOfStream.call(this);
            }
        }

        const $addSourceBuffer = MediaSource.prototype.addSourceBuffer
        MediaSource.prototype.addSourceBuffer = function addSourceBuffer(mime) {
            if (!isClose) {
                if (isEndOfStream) {
                    if (confirm('检测到新的视频流,是否下载已捕获?\n点击“确定”下载已捕获\n点击“取消”重新捕获')) {
                        download();
                    }
                }
                if (document.getElementById('media-source-capture') === null) {
                    appendDom();
                    console.log(this);
                }
                const sourceBuffer = $addSourceBuffer.call(this, mime);
                const append = sourceBuffer.appendBuffer;
                const bufferList = [];
                sourceBufferList.push({
                    mime,
                    bufferList,
                });
                sourceBuffer.appendBuffer = function (buffer) {
                    $downloadNum.innerHTML = `已捕获 ${sourceBufferList[0].bufferList.length} 个片段`;
                    bufferList.push(buffer);
                    append.call(this, buffer);
                }
                return sourceBuffer;
            }
        }
    }

    // 添加操作的 dom
    function appendDom() {
        const baseStyle = `position: fixed; top: 50px; right: 50px; height: 40px; padding: 0 20px; z-index: 99999; color: white; cursor: pointer; font-size: 16px; font-weight: bold; line-height: 40px; text-align: center; border-radius: 4px; background-color: #3498db; box-shadow: 0 3px 6px 0 rgba(0, 0, 0, 0.3);`;
        $tenRate.innerHTML = '16倍速捕获(静音)';
        $downloadNum.innerHTML = `已捕获 0 个片段`;
        $btnDownload.innerHTML = '下载已捕获片段';
        $btnDownload.id = 'media-source-capture';
        $tenRate.style = baseStyle + `top: 150px;`;
        $btnDownload.style = baseStyle + `top: 100px;`;
        $downloadNum.style = baseStyle;
        $closeBtn.style = `position: fixed; top: 200px; right: 50px; text-align: center; z-index: 99999; cursor: pointer;`;
        $btnDownload.addEventListener('click', download);
        $tenRate.addEventListener('click', function () {
            if ($tenRate.innerHTML === '16倍速捕获(静音)') {
                tenRatePlay(16);
                $tenRate.innerHTML = '恢复正常倍速音量';
            } else {
                tenRatePlay(1);
                $tenRate.innerHTML = '16倍速捕获(静音)';
            }
        });
        $closeBtn.addEventListener('click', function () {
            $btnDownload.remove();
            $downloadNum.remove();
            $closeBtn.remove();
            $tenRate.remove();
            sourceBufferList = [];
            isClose = true;
        });
        let $html = document.querySelector("html"), $head = document.querySelector('head');
        $html.insertBefore($tenRate, $head);
        $html.insertBefore($downloadNum, $head);
        $html.insertBefore($btnDownload, $head);
        $html.insertBefore($closeBtn, $head);
    }

    if (window === top) {
        window.addEventListener("message", event => {
            if (event.source !== window) {
                try {
                    let sql = event.data.split("\x00");
                    if (sql[0] === "showTip" && sql[1].constructor === String) {
                        if (sql[2]) showTip(sql[1], sql[2]);
                    }
                } catch (e) {
                    // 排除 下标越界错误 及 指令处理错误
                }
            }
        });
    }

    function showTip(msg, style = ``) {
        // 该函数需要在top内运行,否则可能显示异常
        let root = document.querySelector(`:root`);
        if (window === top) {
            let tip = document.querySelector(`:root > tip`);
            if (tip && tip.nodeType === 1) {
                clearInterval(parseInt(tip.getAttribute("interval")));
                // 防止中途新的showTip事件创建多个tip造成卡顿
                tip.innerHTML = `<style>@keyframes showTip {0%{opacity: 1;} 66.67%{opacity: 1;} 100%{opacity: 0;}}</style>\n` + msg;
                let time = msg.replace(/\s/, ``).length / 1.6;   // TODO 2个字/秒
                // cubic-bezier(起始点, 起始点偏移量, 结束点偏移量, 结束点),这里的 cubic-bezier函数 表示动画速度的变化规律
                tip.style.animation = `showTip ` + (time > 2.5 ? time : 2.5) + `s cubic-bezier(0,` + ((time - 1) > 0 ? (time - 1) / time : 0) + `,` + (1 - ((time - 1) > 0 ? (time - 1) / time : 0)) + `,1) 1 normal`;
            } else {
                tip = document.createElement(`tip`);
                // pointer-events: none; 禁用鼠标事件,input标签使用 disabled='disabled' 禁用input标签
                tip.style = style + `pointer-events: none; opacity: 0; background-color: #222a; color: #fff; font-family: 微软雅黑,黑体,Droid Serif,Arial,sans-serif; font-size: 20px; text-align: center; padding: 6px; border-radius: 16px; position: fixed; transform: translate(-50%, -50%); left: 50%; bottom: 15%; z-index: 2147483647;`;
                tip.innerHTML = `<style>@keyframes showTip {0%{opacity: 0;} 33.34%{opacity: 1;} 66.67%{opacity: 1;} 100%{opacity: 0;}}</style>\n` + msg;
                let time = msg.replace(/\s/, ``).length / 2;   // TODO 2个字/秒
                // cubic-bezier(起始点, 起始点偏移量, 结束点偏移量, 结束点),这里的 cubic-bezier函数 表示动画速度的变化规律
                tip.style.animation = `showTip ` + (time > 2 ? time : 2) + `s cubic-bezier(0,` + ((time - 1) > 0 ? (time - 1) / time : 0) + `,` + (1 - ((time - 1) > 0 ? (time - 1) / time : 0)) + `,1) 1 normal`;
                root.appendChild(tip);
            }
            tip.setAttribute("interval", String(setTimeout(() => {
                try {
                    root.removeChild(tip);
                } catch (e) {
                    // 排除root没有找到tip
                }
            }, time * 1000)));
        } else {
            top.postMessage("showTip\x00" + msg + "\x00" + style, "*");
        }
    }
})();