下载知乎视频

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

当前为 2021-05-08 提交的版本,查看 最新版本

// ==UserScript==
// @name         下载知乎视频
// @version      1.19
// @description  为知乎的视频播放器添加下载功能
// @author       Chao
// @include      *://www.zhihu.com/*
// @match        *://www.zhihu.com/*
// @include      https://v.vzuu.com/video/*
// @include      https://video.zhihu.com/video/*
// @match        https://v.vzuu.com/video/*
// @match        https://video.zhihu.com/video/*
// @connect      zhihu.com
// @connect      vzuu.com
// @grant        GM_info
// @grant        GM_download
// @grant        unsafeWindow
// @namespace    https://gf.qytechs.cn/users/38953
// ==/UserScript==
/* jshint esversion: 6 */

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

  const playlistBaseUrl = 'https://lens.zhihu.com/api/v4/videos/';
  // const videoBaseUrl = 'https://video.zhihu.com/video/';
  const videoId = window.location.pathname.split('/').pop(); // 视频id
  const menuStyle = 'transform:none !important; left:auto !important; right:-0.5em !important;';
  const playerId = 'player';
  const coverSelector = '#' + playerId + ' > div:first-child > div:first-child > div:nth-of-type(2)';
  const controlBarSelector = '#' + playerId + ' > div:first-child > 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 player = document.getElementById(playerId);
  // const resolutions = {'普清': 'ld', '标清': 'sd', '高清': 'hd', '超清': 'fhd'};
  const resolutions = [
    {ename: 'ld', cname: '普清'},
    {ename: 'sd', cname: '标清'},
    {ename: 'hd', cname: '高清'},
    {ename: 'fhd', cname: '超清'}
  ];
  let videos = []; // 存储各分辨率的视频信息
  let downloading = false;

  function getBrowerInfo () {
    const browser = (function (window) {
      const document = window.document;
      const navigator = window.navigator;
      const agent = navigator.userAgent.toLowerCase();
      // IE8+支持, 返回浏览器渲染当前文档所用的模式
      // IE6,IE7: undefined
      // IE8: 8 (兼容模式返回7)
      // IE9: 9 (兼容模式返回7,8)
      // IE10: 10 (兼容模式7,8,9)
      const IEMode = document.documentMode;
      const chrome = window.chrome || false;
      const system = {
        // user-agent
        agent: agent,
        // 是否为IE
        isIE: /trident/.test(agent),
        // Gecko内核
        isGecko: agent.indexOf('gecko') > 0 && agent.indexOf('like gecko') < 0,
        // webkit内核
        isWebkit: agent.indexOf('webkit') > 0,
        // 是否为标准模式
        isStrict: document.compatMode === 'CSS1Compat',
        // 是否支持subtitle
        supportSubTitle: function () {
          return 'track' in document.createElement('track');
        },
        // 是否支持scoped
        supportScope: function () {
          return 'scoped' in document.createElement('style');
        },

        // 获取IE的版本号
        ieVersion: function () {
          let rMsie = /(msie\s|trident.*rv:)([\w.]+)/;
          let match = rMsie.exec(agent);
          try {
            return match[2];
          }
          catch (e) {
            return IEMode;
          }
        },
        // Opera版本号
        operaVersion: function () {
          try {
            if (window.opera) {
              return agent.match(/opera.([\d.]+)/)[1];
            }
            else if (agent.indexOf('opr') > 0) {
              return agent.match(/opr\/([\d.]+)/)[1];
            }
          }
          catch (e) {
            return 0;
          }
        }
      };

      try {
        // 浏览器类型(IE、Opera、Chrome、Safari、Firefox)
        system.type = system.isIE ? 'IE' :
          window.opera || (agent.indexOf('opr') > 0) ? 'Opera' :
            (agent.indexOf('chrome') > 0) ? 'Chrome' :
              //safari也提供了专门的判定方式
              window.openDatabase ? 'Safari' :
                (agent.indexOf('firefox') > 0) ? 'Firefox' :
                  'unknow';

        // 版本号
        system.version = (system.type === 'IE') ? system.ieVersion() :
          (system.type === 'Firefox') ? agent.match(/firefox\/([\d.]+)/)[1] :
            (system.type === 'Chrome') ? agent.match(/chrome\/([\d.]+)/)[1] :
              (system.type === 'Opera') ? system.operaVersion() :
                (system.type === 'Safari') ? agent.match(/version\/([\d.]+)/)[1] :
                  '0';

        // 浏览器外壳
        system.shell = function () {
          if (agent.indexOf('edge') > 0) {
            system.version = agent.match(/edge\/([\d.]+)/)[1] || system.version;
            return 'Edge';
          }
          // 遨游浏览器
          if (agent.indexOf('maxthon') > 0) {
            system.version = agent.match(/maxthon\/([\d.]+)/)[1] || system.version;
            return 'Maxthon';
          }
          // QQ浏览器
          if (agent.indexOf('qqbrowser') > 0) {
            system.version = agent.match(/qqbrowser\/([\d.]+)/)[1] || system.version;
            return 'QQBrowser';
          }
          // 搜狗浏览器
          if (agent.indexOf('se 2.x') > 0) {
            return '搜狗浏览器';
          }

          // Chrome:也可以使用window.chrome && window.chrome.webstore判断
          if (chrome && system.type !== 'Opera') {
            const external = window.external;
            const clientInfo = window.clientInformation;
            // 客户端语言:zh-cn,zh.360下面会返回undefined
            const clientLanguage = clientInfo.languages;

            // 猎豹浏览器:或者agent.indexOf("lbbrowser")>0
            if (external && 'LiebaoGetVersion' in external) {
              return 'LBBrowser';
            }
            // 百度浏览器
            if (agent.indexOf('bidubrowser') > 0) {
              system.version = agent.match(/bidubrowser\/([\d.]+)/)[1] ||
                agent.match(/chrome\/([\d.]+)/)[1];
              return 'BaiDuBrowser';
            }
            // 360极速浏览器和360安全浏览器
            if (system.supportSubTitle() && typeof clientLanguage === 'undefined') {
              const storeKeyLen = Object.keys(chrome.webstore).length;
              const v8Locale = 'v8Locale' in window;
              return storeKeyLen > 1 ? '360极速浏览器' : '360安全浏览器';
            }
            return 'Chrome';
          }
          return system.type;
        };

        // 浏览器名称(如果是壳浏览器,则返回壳名称)
        system.name = system.shell();
        // 对版本号进行过滤过处理
        // System.version = System.versionFilter(System.version);

      }
      catch (e) {
        // console.log(e.message);
      }

      return system;

    })(window);

    if (browser.name == undefined || browser.name == '') {
      browser.name = 'Unknown';
      browser.version = 'Unknown';
    }
    else if (browser.version == undefined) {
      browser.version = 'Unknown';
    }
    return browser;
  }

  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) {
        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();
    });
  }

  // 下载指定url的资源
  async function downloadUrl (url, name = (new Date()).valueOf() + '.mp4') {
    const browser = getBrowerInfo();

    // Greasemonkey 需要把 url 转为 blobUrl
    if (GM_info.scriptHandler === 'Greasemonkey') {
      const res = await fetchRetry(url);
      const blob = await res.blob();
      url = URL.createObjectURL(blob);
    }

    // Chrome 可以使用 Tampermonkey 的 GM_download 函数绕过 CSP(Content Security Policy) 的限制
    if (window.GM_download) {
      GM_download({url, name});
    }
    else {
      // firefox 需要禁用 CSP, about:config -> security.csp.enable => false
      let a = document.createElement('a');
      a.href = url;
      a.download = name;
      a.style.display = 'none';
      // a.target = '_blank';
      document.body.appendChild(a);
      a.click();
      document.body.removeChild(a);

      setTimeout(() => URL.revokeObjectURL(url), 100);
    }
  }

  // 格式化文件大小
  function humanSize (size) {
    let n = Math.log(size) / Math.log(1024) | 0;
    return (size / Math.pow(1024, n)).toFixed(0) + ' ' + (n ? 'KMGTPEZY'[--n] + 'B' : 'Bytes');
  }

  if (!player) return;

  // 获取视频信息
  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 (const [key, video] of Object.entries(videoInfo.playlist)) {
    video.name = key.toLowerCase();
    video.cname = resolutions.find(v => v.ename === video.name)?.cname
    if (!videos.find(v => v.size === video.size)) {
      videos.push(video);
    }
  }

  // 按格式大小排序
  videos = videos.sort(function (v1, v2) {
    const v1Index = resolutions.findIndex(v => v.ename === v1.name);
    const v2Index = resolutions.findIndex(v => v.ename === v2.name);

    return v1Index === v2Index ? 0 : (v1Index > v2Index ? 1 : -1);
    // return v1.size === v2.size ? 0 : (v1.size > v2.size ? 1 : -1);
  }).reverse();

  document.addEventListener('DOMNodeInserted', (evt) => {
    const domControlBar = evt.relatedNode.querySelector(':scope > div:last-child > div:first-child');
    if (!domControlBar || domControlBar.querySelector('.download')) return;

    const domFullScreenBtn = domControlBar.querySelector(':scope > div:nth-last-of-type(1)');
    const domResolutionBtn = Array.from(domControlBar.querySelectorAll(':scope > div')).filter(el => el.innerText.substr(1, 1) === '清')[0];
    let domDownloadBtn, defaultResolution, buttons;
    if (!domFullScreenBtn || !domFullScreenBtn.querySelector('button')) return;

    // 克隆分辨率菜单或全屏按钮为下载按钮
    domDownloadBtn = (domResolutionBtn && (domResolutionBtn.className === domFullScreenBtn.className))
      ? domResolutionBtn.cloneNode(true)
      : domFullScreenBtn.cloneNode(true);

    defaultResolution = domDownloadBtn.querySelector('button').innerText;

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

    buttons = domDownloadBtn.querySelectorAll('button');

    // button 元素添加对应的下载地址属性
    buttons.forEach(dom => {
      const video = videos.find(v => v.cname === dom.innerText) || videos[videos.length - 1];

      dom.dataset.video = video.play_url;
      if (dom.innerText) {
        (dom.innerText = `${dom.innerText} (${humanSize(video.size)})`);
      }
      else if (buttons.length == 1) {
        dom.nextSibling.querySelector('div').innerText = humanSize(video.size);
      }
    });

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

    // 鼠标事件 - 隐藏菜单
    domDownloadBtn.addEventListener('pointerleave', () => {
      const domMenu = domDownloadBtn.querySelector('div:nth-of-type(1)');
      if (domMenu) {
        domMenu.style.cssText = menuStyle;
      }
    });

    // 鼠标事件 - 选择菜单项
    domDownloadBtn.addEventListener('pointerup', event => {
      if (downloading) {
        alert('当前正在执行下载任务,请等待任务完成。');
        return;
      }

      let e = event.srcElement || event.target;

      while (e.tagName !== 'BUTTON') {
        e = e.parentNode;
      }

      downloadUrl(e.dataset.video);
    });

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

QingJ © 2025

镜像随时可能失效,请加Q群300939539或关注我们的公众号极客氢云获取最新地址