Greasy Fork

Greasy Fork is available in English.

[红狐播放器]音视频下载工具(B站专享版)

适用于从B站跳转的内容,抓取页面中音视频链接并分别下载(推荐配合 ffmpeg 合并)

// ==UserScript==
// @name         [红狐播放器]音视频下载工具(B站专享版)
// @namespace    http://tampermonkey.net/
// @version      0.1.2
// @description  适用于从B站跳转的内容,抓取页面中音视频链接并分别下载(推荐配合 ffmpeg 合并)
// @match        https://rdfplayer.mrgaocloud.com/player/*
// @grant        GM_xmlhttpRequest
// @grant        GM_download
// @grant        GM_notification
// @grant        GM_registerMenuCommand
// @icon         data:image/svg+xml,<svg xmlns='http://www.w3.org/2000/svg' width='32' height='32' viewBox='0 0 32 32'><text x='0' y='24' font-size='24'>🦊</text></svg>
// @license      GPL License
// @connect      *
// ==/UserScript==

(function () {
    'use strict';

    const CHECK_INTERVAL = 1000;
    const MAX_RETRY = 3;

    let audioURL = null;
    let videoURL = null;

    let downloadBtnVisible = localStorage.getItem('downloadBtnVisible') !== 'false'; // 默认 true

    GM_registerMenuCommand(downloadBtnVisible ? '🔕 隐藏下载按钮' : '🔔 显示下载按钮', toggleDownloadButton);

    const init = () => {
        if (downloadBtnVisible) {
            addDownloadButton();
        }
        startElementObserver();
    };
    function toggleDownloadButton() {
        downloadBtnVisible = !downloadBtnVisible;
        localStorage.setItem('downloadBtnVisible', downloadBtnVisible);

        const existing = document.getElementById('directDownloadBtn');
        if (existing) {
            existing.remove();
        }

        if (downloadBtnVisible) {
            addDownloadButton();
            GM_notification('下载按钮已显示 ✅');
        } else {
            GM_notification('下载按钮已隐藏 ❌');
        }

        // 更新菜单项显示
        location.reload(); // 简单做法:刷新页面,更新菜单项标题
    }

    const addDownloadButton = () => {
        const btn = document.createElement('button');
        btn.id = 'directDownloadBtn';
        btn.textContent = '⏬ 下载音视频';
        Object.assign(btn.style, {
            position: 'fixed', top: '20px', right: '20px',
            zIndex: 9999, padding: '10px 15px',
            background: '#9E9E9E', color: 'white',
            border: 'none', borderRadius: '4px',
            cursor: 'not-allowed', boxShadow: '0 2px 6px rgba(0,0,0,0.3)'
        });
        btn.disabled = true;
        btn.onclick = handleDownload;
        document.body.appendChild(btn);
    };

    const startElementObserver = () => {
        const interval = setInterval(() => {
            const elements = Array.from(document.querySelectorAll('video[src*=".m4s"]'));
            if (elements.length >= 2) {
                const url1 = elements[0].src;
                const url2 = elements[1].src;

                // 简单通过 URL 字符串中是否包含 "video"、"audio" 或者带宽判断
                if (url1.includes('bw=') && url2.includes('bw=')) {
                    const bw1 = getBandwidth(url1);
                    const bw2 = getBandwidth(url2);

                    if (bw1 > bw2) {
                        videoURL = url1;
                        audioURL = url2;
                    } else {
                        videoURL = url2;
                        audioURL = url1;
                    }
                } else {
                    // 若不含带宽信息,保底用顺序判断
                    videoURL = url1;
                    audioURL = url2;
                }

                console.log('[🎞️ 视频 URL]', videoURL);
                console.log('[🔊 音频 URL]', audioURL);

                const btn = document.getElementById('directDownloadBtn');
                btn.disabled = false;
                btn.style.background = '#4CAF50';
                btn.style.cursor = 'pointer';
            }
        }, CHECK_INTERVAL);
    };

    const getBandwidth = (url) => {
        const match = url.match(/bw=(\d+)/);
        return match ? parseInt(match[1], 10) : 0;
    };

    // 新增:获取视频标题
    const getVideoTitle = () => {
        let titleElem = document.querySelector('.v-title');
        let title = titleElem ? titleElem.textContent.trim() : '';

        if (!title) {
            title = document.title || 'unknown_title';
        }

        title = title
            .normalize('NFKC')                                // 规范化
            .replace(/[^\w\u4e00-\u9fa5\s\-()\[\]【】()]/g, '') // 保留安全字符
            .replace(/\s+/g, '_')                             // ✅ 空格 → 下划线
            .trim();

        if (title.length > 50) {
            title = title.substring(0, 50);
        }

        return title || 'unknown_title';
    };

    const handleDownload = async () => {
        if (!audioURL || !videoURL) return;

    const btn = document.getElementById('directDownloadBtn');
    try {
        btn.textContent = '🔄 下载中...';

        const [audioData, videoData] = await Promise.all([
            fetchWithRetry(audioURL),
            fetchWithRetry(videoURL)
        ]);

        // 获取标题(前面提到的 v-title 元素)
        const safeTitle = getVideoTitle();
        
        const videoFilename = `video_${safeTitle}.mp4`;
        const audioFilename = `audio_${safeTitle}.wav`;
        const outputFilename = `${safeTitle}.mp4`;

        triggerDownload(new Blob([videoData], { type: 'video/mp4' }), videoFilename);
        triggerDownload(new Blob([audioData], { type: 'audio/wav' }), audioFilename);

        GM_notification('✅ 下载完成,准备合并 ffmpeg 命令');

        // ⏬ 弹出提示框:ffmpeg 命令
        const command = `ffmpeg -i "${videoFilename}" -i "${audioFilename}" -c:v copy -c:a aac "${outputFilename}"`;
        setTimeout(() => {
            prompt("✅ 请复制以下 ffmpeg 命令去终端执行合并:", command);
        }, 500); // 等待下载完成后提示
    } catch (err) {
        GM_notification(`❌ 下载失败: ${err}`);
        console.error(err);
    } finally {
        btn.textContent = '⏬ 下载音视频(分离)';
    }
};

    const fetchWithRetry = async (url, retry = MAX_RETRY) => {
        for (let i = 0; i < retry; i++) {
            try {
                const data = await new Promise((resolve, reject) => {
                    GM_xmlhttpRequest({
                        method: 'GET',
                        url: url,
                        headers: {
                            'Referer': window.location.href,
                            'User-Agent': navigator.userAgent
                        },
                        responseType: 'arraybuffer',
                        onload: (res) => res.status === 200 ? resolve(res.response) : reject(),
                        onerror: reject
                    });
                });
                return new Uint8Array(data);
            } catch (err) {
                if (i === retry - 1) throw new Error(`下载失败: ${url}`);
                await new Promise(r => setTimeout(r, 1000 * (i + 1)));
            }
        }
    };

    const triggerDownload = (blob, filename) => {
        const link = document.createElement('a');
        link.href = URL.createObjectURL(blob);
        link.download = filename;
        link.click();
        setTimeout(() => URL.revokeObjectURL(link.href), 1000);
    };

    window.addEventListener('load', init);
})();