Custom CDN of Bilibili (CCB) - 修改哔哩哔哩的视频播放源

修改哔哩哔哩的视频播放源

// ==UserScript==
// @name         Custom CDN of Bilibili (CCB) - 修改哔哩哔哩的视频播放源
// @namespace    CCB
// @license      MIT
// @version      0.0.1
// @description  修改哔哩哔哩的视频播放源
// @author       鼠鼠今天吃嘉然
// @run-at       document-start
// @match        https://www.bilibili.com/video/*
// @match        https://www.bilibili.com/bangumi/play/*
// @connect      cherrynmsl.sbs
// @connect      *
// @grant        GM_xmlhttpRequest
// @grant        GM_getValue
// @grant        GM_setValue
// @grant        unsafeWindow
// ==/UserScript==

const api = 'http://cherrynmsl.sbs:8080'

// 日志输出函数
const PluginName = 'CCB'
const log = console.log.bind(console, `[${PluginName}]:`)

const defaultCdnNode = '使用默认源'
var cdnNodeStored = 'CCB'
var regionStored = 'region'

// 获取当前节点名称
const getCurCdnNode = () => {
    return GM_getValue(cdnNodeStored, cdnList[0])
}

// CDN 列表
const initCdnList = [
    'upos-sz-mirroraliov.bilivideo.com',
    'upos-sz-mirroralib.bilivideo.com',
    'upos-sz-estgcos.bilivideo.com',
]
var cdnList = [
    defaultCdnNode,
    initCdnList
]

// 判断 CCB 是否启用
const isCcbEnabled = () => {
    return getCurCdnNode() !== defaultCdnNode
}

// 替换播放源
const Replacement = (() => {
    const toURL = ((url) => {
        if (url.indexOf('://') === -1) {
            url = 'https://' + url
            return url.endsWith('/') ? url : `${url}/`
        }
    })

    let domain = getCurCdnNode()

    log(`播放源已修改为: ${domain}`)

    return toURL(domain)
})()

// 地区列表
var regionList = ['-']
const getRegionList = async () => {
    try {
        const response = await new Promise((resolve, reject) => {
            GM_xmlhttpRequest({
                method: 'GET',
                url: `${api}/region`,
                headers: {
                    'Content-Type': 'application/json'
                },
                onload: (response) => resolve(response),
                onerror: (error) => reject(error)
            });
        });

        const data = JSON.parse(response.responseText);

        // 确保返回数据中包含该地区的数据
        if (data.data && Array.isArray(data.data)) {
            // 保留默认节点,并添加新的 CDN 列表
            regionList = ["-", ...data.data];
            log(`已更新地区列表: ${data.data}`);
        } else {
            log(`未找到地区数据`);
        }
    } catch (error) {
        log('获取地区列表失败:', error);
    }
}

const getCdnListByRegion = async (region) => {
    try {
        if (region === '-') {
            cdnList = [defaultCdnNode, ...initCdnList];
        }

        const response = await new Promise((resolve, reject) => {
            GM_xmlhttpRequest({
                method: 'GET',
                url: `${api}/cdn?region=${region}`,
                headers: {
                    'Content-Type': 'application/json',
                    'Accept-Charset': 'utf-8'
                },
                onload: (response) => resolve(response),
                onerror: (error) => reject(error)
            });
        });

        const data = JSON.parse(response.responseText);
        // 确保返回数据中包含该地区的数据
        if (data.data && Array.isArray(data.data)) {
            // 保留默认节点,并添加新的 CDN 列表
            cdnList = [defaultCdnNode, ...data.data];

            // 更新 CDN 选择器的选项
            const cdnSelect = document.querySelector('.bpx-player-ctrl-setting-checkbox select:last-child');
            if (cdnSelect) {
                cdnSelect.innerHTML = cdnList.map(cdn =>
                    `<option value="${cdn}"${cdn === GM_getValue(cdnNodeStored, cdnList[0]) ? ' selected' : ''}>${cdn}</option>`
                ).join('');
            }
            log(`已更新 ${region} 地区的 CDN 列表`);
        } else {
            log(`未找到 ${region} 地区的 CDN 数据`);
        }
    } catch (error) {
        log('获取 CDN 列表失败:', error);
    }
}

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) { // 充电专属视频
            log('accept_description:', video_info.accept_description?.join(', '))
            video_info.durl.forEach(durlTransformer)
        } else {
            log('ERR:', err)
        }
    }
}

