// ==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 https://greasyfork.org/users/38953 // @downloadURL none // ==/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 = '' // 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) }) })()