Greasy Fork

Greasy Fork is available in English.

下载知乎视频

给知乎的视频播放器添加下载功能

当前为 2018-06-16 提交的版本,查看 最新版本

您需要先安装一款用户脚本管理器扩展,例如 Tampermonkey 篡改猴Greasemonkey 油猴子Violentmonkey 暴力猴,才能安装此脚本。

您需要先安装一款用户脚本管理器扩展,例如 Tampermonkey 篡改猴,才能安装此脚本。

您需要先安装一款用户脚本管理器扩展,例如 Tampermonkey 篡改猴Violentmonkey 暴力猴,才能安装此脚本。

您需要先安装一款用户脚本管理器扩展,例如 Tampermonkey 篡改猴Userscripts ,才能安装此脚本。

您需要先安装一款用户脚本管理器扩展,例如 Tampermonkey 篡改猴,才能安装此脚本。

您需要先安装一款用户脚本管理器扩展后才能安装此脚本。

(我已经安装了用户脚本管理器,让我安装!)

您需要先安装一款用户样式管理器扩展,比如 Stylus,才能安装此样式。

您需要先安装一款用户样式管理器扩展,比如 Stylus,才能安装此样式。

您需要先安装一款用户样式管理器扩展,比如 Stylus,才能安装此样式。

您需要先安装一款用户样式管理器扩展后才能安装此样式。

您需要先安装一款用户样式管理器扩展后才能安装此样式。

您需要先安装一款用户样式管理器扩展后才能安装此样式。

(我已经安装了用户样式管理器,让我安装!)

// ==UserScript==
// @name         下载知乎视频
// @version      0.8
// @description  给知乎的视频播放器添加下载功能
// @author       Chao
// @include      *://www.zhihu.com/*
// @match        *://www.zhihu.com/*
// @include      https://v.vzuu.com/video/*
// @match        https://v.vzuu.com/video/*
// @connect      zhihu.com
// @connect      vzuu.com
// @grant        GM_download
// @namespace    http://greasyfork.icu/users/38953
// ==/UserScript==

