114514 / Bilibili Video CDN Switcher

// ==UserScript==
// @name         Bilibili Video CDN Switcher
// @name:zh-CN   Bilibili CDN切换
// @name:zh-TW   Bilibili CDN切換
// @name:ja      BilibiliビデオCDNスイッチャー
// @name:en      Bilibili Video CDN Switcher
// @copyright    Free For Personal Use
// @license MIT
// @version      0.1.0
// @description       修改哔哩哔哩播放时的所用CDN 加快视频加载 番剧加速 视频加速
// @description:zh-CN 修改哔哩哔哩播放时的所用CDN 加快视频加载 番剧加速 视频加速
// @description:en    Modify Bilibili's CDN during playback to speed up video loading, supporting Animes & Videos
// @description:zh-TW 修改 Bilibili 播放時的所用CDN 加快影片載入 番劇加速 影片加速
// @description:ja    ビリビリ動画(Bilibili)の動画再生時のCDNを変更して、動画読み込み速度の向上、アニメとビデオ読込高速化
// @run-at       document-start
// @match        https://www.bilibili.com/video/*
// @match        https://www.bilibili.com/bangumi/play/*
// @match        https://www.bilibili.com/blackboard/*
// @match        https://live.bilibili.com/blanc/*
// @match        https://www.bilibili.com/?*
// @match        https://www.bilibili.com/
// @match        https://www.bilibili.com/mooc/*
// @match        https://www.bilibili.com/v/*
// @match        https://www.bilibili.com/documentary/*
// @match        https://www.bilibili.com/variety/*
// @match        https://www.bilibili.com/tv/*
// @match        https://www.bilibili.com/guochuang/*
// @match        https://www.bilibili.com/movie/*
// @match        https://www.bilibili.com/anime/*
// @match        https://www.bilibili.com/match/*
// @match        https://www.bilibili.com/cheese/*
// @match        https://music.bilibili.com/pc/music-center/*
// @match        https://search.bilibili.com/*
// @match        https://m.bilibili.com/video/*
// @match        https://m.bilibili.com/bangumi/play/*
// @match        https://m.bilibili.com/?*
// @match        https://m.bilibili.com/
// @grant        GM_getValue
// @grant        GM_setValue
// @grant        unsafeWindow
// @icon          https://i0.hdslb.com/bfs/static/jinkela/long/images/favicon.ico
// ==/UserScript==

// 在这里的引号内输入自定义的CDN网址 (Enter your custom CDN URL in quotes here.)
var CustomCDN = 'upos-sz-mirrorhwb.bilivideo.com'
// 例如将上一行修改为如下,可以将CDN强制设置为 'upos-sz-mirrorali.bilivideo.com' (e.g. Modify the previous line as follows to force CDN to be set to 'upos-sz-mirrorali.bilivideo.com')
// var CustomCDN = 'upos-sz-mirrorhwb.bilivideo.com'


const PluginName = 'BiliCDNSwitcher'
const log = console.log.bind(console, `[${PluginName}]:`)

let disabled = !!GM_getValue('disabled')
const Replacement = (() => {
    const toURL = ((url) => { if (url.indexOf('://') === -1) url = 'https://' + url; return url.endsWith('/') ? url : `${url}/` })

    log(`CDN=${CustomCDN}`)
    return toURL(CustomCDN)
})()

