// ==UserScript==
// @name 下载知乎视频
// @version 1.19
// @description 为知乎的视频播放器添加下载功能
// @author Chao
// @include *://www.zhihu.com/*
// @match *://www.zhihu.com/*
// @include https://v.vzuu.com/video/*
// @include https://video.zhihu.com/video/*
// @match https://v.vzuu.com/video/*
// @match https://video.zhihu.com/video/*
// @connect zhihu.com
// @connect vzuu.com
// @grant GM_info
// @grant GM_download
// @grant unsafeWindow
// @namespace https://gf.qytechs.cn/users/38953
// ==/UserScript==
/* jshint esversion: 6 */
(async () => {
if (window.location.host == 'www.zhihu.com') return;
const playlistBaseUrl = 'https://lens.zhihu.com/api/v4/videos/';
// const videoBaseUrl = 'https://video.zhihu.com/video/';
const videoId = window.location.pathname.split('/').pop(); // 视频id
const menuStyle = 'transform:none !important; left:auto !important; right:-0.5em !important;';
const playerId = 'player';
const coverSelector = '#' + playerId + ' > div:first-child > div:first-child > div:nth-of-type(2)';
const controlBarSelector = '#' + playerId + ' > 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 player = document.getElementById(playerId);
// const resolutions = {'普清': 'ld', '标清': 'sd', '高清': 'hd', '超清': 'fhd'};
const resolutions = [
{ename: 'ld', cname: '普清'},
{ename: 'sd', cname: '标清'},
{ename: 'hd', cname: '高清'},
{ename: 'fhd', cname: '超清'}
];
let videos = []; // 存储各分辨率的视频信息
let downloading = false;
function getBrowerInfo () {
const browser = (function (window) {
const document = window.document;
const navigator = window.navigator;
const agent = navigator.userAgent.toLowerCase();
// IE8+支持, 返回浏览器渲染当前文档所用的模式
// IE6,IE7: undefined
// IE8: 8 (兼容模式返回7)
// IE9: 9 (兼容模式返回7,8)
// IE10: 10 (兼容模式7,8,9)
const IEMode = document.documentMode;
const chrome = window.chrome || false;
const system = {
// user-agent
agent: agent,
// 是否为IE
isIE: /trident/.test(agent),
// Gecko内核
isGecko: agent.indexOf('gecko') > 0 && agent.indexOf('like gecko') < 0,
// webkit内核
isWebkit: agent.indexOf('webkit') > 0,
// 是否为标准模式
isStrict: document.compatMode === 'CSS1Compat',
// 是否支持subtitle
supportSubTitle: function () {
return 'track' in document.createElement('track');
},
// 是否支持scoped
supportScope: function () {
return 'scoped' in document.createElement('style');
},
// 获取IE的版本号
ieVersion: function () {
let rMsie = /(msie\s|trident.*rv:)([\w.]+)/;
let match = rMsie.exec(agent);
try {
return match[2];
}
catch (e) {
return IEMode;
}
},
// Opera版本号
operaVersion: function () {
try {
if (window.opera) {
return agent.match(/opera.([\d.]+)/)[1];
}
else if (agent.indexOf('opr') > 0) {
return agent.match(/opr\/([\d.]+)/)[1];
}
}
catch (e) {
return 0;
}
}
};
try {
// 浏览器类型(IE、Opera、Chrome、Safari、Firefox)
system.type = system.isIE ? 'IE' :
window.opera || (agent.indexOf('opr') > 0) ? 'Opera' :
(agent.indexOf('chrome') > 0) ? 'Chrome' :
//safari也提供了专门的判定方式
window.openDatabase ? 'Safari' :
(agent.indexOf('firefox') > 0) ? 'Firefox' :
'unknow';
// 版本号
system.version = (system.type === 'IE') ? system.ieVersion() :
(system.type === 'Firefox') ? agent.match(/firefox\/([\d.]+)/)[1] :
(system.type === 'Chrome') ? agent.match(/chrome\/([\d.]+)/)[1] :
(system.type === 'Opera') ? system.operaVersion() :
(system.type === 'Safari') ? agent.match(/version\/([\d.]+)/)[1] :
'0';
// 浏览器外壳
system.shell = function () {
if (agent.indexOf('edge') > 0) {
system.version = agent.match(/edge\/([\d.]+)/)[1] || system.version;
return 'Edge';
}
// 遨游浏览器
if (agent.indexOf('maxthon') > 0) {
system.version = agent.match(/maxthon\/([\d.]+)/)[1] || system.version;
return 'Maxthon';
}
// QQ浏览器
if (agent.indexOf('qqbrowser') > 0) {
system.version = agent.match(/qqbrowser\/([\d.]+)/)[1] || system.version;
return 'QQBrowser';
}
// 搜狗浏览器
if (agent.indexOf('se 2.x') > 0) {
return '搜狗浏览器';
}
// Chrome:也可以使用window.chrome && window.chrome.webstore判断
if (chrome && system.type !== 'Opera') {
const external = window.external;
const clientInfo = window.clientInformation;
// 客户端语言:zh-cn,zh.360下面会返回undefined
const clientLanguage = clientInfo.languages;
// 猎豹浏览器:或者agent.indexOf("lbbrowser")>0
if (external && 'LiebaoGetVersion' in external) {
return 'LBBrowser';
}
// 百度浏览器
if (agent.indexOf('bidubrowser') > 0) {
system.version = agent.match(/bidubrowser\/([\d.]+)/)[1] ||
agent.match(/chrome\/([\d.]+)/)[1];
return 'BaiDuBrowser';
}
// 360极速浏览器和360安全浏览器
if (system.supportSubTitle() && typeof clientLanguage === 'undefined') {
const storeKeyLen = Object.keys(chrome.webstore).length;
const v8Locale = 'v8Locale' in window;
return storeKeyLen > 1 ? '360极速浏览器' : '360安全浏览器';
}
return 'Chrome';
}
return system.type;
};
// 浏览器名称(如果是壳浏览器,则返回壳名称)
system.name = system.shell();
// 对版本号进行过滤过处理
// System.version = System.versionFilter(System.version);
}
catch (e) {
// console.log(e.message);
}
return system;
})(window);
if (browser.name == undefined || browser.name == '') {
browser.name = 'Unknown';
browser.version = 'Unknown';
}
else if (browser.version == undefined) {
browser.version = 'Unknown';
}
return browser;
}
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') {
const browser = getBrowerInfo();
// 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);
}
}
// 格式化文件大小
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');
}
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();
// 获取不同分辨率视频的信息
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');
if (!domControlBar || domControlBar.querySelector('.download')) return;
const domFullScreenBtn = domControlBar.querySelector(':scope > div:nth-last-of-type(1)');
const domResolutionBtn = Array.from(domControlBar.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 => {
if (downloading) {
alert('当前正在执行下载任务,请等待任务完成。');
return;
}
let e = event.srcElement || event.target;
while (e.tagName !== 'BUTTON') {
e = e.parentNode;
}
downloadUrl(e.dataset.video);
});
// 显示下载按钮
domControlBar.appendChild(domDownloadBtn);
});
})();