(async () => {
    if (window.location.host == 'www.zhihu.com') return;

    const playlistBaseUrl = 'https://lens.zhihu.com/api/videos/';
    const videoId = window.location.pathname.split('/').pop(); // 视频id
    const menuStyle = 'transform:none !important; left:auto !important; right:-0.5em !important;';
    const controlBarSelector = '#player > div:first-child > div:last-child > div:last-child > div:first-child';
    const svgDownload = '<path d="M9.5,4 H14.5 V10 H17.8 L12,15.8 L6.2,10 H9.5 Z M6.2,18 H17.8 V20 H6.2 Z"></path>';
    const svgCircle = '<circle cx="12" cy="12" r="8" fill="none" stroke-width="2" stroke="#555" />' +
        '<text x="50%" y="50%" dy=".4em" text-anchor="middle" fill="#fff" font-size="9">0</text>' +
        '<path fill="none" r="8" transform="translate(12,12)" stroke-width="2" stroke="#fff" />';
    const domControlBar = document.querySelector(controlBarSelector);
    const domFullScreenBtn = document.querySelector(controlBarSelector + '> div:nth-last-of-type(1)');
    const domResolutionBtn = document.querySelector(controlBarSelector + '> div:nth-last-of-type(3)');
    let domDownloadBtn = domResolutionBtn.cloneNode(true); // 克隆分辨率按钮为下载按钮
    let domMenuItem = domDownloadBtn.querySelectorAll('button')[1];
    let domMenu = domMenuItem.parentNode;
    let videos = []; // 存储各分辨率的视频信息
    let blobs = null; // 存储视频段
    let ratio;
    let errors = 0;

    function wait(time) {
        return new Promise(function (resolve, reject) {
            setTimeout(resolve, time);
        });
    };

    function fetchRetry(url, options = {}, times = 1, delay = 1000, checkStatus = true) {
        return new Promise((resolve, reject) => {
            // fetch 成功处理函数
            function success(res) {
                if (checkStatus && !res.ok) {
                    failure(res);
                } else {
                    resolve(res);
                }
            }

            // 单次失败处理函数
            function failure(error) {
                times--;

                if (times) {
                    setTimeout(fetchUrl, delay);
                }
                else {
                    reject(error);
                }
            }

            // 总体失败处理函数
            function finalHandler(error) {
                throw error;
            }

            function fetchUrl() {
                return fetch(url, options)
                    .then(success)
                    .catch(failure)
                    .catch(finalHandler);
            }

            fetchUrl();
        });
    }

    function fileSize(size) {
        let n = Math.log(size) / Math.log(1024) | 0;
        return (size / Math.pow(1024, n)).toFixed(0) + ' ' + (n ? 'KMGTPEZY'[--n] + 'B' : 'Bytes');
    };

    // 下载 m3u8 文件
    async function downloadM3u8(url) {
        const res = await fetchRetry(url, {}, 3);
        const m3u8 = await res.text();
        let i = 0;

        blobs = [];
        ratio = 0;
        errors = 0;

        // 初始化进度显示
        domDownloadBtn.querySelector('svg').innerHTML = svgCircle;

        m3u8.split('\n').forEach(function (line) {
            if (line.match(/\.ts/)) {
                blobs[i] = undefined;
                downloadTs(url.replace(/\/[^\/]+?$/, '/' + line), i++);
            }
        });
    };

    // 下载 m3u8 文件中的单个 ts 文件
    async function downloadTs(url, order) {
        let res;
        let blob;

        try {
            res = await fetchRetry(url, {}, 5);
            blob = await res.blob();
        } catch (e) {
            if (++errors == 1) {
                resetDownloadIcon();
                alert('下载视频失败,请重新下载。');
            }
            return;
        }

        ratio++;
        blobs[order] = blob;

        errors
            ? resetDownloadIcon()
            : updateProgress(Math.round(100 * ratio / blobs.length));

        store();
    };

    // 保存视频文件
    function store() {
        for (let [index, blob] of blobs.entries()) {
            if (blob == undefined) return;
        }

        let blob = new Blob(blobs, {type: 'video/h264'}),
            name = (new Date()).valueOf() + '.mp4',
            url = window.URL.createObjectURL(blob),
            userAgent = window.navigator.userAgent;

        blobs = null;

        // 结束进度显示
        resetDownloadIcon();

        // edge
        if (window.navigator && window.navigator.msSaveBlob) {
            window.navigator.msSaveBlob(blob, name);
        }
        else {
            url = window.URL.createObjectURL(blob);

            // Chrome 可以使用 Tampermonkey 的 GM_download 函数绕过 CSP(Content Security Policy) 的限制
            if (userAgent.indexOf('Chrome') > 0 && window.GM_download) {
                GM_download({url, name});
            }
            else {
                // firefox 需要禁用 CSP, about:config -> security.csp.enable => false
                // violentmonkey(暴力猴)没有 GM_download 函数
                var a = document.createElement('a');
                document.body.appendChild(a);
                a.href = url;
                a.download = name;
                //a.target = '_blank';
                a.click();
                document.body.removeChild(a);

                setTimeout(function () {
                    window.URL.revokeObjectURL(url);
                }, 100);
            }
        }
    };

    // 重置下载图标
    function resetDownloadIcon() {
        domDownloadBtn.querySelector('svg').innerHTML = svgDownload;
    };

    // 更新下载进度界面
    function updateProgress(percent) {
        let r = 8;
        let degrees = percent / 100 * 360; // 进度对应的角度值
        let rad = degrees * (Math.PI / 180); // 角度对应的弧度值
        let x = (Math.sin(rad) * r).toFixed(2); // 极坐标转换成直角坐标
        let y = -(Math.cos(rad) * r).toFixed(2);
        let lenghty = Number(degrees > 180); // 大于180°时画大角度弧,小于180°时画小角度弧,(deg > 180) ? 1 : 0
        let paths = ['M', 0, -r, 'A', r, r, 0, lenghty, 1, x, y]; // path 属性

        domDownloadBtn.querySelector('svg > path').setAttribute('d', paths.join(' '));
        domDownloadBtn.querySelector('svg > text').textContent = percent;
    };

    //await wait(500);

    // 读取 playlist
    const res = await fetchRetry(playlistBaseUrl + videoId, {
        headers: {
            'referer': 'refererBaseUrl + videoId',
            'authorization': 'oauth c3cef7c66a1843f8b3a9e6a1e3160e20' // in zplayer.min.js of zhihu
        }
    }, 3);
    const videoInfo = await res.json();

    // 获取不同分辨率视频的信息
    for (let [key, video] of Object.entries(videoInfo.playlist)) {
        video.name = key;
        videos.push(video);
    }

    // 按分辨率大小排序
    videos = videos.sort(function (v1, v2) {
        return v1.width == v2.width ? 0 : (v1.width > v2.width ? 1 : -1);
    }).reverse();

    // 生成下载按钮图标
    domDownloadBtn.querySelector('button:first-child').outerHTML = domFullScreenBtn.cloneNode(true).querySelector('button').outerHTML;
    domDownloadBtn.querySelector('svg').innerHTML = svgDownload;

    // 生成各分辨率菜单
    domMenuItem.className = domMenuItem.className.split('-').shift();
    domMenuItem.parentNode.innerHTML = '';
    for (let [index, video] of videos.entries()) {
        let node = domMenuItem.cloneNode();
        node.innerHTML = video.width + ' (' + fileSize(video.size) + ')';
        node.style.width = '100%';
        node.style.textAlign = 'right';
        node.dataset.videoIndex = index;
        domMenu.appendChild(node);
    }

    // 鼠标事件 - 显示菜单
    domDownloadBtn.addEventListener('pointerenter', () => {
        if (blobs == null) {
            domMenu.parentNode.style.cssText = menuStyle + 'opacity:1 !important; visibility:visible !important';
        }
    });

    // 鼠标事件 - 隐藏菜单
    domDownloadBtn.addEventListener('pointerleave', () => {
        if (blobs == null) {
            domMenu.parentNode.style.cssText = menuStyle;
        }
    });

    // 鼠标事件 - 暂停下载
    // domDownloadBtn.addEventListener('pointerdown', () => {});

    // 鼠标事件 - 选择菜单项
    domDownloadBtn.addEventListener('pointerup', event => {
        let e = event.srcElement || event.target;
        let video;

        if (e.tagName == 'BUTTON' && !e.children.length) {
            video = videos[e.dataset.videoIndex];

            // 隐藏菜单
            domMenu.dispatchEvent(new MouseEvent('pointerleave', {
                'bubbles': true,
                'cancelable': true
            }));

            downloadM3u8(video.play_url);
        }
    });

    // 显示下载按钮
    domControlBar.appendChild(domDownloadBtn);
})();