// ==UserScript==
// @name 网站视频下载器
// @namespace https://github.com/jaysonlong
// @author Jayson Long https://github.com/jaysonlong
// @version 2.2.3
// @match *://www.bilibili.com/*/play/*
// @match *://www.bilibili.com/video/*
// @match *://www.bilibili.com/s/video/*
// @match *://www.iqiyi.com/*.html*
// @match *://tw.iqiyi.com/*.html*
// @match *://www.iq.com/play/*
// @match *://v.qq.com/x/cover/*
// @match *://v.qq.com/x/page/*
// @match *://v.qq.com/tv/*
// @match *://wetv.vip/*
// @match *://www.mgtv.com/b/*
// @match *://w.mgtv.com/b/*
// @require https://unpkg.com/ajax-hook@2.0.0/dist/ajaxhook.min.js
// @require https://cdn.bootcdn.net/ajax/libs/draggabilly/2.3.0/draggabilly.pkgd.min.js
// @resource sweetalert2 https://cdn.bootcdn.net/ajax/libs/limonte-sweetalert2/8.11.8/sweetalert2.all.min.js
// @run-at document-start
// @grant GM_xmlhttpRequest
// @grant GM_getResourceText
// @grant GM_getResourceURL
// @inject-into page
// @downloadURL https://github.com/jaysonlong/webvideo-downloader/raw/master/violentmonkey/WebVideoDownloader.user.js
// @homepageURL https://github.com/jaysonlong/webvideo-downloader
// ==/UserScript==
var storage = {
// 通用
serverAddr: '127.0.0.1:18888',
remoteCallType: 'http', // http || websocket
currDomain: '',
cbFn: {},
downloadBtn: null,
modalInfo: null,
playinfoUrl: null,
// bilibili
playinfoSource: null,
// 腾讯视频
playinfoMethod: null,
playinfoBody: null,
};
var handler = {
'bilibili.com': function() {
// 首次加载
$.ready(function() {
var ele = $('script', each => each.innerText.includes('__playinfo__'));
if (ele.length) {
storage.playinfoSource = "embedded html";
eval(ele[0].innerText);
bilibili_parseResult(window.__playinfo__);
}
});
// 单页跳转
ajaxHook({
open: function([_, url], xhr) {
if (url.indexOf('playurl?') > 0) {
storage.playinfoSource = "xhr request";
storage.playinfoUrl = url.startsWith('http') ? url : location.protocol + url;
fetch(storage.playinfoUrl, {
credentials: 'include'
}).then(resp => resp.json()).then(bilibili_parseResult);
}
}
});
},
'iqiyi.com': function() {
ajaxHook({
open: function([_, url]) {
if (url.indexOf('dash?') > 0) {
storage.playinfoUrl = url;
fetch(url, {
credentials: 'include'
}).then(resp => resp.json()).then(iqiyi_parseResult);
}
}
});
jsonpHook('dash?', iqiyi_parseResult, {
onMatch: url => storage.playinfoUrl = url,
});
},
'iq.com': function() {
// 禁用wasm,防止字幕加密
unsafeWindow.WebAssembly = undefined;
handler['iqiyi.com']();
},
'qq.com': function() {
ajaxHook({
open: ([method, url], xhr) => {
xhr.method = method;
xhr.url = url;
},
send: ([body], xhr) => {
if (xhr.url.includes('qq.com/proxyhttp') && body.includes('vinfoparam')) {
xhr.body = body;
}
},
onreadystatechange: function(xhr) {
if (xhr.body && xhr.readyState == 4) {
Object.assign(storage, {
playinfoUrl: xhr.url,
playinfoMethod: xhr.method,
playinfoBody: xhr.body,
});
tencent_parseResult(xhr.responseText);
}
},
onload: function(xhr) {
if (xhr.body && xhr.readyState == 4) {
Object.assign(storage, {
playinfoUrl: xhr.url,
playinfoMethod: xhr.method,
playinfoBody: xhr.body,
});
tencent_parseResult(xhr.responseText);
}
},
});
},
'wetv.vip': function() {
jsonpHook('getvinfo?', wetv_parseResult, {
onMatch: url => storage.playinfoUrl = url,
});
},
'mgtv.com': function() {
jsonpHook('getSource?', mgtv_parseResult);
ajaxHook({
open: function([_, url], xhr) {
if (url.indexOf('getSource?') > 0) {
fetch(url, {
credentials: 'include'
}).then(resp => resp.json()).then(mgtv_parseResult);
}
}
});
},
}
prepare();
Object.keys(handler).some(domain => {
if (location.href.indexOf(domain) != -1) {
storage.currDomain = domain;
handler[domain]();
return true;
}
});
// --------------------------------------------------------------
// bilibili: 获取视频链接
async function bilibili_parseResult(rs) {
var data = rs.result || rs.data;
$.logEmphasize('VideoInfo', data);
var htmls = [];
htmls.push(`单P下载 (${storage.playinfoSource})`);
var singlePartHtmls = await bilibili_singlePart(data);
htmls = htmls.concat(singlePartHtmls)
htmls.push('多P下载');
if (storage.playinfoUrl) {
var pageUrl = location.href;
pageUrl = pageUrl.indexOf('?') != -1 ? pageUrl : pageUrl + '?';
var playinfoBaseUrl = storage.playinfoUrl.replace(/&cid=[^&]+?$|cid=.+?&/, '');
var sessCookie = document.cookie.split('; ').filter(each => each.startsWith('SESSDATA='));
sessCookie = sessCookie.length ? sessCookie[0] : '';
var url = `${pageUrl}|${playinfoBaseUrl}|${sessCookie}`;
var tips = sessCookie ? '' : '未登录或cookie中的SESSDATA项的HttpOnly属性为true,不一定支持最高清晰度';
htmls.push(`${tips} ${buildLink(url, {clz: 'multi'})}`);
} else {
htmls.push(buildLink(location.href, {clz: 'multi'}));
}
$.waitForTitleChange(() => updateModal({
title: document.title,
content: htmls.join('\n'),
}), storage.downloadBtn ? 3000 : 0);
}
// bilibili: 获取单P视频链接
async function bilibili_singlePart(data) {
var htmls = [];
if (data.dash) {
var sortBw = function(a, b) {
return b.id != a.id ? b.id - a.id : b.bandwidth - a.bandwidth;
}
var { baseUrl: audioUrl } = data.dash.audio.sort(sortBw)[0];
var defns = [];
data.dash.video.sort(sortBw).forEach(video => {
if (defns.includes(video.id)) return;
defns.push(video.id);
var { width, height, baseUrl: videoUrl } = video;
var url = audioUrl + '|' + videoUrl;
var timelength = Math.floor(data.dash.duration / 60);
var fileformat = url.match(/[^/?]+\.([^/?]+)\?/)[1];
var html = `${width}x${height} ${fileformat} ${timelength}分钟 ${buildLink(url)}`;
htmls.push(html);
});
} else if (data.durl) {
var tasks;
if (storage.playinfoUrl) {
tasks = data.accept_quality.map(each => new Promise(resolve => {
var url = storage.playinfoUrl.replace(/qn=\d+/, 'qn=' + each);
fetch(url, { credentials: 'include' }).then(resp => resp.json()).then(rs => {
resolve(rs.result || rs.data)
})
}));
} else {
tasks = [Promise.resolve(data)];
}
var playinfoList = await tasks;
htmls = playinfoList.map(playinfo => {
var { timelength, durl, format: fileformat } = playinfo;
var size = 0, urls = [], url;
for (var each of durl) {
size += each.size;
urls.push(each.url);
}
url = urls.join('|');
size = Math.floor(size / 1024 / 1024);
timelength = Math.floor(timelength / 1000 / 60);
return `${fileformat} ${timelength}分钟 ${size}MB 分${urls.length}段 ${buildLink(url)}`;
});
}
return htmls;
}
// 爱奇艺: 获取视频链接
function iqiyi_parseResult(rs) {
$.logEmphasize('VideoInfo', rs);
var videos = rs.data.program.video.filter(each => each.m3u8 != undefined);
if (!videos.length) {
videos = rs.data.program.video.filter(each => each.fs != undefined);
}
if (videos.length) {
var {
vsize: size,
ff: fileformat,
scrsz: wh,
} = videos[0];
var size = Math.floor(size / 1024 / 1024);
var options = {};
if (storage.currDomain == 'iq.com') {
options = { data: JSON.stringify(rs), text: '点击下载' };
}
var html = `${fileformat} ${wh} ${size}M ${buildLink(storage.playinfoUrl, options)}`;
var updateFn = () => setTimeout(() => updateModal({
title: document.title,
content: html,
}), 300);
$.waitForTitleChange(updateFn, storage.downloadBtn ? 3000 : 0);
}
};
// 腾讯视频: 获取视频链接
function tencent_parseResult(rs) {
var data = typeof rs == "string" ? JSON.parse(rs) : rs;
var vinfo = JSON.parse(data.vinfo);
$.logEmphasize('VideoInfo', vinfo);
var tasks = vinfo.fl.fi.map(each => new Promise(resolve => {
var { name: defn, cname: defDesc } = each;
var body = storage.playinfoBody.replace(/defn=[^&]*/, 'defn=' + defn);
$.fetchWithRetry(storage.playinfoUrl, {
body: body,
method: storage.playinfoMethod,
})
.then(resp => resp.json())
.then(async data => {
var rs = await tencent_parseVideoInfo(data);
return Object.assign(rs, { defDesc });
})
.then(resolve);
}));
Promise.all(tasks).then(rsList => {
var html = '';
rsList.forEach(each => {
try {
var { url, width, height, size, defDesc } = each;
html += `${width}x${height} ${defDesc} ${size}M ${buildLink(url)}\n`;
} catch (e) {}
})
updateModal({
title: document.title,
content: html,
});
});
}
// 腾讯视频: 解析视频信息
async function tencent_parseVideoInfo(data) {
var vinfo = JSON.parse(data.vinfo);
var vi = vinfo.vl.vi[0];
var ui = vi.ul.ui[0];
var url = ui.url;
if (!url.includes('.m3u8')) {
if (ui.hls) {
url += ui.hls.pt;
} else if (vi.cl.fc > 0) {
var fragCnt = vi.cl.fc;
var [vid, mname, suffix] = vi.fn.split('.');
var [_, defId, _] = vi.cl.ci[0].keyid.split('.');
var tasks = Array.apply(null, {length: fragCnt}).map(async (e, i) => {
var fname = `${vid}.${mname}.${i+1}.${suffix}`;
var body = JSON.parse(storage.playinfoBody);
body.buid = 'onlyvkey';
body.vkeyparam = `${body.vinfoparam}&format=${defId}&filename=${fname}`;
body.adparam = body.vinfoparam = undefined;
var fragUrl = await $.fetchWithRetry(storage.playinfoUrl, {
body: JSON.stringify(body),
method: storage.playinfoMethod,
timeout: 1000,
})
.then(resp => resp.json())
.then(async data => {
data = JSON.parse(data.vkey);
return `${url}${fname}?vkey=${data.key}`;
});
return fragUrl;
});
var fragUrls = await Promise.all(tasks);
url = fragUrls.join('|');
} else {
url += `${vi.fn}?vkey=${vi.fvkey}`;
}
}
var { vw: width, vh: height, fs: size } = vi;
size = Math.floor(size / 1024 / 1024);
return { url, width, height, size }
}
// WeTV: 获取视频链接
function wetv_parseResult(rs) {
$.logEmphasize('VideoInfo', rs);
var tasks = rs.fl.fi.map(each => new Promise(resolve => {
var { name: defn, cname: defDesc } = each;
var url = storage.playinfoUrl.replace(/defn=[^&]*/, 'defn=' + defn);
$.jsonp(url).then(rs => {
var data = wetv_parseVideoInfo(rs);
return Object.assign(data, { defDesc });
}).then(resolve);
}));
Promise.all(tasks).then(rsList => {
var html = '';
rsList.forEach(each => {
try {
var { url, width, height, size, defDesc } = each;
html += `${width}x${height} ${defDesc} ${size}M ${buildLink(url)}\n`;
} catch (e) {}
})
updateModal({
title: document.title,
content: html,
});
});
}
// WeTV: 解析视频信息
function wetv_parseVideoInfo(vinfo) {
var vi = vinfo.vl.vi[0];
var ui = vi.ul.ui[0];
var url = ui.url;
if (url.indexOf('.m3u8') == -1) {
url += ui.hls.pt;
}
if (vinfo.sfl && vinfo.sfl.cnt > 0) {
var srts = vinfo.sfl.fi.filter(each => each.url);
var srtsInfo = srts.map(each => {
var srtName = each.name;
var srtUrl = each.url;
if (srtUrl.includes(".vtt.m3u8")) {
srtUrl = srtUrl.replace(".vtt.m3u8", ".vtt");
}
return srtName + '|' + srtUrl;
});
url += '|' + srtsInfo.join('|');
}
var { vw: width, vh: height, fs: size } = vi;
size = Math.floor(size / 1024 / 1024);
return { url, width, height, size }
}
// 芒果TV: 获取视频链接
function mgtv_parseResult(rs) {
$.logEmphasize('VideoInfo', rs);
var host = rs.data.stream_domain[0];
var videoInfo = rs.data.stream.filter(each => each.url != '');
var tasks = videoInfo.map((each, i) => new Promise(resolve => {
var { fileformat, name, url } = each;
url = host + each.url;
$.jsonp(url).then(rs => {
var url = rs.info;
fetch(url).then(resp => resp.text()).then(rs => {
var { width, height, size } = mgtv_parseVideoInfo(rs);
resolve({ width, height, size, fileformat, name, url });
});
});
}));
Promise.all(tasks).then(rsList => {
var html = '';
rsList.forEach(rs => {
var { width, height, size, fileformat, name, url } = rs;
html += `${width}x${height} ${fileformat} ${name} ${size}M ${buildLink(url)}\n`;
})
updateModal({
title: document.title,
content: html,
});
})
}
// 芒果TV: 解析视频信息
function mgtv_parseVideoInfo(rs) {
var width = rs.match(/EXT-MGTV-VIDEO-WIDTH:(\d+)/)[1];
var height = rs.match(/EXT-MGTV-VIDEO-HEIGHT:(\d+)/)[1];
var size = 0;
rs.match(/#EXT-MGTV-File-SIZE:\d+/g).forEach(each => {
var eachSize = parseInt(each.split(':')[1]);
size += eachSize;
});
size = Math.floor(size / 1024 / 1024);
return { width, height, size }
}
// 准备下载信息
function prepareDownload(ele) {
var [url, data] = [ele.href, unescape(ele.dataset.data)];
var queue = [{title:'输入文件名', inputValue: storage.modalInfo.title}];
ele.classList.contains('multi') && queue.push('输入首、尾P(空格分隔)或单P');
Swal.mixin({
input: 'text',
showCancelButton: true,
confirmButtonText: '',
cancelButtonText: '',
progressSteps: Object.keys(queue).map(idx => parseInt(idx) + 1),
}).queue(queue).then((result) => {
if (result.value) {
var payload = {
fileName: result.value[0],
pRange: result.value[1],
linksurl: url,
data: data,
type: 'link',
}
var remoteCallHandler;
if (storage.remoteCallType == 'websocket') {
remoteCallHandler = wsCall;
} else if (storage.remoteCallType == 'http') {
remoteCallHandler = httpCall;
} else {
remoteCallHandler = httpCall;
}
// 创建下载任务
remoteCallHandler(payload).then(msg => {
Swal.fire({
type: 'success',
title: msg,
position: 'top-end',
showConfirmButton: false,
timer: 1000
});
}).catch(msg => {
Swal.fire({
type: 'error',
title: msg,
});
});
}
})
}
// http调用,不受CSP和Mixed Content限制
function httpCall(payload) {
return new Promise((resolve, reject) => {
GM_xmlhttpRequest({
method: "POST",
url: 'http://' + storage.serverAddr,
data: JSON.stringify(payload),
timeout: 1000,
onload: function(res) {
if (res.response == 'success') {
resolve('任务已创建');
} else {
reject('创建任务失败');
}
},
ontimeout: function() {
reject('请先运行 "python daemon.py"');
}
});
});
}
// websocket调用,受CSP和Mixed Content限制,但本地服务器不受影响;支持MSE流传输
function wsCall(payload) {
return new Promise((resolve, reject) => {
var ws = new WebSocket('ws://' + storage.serverAddr);
ws.onerror = function() {
reject('请先运行 "python daemon.py"');
};
ws.onopen = function() {
ws.send(JSON.stringify(payload));
ws.onmessage = e => {
if (e.data == 'success') {
resolve('任务已创建');
} else {
resolve('创建任务失败');
}
ws.close();
}
};
});
}
// 更新下载内容(设置模态框的标题和正文)
function updateModal({title, content}) {
storage.modalInfo = {title, content};
if (storage.downloadBtn) return;
storage.downloadBtn = $.create('div', {
id: 'dl-btn',
innerHTML: '下载
视频',
appendToBody: true,
});
var draggie = new Draggabilly(storage.downloadBtn);
draggie.on('staticClick', e => {
Swal.fire({
title: storage.modalInfo.title,
html: storage.modalInfo.content,
customClass: {
container: 'dl-modal',
title: 'dl-modal-title',
content: 'dl-modal-content',
},
showCloseButton: true,
showConfirmButton: false,
focusConfirm: false,
});
$('.dl-modal')[0].on('click', '.remote', e => {
e.preventDefault();
prepareDownload(e.target);
});
});
}
function buildLink(url, options = {}) {
var { data, clz = '', text = '点击下载'} = options;
var attr = data ? `data-data="${escape(data)}"` : '';
return `${text}`;
}
// --------------------------------------------------------------
// ajax拦截
function ajaxHook() {
ah.hook(...arguments);
Object.assign(XMLHttpRequest, { UNSENT: 0, OPENED: 1, HEADERS_RECEIVED: 2, LOADING: 3, DONE: 4 });
unsafeWindow.XMLHttpRequest = XMLHttpRequest;
}
// jsonp拦截
function jsonpHook(urlKey, cbFunc, options = {}) {
var { cbParamName = 'callback', once = false, onMatch } = options;
var handled = false;
document.createElement = new Proxy(document.createElement, {
apply: function(fn, thisArg, [tagName]) {
var ele = fn.apply(thisArg, [tagName]);
if (tagName.toLowerCase() == 'script') {
setTimeout(() => {
if (ele.src.indexOf(urlKey) > 0) {
if (once && handled) return;
handled = true;
onMatch && onMatch(ele.src);
var cbName = ele.src.match(new RegExp(cbParamName + '=([^&]+)'))[1];
if (!storage.cbFn[cbName]) {
storage.cbFn[cbName] = unsafeWindow[cbName];
Object.defineProperty(unsafeWindow, cbName, {
get: () => {
if (!storage.cbFn[cbName]) {
return undefined;
}
return (rs) => {
try {
cbFunc(rs);
} catch (e) {}
storage.cbFn[cbName](rs);
};
},
set: (fn) => {
storage.cbFn[cbName] = fn;
}
});
}
}
}, 0);
}
return ele;
}
});
}
// 元素选择器
function $(selector, filterFn = null) {
var eles = Array(...document.querySelectorAll(selector));
return filterFn ? eles.filter(filterFn) : eles;
}
// 初始化工作
function prepare() {
unsafeWindow.webvideo_downloader_exist = true;
document.originCreateElement = document.createElement;
Object.assign($, {
create: function(tagName, attrs = {}) {
var ele = document.createElement(tagName);
Object.assign(ele, attrs);
if (attrs.appendToBody) {
document.body.appendChild(ele);
}
return ele;
},
ready: function(callback) {
document.addEventListener("DOMContentLoaded", callback);
},
addStyle: function(source) {
if (source.startsWith('http') || source.startsWith('blob:')) {
$.create('link', {
rel: 'stylesheet',
href: source,
appendToBody: true,
})
} else {
$.create('style', {
innerText: source,
appendToBody: true,
})
}
},
jsonp: function(url, skipHook = true, cbParamName = 'callback') {
$.counter = $.counter ? $.counter + 1 : 1;
var cbName = 'jaysonCb' + $.counter;
return new Promise(resolve => {
var src;
if (url.includes(cbParamName + '=')) {
src = url.replace(new RegExp(`${cbParamName}=[^&]*`), `${cbParamName}=${cbName}`);
} else {
src = `${url}&${cbParamName}=${cbName}`
}
if (skipHook) {
}
var createMethod = skipHook ? 'originCreateElement' : 'createElement'
var script = document[createMethod]('script');
script.src = src;
document.body.appendChild(script);
unsafeWindow[cbName] = function(data) {
resolve(data);
script.remove();
unsafeWindow[cbName] = undefined;
};
})
},
fetchWithTimeout: function() {
var timeout = arguments[1] && arguments[1].timeout || 2000;
var fetchPromise = fetch(...arguments);
var timeoutPromise = new Promise(function(resolve, reject) {
setTimeout(() => reject(new Error('timeout')), timeout);
});
return Promise.race([
fetchPromise,
timeoutPromise
]);
},
fetchWithRetry: async function() {
var maxRetry = arguments[1] && arguments[1].maxRetry || 10;
var times = 0;
var resp = null;
while (!resp && times <= maxRetry) {
try {
resp = await $.fetchWithTimeout(...arguments);
} catch(e) {
console.log("error", e);
times++;
}
}
return resp;
},
waitForTitleChange: function(callback, timeout = 0) {
var handled = false;
var wrappedCb = () => handled || (handled = true) && callback();
$('title')[0].on('childListChanged', wrappedCb, true);
setTimeout(wrappedCb, timeout);
},
logEmphasize: function() {
var args = [...arguments];
args.splice(0, 1, '%c' + args[0], 'color:green;font-size:1.3em;font-weight:bold;background:#abfdc1;');
console.log(...args);
}
});
Object.assign(HTMLElement.prototype, {
is: function(selector) {
return $(selector).includes(this);
},
on: function(event, arg1, arg2) {
if (event === 'childListChanged') {
var [listener, once] = [arg1, arg2];
var observer = new MutationObserver(function() {
listener.call(this);
once && observer.disconnect();
});
observer.observe(this, {childList: true});
} else {
var [selector, listener] = arg2 instanceof Function ? [arg1, arg2] : [null, arg1];
this.addEventListener(event, e => {
if (!selector || e.target.is(selector)) {
listener.call(e.target, e);
}
});
}
return this;
},
});
$.ready(() => {
var sweetalert2 = GM_getResourceText('sweetalert2');
var sweetalert2Url = GM_getResourceURL('sweetalert2');
if (sweetalert2) {
eval(sweetalert2);
window.Swal = this.Sweetalert2;
} else {
$.create('script', {
src: sweetalert2Url,
appendToBody: true,
});
}
$.create('i', {
className: 'fa fa-arrow-right fa-times',
style: 'visibility:hidden;height:0;width:0;',
appendToBody: true,
});
$.addStyle('https://cdn.bootcdn.net/ajax/libs/font-awesome/4.0.0/css/font-awesome.min.css');
$.addStyle(`
.swal2-container {
font-size: 18px;
z-index: 10000;
}
.swal2-modal {
font-size: 1em;
}
#dl-btn {
z-index: 1000;
position: fixed;
top: 200px;
left: 5px;
width: 50px;
height: 50px;
line-height: 50px;
font-size: 12px;
border-radius: 50%;
border: #fff solid 1.5px;
box-shadow: 0 3px 10px rgb(48, 133, 214);
text-align: center;
background: rgb(48, 133, 214);
color: white;
cursor: pointer;
}
#dl-btn:hover {
background-image: linear-gradient(rgba(0,0,0,.1),rgba(0,0,0,.1));
}
#dl-btn span {
display: inline-block;
font-size: 12px;
line-height: 15px;
vertical-align: middle;
}
.dl-modal-title {
font-size: 18px;
}
.dl-modal-content {
font-size: 15px;
line-height: 30px;
white-space: pre-wrap;
}
.dl-modal-content a {
color: blue;
}
.dl-modal-content b {
font-weight: bold;
}
`);
});
}