Greasy Fork

Greasy Fork is available in English.

下载知乎视频

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

// ==UserScript==
// @name         下载知乎视频
// @version      2.0
// @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    http://greasyfork.icu/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()

    const observer = new MutationObserver(async mutationRecords => {
      for (const mutationRecord of mutationRecords) {
        if (mutationRecord.addedNodes.length && mutationRecord.addedNodes.item(0).innerText.includes('倍速')) {
          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} ${video.width}P`
              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 // 观察更低的后代节点
    })
  }
})()