下载知乎视频

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

// ==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 // 观察更低的后代节点
    })
  }
})()