mmdfans 一键下载

在网页上检测视频标签并获取标题和下载链接,点击下载按钮会跳转到视频链接下载并尝试返回页面。

// ==UserScript==
// @name         mmdfans 一键下载
// @namespace    http://tampermonkey.net/
// @version      1.0.4
// @description  在网页上检测视频标签并获取标题和下载链接,点击下载按钮会跳转到视频链接下载并尝试返回页面。
// @author       wzj042
// @match        https://mmdfans.net/mmd/*
// @match        https://cdn.mmdlibrary.eu.org/*
// @match        https://cdn.mmdlibrary2.eu.org/*
// @match        https://cdn.mmdlibrary3.eu.org/*
// @match        https://cdn.bakabakaxi.eu.org/*
// @match        https://cdn.baka6.eu.org/*
// @match        https://cdn.baka7.eu.org/*
// @match        https://cdn.baka8.eu.org/*
// @match        https://cdn.baka9.eu.org/*
// @match        https://cirno.baka9.eu.org/*
// @license      MIT
// @grant        none
// ==/UserScript==


(function () {
    'use strict';

    /**
     * 手动添加 CDN 链接时别忘了在上方添加匹配链接 `match {你要添加的链接}/*`
     */
    const CDN_LIST = [
        'https://cdn.mmdlibrary.eu.org',
        'https://cdn.mmdlibrary2.eu.org',
        'https://cdn.mmdlibrary3.eu.org',
        'https://cdn.bakabakaxi.eu.org',
        'https://cdn.baka6.eu.org',
        'https://cdn.baka7.eu.org',
        'https://cdn.baka8.eu.org',
        'https://cdn.baka9.eu.org',
        'https://cirno.baka9.eu.org',
        // 添加你要添加的 CDN 链接
    ]

    /**
     * 自定义文件名的 slot
     * name: slot 名称
     * transform: 转换函数
     * source: 数据来源
    */
    const CUSTOM_FILENAME_SLOT = [
        {
            name: 'postDate',
            transform: (postAt) => {
                const datePart = postAt.split(' ')[0];
                return datePart;
            },
            source: (videoInfo) => videoInfo['postAt']
        },
        {
            name: 'curDate',
            transform: () => {
                return new Date().toISOString().split('T')[0];
            },
            source: () => {}
        }
    ]
    
    let downloadFlag = false; 
    const TIME_OUT = 1000;
    /**
     * 默认文件名格式
     * 使用 {param} 的方式表示参数,例如 {author} 表示作者
     * 具体的参数名点击显示视频参数按钮后会显示
     */
    const DEF_FILENAME = '[iwara]{author}-{title}-{postAt}.mp4';

    const delay = (ms) => new Promise((resolve) => setTimeout(resolve, ms));

    // 加一个下载防抖事件
    const downloadDebounce = (function () {
        let timer = null;
        return function (pathUrl) {
            if (timer) {
                clearTimeout(timer);
            }
            timer = setTimeout(() => {
                download(pathUrl);
            }, 500);
        };
    })();

    /**
     * 检查视频标签是否存在,如果存在则添加下载按钮
     */
    const checkVideoTag = async () => {
        while (true) {
            const videoTag = document.getElementsByClassName('mdui-video-fluid');
            const sourceElement = document.querySelector('video source');
            const downloadLink = sourceElement ? sourceElement.getAttribute('src') : '';
            const titleElement = document.querySelector('h2.title');
            const title = titleElement ? titleElement.textContent.trim() : '无标题';

            if (videoTag.length > 0) {
                const videoInfo = gatherVideoInfo(downloadLink, title);

                createTagContainer();
                createDownloadButton(videoInfo);
                createCopyCdnButton(videoInfo.baseurl);
                createJsonButton(videoInfo);

                createFilenameInput();

                break;
            } else if (!downloadFlag) {
                const curUrl = window.location.href
                if (isCdnLink(curUrl)) {
                    downloadDebounce(curUrl);
                }
            }

            await delay(TIME_OUT); // 等待下一次检查
        }
    };

    /**
     * 检查当前页面是否为 mmdfans 视频的 CDN 链接
     * @param   {string}  url - 当前页面链接 
     * @returns {boolean}     - 如果是 mmdfans 视频的 CDN 链接,则返回 true,否则返回 false
     */
    function isCdnLink(url) {
        return CDN_LIST.some(cdn => url.startsWith(cdn));
    }

    function download(pathUrl) {
        const a = document.createElement("a");
        const url = new URL(pathUrl);
        const downloadURL = url.protocol + '//' + url.host + url.pathname;
        const filename = url.searchParams.get('filename');

        a.download = filename;
        a.href = downloadFlag ? downloadURL : pathUrl;
        a.click();
        a.remove();
        setTimeout(function () {
            /**
             * 下载完成后返回上一页
             */
            if (isCdnLink(window.location.href) && downloadFlag) {
                window.history.back();
            }
            downloadFlag = false;
        }, TIME_OUT);
        
        downloadFlag = true;
    }

    /**
     * 匹配文件名中的参数并替换为对应的值,返回完整的下载链接
     * @param {*} filenameInfo - 文件名信息对象
     * @param {*} videoInfo - 视频信息对象
     * @returns {string} - 下载链接
     */
    function filename2Url(filenameInfo, videoInfo) {
        const params = filenameInfo.params;
        let filename = filenameInfo.filename;
        console.log(filenameInfo, videoInfo);
        // 校验参数
        if (!params || params.length === 0 || !filename) {
            return videoInfo.downloadLink;
        }

        // 匹配 params 和 videoInfo 中的属性
        params.forEach(param => {
            let value = videoInfo[param];

            // 如果是自定义的 slot 则调用 transform 方法
            const customSlot = CUSTOM_FILENAME_SLOT.find(slot => slot.name === param);
            if (customSlot && customSlot.transform) {
                value = customSlot.transform(customSlot.source(videoInfo));
            }

            filename = filename.replace(`{${param}}`, value);
        });
        // 为了防止文件名中含有特殊字符,需要对文件名进行编码
        return `${videoInfo.downloadLink}?filename=${encodeURIComponent(filename)}`;
    }

    function createFilenameInput() {
        const filenameButton = document.createElement('button');
        filenameButton.innerHTML = 'FileName';
        filenameButton.classList.add('mdui-btn');
    
        const filenameInput = document.createElement('input');
        filenameInput.id = 'filename-input';
        filenameInput.placeholder = '请输入文件名格式';
        filenameInput.type = 'text';
        // 如果 localStorage 中有 filename 则使用 localStorage 中的值
        filenameInput.value = localStorage.getItem('filename') || DEF_FILENAME;
        
        filenameInput.style.width = '50%';
        filenameInput.style.display = 'inline-block';

        filenameInput.classList.add('mdui-textfield-input');

        // 编辑完成后保存到 localStorage
        filenameInput.onchange = () => {
            localStorage.setItem('filename', filenameInput.value);
        };
    
        const tagDivider = document.querySelector('.mdui-divider.tag-divider');
        const tagContainer = document.createElement('div');
        tagContainer.classList.add('tag-container');
        
        tagContainer.appendChild(filenameButton);
        tagContainer.appendChild(filenameInput);
        tagDivider.parentNode.insertBefore(tagContainer, tagDivider);

    }

    /**
     * 在标题上方添加一个标签容器,用于存放下载按钮
     */
    function createTagContainer() {
        const tagContainer = document.createElement('div');
        tagContainer.classList.add('tag-container');
        const titleDiv = document.querySelector('.mdui-typo');
        titleDiv.parentNode.insertBefore(tagContainer, titleDiv);
    }

    function createDownloadButton(videoInfo) {
        const downloadButton = document.createElement('button');
        downloadButton.innerHTML = '下载视频';
        downloadButton.classList.add('mdui-btn', 'mdui-color-theme-accent', 'mdui-ripple');

        downloadButton.onclick = () => {

            let baseurl = videoInfo.baseurl;

            if (!isCdnLink(baseurl)) {
                showAlertWithButtons(baseurl);
                return;
            }

            downloadDebounce(filename2Url(gatherFilenameInfo(), videoInfo));
        };

        const tagContainer = document.querySelector('.tag-container');
        tagContainer.appendChild(downloadButton);
        tagContainer.appendChild(createSpace());
    }

    function createCopyCdnButton(downloadLink) {
        const copyCdnButton = document.createElement('button');
        copyCdnButton.innerHTML = '复制 CDN 链接';
        copyCdnButton.classList.add('mdui-btn', 'mdui-color-theme-accent', 'mdui-ripple');

        copyCdnButton.onclick = () => {
            if (!downloadLink) {
                alert('未获取到视频链接');
                return;
            }
            navigator.clipboard.writeText(downloadLink);
            alert('复制成功');
        };

        const tagContainer = document.querySelector('.tag-container');
        tagContainer.appendChild(copyCdnButton);
        tagContainer.appendChild(createSpace());
    }

    /**
     * 创建一个 Html 空格(占位)元素
     * @returns {HTMLElement} - 返回一个空格元素
     */
    function createSpace() {
        const space = document.createElement('span');
        space.innerHTML = '  ';
        return space;
    }

    function createJsonButton(videoInfo) {
        const jsonButton = document.createElement('button');
        jsonButton.innerHTML = '显示视频信息';
        jsonButton.classList.add('mdui-btn', 'mdui-color-theme-accent', 'mdui-ripple');
    
        jsonButton.onclick = () => {
            showPopup(videoInfo);
        };
    
        const tagContainer = document.querySelector('.tag-container');
        tagContainer.appendChild(jsonButton);
        tagContainer.appendChild(createSpace());
    }

    /**
     * 展示视频信息的弹窗
     * @param {*} videoInfo - 视频信息
     */
    function showPopup(videoInfo) {
        const popup = document.createElement('div');
        popup.style.position = 'fixed';
        popup.style.top = '50%';
        popup.style.left = '50%';
        popup.style.transform = 'translate(-50%, -50%)';
        popup.style.padding = '20px';
        popup.style.width = '42%';
        popup.style.backgroundColor = '#fff';
        popup.style.boxShadow = '0 2px 10px rgba(0, 0, 0, 0.1)';
        popup.style.zIndex = '1000';
        popup.style.overflowX = 'auto';
    
    
        const pre = document.createElement('pre');
        pre.innerHTML = JSON.stringify(videoInfo, null, 2);
        popup.appendChild(pre);

        
        const closeButton = document.createElement('button');
        closeButton.innerHTML = '关闭';
        closeButton.classList.add('mdui-btn', 'mdui-color-theme-accent', 'mdui-ripple');
        closeButton.style.width = '100%';
        closeButton.onclick = () => popup.remove();
        popup.appendChild(closeButton);

        popup.onclick = (e) => {
            if (e.target === popup) {
                popup.remove();
            }
        };


        document.body.appendChild(popup);
    }
    
    /**
     * 展示提示框,提示用户手动添加 CDN 链接
     * @param {*} cdnLink - 视频下载链接
     */
    function showAlertWithButtons(cdnLink) {
        const div = document.createElement('div');
        div.innerHTML = `
            <p>当前 CDN 链接需要手动添加 CDN 链接(脚本主页有教程),否则无法启动下载任务</p>
            <button id="open-homepage" class="mdui-btn mdui-color-theme-accent mdui-ripple">打开脚本主页</button>
            <button id="copy-baseurl" class="mdui-btn mdui-color-theme-accent mdui-ripple">复制视频 CDN 链接</button>
        `;
        const tagContainer = document.querySelector('.tag-container');
        tagContainer.appendChild(div);

        document.getElementById('open-homepage').onclick = () => {
            window.open('https://greasyfork.org/zh-CN/scripts/476557-mmdfans%E4%B8%80%E9%94%AE%E4%B8%8B%E8%BD%BD', '_blank');
        };

        document.getElementById('copy-baseurl').onclick = () => {
            navigator.clipboard.writeText(cdnLink);
            alert('已复制 CDN 链接');
        };
    }

    /**
     * 获取文件名输入框中的参数
     * @returns {*} - 返回文件名参数
     * @example 
     * {
     *   filename: '[iwara]{author}{title}.mp4',
     *   params: ['author', 'title']
     * }
     */
    function gatherFilenameInfo() {
        const filenameInput = document.getElementById('filename-input');
        const filename = filenameInput ? filenameInput.value : '';
        const paramMatches = filename.match(/\{(\w+)\}/g);
        const params = paramMatches ? paramMatches.map(param => param.slice(1, -1)) : [];
    
        return {
            filename: filename,
            params: params
        };
    }

    /**
     * 将视频信息整合到一个对象中
     * @param {string} downloadLink - 视频下载链接
     * @param {string} title - 视频标题
     * @returns {object} 视频信息对象
     */
    function gatherVideoInfo(downloadLink, title) {
        const tagContainers = document.querySelectorAll('.tag-container');
        const info = {
            title: title,
            downloadLink: downloadLink,
            tags: [],
            author: '无作者',
            baseurl: '',
            source: '',
            postAt: '',
            comment: ''
        };

        if (downloadLink) {
            const url = new URL(downloadLink);
            info.baseurl = url.protocol + '//' + url.host;
        }

        tagContainers.forEach(container => {
            const buttons = container.querySelectorAll('button');
            
            buttons.forEach(button => {
                const text = button.textContent.trim();
                switch (text) {
                    case 'Tags':
                    case 'Source':
                    case 'PostAt':
                    case 'Author':
                        break;
                    default:
                        if (container.textContent.includes('Tags')) {
                            info.tags.push(text);
                        } else if (container.textContent.includes('Author')) {
                            info.author = text;
                        } else if (container.textContent.includes('Source')) {
                            info.source = container.querySelector('a').href;
                        } else if (container.textContent.includes('PostAt')) {
                            info.postAt = text;
                        }
                        break;
                }
            });
            if (container.textContent.includes('Comment')) {
                info.comment = document.querySelector('blockquote').textContent.trim();
            }
        });

        return info;
    }

    checkVideoTag();
})();