下载知乎视频

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

You will need to install an extension such as Tampermonkey, Greasemonkey or Violentmonkey to install this script.

You will need to install an extension such as Tampermonkey or Violentmonkey to install this script.

You will need to install an extension such as Tampermonkey or Violentmonkey to install this script.

You will need to install an extension such as Tampermonkey or Userscripts to install this script.

You will need to install an extension such as Tampermonkey to install this script.

You will need to install a user script manager extension to install this script.

(I already have a user script manager, let me install it!)

You will need to install an extension such as Stylus to install this style.

You will need to install an extension such as Stylus to install this style.

You will need to install an extension such as Stylus to install this style.

You will need to install a user style manager extension to install this style.

You will need to install a user style manager extension to install this style.

You will need to install a user style manager extension to install this style.

(I already have a user style manager, let me install it!)

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

(async () => {
  console.log('知乎视频下载')

  async function downloadUrl(url, name = (new Date()).valueOf() + '.mp4') {
    // Greasemonkey 需要把 url 转为 blobUrl
    if (GM_info.scriptHandler === 'Greasemonkey') {
      const res = await fetch(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 getVideoInfo(videoId) {
    const playlistUrl = `https://lens.zhihu.com/api/v4/videos/${videoId}`
    const videoInfo = await (await fetch(playlistUrl, { credentials: 'include' })).json()
    let videos = []

    // 不同分辨率视频的信息
    for (const [key, video] of Object.entries(videoInfo.playlist_v2 || videoInfo.playlist)) {
      video.resolution_ename = key
      video.resolution_cname = resolutionsName.find(v => v.ename === video.resolution_ename)?.cname
      if (!videos.find(v => v.size === video.size)) {
        videos.push(video)
      }
    }

    // 按大小排序
    videos = videos.sort(function (v1, v2) {
      const v1Index = resolutionsName.findIndex(v => v.ename === v1.resolution_ename)
      const v2Index = resolutionsName.findIndex(v => v.ename === v2.resolution_ename)
      return v1Index === v2Index ? 0 : (v1Index > v2Index ? 1 : -1)
    })

    return videos
  }

  // 处理单张卡片(问题/文章)
  function processCard(domCard) {
    const data = JSON.parse(domCard.dataset.zaExtraModule)

    // 视频卡片
    if (data.card?.content?.video_id) {
      processVideo(domCard)
    }
  }

  // 处理详细内容页面
  function processContent(domArticle) {
    const data = JSON.parse(domArticle.dataset.zaExtraModule)

    if (data.card?.content?.video_id) {
      processVideo(domArticle)
    }
  }

  // 处理视频
  function processVideo(dom) {
    const domData = JSON.parse(dom?.dataset?.zaExtraModule || null)
    const itemData = JSON.parse(dom.querySelector('div[data-zop]')?.dataset?.zop || null)
    const videoId = domData ? domData.card.content.video_id : window.location.pathname.split('/').pop()

    let observer = new MutationObserver(async mutationRecords => {
      for (const mutationRecord of mutationRecords) {
        if (mutationRecord.addedNodes.length && mutationRecord.addedNodes.item(0).innerText.includes('倍速')) {
          observer.disconnect()
          observer = null

          const curVideoUrl = mutationRecord.target.parentElement.children[0].querySelector('video').getAttribute('src')
          const toolbar = mutationRecord.addedNodes.item(0).children.item(0).children.item(1).children.item(1)

          // 克隆全屏按钮并修改图标作为下载按钮
          const domDownload = toolbar.children.item(toolbar.children.length - 3).cloneNode(true)
          domDownload.dataset.videoUrl = curVideoUrl
          domDownload.querySelector('svg').setAttribute('viewBox', '0 0 24 24')
          domDownload.querySelector('svg').innerHTML = svgDownload
          domDownload.classList.add('download')
          domDownload.children.item(0).setAttribute('aria-label', '下载')
          domDownload.children.item(1).innerText = '下载'
          domDownload.addEventListener('click', (event) => {
            event.stopPropagation()
            downloadUrl(domDownload.dataset.videoUrl)
          })
          domDownload.addEventListener('pointerenter', () => {
            const domMenu = domDownload.children.item(1)
            domMenu.style.opacity = 1
            domMenu.style.visibility = 'visible'
          })
          domDownload.addEventListener('pointerleave', () => {
            const domMenu = domDownload.children.item(1)
            domMenu.style.opacity = 0
            domMenu.style.visibility = 'hidden'
          })
          toolbar.appendChild(domDownload)

          // 获取视频信息
          const videos = await getVideoInfo(videoId)

          // 如果有不同清晰度的视频,添加下载弹出菜单
          if (videos.length > 1) {
            const curResolute = toolbar.children.item(1).children.item(0).innerText
            // 克隆倍速菜单为下载菜单
            const menu = toolbar.children.item(0).children.item(1).cloneNode(true)
            const menuItemContainer = menu.children.item(0)
            const menuItemTemplate = menuItemContainer.children.item(0).cloneNode(true)
            let menuItem

            //menu.style.left = 'auto'
            menuItemContainer.innerHTML = ''

            for (const video of videos) {
              menuItem = menuItemTemplate.cloneNode(true)
              menuItem.dataset.videoUrl = video.play_url
              menuItem.innerText = video.resolution_cname
              menuItem.addEventListener('click', (event) => {
                event.stopPropagation()
                downloadUrl(event.srcElement.dataset.videoUrl)
              })
              menuItemContainer.appendChild(menuItem)
            }

            domDownload.removeChild(domDownload.children.item(1))
            domDownload.appendChild(menu)
          }
        }
      }
    })

    observer.observe(dom, {
      childList: true, // 观察直接子节点
      subtree: true // 观察更低的后代节点
    })
  }

  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 resolutionsName = [
    { ename: 'FHD', cname: '超清' },
    { ename: 'HD', cname: '高清' },
    { ename: 'SD', cname: '清晰' },
    { ename: 'LD', cname: '普清' }
  ]

  if (['video.zhihu.com', 'v.vzuu.com'].includes(window.location.host)) {
    processVideo(document.getElementById('player'))
  }
  else {
    const observer = new MutationObserver(mutationRecords => {
      for (const mutationRecord of mutationRecords) {
        if (!mutationRecord.oldValue) {
          if (mutationRecord.target?.dataset?.zaDetailViewPathModule === 'FeedItem') {
            processCard(mutationRecord.target)
          }
          else if (mutationRecord.target?.dataset?.zaDetailViewPathModule === 'Content' && mutationRecord.target.tagName === 'ARTICLE') {
            processContent(mutationRecord.target)
          }
        }
      }
    })

    observer.observe(document.body, {
      attributeFilter: ['data-za-detail-view-path-module'], // 只观察指定特性的变化
      attributeOldValue: true, // 是否将特性的旧值传递给回调
      attributes: true, // 观察目标节点的属性节点(新增或删除了某个属性,以及某个属性的属性值发生了变化)
      childList: false, // 观察直接子节点
      subtree: true // 观察更低的后代节点
    })
  }
})()