// 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';

    // Hook Bilibili PlayUrl Api
    interceptNetResponse((response, url) => {
        if (!isCcbEnabled()) 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)
        }
    });

    // 响应式 window.__playinfo__
    if (unsafeWindow.__playinfo__) {
        playInfoTransformer(unsafeWindow.__playinfo__)
    } else {
        let internalPlayInfo = unsafeWindow.__playinfo__
        Object.defineProperty(unsafeWindow, '__playinfo__', {
            get: () => internalPlayInfo,
            set: v => {
                if (isCcbEnabled()) playInfoTransformer(v);
                internalPlayInfo = v
            }
        })
    }

    // 添加组件
    if (location.href.startsWith('https://www.bilibili.com/video/') || location.href.startsWith('https://www.bilibili.com/bangumi/play/')) {
        waitForElm('#bilibili-player > div > div > div.bpx-player-primary-area > div.bpx-player-video-area > div.bpx-player-control-wrap > div.bpx-player-control-entity > div.bpx-player-control-bottom > div.bpx-player-control-bottom-left')
            .then(async settingsBar => {

                // 先获取地区列表
                await getRegionList();
                // 根据之前保存的地区信息加载 CDN 列表
                await getCdnListByRegion(GM_getValue(regionStored, regionList[0]))

                const regionSelector = fromHTML(`
                    <div id="cdn-selector" class="bpx-player-ctrl-setting-checkbox" style="margin-left: 10px;">
                        <select class="bui-select" style="background: #2b2b2b; color: white; border: 1px solid #444; padding: 2px 5px; border-radius: 4px; width: 60px; height: 22px; font-size: 12px;">
                            ${regionList.map(region => `<option value="${region}"${region === GM_getValue(regionStored, regionList[0]) ? ' selected' : ''}>${region}</option>`).join('')}
                        </select>
                    </div>
                `)

                // 监听地区选择框, 一旦改变就保存最新信息并获取该地区的 CDN 列表
                const regionNode = regionSelector.querySelector('select')
                regionNode.addEventListener('change', async (e) => {
                    const selectedRegion = e.target.value
                    GM_setValue(regionStored, selectedRegion)
                    // 请求该地区的 CDN 列表
                    await getCdnListByRegion(selectedRegion)
                })

                // 添加 CDN 选择下拉列表
                const cdnSelector = fromHTML(`
                    <div class="bpx-player-ctrl-setting-checkbox" style="margin-left: 10px;">
                        <select class="bui-select" style="background: #2b2b2b; color: white; border: 1px solid #444; padding: 2px 5px; border-radius: 4px; width: 150px; height: 22px; font-size: 12px;">
                            ${cdnList.map(cdn => `<option value="${cdn}"${cdn === GM_getValue(cdnNodeStored, cdnList[0]) ? ' selected' : ''}>${cdn}</option>`).join('')}
                        </select>
                    </div>
                `)

                // 监听 CDN 选择框, 一旦改变就保存最新信息并刷新页面
                const selectNode = cdnSelector.querySelector('select')
                selectNode.addEventListener('change', (e) => {
                    const selectedCDN = e.target.value
                    GM_setValue(cdnNodeStored, selectedCDN)
                    // 刷新网页
                    location.reload()
                })

                settingsBar.appendChild(regionNode)
                settingsBar.appendChild(cdnSelector)
                log('CDN selector added')
            });
    }
})();