// ==UserScript==
// @name bilibili merged flv+mp4+ass
// @namespace http://qli5.tk/
// @homepageURL http://qli5.tk/
// @description bilibili:超清FLV下载,FLV合并,原生MP4下载,ASS弹幕下载,TTPS,用原生appsecret,不需要额外权限。
// @include http://www.bilibili.com/video/av*
// @include https://www.bilibili.com/video/av*
// @include http://bangumi.bilibili.com/anime/*/play*
// @include https://bangumi.bilibili.com/anime/*/play*
// @version 0.9
// @author qli5
// @copyright qli5, 2014+, 田生, grepmusic
// @license Mozilla Public License 2.0; http://www.mozilla.org/MPL/2.0/
// @run-at document-end
// ==/UserScript==
// 内测开关 1开启 0关闭
let uOption = {
cache: 1, // 缓存满了才自动删
partial: 1, // 断点续传
proxy: 1, // 播放器更流畅
};
/* cache + proxy = Service Worker
* Hope bilibili will have a SW as soon as possible.
* partial = Stream
* Hope the fetch API will be stabilized as soon as possible.
* If you are using your grandpa's browser, do not enable these functions.
**/
/* BiliMonkey
* A bilibili user script
* by qli5 goodlq11[at](gmail|163).com
*
* The FLV merge utility is a Javascript translation of
* https://github.com/grepmusic/flvmerge
* by grepmusic
*
* The ASS convert utility is a wrapper of
* https://tiansh.github.io/us-danmaku/bilibili/
* by tiansh
* (This script is loaded dynamically so that updates can be applied
* instantly. If github gets blocked from your region, please give
* BiliMonkey::loadASSScript a new default src.)
* (如果github被墙了,Ctrl+F搜索loadASSScript,给它一个新的网址。)
*
* This script is licensed under Mozilla Public License 2.0
* https://www.mozilla.org/MPL/2.0/
*
* Covered Software is provided under this License on an “as is” basis,
* without warranty of any kind, either expressed, implied, or statutory,
* including, without limitation, warranties that the Covered Software
* is free of defects, merchantable, fit for a particular purpose or
* non-infringing. The entire risk as to the quality and performance of
* the Covered Software is with You. Should any Covered Software prove
* defective in any respect, You (not any Contributor) assume the cost
* of any necessary servicing, repair, or correction. This disclaimer
* of warranty constitutes an essential part of this License. No use of
* any Covered Software is authorized under this License except under
* this disclaimer.
*
* Under no circumstances and under no legal theory, whether tort
* (including negligence), contract, or otherwise, shall any Contributor,
* or anyone who distributes Covered Software as permitted above, be
* liable to You for any direct, indirect, special, incidental, or
* consequential damages of any character including, without limitation,
* damages for lost profits, loss of goodwill, work stoppage, computer
* failure or malfunction, or any and all other commercial damages or
* losses, even if such party shall have been informed of the possibility
* of such damages. This limitation of liability shall not apply to
* liability for death or personal injury resulting from such party’s
* negligence to the extent applicable law prohibits such limitation.
* Some jurisdictions do not allow the exclusion or limitation of
* incidental or consequential damages, so this exclusion and limitation
* may not apply to You.
**/
class TwentyFourDataView extends DataView {
constructor(...args) {
if (TwentyFourDataView.es6) {
super(...args);
}
else {
// ES5 polyfill
// It is dirty. Very dirty.
if (TwentyFourDataView.es6 === undefined) {
try {
TwentyFourDataView.es6 = 1;
return super(...args);
}
catch (e) {
if (e.name == 'TypeError') {
TwentyFourDataView.es6 = 0;
let setPrototypeOf = Object.setPrototypeOf || function (obj, proto) {
obj.__proto__ = proto;
return obj;
};
setPrototypeOf(TwentyFourDataView, Object);
}
else throw e;
}
}
super();
let _dataView = new DataView(...args);
_dataView.getUint24 = TwentyFourDataView.prototype.getUint24;
_dataView.setUint24 = TwentyFourDataView.prototype.setUint24;
_dataView.indexOf = TwentyFourDataView.prototype.indexOf;
return _dataView;
}
}
getUint24(byteOffset, littleEndian) {
if (littleEndian) throw 'littleEndian int24 not supported';
let msb = this.getUint8(byteOffset);
return (msb << 16 | this.getUint16(byteOffset + 1));
}
setUint24(byteOffset, value, littleEndian) {
if (littleEndian) throw 'littleEndian int24 not supported';
if (value > 0x00FFFFFF) throw 'setUint24: number out of range';
let msb = value >> 16;
let lsb = value & 0xFFFF;
this.setUint8(byteOffset, msb);
this.setUint16(byteOffset + 1, lsb);
}
indexOf(search, startOffset = 0, endOffset = this.byteLength - search.length + 1) {
// I know it is NAIVE
if (search.charCodeAt) {
for (let i = startOffset; i < endOffset; i++) {
if (this.getUint8(i) != search.charCodeAt(0)) continue;
let found = 1;
for (let j = 0; j < search.length; j++) {
if (this.getUint8(i + j) != search.charCodeAt(j)) {
found = 0;
break;
}
}
if (found) return i;
}
return -1;
}
else {
for (let i = startOffset; i < endOffset; i++) {
if (this.getUint8(i) != search[0]) continue;
let found = 1;
for (let j = 0; j < search.length; j++) {
if (this.getUint8(i + j) != search[j]) {
found = 0;
break;
}
}
if (found) return i;
}
return -1;
}
}
}
class FLVTag {
constructor(dataView, currentOffset) {
this.tagHeader = new TwentyFourDataView(dataView.buffer, dataView.byteOffset + currentOffset, 11);
this.tagData = new TwentyFourDataView(dataView.buffer, dataView.byteOffset + currentOffset + 11, this.dataSize);
this.previousSize = new TwentyFourDataView(dataView.buffer, dataView.byteOffset + currentOffset + 11 + this.dataSize, 4);
}
get tagType() {
return this.tagHeader.getUint8(0);
}
get dataSize() {
return this.tagHeader.getUint24(1);
}
get timestamp() {
return this.tagHeader.getUint24(4);
}
get timestampExtension() {
return this.tagHeader.getUint8(7);
}
get streamID() {
return this.tagHeader.getUint24(8);
}
stripKeyframesScriptData() {
let hasKeyframes = 'hasKeyframes\x01';
let keyframes = '\x00\x09keyframs\x03';
if (this.tagType != 0x12) throw 'can not strip non-scriptdata\'s keyframes';
let index;
index = this.tagData.indexOf(hasKeyframes);
if (index != -1) {
//0x0101 => 0x0100
this.tagData.setUint8(index + hasKeyframes.length, 0x00);
}
// Well, I do not think it is necessary
/*index = this.tagData.indexOf(keyframes)
if (index != -1) {
this.dataSize = index;
this.tagHeader.setUint24(1, index);
this.tagData = new TwentyFourDataView(this.tagData.buffer, this.tagData.byteOffset, index);
}*/
}
getDuration() {
if (this.tagType != 0x12) throw 'can not find non-scriptdata\'s duration';
let duration = 'duration\x00';
let index = this.tagData.indexOf(duration);
if (index == -1) throw 'can not get flv meta duration';
index += 9;
return this.tagData.getFloat64(index);
}
getDurationAndView() {
if (this.tagType != 0x12) throw 'can not find non-scriptdata\'s duration';
let duration = 'duration\x00';
let index = this.tagData.indexOf(duration);
if (index == -1) throw 'can not get flv meta duration';
index += 9;
return {
duration: this.tagData.getFloat64(index),
durationDataView: new TwentyFourDataView(this.tagData.buffer, this.tagData.byteOffset + index, 8)
};
}
getCombinedTimestamp() {
return (this.timestampExtension << 24 | this.timestamp);
}
setCombinedTimestamp(timestamp) {
if (timestamp < 0) throw 'timestamp < 0';
this.tagHeader.setUint8(7, timestamp >> 24);
this.tagHeader.setUint24(4, timestamp & 0x00FFFFFF);
}
}
class FLV {
constructor(dataView) {
if (dataView.indexOf('FLV', 0, 1) != 0) throw 'Invalid FLV header';
this.header = new TwentyFourDataView(dataView.buffer, dataView.byteOffset, 9);
this.firstPreviousTagSize = new TwentyFourDataView(dataView.buffer, dataView.byteOffset + 9, 4);
this.tags = [];
let offset = this.headerLength + 4;
while (offset < dataView.byteLength) {
let tag = new FLVTag(dataView, offset);
// debug for scrpit data tag
// if (tag.tagType != 0x08 && tag.tagType != 0x09) debugger;
offset += 11 + tag.dataSize + 4;
this.tags.push(tag);
}
if (offset != dataView.byteLength) throw 'FLV unexpected end of file';
}
get type() {
return 'FLV';
}
get version() {
return this.header.getUint8(3);
}
get typeFlag() {
return this.header.getUint8(4);
}
get headerLength() {
return this.header.getUint32(5);
}
static merge(flvs) {
if (flvs.length < 1) throw 'Usage: FLV.merge([flvs])';
let blobParts = [];
let basetimestamp = [0, 0];
let lasttimestamp = [0, 0];
let duration = 0.0;
let durationDataView;
blobParts.push(flvs[0].header);
blobParts.push(flvs[0].firstPreviousTagSize);
for (let flv of flvs) {
let bts = duration * 1000;
basetimestamp[0] = lasttimestamp[0];
basetimestamp[1] = lasttimestamp[1];
bts = Math.max(bts, basetimestamp[0], basetimestamp[1]);
let foundDuration = 0;
for (let tag of flv.tags) {
if (tag.tagType == 0x12 && !foundDuration) {
duration += tag.getDuration();
foundDuration = 1;
if (flv == flvs[0]) {
({ duration, durationDataView } = tag.getDurationAndView());
tag.stripKeyframesScriptData();
blobParts.push(tag.tagHeader);
blobParts.push(tag.tagData);
blobParts.push(tag.previousSize);
}
}
else if (tag.tagType == 0x08 || tag.tagType == 0x09) {
lasttimestamp[tag.tagType - 0x08] = bts + tag.getCombinedTimestamp();
tag.setCombinedTimestamp(lasttimestamp[tag.tagType - 0x08]);
blobParts.push(tag.tagHeader);
blobParts.push(tag.tagData);
blobParts.push(tag.previousSize);
}
}
}
durationDataView.setFloat64(0, duration);
return new Blob(blobParts);
}
static async mergeBlobs(blobs) {
// Blobs can be swaped to disk, while Arraybuffers can not.
// This is a RAM saving workaround. Somewhat.
if (blobs.length < 1) throw 'Usage: FLV.mergeBlobs([blobs])';
let resultParts = [];
let basetimestamp = [0, 0];
let lasttimestamp = [0, 0];
let duration = 0.0;
let durationDataView;
for (let blob of blobs) {
let bts = duration * 1000;
basetimestamp[0] = lasttimestamp[0];
basetimestamp[1] = lasttimestamp[1];
bts = Math.max(bts, basetimestamp[0], basetimestamp[1]);
let foundDuration = 0;
let flv = await new Promise((resolve, reject) => {
let fr = new FileReader();
fr.onload = () => resolve(new FLV(new TwentyFourDataView(fr.result)));
fr.readAsArrayBuffer(blob);
fr.onerror = reject;
});
for (let tag of flv.tags) {
if (tag.tagType == 0x12 && !foundDuration) {
duration += tag.getDuration();
foundDuration = 1;
if (blob == blobs[0]) {
resultParts.push(new Blob([flv.header, flv.firstPreviousTagSize]));
({ duration, durationDataView } = tag.getDurationAndView());
tag.stripKeyframesScriptData();
resultParts.push(new Blob([tag.tagHeader]));
resultParts.push(tag.tagData);
resultParts.push(new Blob([tag.previousSize]));
}
}
else if (tag.tagType == 0x08 || tag.tagType == 0x09) {
lasttimestamp[tag.tagType - 0x08] = bts + tag.getCombinedTimestamp();
tag.setCombinedTimestamp(lasttimestamp[tag.tagType - 0x08]);
resultParts.push(new Blob([tag.tagHeader, tag.tagData, tag.previousSize]));
}
}
}
durationDataView.setFloat64(0, duration);
return new Blob(resultParts);
}
}
class CacheDB {
constructor(dbName = 'biliMonkey', osName = 'flv', keyPath = 'name', maxItemSize = 100 * 1024 * 1024) {
this.dbName = dbName;
this.osName = osName;
this.keyPath = keyPath;
this.maxItemSize = maxItemSize;
this.db = null;
}
async getDB() {
if (this.db) return this.db;
this.db = new Promise((resolve, reject) => {
let openRequest = indexedDB.open(this.dbName);
openRequest.onupgradeneeded = e => {
let db = e.target.result;
if (!db.objectStoreNames.contains(this.osName)) {
db.createObjectStore(this.osName, { keyPath: this.keyPath });
}
}
openRequest.onsuccess = e => {
resolve(this.db = e.target.result);
}
openRequest.onerror = reject;
});
return this.db;
}
async addData(item, name = item.name, data = item.data) {
if (!data.size) throw 'CacheDB: data must be a Blob';
let db = await this.getDB();
let itemChunks = [];
let numChunks = Math.ceil(data.size / this.maxItemSize);
for (let i = 0; i < numChunks; i++) {
itemChunks.push({
name: `${name}_part_${i}`,
numChunks,
data: data.slice(i * this.maxItemSize, (i + 1) * this.maxItemSize)
});
}
let reqArr = [];
for (let chunk of itemChunks) {
reqArr.push(new Promise((resolve, reject) => {
let req = db
.transaction([this.osName], "readwrite")
.objectStore(this.osName)
.add(chunk);
req.onsuccess = resolve;
req.onerror = reject;
}));
}
return Promise.all(reqArr);
}
async putData(item, name = item.name, data = item.data) {
if (!data.size) throw 'CacheDB: data must be a Blob';
let db = await this.getDB();
let itemChunks = [];
let numChunks = Math.ceil(data.size / this.maxItemSize);
for (let i = 0; i < numChunks; i++) {
itemChunks.push({
name: `${name}_part_${i}`,
numChunks,
data: data.slice(i * this.maxItemSize, (i + 1) * this.maxItemSize)
});
}
let reqArr = [];
for (let chunk of itemChunks) {
reqArr.push(new Promise((resolve, reject) => {
let req = db
.transaction([this.osName], "readwrite")
.objectStore(this.osName)
.put(chunk);
req.onsuccess = resolve;
req.onerror = reject;
}));
}
return Promise.all(reqArr);
}
async getData(index) {
let db = await this.getDB();
let item_0 = await new Promise((resolve, reject) => {
let req = db
.transaction([this.osName])
.objectStore(this.osName)
.get(`${index}_part_0`);
req.onsuccess = () => resolve(req.result);
req.onerror = reject;
});
if (!item_0) return undefined;
let { numChunks, data: data_0 } = item_0;
let reqArr = [Promise.resolve(data_0)];
for (let i = 1; i < numChunks; i++) {
reqArr.push(new Promise((resolve, reject) => {
let req = db
.transaction([this.osName])
.objectStore(this.osName)
.get(`${index}_part_${i}`);
req.onsuccess = () => resolve(req.result.data);
req.onerror = reject;
}));
}
let itemChunks = await Promise.all(reqArr);
return { name: index, data: new Blob(itemChunks) };
}
async deleteData(index) {
let db = await this.getDB();
let item_0 = await new Promise((resolve, reject) => {
let req = db
.transaction([this.osName])
.objectStore(this.osName)
.get(`${index}_part_0`);
req.onsuccess = () => resolve(req.result);
req.onerror = reject;
});
if (!item_0) return undefined;
let numChunks = item_0.numChunks;
let reqArr = [];
for (let i = 0; i < numChunks; i++) {
reqArr.push(new Promise((resolve, reject) => {
let req = db
.transaction([this.osName], "readwrite")
.objectStore(this.osName)
.delete(`${index}_part_${i}`);
req.onsuccess = resolve;
req.onerror = reject;
}));
}
return Promise.all(reqArr);
}
async deleteEntireDB() {
let req = indexedDB.deleteDatabase(this.dbName);
return new Promise((resolve, reject) => {
req.onsuccess = () => resolve(this.db = null);
req.onerror = reject;
});
}
}
class DetailedFetchBlob {
constructor(input, init = {}, onprogress = init.onprogress, onabort = init.onabort, onerror = init.onerror) {
// Now I know why standardizing cancelable Promise is that difficult
// PLEASE refactor me!
this.onprogress = onprogress;
this.onabort = onabort;
this.onerror = onerror;
this.loaded = 0;
this.total = 0;
this.lengthComputable = false;
this.buffer = [];
this.blob = null;
this.abort = null;
this.reader = null;
this.blobPromise = fetch(input, init).then(res => {
if (!res.ok) throw `HTTP Error ${res.status}: ${res.statusText}`;
this.lengthComputable = res.headers.has("Content-Length");
this.total = parseInt(res.headers.get("Content-Length")) || Infinity;
this.total += init.cacheLoaded || 0;
this.loaded = init.cacheLoaded || 0;
if (this.lengthComputable) {
this.reader = res.body.getReader();
return this.blob = this.consume();
}
else {
if (this.onprogress) this.onprogress(this.loaded, this.total, this.lengthComputable);
return this.blob = res.blob();
}
});
this.blobPromise.catch(e => this.onerror({ target: this, type: e }));
this.promise = Promise.race([
this.blobPromise,
new Promise((resolve, reject) => this.abort = () => {
this.onabort({ target: this, type: 'abort' });
reject('abort');
this.buffer = [];
this.blob = null;
if (this.reader) this.reader.cancel();
})
]);
this.then = this.promise.then.bind(this.promise);
this.catch = this.promise.catch.bind(this.promise);
}
getPartialBlob() {
return new Blob(this.buffer);
}
async pump() {
while (true) {
let { done, value } = await this.reader.read();
if (done) return this.loaded;
this.loaded += value.byteLength;
this.buffer.push(value);
if (this.onprogress) this.onprogress(this.loaded, this.total, this.lengthComputable);
}
}
async consume() {
await this.pump();
this.blob = new Blob(this.buffer);
this.buffer = null;
return this.blob;
}
async getBlob() {
return this.promise;
}
}
class BiliMonkey {
constructor(playerWin, cache = null, partial = false, proxy = false) {
this.playerWin = playerWin;
this.protocol = playerWin.location.protocol;
this.cid = null;
this.flvs = null;
this.mp4 = null;
this.ass = null;
this.promises = null;
// experimental
this.cache = cache;
this.partial = partial;
this.proxy = proxy;
this.flvsDetailedFetch = [];
this.flvsBlob = [];
this.flvsBlobURL = [];
// obsolete
this.flvsXHR = [];
}
async getInfo() {
await this.getPlayer();
const trivialRes = { 'from': 'local', 'result': 'suee', 'format': 'hdmp4', 'timelength': 10, 'accept_format': 'flv,hdmp4,mp4', 'accept_quality': [3, 2, 1], 'seek_param': 'start', 'seek_type': 'second', 'durl': [{ 'order': 1, 'length': 1000, 'size': 30000, 'url': '', 'backup_url': ['', ''] }] };
const jq = this.playerWin == window ? $ : this.playerWin.$;
const _ajax = jq.ajax;
const defquality = this.playerWin.localStorage && this.playerWin.localStorage.bilibili_player_settings ? (2 + JSON.parse(this.playerWin.localStorage.bilibili_player_settings).setting_config.defquality) % 3 + 1 : 3;
let flvPromise, mp4Promise, assPromise;
let mp4Request;
// OK, I know code reuse is good. BUT it proved to have many many many ifs, which is completely unreadable. I hate it.
if (defquality == 2) return this.getInfoDefaultIs2();
// jq hijack
mp4Request = await new Promise(resolve => {
let buttonEnabled = 0;
jq.ajax = function (a, c) {
if (a.url.search('interface.bilibili.com/playurl?') != -1 || a.url.search('bangumi.bilibili.com/player/web_api/playurl?') != -1) {
// Send back a fake response to enable the FHD button.
if (!buttonEnabled) {
a.success(trivialRes);
buttonEnabled = 1;
}
// However, the player will retry - make sure it gets stuck.
else {
resolve([a, c]);
}
}
else {
_ajax.call(jq, a, c);
}
};
this.playerWin.document.querySelector('div.bilibili-player-video-btn.bilibili-player-video-btn-quality > div > ul > li:nth-child(2)').click();
});
this.cid = mp4Request[0].url.match(/cid=\d*/)[0].slice(4);
flvPromise = new Promise(resolve => {
let self = this;
jq.ajax = function (a, c) {
if (a.url.search('interface.bilibili.com/playurl?') != -1 || a.url.search('bangumi.bilibili.com/player/web_api/playurl?') != -1) {
let _success = a.success;
jq.ajax = _ajax;
a.success = res => {
if (res.format != 'flv') throw 'flv fail: response is not flv';
if (!self.proxy) {
_success(res);
self.flvs = res.durl.map(e => e.url.replace('http:', self.protocol));
}
else {
self.flvs = res.durl.map(e => e.url.replace('http:', self.protocol));
self.setupProxy(res, _success);
}
resolve(res);
};
if (defquality == 1) { _success({}); a.success = res => { if (res.format != 'flv') throw 'flv fail: response is not flv'; self.flvs = res.durl.map(e => e.url.replace('http:', self.protocol)); resolve(res); }; self.playerWin.document.querySelector('div.bilibili-player-video-btn.bilibili-player-video-btn-quality > div > ul > li:nth-child(3)').click(); }
}
_ajax.call(jq, a, c);
};
this.playerWin.document.querySelector('div.bilibili-player-video-btn.bilibili-player-video-btn-quality > div > ul > li:nth-child(1)').click();
});
mp4Promise = new Promise(resolve => {
mp4Request[0].success = res => {
if (res.format != 'hdmp4') throw 'hdmp4 fail: response is not hdmp4';
this.mp4 = res.durl[0].url.replace('http:', this.protocol);
resolve(res);
};
_ajax.apply(jq, mp4Request);
});
assPromise = new Promise(async resolve => {
let { fetchDanmaku, generateASS, setPosition } = await BiliMonkey.loadASSScript();
fetchDanmaku(this.cid, danmaku => {
let ass = generateASS(setPosition(danmaku), {
'title': name,
'ori': location.href,
});
// I would assume most users are using Windows
let blob = new Blob(['\ufeff' + ass], { type: 'application/octet-stream' });
this.ass = window.URL.createObjectURL(blob);
resolve(this.ass);
});
});
this.promises = [Promise.resolve(this), flvPromise, mp4Promise, assPromise];
return Promise.all(this.promises);
}
async getInfoDefaultIs2() {
const trivialRes = { 'from': 'local', 'result': 'suee', 'format': 'flv', 'timelength': 10, 'accept_format': 'flv', 'accept_quality': [3], 'seek_param': 'start', 'seek_type': 'second', 'durl': [{ 'order': 1, 'length': 1000, 'size': 30000, 'url': '', 'backup_url': ['', ''] }] };
const jq = this.playerWin == window ? $ : this.playerWin.$;
const _ajax = jq.ajax;
const defquality = 2;
let flvPromise, mp4Promise, assPromise;
let flvRequest;
// jq hijack
flvRequest = await new Promise(resolve => {
let buttonEnabled = 0;
let flv_a_c;
jq.ajax = function (a, c) {
if (a.url.search('interface.bilibili.com/playurl?') != -1 || a.url.search('bangumi.bilibili.com/player/web_api/playurl?') != -1) {
// Send back a fake response to enable the FHD button.
if (!buttonEnabled) {
a.success(trivialRes);
buttonEnabled = 1;
flv_a_c = [a, c];
}
// However, the player will retry - make sure it gets stuck.
else {
resolve(flv_a_c);
}
}
else {
_ajax.call(jq, a, c);
}
};
this.playerWin.document.querySelector('div.bilibili-player-video-btn.bilibili-player-video-btn-quality > div > ul > li:nth-child(1)').click();
});
this.cid = flvRequest[0].url.match(/cid=\d*/)[0].slice(4);
flvPromise = new Promise(resolve => {
flvRequest[0].success = res => {
if (res.format != 'flv') throw 'flv fail: response is not flv';
this.flvs = res.durl.map(e => e.url.replace('http:', this.protocol));
resolve(res);
};
_ajax.apply(jq, flvRequest);
});
mp4Promise = new Promise(resolve => {
let self = this;
jq.ajax = function (a, c) {
if (a.url.search('interface.bilibili.com/playurl?') != -1 || a.url.search('bangumi.bilibili.com/player/web_api/playurl?') != -1) {
let _success = a.success;
jq.ajax = _ajax;
a.success = res => {
if (res.format != 'hdmp4') throw 'hdmp4 fail: response is not hdmp4';
_success(res);
self.mp4 = res.durl[0].url.replace('http:', self.protocol);
resolve(res);
};
}
_ajax.call(jq, a, c);
};
this.playerWin.document.querySelector('div.bilibili-player-video-btn.bilibili-player-video-btn-quality > div > ul > li:nth-child(2)').click();
});
assPromise = new Promise(async resolve => {
let { fetchDanmaku, generateASS, setPosition } = await BiliMonkey.loadASSScript();
fetchDanmaku(this.cid, danmaku => {
let ass = generateASS(setPosition(danmaku), {
'title': name,
'ori': location.href,
});
// I would assume most users are using Windows
let blob = new Blob(['\ufeff' + ass], { type: 'application/octet-stream' });
this.ass = window.URL.createObjectURL(blob);
resolve(this.ass);
});
});
this.promises = [Promise.resolve(this), flvPromise, mp4Promise, assPromise];
return Promise.all(this.promises);
}
async getPlayer() {
if (this.playerWin.document.querySelector('div.bilibili-player-video-btn.bilibili-player-video-btn-quality > div > ul > li:nth-child(2)')) {
return this.playerWin;
}
else if (MutationObserver) {
return new Promise(resolve => {
let observer = new MutationObserver(() => {
if (this.playerWin.document.querySelector('div.bilibili-player-video-btn.bilibili-player-video-btn-quality > div > ul > li:nth-child(2)')) {
observer.disconnect();
resolve(this.playerWin);
}
});
observer.observe(this.playerWin.document.getElementById('bilibiliPlayer'), { childList: true });
});
}
else {
return new Promise(resolve => {
let t = setInterval(() => {
if (this.playerWin.document.querySelector('div.bilibili-player-video-btn.bilibili-player-video-btn-quality > div > ul > li:nth-child(2)')) {
clearInterval(t);
resolve(this.playerWin);
}
}, 600);
});
}
}
async hangPlayer() {
await this.getPlayer();
let trivialRes = { 'from': 'local', 'result': 'suee', 'format': 'hdmp4', 'timelength': 10, 'accept_format': 'flv,hdmp4,mp4', 'accept_quality': [3, 2, 1], 'seek_param': 'start', 'seek_type': 'second', 'durl': [{ 'order': 1, 'length': 1000, 'size': 30000, 'url': '', 'backup_url': ['', ''] }] };
const qualityToFormat = ['mp4', 'hdmp4', 'flv'];
const jq = this.playerWin == window ? $ : this.playerWin.$;
const _ajax = jq.ajax;
// jq hijack
return new Promise(async resolve => {
// Magic number. Do not know why.
for (let i = 0; i < 4; i++) {
let trivialResSent = new Promise(r => {
jq.ajax = function (a, c) {
if (a.url.search('interface.bilibili.com/playurl?') != -1 || a.url.search('bangumi.bilibili.com/player/web_api/playurl?') != -1) {
// Send back a fake response to abort current loading.
trivialRes.format = qualityToFormat[a.url.match(/quality=(\d)/)[1]];
a.success(trivialRes);
window.ddbg = () => a.success(trivialRes);
// Requeue. Again, magic number.
setTimeout(r, 500);
}
else {
_ajax.call(jq, a, c);
}
};
})
// Find a random available button
let button = Array
.from(this.playerWin.document.querySelector('div.bilibili-player-video-btn.bilibili-player-video-btn-quality > div > ul').children)
.find(e => !e.getAttribute('data-selected'));
button.click();
await trivialResSent;
}
resolve(this.playerWin.document.querySelector('#bilibiliPlayer video'));
jq.ajax = _ajax;
});
}
async loadFLVFromCache(index) {
if (!this.cache) return;
if (!this.flvs) throw 'BiliMonkey: info uninitialized';
let name = this.flvs[index].match(/\d*-\d*.flv/)[0];
let item = await this.cache.getData(name);
if (!item) return;
return this.flvsBlob[index] = item.data;
}
async loadPartialFLVFromCache(index) {
if (!this.cache) return;
if (!this.flvs) throw 'BiliMonkey: info uninitialized';
let name = this.flvs[index].match(/\d*-\d*.flv/)[0];
name = 'PC_' + name;
let item = await this.cache.getData(name);
if (!item) return;
return item.data;
}
async loadAllFLVFromCache() {
if (!this.cache) return;
if (!this.flvs) throw 'BiliMonkey: info uninitialized';
let promises = [];
for (let i = 0; i < this.flvs.length; i++) promises.push(this.loadFLVFromCache(i));
return Promise.all(promises);
}
async saveFLVToCache(index, blob) {
if (!this.cache) return;
if (!this.flvs) throw 'BiliMonkey: info uninitialized';
let name = this.flvs[index].match(/\d*-\d*.flv/)[0];
return this.cache.addData({ name, data: blob });
}
async savePartialFLVToCache(index, blob) {
if (!this.cache) return;
if (!this.flvs) throw 'BiliMonkey: info uninitialized';
let name = this.flvs[index].match(/\d*-\d*.flv/)[0];
name = 'PC_' + name;
return this.cache.putData({ name, data: blob });
}
async cleanPartialFLVInCache(index) {
if (!this.cache) return;
if (!this.flvs) throw 'BiliMonkey: info uninitialized';
let name = this.flvs[index].match(/\d*-\d*.flv/)[0];
name = 'PC_' + name;
return this.cache.deleteData(name);
}
async getFLVBlob(index, progressHandler) {
if (this.flvsBlob[index]) return this.flvsBlob[index];
if (!this.flvs) throw 'BiliMonkey: info uninitialized';
this.flvsBlob[index] = new Promise(async (resolve, reject) => {
let cache = await this.loadFLVFromCache(index);
if (cache) {
resolve(this.flvsBlob[index] = cache);
return;
}
let partialCache = await this.loadPartialFLVFromCache(index);
let opt = { method: 'GET', mode: 'cors', cacheLoaded: partialCache ? partialCache.size : 0 };
opt.onprogress = progressHandler;
opt.onerror = opt.onabort = ({ target, type }) => {
let pBlob = target.getPartialBlob();
if (partialCache) pBlob = new Blob([partialCache, pBlob]);
this.savePartialFLVToCache(index, pBlob);
// reject(type);
}
let burl = this.flvs[index];
if (partialCache) burl += `&bstart=${partialCache.size}`;
let fch = new DetailedFetchBlob(burl, opt);
this.flvsDetailedFetch[index] = fch;
let fullResponse;
try {
fullResponse = await fch.getBlob();
}
catch (e) { if (e == 'abort') return new Promise(() => { }); throw e; }
if (partialCache) {
fullResponse = new Blob([partialCache, fullResponse]);
this.cleanPartialFLVInCache(index);
}
this.saveFLVToCache(index, fullResponse);
resolve(this.flvsBlob[index] = fullResponse);
/* ****obsolete****
let xhr = new XMLHttpRequest();
this.flvsXHR[index] = xhr;
xhr.onload = () => {
let fullResponse = xhr.response;
if (partialCache) fullResponse = new Blob([partialCache, xhr.response]);
this.saveFLVToCache(index, fullResponse);
resolve(this.flvsBlob[index] = fullResponse);
}
xhr.onerror = reject;
xhr.onabort = () => {
this.savePartialFLVToCache(index, xhr);
}
xhr.onprogress = event => progressHandler(event.loaded, event.total, index);
xhr.onreadystatechange = () => {
if (this.readyState == this.HEADERS_RECEIVED) {
console.log(`Size of ${index}: ${xhr.getResponseHeader('Content-Length')}`);
}
}
xhr.responseType = 'blob';
xhr.open('GET', this.flvs[index], true);
if (partialCache) {
xhr.setRequestHeader('Range', `bytes=${partialCache.size}-`);
}
xhr.send();*/
});
return this.flvsBlob[index];
}
async getFLV(index, progressHandler) {
if (this.flvsBlobURL[index]) return this.flvsBlobURL[index];
let blob = await this.getFLVBlob(index, progressHandler);
this.flvsBlobURL[index] = URL.createObjectURL(blob);
return this.flvsBlobURL[index];
}
async abortFLV(index) {
if (this.flvsDetailedFetch[index]) return this.flvsDetailedFetch[index].abort();
}
async getAllFLVsBlob(progressHandler) {
if (!this.flvs) throw 'BiliMonkey: info uninitialized';
let promises = [];
for (let i = 0; i < this.flvs.length; i++) promises.push(this.getFLVBlob(i, progressHandler));
return Promise.all(promises);
}
async getAllFLVs(progressHandler) {
if (!this.flvs) throw 'BiliMonkey: info uninitialized';
let promises = [];
for (let i = 0; i < this.flvs.length; i++) promises.push(this.getFLV(i, progressHandler));
return Promise.all(promises);
}
async cleanAllFLVsInCache() {
if (!this.cache) return;
if (!this.flvs) throw 'BiliMonkey: info uninitialized';
let promises = [];
for (let flv of this.flvs) {
let name = flv.match(/\d*-\d*.flv/)[0];
promises.push(this.cache.deleteData(name));
}
return Promise.all(promises);
}
async setupProxy(res, onsuccess) {
(() => {
let _fetch = fetch;
fetch = function (input, init) {
if (!(input.slice && input.slice(0, 5) == 'blob:'))
return _fetch(input, init);
let bstart = input.search(/\?bstart=/);
if (bstart < 0) return _fetch(input, init);
if (!init.headers instanceof Headers) init.headers = new Headers(init.headers);
init.headers.set('Range', `bytes=${input.slice(bstart + 8)}-`);
return _fetch(input.slice(0, bstart), init)
}
})();
await this.loadAllFLVFromCache();
let resProxy = {};
Object.assign(resProxy, res);
for (let i = 0; i < this.flvsBlob.length; i++) {
if (this.flvsBlob[i]) {
this.flvsBlobURL[i] = URL.createObjectURL(this.flvsBlob[i]);
resProxy.durl[i].url = this.flvsBlobURL[i];
}
}
return onsuccess(resProxy);
}
static async loadASSScript(src = 'https://tiansh.github.io/us-danmaku/bilibili/bilibili_ASS_Danmaku_Downloader.user.js') {
let script = await fetch(src).then(res => res.text());
script = script.slice(0, script.search('var init = function ()'));
let head = `
(() => {
`;
let foot = `
fetchXML = function (cid, callback) {
var oReq = new XMLHttpRequest();
oReq.open('GET', 'https://comment.bilibili.com/{{cid}}.xml'.replace('{{cid}}', cid));
oReq.onload = function () {
var content = oReq.responseText.replace(/(?:[\0-\x08\x0B\f\x0E-\x1F\uFFFE\uFFFF]|[\uD800-\uDBFF](?![\uDC00-\uDFFF])|(?:[^\uD800-\uDBFF]|^)[\uDC00-\uDFFF])/g, "");
callback(content);
};
oReq.send();
};
initFont();
return { fetchDanmaku: fetchDanmaku, generateASS: generateASS, setPosition: setPosition };
})()
`;
script = `${head}${script}${foot}`;
return eval(script);
}
static async getIframeWin() {
if (document.querySelector('#bofqi > iframe').contentDocument.querySelector('div.bilibili-player-video-btn.bilibili-player-video-btn-quality > div > ul > li:nth-child(2)')) {
return document.querySelector('#bofqi > iframe').contentWindow;
}
else {
return new Promise(resolve => {
document.querySelector('#bofqi > iframe').addEventListener('load', () => {
resolve(document.querySelector('#bofqi > iframe').contentWindow);
});
});
}
}
static async getPlayerWin() {
if (location.host == 'bangumi.bilibili.com') {
if (document.querySelector('#bofqi > iframe')) {
return BiliMonkey.getIframeWin();
}
else if (MutationObserver) {
return new Promise(resolve => {
let observer = new MutationObserver(() => {
if (document.querySelector('#bofqi > iframe')) {
observer.disconnect();
resolve(BiliMonkey.getIframeWin());
}
else if (document.querySelector('#bofqi > object')) {
observer.disconnect();
throw 'Need H5 Player';
}
});
observer.observe(window.document.getElementById('bofqi'), { childList: true });
});
}
else {
return new Promise(resolve => {
let t = setInterval(() => {
if (document.querySelector('#bofqi > iframe')) {
clearInterval(t);
resolve(BiliMonkey.getIframeWin());
}
else if (document.querySelector('#bofqi > object')) {
clearInterval(t);
throw 'Need H5 Player';
}
}, 600);
});
}
}
else {
if (document.querySelector('#bofqi > object')) {
throw 'Need H5 Player';
}
else {
return window;
}
}
}
}
class UI {
static requestH5Player() {
let h = document.querySelector('div.tminfo');
h.insertBefore(document.createTextNode('[[视频下载插件需要HTML5播放器(弹幕列表右上角三个点的按钮切换)]] '), h.firstChild);
}
static titleAppend(monkey, flvs = monkey.flvs, mp4 = monkey.mp4, ass = monkey.ass) {
let h = document.querySelector('div.viewbox div.info');
let tminfo = document.querySelector('div.tminfo');
let div = document.createElement('div');
let flvA = document.createElement('a');
let mp4A = document.createElement('a');
let assA = document.createElement('a');
flvA.textContent = '超清FLV';
mp4A.textContent = '原生MP4';
assA.textContent = '弹幕ASS';
let table = UI.genFLVTable(monkey);
document.body.appendChild(table);
flvA.onclick = () => table.style.display = 'block';
mp4A.href = mp4;
assA.href = ass;
assA.download = mp4.match(/\d(\d|-|hd)*(?=\.mp4)/)[0] + '.ass';
flvA.style.fontSize = mp4A.style.fontSize = assA.style.fontSize = '16px';
div.appendChild(flvA);
div.appendChild(document.createTextNode(' '));
div.appendChild(mp4A);
div.appendChild(document.createTextNode(' '));
div.appendChild(assA);
div.className = 'info';
div.style.zIndex = '1';
div.style.width = '32%';
tminfo.style.float = 'left';
tminfo.style.width = '68%';
h.insertBefore(div, tminfo);
}
static async downloadAllFLVs(a, monkey, table) {
if (table.rows[0].cells.length < 3) return;
monkey.hangPlayer();
table.insertRow(-1).innerHTML = '<td colspan="3">已屏蔽网页播放器的网络链接。切换清晰度可重新激活播放器。</td>';
for (let i = 0; i < monkey.flvs.length; i++) {
if (table.rows[i].cells[1].children[0].textContent == '缓存本段')
table.rows[i].cells[1].children[0].click();
}
let bar = a.parentNode.nextSibling.children[0];
bar.max = monkey.flvs.length + 1;
bar.value = 0;
for (let i = 0; i < monkey.flvs.length; i++) monkey.getFLVBlob(i).then(e => bar.value++);
let blobs;
blobs = await monkey.getAllFLVsBlob();
let mergedFLV = await FLV.mergeBlobs(blobs);
let url = URL.createObjectURL(mergedFLV);
let outputName = monkey.flvs[0].match(/\d*-\d.flv/);
if (outputName) outputName = outputName[0].replace(/-\d/, "");
else outputName = 'merge.flv';
bar.value++;
table.insertRow(0).innerHTML = `
<td colspan="3" style="border: 1px solid black">
<a href="${url}" download="${outputName}">保存合并后FLV</a>
<a href="${monkey.ass}" download="${outputName.slice(0, -3)}ass">弹幕ASS</a>
记得清理分段缓存哦~
</td>
`;
return url;
}
static async downloadFLV(a, monkey, index, bar = a.parentNode.nextSibling.children[0]) {
a.textContent = '取消';
a.onclick = () => {
a.onclick = null;
a.textContent = '已取消';
monkey.abortFLV(index);
};
let url;
try {
url = await monkey.getFLV(index, (loaded, total) => {
bar.value = loaded;
bar.max = total;
});
if (bar.value == 0) bar.value = bar.max = 1;
} catch (e) {
a.onclick = null;
a.textContent = '错误';
throw e;
}
a.onclick = null;
a.textContent = '另存为';
a.download = monkey.flvs[index].match(/\d*-\d*.flv/)[0];
a.href = url;
return url;
}
static copyToClipboard(text) {
let textarea = document.createElement('textarea');
document.body.appendChild(textarea);
textarea.value = text;
textarea.select();
document.execCommand('copy');
document.body.removeChild(textarea);
}
static genFLVTable(monkey, flvs = monkey.flvs, cache = monkey.cache) {
let div = document.createElement('div');
div.style.position = 'fixed';
div.style.zIndex = '10036';
div.style.top = '50%';
div.style.marginTop = '-200px';
div.style.left = '50%';
div.style.marginLeft = '-320px';
div.style.width = '540px';
div.style.padding = '30px 50px';
div.style.backgroundColor = 'white';
div.style.borderRadius = '6px';
div.style.boxShadow = 'rgba(0, 0, 0, 0.6) 1px 1px 40px 0px';
div.style.display = 'none';
let table = document.createElement('table');
// table.style.border = '1px solid black';
table.style.width = '100%';
table.style.lineHeight = '2em';
for (let i = 0; i < flvs.length; i++) {
let tr = table.insertRow(-1);
tr.insertCell(0).innerHTML = `<a href="${flvs[i]}">FLV分段 ${i + 1}</a>`;
tr.insertCell(1).innerHTML = '<a>缓存本段</a>';
tr.insertCell(2).innerHTML = '<progress value="0" max="100">进度条</progress>';
tr.children[1].children[0].onclick = () => {
UI.downloadFLV(tr.children[1].children[0], monkey, i, tr.children[2].children[0]);
}
}
let tr = table.insertRow(-1);
tr.insertCell(0).innerHTML = `<a>全部复制到剪贴板</a>`;
tr.insertCell(1).innerHTML = '<a>缓存全部+自动合并</a>';
tr.insertCell(2).innerHTML = `<progress value="0" max="${flvs.length + 1}">进度条</progress>`;
tr.children[0].children[0].onclick = () => {
UI.copyToClipboard(flvs.join('\n'));
}
tr.children[1].children[0].onclick = () => {
UI.downloadAllFLVs(tr.children[1].children[0], monkey, table);
}
table.insertRow(-1).innerHTML = '<td colspan="3">合并功能推荐配置:至少8G RAM。把自己下载的分段FLV拖动到这里,也可以合并哦~</td>';
//table.insertRow(-1).innerHTML = '<td colspan="3">完全下载的缓存分段会暂时停留在电脑里,过一段时间会自动消失。建议只开一个标签页。</td>';
table.insertRow(-1).innerHTML = '<td colspan="3">建议只开一个标签页。关掉标签页后,缓存就会被清理。别忘了另存为!</td>';
UI.displayQuota(table.insertRow(-1));
let option = UI.getOption();
table.insertRow(-1).innerHTML = `
<td colspan="3">
内测中:
关标签页不清缓存${option.cache ? '✓' : '✕'}
断点续传${option.partial ? '✓' : '✕'}
用缓存加速播放器${option.proxy ? '✓' : '✕'}
(打开脚本第一行有惊喜)
</td>`;
div.appendChild(table);
div.ondragenter = div.ondragover = e => {
e.stopPropagation();
e.preventDefault();
};
div.ondrop = async e => {
e.stopPropagation();
e.preventDefault();
let files = Array.from(e.dataTransfer.files);
if (files.every(e => e.name.search(/\d*-\d*.flv/) != -1)) {
files.sort((a, b) => a.name.match(/\d*-(\d*).flv/)[1] - b.name.match(/\d*-(\d*).flv/)[1]);
}
for (let file of files) {
table.insertRow(-1).innerHTML = `<td colspan="3">${file.name}</td>`;
}
let outputName = files[0].name.match(/\d*-\d.flv/);
if (outputName) outputName = outputName[0].replace(/-\d/, "");
else outputName = 'merge_' + files[0].name;
let url = await UI.mergeFLVFiles(files);
table.insertRow(-1).innerHTML = `<td colspan="3"><a href="${url}" download="${outputName}">${outputName}</a></td>`;
}
let buttons = [];
for (let i = 0; i < 3; i++) buttons.push(document.createElement('button'));
buttons.map(btn => btn.style.padding = '0.5em');
buttons.map(btn => btn.style.margin = '0.2em');
buttons[0].textContent = '关闭';
buttons[0].onclick = () => {
div.style.display = 'none';
}
buttons[1].textContent = '清空这个视频的缓存';
buttons[1].onclick = () => {
monkey.cleanAllFLVsInCache();
}
buttons[2].textContent = '清空所有视频的缓存';
buttons[2].onclick = () => {
UI.clearCacheDB(cache);
}
buttons.map(btn => div.appendChild(btn));
return div;
}
static async mergeFLVFiles(files) {
let merged = await FLV.mergeBlobs(files)
return URL.createObjectURL(merged);
}
static async clearCacheDB(cache) {
if (cache) return cache.deleteEntireDB();
}
static async displayQuota(tr) {
return new Promise(resolve => {
let temporaryStorage = window.navigator.temporaryStorage
|| window.navigator.webkitTemporaryStorage
|| window.navigator.mozTemporaryStorage
|| window.navigator.msTemporaryStorage;
if (!temporaryStorage) resolve(tr.innerHTML = `<td colspan="3">这个浏览器不支持缓存呢~关掉标签页后,缓存马上就会消失哦</td>`)
temporaryStorage.queryUsageAndQuota((usage, quota) =>
resolve(tr.innerHTML = `<td colspan="3">缓存已用空间:${Math.round(usage / 1048576)}MB / ${Math.round(quota / 1048576)}MB 也包括了B站本来的缓存</td>`)
);
});
}
static getOption() {
if (uOption) return uOption;
try {
return JSON.parse(localStorage.biliMonkey);
}
catch (e) {
return {};
}
}
static saveOption(option) {
try {
return localStorage.biliMonkey = JSON.stringify(option);
}
catch (e) {
return false;
}
}
static async init() {
if (!Promise) alert('这个浏览器实在太老了,视频解析脚本决定罢工。');
let option = UI.getOption();
let playerWin;
try {
playerWin = await BiliMonkey.getPlayerWin();
} catch (e) {
if (e == 'Need H5 Player') UI.requestH5Player();
return;
}
let cache = option.cache ? new CacheDB() : null;
try {
await cache.getDB();
} catch (e) { cache = null; }
let monkey = new BiliMonkey(playerWin, cache, option.partial, option.proxy);
window.m = monkey;
await monkey.getPlayer();
await monkey.getInfo();
UI.titleAppend(monkey);
}
}
UI.init();
// export {TwentyFourDataView, FLV, CacheDB, DetailedFetchBlob, BiliMonkey};
//if (clear) clear();