哔哩哔哩收藏夹修复

修复哔哩哔哩失效的收藏,可查看av号、简介、标题、封面等

// ==UserScript==
// @name         哔哩哔哩收藏夹修复
// @namespace    @justorez
// @homepage     https://github.com/justorez
// @version      1.0.1
// @description  修复哔哩哔哩失效的收藏,可查看av号、简介、标题、封面等
// @author       justorez
// @license      GPL-3.0
// @supportURL   https://github.com/justorez/bilibili-favorites-fix/issues
// @match        https://space.bilibili.com/*
// @resource iconError https://cdn.jsdelivr.net/gh/Mr-Po/bilibili-favorites-fix/media/error.png
// @resource iconSuccess https://cdn.jsdelivr.net/gh/Mr-Po/bilibili-favorites-fix/media/success.png
// @resource iconInfo https://cdn.jsdelivr.net/gh/Mr-Po/bilibili-favorites-fix/media/info.png
// @connect      biliplus.com
// @connect      api.bilibili.com
// @grant        GM_xmlhttpRequest
// @grant        GM_notification
// @grant        GM_setClipboard
// @grant        GM_getResourceURL
// @grant        GM_openInTab
// ==/UserScript==

;(function () {
    'use strict'

    /**
     * 失效收藏标题颜色(默认为灰色)。
     * @type {String}
     */
    const invalidColor = '#999'

    /**
     * 是否启用调试模式。
     * 启用后,浏览器控制台会显示此脚本运行时的调试数据。
     * @type {Boolean}
     */
    const isDebug = false

    /**
     * 重试延迟(秒)
     * @type {Number}
     */
    const retryDelay = 5

    /**
     * 每隔 space 毫秒检查一次,是否有新的收藏被加载出来。
     * 此值越小,检查越快;过小会造成浏览器卡顿。
     * @type {Number}
     */
    const space = 2000

    /******************************************************/

    /**
     * 收藏夹地址正则
     * @type {RegExp}
     */
    const favlistRegex = /https:\/\/space\.bilibili\.com\/\d+\/favlist.*/

    /**
     * 处理收藏
     */
    function handleFavorites() {
        if (!favlistRegex.test(window.location.href)) {
            return
        }

        // 失效收藏节点列表
        const list = document.querySelectorAll(
            'ul.fav-video-list.content li.small-item.disabled'
        )

        if (list.length > 0) {
            console.info(`${list.length}个收藏待修复...`)

            list.forEach((el) => {
                const bv = el.getAttribute('data-aid')
                const aid = bv2aid(bv)

                // 多个超链接
                const alinks = el.querySelectorAll('a')
                alinks.forEach((link) => {
                    link.href = `https://www.biliplus.com/video/av${aid}/`
                    link.target = '_blank'
                    link.classList.remove('disabled') // 移除禁用样式
                })

                fixTitleAndCover(el, alinks[1], aid) // 修复标题和封面
                el.classList.remove('disabled') // 移除禁用样式
                addCopyAVCodeButton(el, aid) // 添加 avid 复制按钮
                addCopyBVCodeButton(el, bv) // 添加 bvid 复制按钮
            })

            showDetail(list)
        }
    }

    /**
     * 扩展收藏项的操作菜单
     *
     * @param {Element} item
     * @param {string} name
     * @param {Function} fn
     */
    function addOperation(item, name, fn) {
        if (item.classList.contains('disabled')) {
            return
        }

        const ul = item.querySelector('.be-dropdown-menu')
        const lastChild = ul.children[ul.children.length - 1]

        // 未添加过扩展
        if (!lastChild.classList.contains('be-dropdown-item-extend')) {
            lastChild.classList.add('be-dropdown-item-delimiter')
        }

        const s = `<li class="be-dropdown-item be-dropdown-item-extend">${name}</li>`
        const li = new DOMParser().parseFromString(s, 'text/html').body.firstChild
        li.onclick = fn

        ul.append(li)
    }

    function addCopyAVCodeButton($item, aid) {
        addOperation($item, '复制AV号', function () {
            GM_setClipboard(`av${aid}`, 'text')
        })
    }

    function addCopyBVCodeButton($item, bv) {
        addOperation($item, '复制BV号', function () {
            GM_setClipboard(bv, 'text')
        })
    }

    function addCopyInfoButton($item, content) {
        addOperation($item, '复制信息', function () {
            GM_setClipboard(content, 'text')
        })
    }

    function addOpenUpSpaceButton($item, mid) {
        addOperation($item, 'UP主页', function () {
            GM_openInTab(`https://space.bilibili.com/${mid}`, {
                active: true,
                insert: true,
                setParent: true
            })
        })
    }

    /**
     * 修改样式:标记失效的收藏
     *
     * @param {Element} item 收藏项
     * @param {HTMLLinkElement} link 标题链接
     */
    function markAsInvalid(item, link) {
        // 增加删除线 + 置灰
        link.setAttribute(
            'style',
            `text-decoration:line-through;color:${invalidColor};`
        )
        // 收藏时间
        const pubdate = item.querySelector('div.meta.pubdate')
        // 增加删除线
        pubdate.setAttribute('style', 'text-decoration:line-through')
    }

    /**
     * 绑定重新加载
     *
     * @param {HTMLLinkElement} link 标题链接
     * @param {Function} fn 重试方法
     */
    function bindReload(link, fn) {
        link.textContent = '-> 手动加载 <-'
        link.onclick = () => {
            link.textContent = '加载中...'
            fn()
        }
    }

    /**
     * 再次尝试加载
     *
     * @param {Element} link 标题链接
     * @param {number} aid AV号
     * @param {boolean}	delay 延迟重试
     * @param {Function} fn 重试方法
     */
    function afterRetry(link, aid, delay, fn) {
        console.warn(`查询:av${aid},请求过快`)

        if (delay) {
            // 延迟绑定
            link.text(`请求过快,${delay}秒后再试`)
            setTimeout(bindReload, retryDelay * 1000, link, fn)
            countdown(link, retryDelay)
        } else {
            // 首次,立即绑定
            link.href = 'javascript:void(0);'
            bindReload(link, fn)
        }
    }

    /**
     * 重新绑定倒计时
     *
     * @param {HTMLLIElement} link 标题链接
     * @param {number} second
     */
    function countdown(link, second) {
        if (link.textContent.indexOf('请求过快') === 0) {
            link.textContent = `请求过快,${second}秒后再试!`
            if (second > 1) {
                setTimeout(countdown, 1000, link, second - 1)
            }
        }
    }

    /**
     * 修复收藏
     *
     * @param {Element} item 收藏项
     * @param {HTMLLIElement} link 标题链接
     * @param {number|string} aid av号
     * @param {string} title 标题
     * @param {string} cover 封面
     * @param {string} history 历史归档,若无时,使用空字符串
     */
    function fixFavorites(item, link, aid, title, cover, history) {
        history ||= ''

        // 设置新标题
        link.textContent = title
        link.title = title

        // 设置新标题链接
        item.querySelectorAll('a').forEach((a) => {
            a.href = `https://www.biliplus.com/${history}video/av${aid}`
        })

        markAsInvalid(item, link)

        if (cover) {
            const img = item.querySelector('img')
            img.src = cover
            item.querySelectorAll('source').forEach((s) => s.remove())
        }
    }

    /**
     * 请求 BiliPlus 接口获取标题和封面
     *
     * @param {Element} item 收藏项
     * @param {HTMLLinkElement} link 标题链接
     * @param {string|number} aid av号
     */
    function fixTitleAndCover(item, link, aid) {
        link.textContent = '加载中...'

        GM_xmlhttpRequest({
            url: `https://www.biliplus.com/api/view?id=${aid}`,
            method: 'GET',
            responseType: 'json',
            onload: (response) => {
                const res = response.response

                if (isDebug) {
                    console.log('fixTitleAndCover', url, res)
                }
        
                if (res.title) {
                    // 找到了
                    fixFavorites(item, link, aid, res.title, res.pic)
                } else if (res.code == -503) {
                    // 请求过快
                    afterRetry(item, link, true, () => fixTitleAndCover(item, link, aid, true))
                } else {
                    // 未找到
                    fixFavorites(item, link, aid, `未找到(${aid})`)
                }
            }
        })
    }

    /**
     * 显示详情
     *
     * @param {Element} list 失效收藏节点列表
     */
    async function showDetail(list) {
        const fidRegex = window.location.href.match(/fid=(\d+)/)
        const fid = fidRegex
            ? fidRegex[1]
            : document.querySelector('div.fav-item.cur').getAttribute('fid')

        // 当前页码
        const pn = document.querySelector(
            'ul.be-pager li.be-pager-item.be-pager-item-active'
        ).textContent

        // 该接口已失效:https://api.bilibili.com/medialist/gateway/base/spaceDetail?media_id=${fid}&pn=${pn}&ps=20&keyword=&order=mtime&type=0&tid=0&jsonp=jsonp
        const url = `https://api.bilibili.com/x/v3/fav/resource/list?media_id=${fid}&pn=${pn}&ps=20&keyword=&order=mtime&type=0&tid=0&platform=web`
        const response = await fetch(url, { credentials: 'include' })
        const json = await response.json()

        const mediasJson = json.data.medias

        list.forEach((node) => {
            const bv = node.getAttribute('data-aid')
            const media = mediasJson.find((x) => x.bvid === bv)

            let titles = ''
            if (media.pages) {
                titles = media.pages.map((m) => m.title).join('、')
            }

            const content =
                `子P数:${media.page}\n` +
                `子P标题:${titles}\n` +
                `简介:${media.intro}\n` +
                `弹幕数:${media.cnt_info.danmaku}`

            node.querySelector('a').title = content
            // addCopyInfoButton(node, content)
            addOpenUpSpaceButton(node, media.upper.mid)
        })
    }

    /**
     * BV号转AV号
     *
     * 原脚本算法已经失效,新算法引用自链接项目
     *
     * @param {string} bvid
     * @see https://github.com/magicdawn/bilibili-app-recommend
     * @see https://greasyfork.org/zh-CN/scripts/443530
     */
    function bv2aid(bvid) {
        const XOR_CODE = 23442827791579n
        const MASK_CODE = 2251799813685247n
        const BASE = 58n
        const CHAR_TABLE = 'FcwAPNKTMug3GV5Lj7EJnHpWsx4tb8haYeviqBz6rkCy12mUSDQX9RdoZf'
        const bvidArr = Array.from(bvid)
        ;[bvidArr[3], bvidArr[9]] = [bvidArr[9], bvidArr[3]]
        ;[bvidArr[4], bvidArr[7]] = [bvidArr[7], bvidArr[4]]
        bvidArr.splice(0, 3)
        const tmp = bvidArr.reduce(
            (pre, bvidChar) => pre * BASE + BigInt(CHAR_TABLE.indexOf(bvidChar)),
            0n
        )
        return Number((tmp & MASK_CODE) ^ XOR_CODE)
    }

    function tip(text, iconName) {
        GM_notification({
            text: text,
            image: GM_getResourceURL(iconName)
        })
    }

    function tipInfo(text) {
        tip(text, 'iconInfo')
    }

    function tipError(text) {
        tip(text, 'iconError')
    }

    function tipSuccess(text) {
        tip(text, 'iconSuccess')
    }

    setInterval(handleFavorites, space)
})()