哔哩哔哩(B站|Bilibili)收藏夹Fix (cerenkov修改版)

修复 哔哩哔哩(www.bilibili.com) 失效的收藏。(可查看av号、简介、标题、封面、数据等)

// ==UserScript==
// @name         哔哩哔哩(B站|Bilibili)收藏夹Fix (cerenkov修改版)
// @namespace    http://tampermonkey.net/
// @version      1.3.0
// @description  修复 哔哩哔哩(www.bilibili.com) 失效的收藏。(可查看av号、简介、标题、封面、数据等)
// @author       cerenkov
// @license      GPL-3.0
// @match        *://space.bilibili.com/*/favlist*
// @require      https://cdnjs.cloudflare.com/ajax/libs/jquery/1.11.0/jquery.min.js
// @resource iconError https://cdn.jsdelivr.net/gh/crnkv/bilibili-favorites-fix-cerenkov-mod/media/error.png
// @resource iconSuccess https://cdn.jsdelivr.net/gh/crnkv/bilibili-favorites-fix-cerenkov-mod/media/success.png
// @resource iconInfo https://cdn.jsdelivr.net/gh/crnkv/bilibili-favorites-fix-cerenkov-mod/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==

/*jshint esversion: 8 */
(function() {
    'use strict';

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

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

    // 值为 true : 简化查询(新模式)。不再调用历史归档查询,更快出结果,且更不容易碰到“请求过快”警告。反正常规查询查不到的,历史归档查询基本上也查不到。适合有大量失效视频的收藏夹
    // 值为 false: 深度查询(旧模式)。即Mr.Po原脚本所用逻辑。常规查询失败时会调用历史归档查询,花费更多时间,且更容易碰到“请求过快”警告,但似乎得不到更多的结果。适合失效视频数量不多的情况
    let tryLess = true;

    /**
     * 重试延迟[秒]。
     * @type {Number}
     */
    const retryDelay = 5;

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

    let isFirefox = false;
    let isChromium = false;
    let brands = GM_info.userAgentData.brands;
    if (brands && brands.length > 0) {
        if (brands.some(x => x.brand.match(/firefox/i))) {
            isFirefox = true;
        } else if (brands.some(x => x.brand.match(/chromium|chrome|edge/i))) {
            isChromium = true;
        }
    }
    // 阿B是真丢人啊,Firefox下,一旦标题<a>内文字过长出现text-overflow,菜单按钮就无法在鼠标hover时显示
    // 这么基础的毛病,新UI铺开之前都测试不出来吗?
    // 对于一般视频问题不大,但失效恢复视频的功能很需要这个功能菜单
    // 在阿B修好之前,只能我代为临时处理一下了
    function stripTitleFirefox(title) {
        if (isFirefox && title.length > 24) {
            return title.slice(0,24)+"..";
        } else {
            return title;
        }
    }

    // 是否B站新网页界面,在首次(每次)运行handleFavorites()时会检测网页并记录在该变量中
    let isNewUI = false;

    // 缓存已经查询过并且有结果的视频标题和封面(包括查到的和查不到的,不包括查询过程中请求过快、网络错误和解析错误的)
    let cache = {};

    var XOR_CODE = 23442827791579n;
    var MASK_CODE = 2251799813685247n;
    var BASE = 58n;
    var CHAR_TABLE = "FcwAPNKTMug3GV5Lj7EJnHpWsx4tb8haYeviqBz6rkCy12mUSDQX9RdoZf";

    function bv2av(bvid) {
        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 handleFavorites() {
        isNewUI = $("div.fav-list-main div.items").length > 0;
        if (isDebug) console.log(`[bilibili-fav-fix] isNewUI: ${isNewUI}`);

        // 失效收藏节点集
        let $targetItems = null;
        if (isNewUI) {
            $targetItems = $("div.fav-list-main div.items > div").filter(function (i, item) { return $(item).find(".bili-video-card__title a").first().text() == "已失效视频"; });
        } else if ($("ul.fav-video-list.content").length > 0) {
            $targetItems = $("ul.fav-video-list.content li.small-item.disabled");
        } else {
            console.error('[bilibili-fav-fix] B站网页样式无法识别');
        }
        if (isDebug) console.log(`[bilibili-fav-fix] $targetItems.length: ${$targetItems.length}`);

        if ($targetItems.length > 0) {
            console.info(`[bilibili-fav-fix] ${$targetItems.length}个收藏待修复...`);

            showDetail($targetItems);

            $targetItems.each(function(i, item) {
                const $item = $(item);
                const bvid = getItemBVID($item);
                const avid = bv2av(bvid);
                if (isDebug) console.log(`[bilibili-fav-fix] BVID needed to fix: ${bvid}`);

                // 更改封面图超链接和标题行超链接,跳过新UI的up主行的超链接
                const $aElems = $item.find("a:not(.bili-video-card__author)");
                $aElems.attr("href", `https://www.biliplus.com/video/av${avid}/`);
                $aElems.attr("target", "_blank");

                addCopyAVIDButton($item, avid);
                addCopyBVIDButton($item, bvid);

                // 移除禁用样式
                if (!isNewUI) {
                    $item.removeClass("disabled");
                    $aElems.removeClass("disabled");
                }

                const $titleElem = $($aElems[1]);
                if (cache[avid]) {
                    if (cache[avid].success) {
                        // 从缓存中读出
                        fixFavorites($item, $titleElem, avid, cache[avid].title, cache[avid].pic, cache[avid].history, cache[avid].parts);
                    } else {
                        fixFailed($item, $titleElem, avid);
                    }
                } else {
                    fixTitleAndPic($item, $titleElem, avid);
                }
            });
        }
    }

    /**
     * 显示详细
     * @param  {$节点} $targetItems 失效收藏节点集
     */
    function showDetail($targetItems) {
        const url = getBilibiliApiUrl();
        GM_xmlhttpRequest({
            method: 'GET',
            url: url,
            responseType: "json",
            onload: function(res) {
                const json = res.response;
                const medias = json.data.medias;

                $targetItems.each(function(i, item) {
                    const $item = $(item);
                    const bvid = getItemBVID($item);
                    if (isDebug) console.log(`[bilibili-fav-fix] showDetail: ${bvid}`);

                    let media = medias.filter((m) => (m.bvid == bvid));
                    if (media.length > 0) {
                        media = media[0];
                        if (isDebug) console.log(media);
                    } else {
                        console.warn(`[bilibili-fav-fix] ${bvid} not found in Bilibili API JSON (wrong params?): ${url}`);
                        return;
                    }

                    let title = media.title;
                    if (title == "已失效视频") {
                        // 如果 biliplus 查询先有了结果并且被保存在节点上,则使用 biliplus 得来的数据
                        if ($item.attr("_title")) title = $item.attr("_title");
                    }
                    let duration = new Date(media.duration * 1000).toISOString().slice(11, 19);
                    if (duration.slice(0, 2) == "00") duration = duration.slice(3);

                    // 以前在 media.pages 里有子P标题,现在好像B站删了
                    // 如果 biliplus 查询先有了结果并且被保存在节点上,则使用 biliplus 得来的数据
                    let partTitles = null;
                    if ($item.attr("_parts")) partTitles = $item.attr("_parts");
                    if (media.pages) partTitles = media.pages.map((page, i, arry) => "* "+page.title).join("\n");
                    const partsInfo = ( (media.page > 1) ? `分P数量:${media.page}\n` : "" ) + ( partTitles ? `子P标题:\n${partTitles}\n` : "" );

                    let reason;
                    if (media.attr) {
                        if (media.attr == 0) {
                            reason = "未失效(0)";
                        } else if (media.attr == 9) {
                            reason = "UP主自己删除(9)";
                        } else if (media.attr == 1) {
                            reason = "其他原因删除(1)";
                        } else {
                            reason = `原因编号意义未明(${media.attr})`;
                        }
                    }

                    const content = `AV号:${media.id}
BV号:${bvid}
标题:${title}
UP主:${media.upper.name} (https://space.bilibili.com/${media.upper.mid})
简介:${media.intro}
时长:${duration}
发布时间:${new Date(media.pubtime * 1000).toLocaleString()}
收藏时间:${new Date(media.fav_time * 1000).toLocaleString()}
${partsInfo}播放数:${media.cnt_info.play}
收藏数:${media.cnt_info.collect}
弹幕数:${media.cnt_info.danmaku}
失效原因:${reason}`;
                    const $aElems = $item.find("a:not(.bili-video-card__author)");
                    const $coverElem = $aElems.first();
                    $coverElem.attr("title", content);

                    addCopyInfoButton($item);
                    addOpenUpSpaceButton($item, media.upper.mid);
                    addToggleModeButton($item);
                    addSaveLoadCacheButton($item);
                });
            }
        });
    }

    function getBilibiliApiUrl() {
        let fid = window.location.href.match(/fid=(\d+)/i);
        if (fid) {
            fid = fid[1];
        } else if (isNewUI) {
            fid = $("div.fav-sidebar-item:has(.vui_sidebar-item--active)").first().attr("id");
        } else {
            fid = $("li.fav-item.cur").first().attr("fid");
        }
        if (isDebug) console.log(`[bilibili-fav-fix] fid: ${fid}`);

        let pn = 1;
        if (isNewUI) {
            pn = $("div.vui_pagenation--btns .vui_button.vui_button--active").text().trim();
        } else {
            pn = $("ul.be-pager li.be-pager-item.be-pager-item-active").text().trim();
        }
        if (isDebug) console.log(`[bilibili-fav-fix] pn: ${pn}`);

        let order = "mtime";
        if (isNewUI) {
            order = $("div.fav-list-header-filter__left div.radio-filter__item--active").first().text().trim();
        } else {
            order = $($("div.fav-filters > div")[2]).find("span").first().text().trim();
        }
        order = new Map([["最近收藏", "mtime"], ["最多播放", "view"], ["最新投稿", "pubtime"], ["最近投稿", "pubtime"]]).get(order);
        if (order === undefined) order = "mtime";    // 执行收藏夹搜索时无从得知排序,只能手动指定成“最近收藏”,不保证结果正确
        if (isDebug) console.log(`[bilibili-fav-fix] order: ${order}`);

        let tid = 0;
        if (isNewUI) {
            tid = $("div.fav-list-header-collapse div.radio-filter__item--active").first().text().trim().replace(/\s+\d+/, "");
        } else {
            tid = $($("div.fav-filters > div")[1]).find("span").first().text().trim();
        }
        tid = new Map([["全部分区", 0], ["动画", 1], ["音乐", 3], ["游戏", 4], ["娱乐", 5], ["电视剧", 11], ["番剧", 13], ["电影", 23], ["知识", 36], ["鬼畜", 119], ["舞蹈", 129], ["时尚", 155], ["生活", 160], ["国创", 167], ["纪录片", 177], ["影视", 181], ["资讯", 202], ["美食", 211], ["动物圈", 217], ["汽车", 223], ["运动", 234], ["科技", 188], ["版权内容", -24]]).get(tid);
        if (tid === undefined) tid = 0;    // 一些被下线和撤除的分区,无从得知其名称和tid,只能手动指定成“全部分区”,返回的结果很大概率不包含目标视频的数据
        if (isDebug) console.log(`[bilibili-fav-fix] tid: ${tid}`);

        let searchType = 0;
        let keyword = "";
        if (isNewUI) {
            if ($("div.fav-list-header-filter__desc").length > 0) {
                searchType = $("div.fav-list-header-filter__right button").first().text().trim();
                searchType = new Map([["当前", 0], ["全部", 1]]).get(searchType);
                keyword = encodeURIComponent($("div.fav-list-header-filter__right input").first().val());
            }
        } else {
            if ($("div.search-results-num").length > 0) {
                searchType = $("div.search-types > div > div").first().text().trim();
                searchType = new Map([["当前", 0], ["全部", 1]]).get(searchType);
                keyword = encodeURIComponent($("input.search-fav-input").first().val());
            }
        }
        if (isDebug) console.log(`[bilibili-fav-fix] searchType: ${searchType}\n[bilibili-fav-fix] keyword: ${keyword}`);

        return `https://api.bilibili.com/x/v3/fav/resource/list?media_id=${fid}&pn=${pn}&ps=${isNewUI ? 40 : 20}&keyword=${keyword}&order=${order}&type=${searchType}&tid=${tid}&platform=web`;
    }

    function getItemBVID($item) {
        if ($item.attr("bvid")) {
            return $item.attr("bvid");
        }
        let bvid = "";
        if (isNewUI) {
            bvid = $item.find(".bili-cover-card").first().attr("href").match(/bilibili\.com\/video\/(\w+)/i)[1];
        } else {
            bvid = $item.attr("data-aid");
        }
        $item.attr("bvid", bvid);
        return bvid;
    }

    function addCopyAVIDButton($item, avid) {
        addButton($item, "复制AV号", function() {
            GM_setClipboard(`av${avid}`, "text");
            tipSuccess("AV号复制成功!");
        });
    }

    function addCopyBVIDButton($item, bvid) {
        addButton($item, "复制BV号", function() {
            GM_setClipboard(bvid, "text");
            tipSuccess("BV号复制成功!");
        });
    }

    function addCopyInfoButton($item) {
        addButton($item, "复制稿件信息", function() {
            const $aElems = $item.find("a:not(.bili-video-card__author)");
            const $coverElem = $aElems.first();
            GM_setClipboard($coverElem.attr("title"), "text");
            tipSuccess("稿件信息复制成功!");
        });
    }

    function addOpenUpSpaceButton($item, mid) {
        addButton($item, "跳转UP主空间", function () {
            GM_openInTab(`https://space.bilibili.com/${mid}`, {active: true, insert: true, setParent: true});
        });
    }

    function addToggleModeButton($item) {
        addButton($item, function () { return tryLess ? "切至深度查询" : "切至简化查询"; }, function () {
            if (tryLess) {
                tryLess = false;
                for (let k of Object.keys(cache)) {
                    if (!cache[k].success) delete cache[k];
                }
                $(".bili-fav-fix-menu-item").each(function (i, item) {
                    if ($(item).text() == "切至深度查询") $(item).text("切至简化查询");
                })
                tipSuccess("已切至深度查询(旧模式),更花时间,查询结果未必更多,且更容易碰到“请求过快”需手动加载,适合失效视频数量不多的情况");
            } else {
                tryLess = true;
                $(".bili-fav-fix-menu-item").each(function (i, item) {
                    if ($(item).text() == "切至简化查询") $(item).text("切至深度查询");
                })
                tipSuccess("已切至简化查询(新模式),速度更快,查询结果或许有漏,但不容易碰到“请求过快”警告,适合有大量失效视频的收藏夹");
            }
        });
    }

    function addSaveLoadCacheButton($item) {
        addButton($item, "导出/导入缓存", function () {
            if (unsafeWindow.confirm("【导出】点击确定,即可将当前标签页脚本运行期间查询到的标题/封面缓存数据导出至剪贴板(缓存将在网页刷新时消失)")) {
                GM_setClipboard(JSON.stringify(cache), "text");
                tipSuccess("缓存导出至剪贴板成功!");
            } else {
                let input = unsafeWindow.prompt("【导入】粘贴输入缓存数据,即可导入至当前标签页脚本中(缓存将在网页刷新时消失)");
                if (input) {
                    try {
                        cache = JSON.parse(input);
                        tipSuccess("缓存导入成功!");
                    } catch (e) {
                        tipError("缓存导入失败!");
                    }
                }
            }
        });
    }

    function addButton($item, name, fun) {
        if (isNewUI) {
            const $dropdownTrigger = $item.find(".bili-card-dropdown").first();
            $dropdownTrigger.hover(
                function() {
                    setTimeout(function() {
                        if (typeof name == "function") name = name();
                        // 延时获取dropdownMenu元素,因为B站新UI动态生成该元素
                        const $dropdownMenu = $(".bili-card-dropdown-popper.visible").first();
                        if (! $dropdownMenu.find(".bili-fav-fix-menu-item").text().includes(name) ) {
                            const $menuItem = $(`<div class="bili-card-dropdown-popper__item bili-fav-fix-menu-item">${name}</div>`);
                            $menuItem.click(fun);
                            $dropdownMenu.append($menuItem);
                        }
                    }, 500);
                }, function() {}
            );
        } else {
            if (typeof name == "function") name = name();
            const $dropdownMenu = $item.find(".be-dropdown-menu").first();
            if (! ($dropdownMenu.find(".bili-fav-fix-menu-item").text().includes(name)) ) {
                const $lastChild = $dropdownMenu.children().last();
                // 未添加过扩展
                if (!$lastChild.hasClass('bili-fav-fix-menu-item')) {
                    $lastChild.addClass("be-dropdown-item-delimiter");
                }

                const $menuItem = $(`<li class="be-dropdown-item bili-fav-fix-menu-item">${name}</li>`);
                $menuItem.click(fun);
                $dropdownMenu.append($menuItem);
            }
        }
    }

    function tipInfo(text) {
        tip(text, "iconInfo");
    }

    function tipError(text) {
        tip(text, "iconError");
    }

    function tipSuccess(text) {
        tip(text, "iconSuccess");
    }

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



    /**
     * 修复标题和海报
     * @param  {$节点}  $item 当前收藏Item
     * @param  {$节点}  $titleElem  标题链接
     * @param  {数字}   avid av号
     */
    function fixTitleAndPic($item, $titleElem, avid) {
        $titleElem.text("Loading...");
        fixTitleAndPicEnhance3($item, $titleElem, avid);    // 常规查询入口
    }

    /**
     * 修复标题和海报 增强 - 3
     * 模拟常规查询
     * @param  {$节点}    $item 当前收藏Item
     * @param  {$节点}    $titleElem  标题链接
     * @param  {数字}     avid av号
     */
    function fixTitleAndPicEnhance3($item, $titleElem, avid) {

        GM_xmlhttpRequest({
            method: 'GET',
            url: `https://www.biliplus.com/video/av${avid}/`,
            onload: function(response) {
                try {
                    if (isDebug) {
                        console.log("[bilibili-fav-fix] 3---->:");
                        console.log(response.response);
                    }

                    let jsonRegex = response.responseText.match(/window\.addEventListener\('DOMContentLoaded',function\(\){view\((.+)\);}\);/);
                    if (isDebug) console.log(jsonRegex);

                    const jsonStr = jsonRegex[1];
                    if (isDebug) console.log(jsonStr);

                    const res = $.parseJSON(jsonStr);
                    if (res.title) { // 存在
                        let partTitles = null;
                        if (res.list && res.list.length > 1) {
                            partTitles = res.list.map((part, i, arry) => part.part);
                        }
                        fixFavorites($item, $titleElem, avid, res.title, res.pic, null, partTitles);
                    } else if (res.code == -503) { // 请求过快
                        // 出现提示手动点击加载,转入API查询
                        retryLoad($titleElem, avid, null, function() {
                            fixTitleAndPicEnhance0($item, $titleElem, avid, true);
                        });
                    } else { // 常规查询无结果
                        if (tryLess) { // 简化查询,常规查询失败就失败,不再尝试历史归档查询,反正大概率也查不到
                            fixFailed($item, $titleElem, avid);
                        } else {
                            $titleElem.text("常规查询无结果,转入历史归档查询...");
                            fixTitleAndPicEnhance1($item, $titleElem, avid);
                        }
                    }
                } catch (e) { // 网页内容解析错误(很可能是请求过快),出现提示手动点击加载,转入API查询
                    console.error("[bilibili-fav-fix] 常规查询结果解析出错(很可能是请求过快)");
                    retryLoad($titleElem, avid, null, function() {
                        fixTitleAndPicEnhance0($item, $titleElem, avid, true);
                    });
                }
            },
            onerror: function(e) {
                $titleElem.text("常规查询出错,请检查网络连接");
            }
        });
    }

    /**
     * 修复标题和海报 增强 - 0
     * 使用公开的API
     * @param  {$节点}   $item 当前收藏Item
     * @param  {$节点}   $titleElem  标题链接
     * @param  {数字}    avid av号
     * @param  {布尔}    delayRetry 延迟重试
     */
    function fixTitleAndPicEnhance0($item, $titleElem, avid, delayRetry) {
        // 传入的delayRetry似乎只有true,即遇到503时永远需要强制延迟
        GM_xmlhttpRequest({
            method: 'GET',
            url: `https://www.biliplus.com/api/view?id=${avid}`,
            responseType: "json",
            onload: function(response) {
                const res = response.response;
                if (isDebug) {
                    console.log("[bilibili-fav-fix] 0---->:");
                    console.log(res);
                }

                if (res.title) { // 找到了
                    let partTitles = null;
                    if (res.list && res.list.length > 1) {
                        partTitles = res.list.map((part, i, arry) => part.part);
                    }
                    fixFavorites($item, $titleElem, avid, res.title, res.pic, null, partTitles);
                } else if (res.code == -503) { // 请求过快
                    retryLoad($titleElem, avid, delayRetry, function() {
                        fixTitleAndPicEnhance0($item, $titleElem, avid, true);
                    });
                } else { // API查询无结果(或json解析格式出错)
                    if (tryLess) { // 简化查询,API查询失败就失败,不再尝试历史归档查询,反正大概率也查不到
                        fixFailed($item, $titleElem, avid);
                    } else {
                        $titleElem.text("API查询无结果,转入历史归档查询...");
                        fixTitleAndPicEnhance1($item, $titleElem, avid);
                    }
                }
            },
            onerror: function(e) {
                console.error("[bilibili-fav-fix] API查询出错");
                $titleElem.text("API查询出错,请检查网络连接");
            }
        });
    }

    /**
     * 修复标题和海报 增强 - 1
     * 使用cache库 (历史归档查询)
     * @param  {$节点}  $item 当前收藏Item
     * @param  {$节点}  $titleElem  标题链接
     * @param  {数字}   avid av号
     */
    function fixTitleAndPicEnhance1($item, $titleElem, avid) {

        GM_xmlhttpRequest({
            method: 'GET',
            url: `https://www.biliplus.com/all/video/av${avid}/`,
            onload: function(response) {
                try {
                    if (isDebug) {
                        console.log("[bilibili-fav-fix] 1---->:");
                        console.log(response.response);
                    }

                    const params = response.responseText.match(/getjson\('(\/api\/view_all.+)'/);
                    fixTitleAndPicEnhance2($item, $titleElem, avid, params[1]);    // 不传入delayRetry参数,第一次503时可立刻点击重载
                } catch (e) { // 网页内容解析错误
                    console.error("[bilibili-fav-fix] 历史归档查询结果解析出错(1)或请求过快");
                    $titleElem.text("历史归档查询结果解析出错(1)或请求过快");
                }
            },
            onerror: function(e) {
                $titleElem.text("历史归档查询出错(1),请检查网络连接");
            }
        });
    }

    /**
     * 修复标题和海报 增强 - 2
     * 使用cache库,第一段,需与fixTitleAndPicEnhance1连用
     * @param  {$节点}    $item       当前收藏Item
     * @param  {$节点}    $titleElem          标题链接
     * @param  {数字}     avid        av号
     * @param  {字符串}    param       待拼接参数
     * @param  {布尔}     delayRetry  延迟重试
     */
    function fixTitleAndPicEnhance2($item, $titleElem, avid, param, delayRetry) {

        GM_xmlhttpRequest({
            method: 'GET',
            url: `https://www.biliplus.com${param}`,
            responseType: "json",
            onload: function(response) {
                try {
                    const res = response.response;
                    if (isDebug) {
                        console.log("[bilibili-fav-fix] 2---->:");
                        console.log(res);
                    }

                    if (!res.code) throw "JSON格式不正确";
                    if (res.code === 0) { // 找到了
                        let partTitles = null;
                        if (res.data.parts && res.data.parts.length > 1) {
                            partTitles = res.data.parts.map((part, i, arry) => part.part);
                        }
                        fixFavorites($item, $titleElem, avid, res.data.info.title, res.data.info.pic, "all/", partTitles);
                    } else if (res.code == -503) { // 请求过快
                        retryLoad($titleElem, avid, delayRetry, function() {
                            fixTitleAndPicEnhance2($item, $titleElem, avid, param, true);
                        });
                    } else { // 历史归档查询无结果
                        fixFailed($item, $titleElem, avid);
                    }
                } catch (e) { // JSON内容解析错误
                    console.error("[bilibili-fav-fix] 历史归档查询结果解析出错(2)");
                    $titleElem.text("历史归档查询结果解析出错(2)");
                }
            },
            onerror: function(e) {
                $titleElem.text("历史归档查询出错(2),请检查网络连接");
            }
        });
    }

    /**
     * 修复收藏
     * @param  {$节点}    $item   当前收藏Item
     * @param  {$节点}    $titleElem      标题链接
     * @param  {数字}     avid    av号
     * @param  {字符串}    title   标题
     * @param  {字符串}    pic     海报
     * @param  {字符串}    history 历史归档,若无时,使用 null
     * @param  {字符串列表}    parts   子P标题,默认为 null
     */
    function fixFavorites($item, $titleElem, avid, title, pic, history, parts) {

        // 录入缓存
        if (!cache[avid] || !(cache[avid].success)) {
            cache[avid] = {success: true, title: title, pic: pic};
            if (history) cache[avid].history = history;
            if (parts) cache[avid].parts = parts;
        }

        // 设置多个超链接跳转 biliplus
        const $aElems = $item.find("a:not(.bili-video-card__author)");
        $aElems.attr("href", `https://www.biliplus.com/${history ? history : ""}video/av${avid}/`);

        // 设置标题文字
        $titleElem.text(stripTitleFirefox(title));
        $titleElem.attr("title", title);

        // 保存标题和子P标题到节点上,以便让 showDetail 读取
        $item.attr("_title", title);
        if (parts) parts = "* "+parts.join("\n* ");
        if (parts) $item.attr("_parts", parts);

        // 如果 showDetail 已经生成浮块,则替换浮块中的文本
        const $coverElem = $aElems.first();
        let content = $coverElem.attr("title");
        if (content) {
            content = content.replace(/\n标题:.*\n/, `\n标题:${title}\n`);
            if (parts) content = content.replace("播放数:", `子P标题:\n${parts}\n播放数:`);
            $coverElem.attr("title", content);
        }

        // 设置标题样式
        setInvalItemStyle($item, $titleElem);

        // 替换封面
        const $img = $item.find("img");
        $img.attr("src", pic);
        $item.find("source").remove();
    }

    function fixFailed($item, $titleElem, avid) {
        $titleElem.text(`查不到标题/封面(${avid})`);
        $titleElem.attr("title", `查不到标题/封面(${avid})`);
        // 录入缓存
        if (!cache[avid]) cache[avid] = {success: false};
    }

    /**
     * 标记失效的收藏
     * @param  {$节点}  $item 当前收藏Item
     * @param  {$节点}  $titleElem  标题链接
     */
    function setInvalItemStyle($item, $titleElem) {
        // 增加 删除线 + 置(灰)
        $titleElem.attr("style", `text-decoration:line-through;color:${invalTitleColor};`);
        // 收藏时间 + UP主(新UI)
        let $subtitle;
        if (isNewUI) {
            $subtitle = $item.find("div.bili-video-card__subtitle");
        } else {
            $subtitle = $item.find("div.meta.pubdate");
        }
        // 增加 删除线
        $subtitle.attr("style", "text-decoration:line-through");
    }

    /**
     * 再次尝试加载
     * @param  {$节点}    $titleElem          标题链接
     * @param  {数字} avid        AV号
     * @param  {布尔} delayRetry  延迟重试
     * @param  {函数} fun         重试方法
     */
    function retryLoad($titleElem, avid, delayRetry, fun) {

        console.warn(`[bilibili-fav-fix] 查询:av${avid},请求过快!`);

        if (delayRetry) { // 延迟绑定
            $titleElem.text(`请求过快,${retryDelay}秒后再试!`);
            setTimeout(bindReload, retryDelay * 1000, $titleElem, fun);
            countdown($titleElem, retryDelay);
        } else { // 首次,立即绑定
            $titleElem.attr("href", "javascript:void(0);");
            $titleElem.attr("target", "_self");
            bindReload($titleElem, fun);
        }
    }

    /**
     * 绑定重新加载
     * @param  {$节点}  $titleElem  标题链接
     * @param  {函数}   fun 重试方法
     */
    function bindReload($titleElem, fun) {
        $titleElem.text("->点击手动加载<-");
        $titleElem.click(function() {
            $(this).unbind("click");
            $titleElem.text("Loading...");
            fun();
        });
    }

    /**
     * 重新绑定倒计时
     * @param  {$节点}    $titleElem      标题链接
     * @param  {数字}     second  秒
     */
    function countdown($titleElem, second) {
        if ($titleElem.text().indexOf("请求过快") === 0) {
            $titleElem.text(`请求过快,${second}秒后再试!`);
            if (second > 1) {
                setTimeout(countdown, 1000, $titleElem, second - 1);
            }
        }
    }

    setInterval(handleFavorites, interval);
})();