🏠 返回首頁 

Greasy Fork is available in English.

下载知乎视频

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


安装此脚本?
  1. // ==UserScript==
  2. // @name 下载知乎视频
  3. // @version 2.1
  4. // @description 为知乎的视频播放器添加下载功能
  5. // @author 王超
  6. // @license MIT
  7. // @match https://www.zhihu.com/*
  8. // @match https://video.zhihu.com/video/*
  9. // @match https://v.vzuu.com/video/*
  10. // @connect zhihu.com
  11. // @connect video.zhihu.com
  12. // @connect vzuu.com
  13. // @grant GM_info
  14. // @grant GM_download
  15. // @grant unsafeWindow
  16. // @namespace https://greasyfork.org/users/38953
  17. // ==/UserScript==
  18. /* jshint esversion: 8 */
  19. (async () => {
  20. console.log('知乎视频下载')
  21. async function downloadUrl(url, name = (new Date()).valueOf() + '.mp4') {
  22. // Greasemonkey 需要把 url 转为 blobUrl
  23. if (GM_info.scriptHandler === 'Greasemonkey') {
  24. const res = await fetch(url)
  25. const blob = await res.blob()
  26. url = URL.createObjectURL(blob)
  27. }
  28. // Chrome 可以使用 Tampermonkey 的 GM_download 函数绕过 CSP(Content Security Policy) 的限制
  29. if (window.GM_download) {
  30. GM_download({ url, name })
  31. }
  32. else {
  33. // firefox 需要禁用 CSP, about:config -> security.csp.enable => false
  34. let a = document.createElement('a')
  35. a.href = url
  36. a.download = name
  37. a.style.display = 'none'
  38. // a.target = '_blank';
  39. document.body.appendChild(a)
  40. a.click()
  41. document.body.removeChild(a)
  42. setTimeout(() => URL.revokeObjectURL(url), 100)
  43. }
  44. }
  45. async function getVideoInfo(videoId) {
  46. const playlistUrl = `https://lens.zhihu.com/api/v4/videos/${videoId}`
  47. const videoInfo = await (await fetch(playlistUrl, { credentials: 'include' })).json()
  48. let videos = []
  49. // 不同分辨率视频的信息
  50. for (const [key, video] of Object.entries(videoInfo.playlist_v2 || videoInfo.playlist)) {
  51. video.resolution_ename = key
  52. video.resolution_cname = resolutionsName.find(v => v.ename === video.resolution_ename)?.cname
  53. if (!videos.find(v => v.size === video.size)) {
  54. videos.push(video)
  55. }
  56. }
  57. // 按大小排序
  58. videos = videos.sort(function (v1, v2) {
  59. const v1Index = resolutionsName.findIndex(v => v.ename === v1.resolution_ename)
  60. const v2Index = resolutionsName.findIndex(v => v.ename === v2.resolution_ename)
  61. return v1Index === v2Index ? 0 : (v1Index > v2Index ? 1 : -1)
  62. })
  63. return videos
  64. }
  65. // 处理单张卡片(问题/文章)
  66. function processCard(domCard) {
  67. const data = JSON.parse(domCard.dataset.zaExtraModule)
  68. // 视频卡片
  69. if (data.card?.content?.video_id) {
  70. processVideo(domCard)
  71. }
  72. }
  73. // 处理详细内容页面
  74. function processContent(domArticle) {
  75. const data = JSON.parse(domArticle.dataset.zaExtraModule)
  76. if (data.card?.content?.video_id) {
  77. processVideo(domArticle)
  78. }
  79. }
  80. // 处理视频
  81. function processVideo(dom) {
  82. const domData = JSON.parse(dom?.dataset?.zaExtraModule || null)
  83. const itemData = JSON.parse(dom.querySelector('div[data-zop]')?.dataset?.zop || null)
  84. const videoId = domData ? domData.card.content.video_id : window.location.pathname.split('/').pop()
  85. let observer = new MutationObserver(async mutationRecords => {
  86. for (const mutationRecord of mutationRecords) {
  87. if (mutationRecord.addedNodes.length && mutationRecord.addedNodes.item(0).innerText.includes('倍速')) {
  88. observer.disconnect()
  89. observer = null
  90. const curVideoUrl = mutationRecord.target.parentElement.children[0].querySelector('video').getAttribute('src')
  91. const toolbar = mutationRecord.addedNodes.item(0).children.item(0).children.item(1).children.item(1)
  92. // 克隆全屏按钮并修改图标作为下载按钮
  93. const domDownload = toolbar.children.item(toolbar.children.length - 3).cloneNode(true)
  94. domDownload.dataset.videoUrl = curVideoUrl
  95. domDownload.querySelector('svg').setAttribute('viewBox', '0 0 24 24')
  96. domDownload.querySelector('svg').innerHTML = svgDownload
  97. domDownload.classList.add('download')
  98. domDownload.children.item(0).setAttribute('aria-label', '下载')
  99. domDownload.children.item(1).innerText = '下载'
  100. domDownload.addEventListener('click', (event) => {
  101. event.stopPropagation()
  102. downloadUrl(domDownload.dataset.videoUrl)
  103. })
  104. domDownload.addEventListener('pointerenter', () => {
  105. const domMenu = domDownload.children.item(1)
  106. domMenu.style.opacity = 1
  107. domMenu.style.visibility = 'visible'
  108. })
  109. domDownload.addEventListener('pointerleave', () => {
  110. const domMenu = domDownload.children.item(1)
  111. domMenu.style.opacity = 0
  112. domMenu.style.visibility = 'hidden'
  113. })
  114. toolbar.appendChild(domDownload)
  115. // 获取视频信息
  116. const videos = await getVideoInfo(videoId)
  117. // 如果有不同清晰度的视频,添加下载弹出菜单
  118. if (videos.length > 1) {
  119. const curResolute = toolbar.children.item(1).children.item(0).innerText
  120. // 克隆倍速菜单为下载菜单
  121. const menu = toolbar.children.item(0).children.item(1).cloneNode(true)
  122. const menuItemContainer = menu.children.item(0)
  123. const menuItemTemplate = menuItemContainer.children.item(0).cloneNode(true)
  124. let menuItem
  125. //menu.style.left = 'auto'
  126. menuItemContainer.innerHTML = ''
  127. for (const video of videos) {
  128. menuItem = menuItemTemplate.cloneNode(true)
  129. menuItem.dataset.videoUrl = video.play_url
  130. menuItem.innerText = video.resolution_cname
  131. menuItem.addEventListener('click', (event) => {
  132. event.stopPropagation()
  133. downloadUrl(event.srcElement.dataset.videoUrl)
  134. })
  135. menuItemContainer.appendChild(menuItem)
  136. }
  137. domDownload.removeChild(domDownload.children.item(1))
  138. domDownload.appendChild(menu)
  139. }
  140. }
  141. }
  142. })
  143. observer.observe(dom, {
  144. childList: true, // 观察直接子节点
  145. subtree: true // 观察更低的后代节点
  146. })
  147. }
  148. 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>'
  149. const resolutionsName = [
  150. { ename: 'FHD', cname: '超清' },
  151. { ename: 'HD', cname: '高清' },
  152. { ename: 'SD', cname: '清晰' },
  153. { ename: 'LD', cname: '普清' }
  154. ]
  155. if (['video.zhihu.com', 'v.vzuu.com'].includes(window.location.host)) {
  156. processVideo(document.getElementById('player'))
  157. }
  158. else {
  159. const observer = new MutationObserver(mutationRecords => {
  160. for (const mutationRecord of mutationRecords) {
  161. if (!mutationRecord.oldValue) {
  162. if (mutationRecord.target?.dataset?.zaDetailViewPathModule === 'FeedItem') {
  163. processCard(mutationRecord.target)
  164. }
  165. else if (mutationRecord.target?.dataset?.zaDetailViewPathModule === 'Content' && mutationRecord.target.tagName === 'ARTICLE') {
  166. processContent(mutationRecord.target)
  167. }
  168. }
  169. }
  170. })
  171. observer.observe(document.body, {
  172. attributeFilter: ['data-za-detail-view-path-module'], // 只观察指定特性的变化
  173. attributeOldValue: true, // 是否将特性的旧值传递给回调
  174. attributes: true, // 观察目标节点的属性节点(新增或删除了某个属性,以及某个属性的属性值发生了变化)
  175. childList: false, // 观察直接子节点
  176. subtree: true // 观察更低的后代节点
  177. })
  178. }
  179. })()