// ==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
// ==/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 = '<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 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)
})
})()