// ==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 // @downloadURL none // ==/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 = '已屏蔽网页播放器的网络链接。切换清晰度可重新激活播放器。'; 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 = ` 保存合并后FLV 弹幕ASS 记得清理分段缓存哦~ `; 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 = `FLV分段 ${i + 1}`; tr.insertCell(1).innerHTML = '缓存本段'; tr.insertCell(2).innerHTML = '进度条'; 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 = `全部复制到剪贴板`; tr.insertCell(1).innerHTML = '缓存全部+自动合并'; tr.insertCell(2).innerHTML = `进度条`; 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 = '合并功能推荐配置:至少8G RAM。把自己下载的分段FLV拖动到这里,也可以合并哦~'; //table.insertRow(-1).innerHTML = '完全下载的缓存分段会暂时停留在电脑里,过一段时间会自动消失。建议只开一个标签页。'; table.insertRow(-1).innerHTML = '建议只开一个标签页。关掉标签页后,缓存就会被清理。别忘了另存为!'; UI.displayQuota(table.insertRow(-1)); let option = UI.getOption(); table.insertRow(-1).innerHTML = ` 内测中: 关标签页不清缓存${option.cache ? '✓' : '✕'} 断点续传${option.partial ? '✓' : '✕'} 用缓存加速播放器${option.proxy ? '✓' : '✕'} (打开脚本第一行有惊喜) `; 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 = `${file.name}`; } 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 = `${outputName}`; } 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 = `这个浏览器不支持缓存呢~关掉标签页后,缓存马上就会消失哦`) temporaryStorage.queryUsageAndQuota((usage, quota) => resolve(tr.innerHTML = `缓存已用空间:${Math.round(usage / 1048576)}MB / ${Math.round(quota / 1048576)}MB 也包括了B站本来的缓存`) ); }); } 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();