const playInfoTransformer = playInfo => {
    const urlTransformer = i => {
        const newUrl = i.base_url.replace(
            /https:\/\/.*?\//,
            Replacement
        )
        i.baseUrl = newUrl; i.base_url = newUrl
    };
    const durlTransformer = i => { i.url = i.url.replace(/https:\/\/.*?\//, Replacement) };

    if (playInfo.code !== (void 0) && playInfo.code !== 0) {
        log('Failed to get playInfo, message:', playInfo.message)
        return
    }

    let video_info
    if (playInfo.result) { // bangumi pages'
        video_info = playInfo.result.dash === (void 0) ? playInfo.result.video_info : playInfo.result
        if (!video_info?.dash) {
            if (playInfo.result.durl && playInfo.result.durls) {
                video_info = playInfo.result // documentary trail viewing, m.bilibili.com/bangumi/play/* trail or non-trail viewing
            } else {
                log('Failed to get video_info, limit_play_reason:', playInfo.result.play_check?.limit_play_reason)
            }

            // durl & durls are for trial viewing, and they usually exist when limit_play_reason=PAY
            video_info?.durl?.forEach(durlTransformer)
            video_info?.durls?.forEach(durl => { durl.durl?.forEach(durlTransformer) })
            return
        }
    } else { // video pages'
        video_info = playInfo.data
    }
    try {
        video_info.dash.video.forEach(urlTransformer)
        video_info.dash.audio.forEach(urlTransformer)
    } catch (err) {
        if (video_info.durl) { // 充电专属视频、m.bilibili.com/video/*
            log('accept_description:', video_info.accept_description?.join(', '))
            video_info.durl.forEach(durlTransformer)
        } else {
            log('ERR:', err)
        }
    }
    return
}

// Network Request Interceptor
const interceptNetResponse = (theWindow => {
    const interceptors = []
    const interceptNetResponse = (handler) => interceptors.push(handler)

    // when response === null && url is String, it's checking if the url is handleable
    const handleInterceptedResponse = (response, url) => interceptors.reduce((modified, handler) => {
        const ret = handler(modified, url)
        return ret ? ret : modified
    }, response)
    const OriginalXMLHttpRequest = theWindow.XMLHttpRequest

    class XMLHttpRequest extends OriginalXMLHttpRequest {
        get responseText() {
            if (this.readyState !== this.DONE) return super.responseText
            return handleInterceptedResponse(super.responseText, this.responseURL)
        }
        get response() {
            if (this.readyState !== this.DONE) return super.response
            return handleInterceptedResponse(super.response, this.responseURL)
        }
    }

    theWindow.XMLHttpRequest = XMLHttpRequest

    const OriginalFetch = fetch
    theWindow.fetch = (input, init) => (!handleInterceptedResponse(null, input) ? OriginalFetch(input, init) :
        OriginalFetch(input, init).then(response =>
            new Promise((resolve) => response.text()
                .then(text => resolve(new Response(handleInterceptedResponse(text, input), {
                    status: response.status,
                    statusText: response.statusText,
                    headers: response.headers
                })))
            )
        )
    );

    return interceptNetResponse
})(unsafeWindow)

const waitForElm = (selector) => new Promise(resolve => {
    let ele = document.querySelector(selector)
    if (ele) return resolve(ele)

    const observer = new MutationObserver(mutations => {
        let ele = document.querySelector(selector)
        if (ele) {
            observer.disconnect()
            resolve(ele)
        }
    })

    observer.observe(document.documentElement, {
        childList: true,
        subtree: true
    })

    log('waitForElm, MutationObserver started.')
})

// Parse HTML string to DOM Element
function fromHTML(html) {
    if (!html) throw Error('html cannot be null or undefined', html)
    const template = document.createElement('template')
    template.innerHTML = html
    const result = template.content.children
    return result.length === 1 ? result[0] : result
}

(function () {
    'use strict';
    if (disabled) log('Plugin is Disabled');

    // Hook Bilibili PlayUrl Api
    interceptNetResponse((response, url) => {
        if (disabled) return
        if (url.startsWith('https://api.bilibili.com/x/player/wbi/playurl') ||
            url.startsWith('https://api.bilibili.com/pgc/player/web/v2/playurl') ||
            url.startsWith('https://api.bilibili.com/x/player/playurl') ||
            url.startsWith('https://api.bilibili.com/pgc/player/web/playurl') ||
            url.startsWith('https://api.bilibili.com/pugv/player/web/playurl') // at /cheese/
        ) {
            if (response === null) return true // the url is handleable

            log('(Intercepted) playurl api response.')
            const responseText = response
            const playInfo = JSON.parse(responseText)
            playInfoTransformer(playInfo)
            return JSON.stringify(playInfo)
        }
    });

    // Modify Pages playinfo
    if (location.host === 'm.bilibili.com') {
        const optionsTransformer = (opts) => (opts.readyVideoUrl = opts.readyVideoUrl?.replace(/https:\/\/.*?\//, Replacement))

        if (!disabled && unsafeWindow.options) { // Modify unsafeWindow.options
            log('Directly modify the window.options')
            optionsTransformer(unsafeWindow.options)
        } else {
            let internalOptions = unsafeWindow.options
            Object.defineProperty(unsafeWindow, 'options', {
                get: () => internalOptions,
                set: v => {
                    if (!disabled) optionsTransformer(v);
                    internalOptions = v
                }
            })
        }
    } else {
        if (!disabled && unsafeWindow.__playinfo__) { // Modify unsafeWindow.__playinfo__
            log('Directly modify the window.__playinfo__')
            playInfoTransformer(unsafeWindow.__playinfo__)
        } else {
            let internalPlayInfo = unsafeWindow.__playinfo__
            Object.defineProperty(unsafeWindow, '__playinfo__', {
                get: () => internalPlayInfo,
                set: v => {
                    if (!disabled) playInfoTransformer(v);
                    internalPlayInfo = v
                }
            })
        }
    }
})();