Greasy Fork is available in English.

下载知乎视频

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

目前为 2022-09-04 提交的版本。查看 最新版本

// ==UserScript==
// @name         下载知乎视频
// @version      1.31
// @description  为知乎的视频播放器添加下载功能
// @author       王超
// @license      MIT
// @match        https://www.zhihu.com/*
// @match        https://v.vzuu.com/video/*
// @match        https://video.zhihu.com/video/*
// @match        https://www.zhihu.com/zvideo/*
// @connect      zhihu.com
// @connect      vzuu.com
// @grant        GM_info
// @grant        GM_download
// @grant        unsafeWindow
// @namespace    https://greasyfork.org/users/38953
// ==/UserScript==
/* jshint esversion: 8 */

(async () => {
  if (window.location.host === 'www.zhihu.com' && !window.location.pathname.startsWith('/zvideo')) return

  console.log('知乎视频下载:')

  let videoId = window.location.pathname.split('/').pop() // 视频id
  let playerSelector = '#player' // 播放器的查询器
  if (window.location.pathname.startsWith('/zvideo')) {
    const articleId = videoId
    const initialDataJson = JSON.parse(document.getElementById('js-initialData').textContent)
    videoId = initialDataJson.initialState.entities.zvideos[articleId].video.videoId
    playerSelector = 'div.ZVideo-player'
    await waitElement('div.ZVideo-player')
  }

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

  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') {
    // 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)
    }
  }

  async function waitElement (selector) {
    return new Promise((resolve, reject) => {
      if (document.body.querySelector(selector)) return resolve()

      const observer = new MutationObserver(mutationRecords => {
        for (const mutationRecord of mutationRecords) {
          if (mutationRecord.type === 'childList' && mutationRecord.target.querySelector(selector)) {
            return resolve()
          }
        }
      })

      observer.observe(document.body, {
        childList: true, // 观察直接子节点
        subtree: true, // 及其更低的后代节点
        attributes: false // 观察目标节点的属性节点(新增或删除了某个属性,以及某个属性的属性值发生了变化)
      })
    })
  }

  // 格式化文件大小
  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')
  }

  console.log(player)
  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()
  console.log(videoInfo)
  // 获取不同分辨率视频的信息
  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 > div:nth-of-type(2)')
    if (!domControlBar || domControlBar.querySelector('.download')) return

    const domButtonsBar = domControlBar.querySelector(':scope > div:last-child')
    const domFullScreenBtn = domButtonsBar.querySelector(':scope > div:nth-last-of-type(2)')
    const domResolutionBtn = Array.from(domButtonsBar.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 => {
      let e = event.srcElement || event.target

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

      downloadUrl(e.dataset.video)
    })

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