Greasy Fork

Greasy Fork is available in English.

下载知乎视频

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

当前为 2022-09-04 提交的版本,查看 最新版本

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

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

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

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

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

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

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

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

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

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

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

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

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

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

// ==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    http://greasyfork.icu/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)
  })
})()