B站番剧显示BGM评分

在B站番剧页面显示动漫和BGM评分,播放页面添加BGM评分

您需要先安装一个扩展,例如 篡改猴Greasemonkey暴力猴,之后才能安装此脚本。

您需要先安装一个扩展,例如 篡改猴暴力猴,之后才能安装此脚本。

您需要先安装一个扩展,例如 篡改猴暴力猴,之后才能安装此脚本。

您需要先安装一个扩展,例如 篡改猴Userscripts ,之后才能安装此脚本。

您需要先安装一款用户脚本管理器扩展,例如 Tampermonkey,才能安装此脚本。

您需要先安装用户脚本管理器扩展后才能安装此脚本。

(我已经安装了用户脚本管理器,让我安装!)

您需要先安装一款用户样式管理器扩展,比如 Stylus,才能安装此样式。

您需要先安装一款用户样式管理器扩展,比如 Stylus,才能安装此样式。

您需要先安装一款用户样式管理器扩展,比如 Stylus,才能安装此样式。

您需要先安装一款用户样式管理器扩展后才能安装此样式。

您需要先安装一款用户样式管理器扩展后才能安装此样式。

您需要先安装一款用户样式管理器扩展后才能安装此样式。

(我已经安装了用户样式管理器,让我安装!)

// ==UserScript==
// @name         B站番剧显示BGM评分
// @namespace    http://tampermonkey.net/
// @version      1.3
// @description  在B站番剧页面显示动漫和BGM评分,播放页面添加BGM评分
// @author       blue bird
// @match        https://www.bilibili.com/anime/index/*
// @match        https://www.bilibili.com/bangumi/play/ep*
// @match        https://www.bilibili.com/bangumi/play/ss*
// @resource     BILI_BANGUMI_MAPPING https://rhilip.github.io/BangumiExtLinker/data/anime_map.json
// @require      https://scriptcat.org/lib/513/2.1.0/ElementGetter.js#sha256=aQF7JFfhQ7Hi+weLrBlOsY24Z2ORjaxgZNoni7pAz5U=
// @require      https://scriptcat.org/lib/637/1.4.8/ajaxHooker.js#sha256=dTF50feumqJW36kBpbf6+LguSLAtLr7CEs3oPmyfbiM=
// @require      https://cdnjs.cloudflare.com/ajax/libs/jquery/3.3.1/jquery.min.js
// @grant        GM_getResourceText
// @grant        GM_xmlhttpRequest
// @grant        GM_getValue
// @grant        GM_setValue
// @connect      api.bgm.tv
// @run-at       document-start
// @license MIT
// ==/UserScript==

/*global elmGetter,ajaxHooker*/

(function () {
    'use strict';

    let requestCache = GM_getValue('bangumiRatingCache', {});

    // 存储API获取的动漫数据(key: season_id, value: 动漫信息对象)
    const animeDataMap = GM_getValue('bilibiliAnimeCache', {});

    // 1. 处理获取到的动漫数据
    function processAnimeData(data) {
        console.debug("processAnimeData", data);

        if (data.code === 0 && data.data?.list?.length > 0) {
            data.data.list.forEach(anime => {
                // 只存储有season_id和score的有效数据
                if (anime.season_id && anime.score) {
                    animeDataMap[anime.season_id] = anime;
                }
            });

            GM_setValue('bilibiliAnimeCache', animeDataMap);
        }
    }

    ajaxHooker.filter([
        { url: /^https:\/\/api\.bilibili\.com/ },
    ]);

    // 接口拦截
    ajaxHooker.hook(request => {
        // 处理原有接口
        if (request.url.startsWith('https://api.bilibili.com/pgc/season/index/result')) {
            request.response = (res) => {
                try {
                    const data = JSON.parse(res.responseText);
                    processAnimeData(data);
                } catch (e) {
                    console.error('解析B站动漫API(XHR)数据失败:', e);
                }
            };
        }
        return request;
    });

    // 4. 根据评分获取对应的颜色(高分绿色/蓝色,低分红色)
    function getScoreColor(score) {
        const scoreNum = parseFloat(score);
        if (scoreNum >= 9.5) return '#00b42a';    // 深绿色:极高分
        if (scoreNum >= 9.0) return '#36b37e';    // 绿色:高分
        if (scoreNum >= 8.0) return '#165dff';    // 蓝色:良好
        if (scoreNum >= 7.0) return '#ff7d00';    // 橙色:中等
        return '#ff3838';                         // 红色:低分(预警)
    }

    // 5. 查找动漫卡片对应的season_id(从链接中提取)
    function getSeasonIdFromCard(cardElement) {
        // 获取动漫卡片的链接(cover-wrapper的href)
        const linkElem = cardElement.querySelector('a.cover-wrapper');
        if (!linkElem) return null;

        const linkHref = linkElem.getAttribute('href');
        // 匹配链接中的ss数字(season_id),例如:https://www.bilibili.com/bangumi/play/ss21466
        const seasonIdMatch = linkHref.match(/ss(\d+)/);
        return seasonIdMatch ? parseInt(seasonIdMatch[1], 10) : null;
    }


    function renderBiliScore(card) {
        // 获取当前卡片的season_id
        const seasonId = getSeasonIdFromCard(card);
        if (!seasonId) {
            console.log("未找到season_id");
            return;
        }

        // 从存储中获取对应的动漫数据
        const animeData = animeDataMap[seasonId];
        if (!animeData || !animeData.score) {
            console.log("未找到对应的动漫数据", seasonId);
            return;
        }

        // 检查是否已经添加过评分,避免重复添加
        const existingScore = card.querySelector('.bili-score-tag');
        if (existingScore) {
            return;
        }

        // 找到封面容器元素
        const coverWrapper = card.querySelector('a.cover-wrapper');
        if (!coverWrapper) {
            console.log("未找到cover-wrapper元素");
            return;
        }

        // 获取评分颜色
        const scoreColor = getScoreColor(animeData.score);

        // 创建评分标签元素
        const scoreTag = document.createElement('span');
        scoreTag.className = 'corner-tag bili-score-tag';
        scoreTag.style.cssText = `
        position: absolute;
        width: 60px; /* 比"独家"标签窄一些 */
        height: 24px;
        line-height: 24px;
        border-radius: 0 0 4px 0; /* 左下角圆角 */
        top: 0;
        left: 0; /* 定位到左上角 */
        font-size: 12px;
        text-align: center;
        background-color: ${scoreColor};
        color: #fff;
        z-index: 1; /* 确保显示在封面上方 */
    `;
        scoreTag.textContent = "★" + animeData.score;

        // 将评分标签添加到封面容器中
        coverWrapper.appendChild(scoreTag);
    }


    // 6. 渲染评分到动漫卡片
    function renderBiliScores() {
        // 获取所有动漫卡片元素
        const animeCards = document.querySelectorAll(
            '#app > div.bangumi-index-body.clearfix > div.filter-body > ul.bangumi-list.clearfix > li'
        );
        animeCards.forEach(card => renderBiliScore(card));
    }


    // -------------------------- 排行榜页面Bangumi评分逻辑(核心优化) --------------------------
    /**
     * 获取Bangumi评分颜色(紫色系,与B站区分)
     * @param {string|number} score - 评分(如 9.2)
     * @returns {string} 颜色十六进制值
     */
    function getBangumiScoreColor(score) {
        const scoreNum = parseFloat(score);
        if (scoreNum >= 9.5) return '#9c27b0';    // 深紫:极高分
        if (scoreNum >= 9.0) return '#7b1fa2';    // 紫色:高分
        if (scoreNum >= 8.0) return '#673ab7';    // 靛蓝:良好
        if (scoreNum >= 7.0) return '#5c6bc0';    // 浅靛:中等
        return '#7986cb';                         // 浅紫:低分(不刺眼)
    }


    function renderBangumiScore(card) {
        console.debug("renderBangumiScore");
        console.debug(card);

        // 1. 避免重复渲染(添加标记属性)
        if (card.hasAttribute('data-bgm-render-error') || $(card).find('.bgm_score').length > 0 || card.hasAttribute('data-bgm-processing')) {
            return;
        }

        // 2. 提取season_id → 从animeDataMap获取mediaId(核心优化)
        const seasonId = getSeasonIdFromCard(card);
        if (!seasonId) {
            card.setAttribute('data-bgm-render-error', 'no season_id');
            return;
        }
        const animeData = animeDataMap[seasonId];
        // 关键判断:是否有有效的mediaId(mdxxx)
        if (!animeData || !animeData.media_id) {
            console.warn(`未获取到seasonId=${seasonId}的mediaId`);

            card.setAttribute('data-bgm-render-error', 'no media_id');
            return;
        }

        // 3. 用mediaId匹配Bangumi ID(无需解析链接,更稳定)
        const bangumiId = getBangumiIdFromMediaId(animeData.media_id);
        if (!bangumiId) {
            console.warn(`mediaId=${animeData.mediaId}无匹配的Bangumi ID`);
            card.setAttribute('data-bgm-render-error', 'no bgm_id');
            return;
        }

        card.setAttribute('data-bgm-processing', 'true');
        // 4. 获取Bangumi评分并渲染
        getBangumiRating(bangumiId, (ratingData) => {
            const shadowElem = card.querySelector('div.shadow');
            if (!shadowElem || !ratingData) {
                card.setAttribute('data-bgm-render-error', 'no rating data');
                card.removeAttribute('data-bgm-processing');
                return;
            }

            // 5. 渲染Bangumi评分(紫色系+“BGM”标识)
            const scoreColor = getBangumiScoreColor(ratingData.score);
            const scoreHtml = `
                    <span style="
                        margin-left: 2px; /* 进一步缩小间距(原6px) */
                        padding: 1px 4px; /* 与B站评分一致 */
                        border-radius: 2px; /* 与B站评分一致 */
                        font-size: 14px; /* 与B站评分一致 */
                        font-weight: 500; /* 与B站评分一致 */
                        color: white;
                        background-color: ${scoreColor};
                        white-space: nowrap; /* 强制不换行 */
                        overflow: visible; /* 避免溢出隐藏 */
                        display: inline-block; /* 确保布局稳定 */
                    " class="bgm_score">
                        BGM ★${ratingData.score} <!-- 去掉"★",减少冗余 bgm score -->
                    </span>
                `;
            shadowElem.innerHTML += scoreHtml;
            card.removeAttribute('data-bgm-processing');
        });
    }
    /**
     * 渲染Bangumi评分到排行榜卡片(关键优化:用animeData.mediaId替代链接提取)
     */
    function renderBangumiScores() {
        const animeCards = document.querySelectorAll(
            '#app > div.bangumi-index-body.clearfix > div.filter-body > ul.bangumi-list.clearfix > li'
        );

        animeCards.forEach(card => renderBangumiScore(card));
    }





    // 1. 获取预加载的JSON文本
    const jsonText = GM_getResourceText('BILI_BANGUMI_MAPPING');

    // 2. 解析为JSON对象
    var linkData = {};
    // -------------------------- 工具函数 --------------------------
    /**
     * 1. 从a标签提取B站md号(格式:md+数字,如md28229676)
     * @param {string} link - 包含md号的url
     * @returns {string|null} md号(如md28229676)
     */
    function extractMdId(link) {
        const href = link;
        if (!href) return null;
        // 匹配 href 中的 "/md数字" 格式(如 "/bangumi/media/md28229676")
        const mdMatch = href.match(/\/media\/md(\d+)/) || href.match(/\/media\/ss(\d+)/);
        return mdMatch ? `${mdMatch[1]}` : null;
    }

    /**
     * 2. 从映射数据中通过md号获取Bangumi ID
     * @param {string} mdId - B站md号(如md28229676)
     * @param {function} callback - 回调函数(参数:bangumiId或null)
     */
    function getBangumiIdFromMediaId(Id) {
        const mdId = `md${Id}`;
        if (!linkData || Object.keys(linkData).length === 0) {
            linkData = JSON.parse(jsonText)
        }
        const item = Object.values(linkData).find(item =>
            item.bili_id && item.bili_id === mdId
        );
        return item ? item.bgm_id : null;
    }





    function saveCache() {
        GM_setValue('bangumiRatingCache', requestCache);
    }
    /**
     * 3. 调用Bangumi API获取评分数据
     * @param {string} bangumiId - Bangumi条目ID(如400602)
     * @param {function} callback - 回调函数(参数:评分数据或null)
     */
    function getBangumiRating(bangumiId, callback) {
        const BANGUMI_API_BASE = "https://api.bgm.tv/v0/subjects/";
        const cacheKey = `${BANGUMI_API_BASE}${bangumiId}`;
        if (requestCache[cacheKey]) {
            callback(requestCache[cacheKey]);
            return;
        }
        GM_xmlhttpRequest({
            method: "GET",
            url: `${BANGUMI_API_BASE}${bangumiId}`,
            responseType: "json",
            headers: {
                // Bangumi API要求User-Agent)
                "User-Agent": "bluebird/userscript",
                "Accept": "application/json"
            },
            timeout: 10000,

            onload: (response) => {
                if (response.status === 200 && response.response) {
                    const { rating } = response.response;
                    if (rating) {
                        // 提取关键评分数据(score:评分,users_count:评分人数)
                        const result = {
                            score: rating.score.toFixed(1), // 保留1位小数(如8.9)
                            userCount: formatNumber(rating.total) // 格式化人数(如1.2万)
                        };
                        requestCache[cacheKey] = result;
                        saveCache();
                        callback(result);
                    } else {
                        console.error("Bangumi API无评分数据");
                        callback(null);
                    }
                } else {
                    console.error("Bangumi API请求失败,状态码:", response.status);
                    callback(null);
                }
            },
            onerror: (error) => {
                console.error("Bangumi API请求错误:", error);
                callback(null);
            },
            ontimeout: () => {
                console.error("Bangumi API请求超时");
                callback(null);
            }
        });
    }

    /**
     * 4. 将Bangumi评分插入到B站评分容器旁
     * @param {jQuery} targetContainer - B站原评分容器(jQuery对象)
     * @param {Object} ratingData - Bangumi评分数据(score, userCount)
     */
    function insertBangumiRating(targetContainer, ratingData) {
        // 创建Bangumi评分容器(样式模仿B站原评分)
        const bangumiRating = document.createElement("div");
        bangumiRating.className = "mediainfo_mediaRating__C5uvV";
        bangumiRating.style.cssText = `
        display: flex;
        flex-direction: column;
        align-items: center;
        margin-left: 16px; /* 与B站评分保持间距 */
        padding: 2px 0;
    `;

        // 评分数字(模仿B站粉色+大字体)
        const scoreElem = document.createElement("div");
        scoreElem.className = "mediainfo_score__SQ_KG"; // 复用B站原有类名保持样式一致
        scoreElem.style.color = "#FB7299"; // B站粉色主色
        scoreElem.innerHTML = `${ratingData.score}<span class="mediainfo_suffix__fXV4_">分</span>`;

        // 评分人数(模仿B站灰色+小字体)
        const countElem = document.createElement("div");
        countElem.className = "mediainfo_ratingText__N8GtM"; // 复用B站原有类名
        countElem.style.color = "#9499A0"; // B站辅助灰色
        countElem.textContent = `${ratingData.userCount}人评分`;

        bangumiRating.appendChild(scoreElem);
        bangumiRating.appendChild(countElem);


        // 将Bangumi评分插入到B站评分容器旁边(横向排列)
        targetContainer.parent().css("display", "flex"); // 父容器改为flex布局(jQuery方式)
        $(bangumiRating).insertBefore(targetContainer.next()); // 插入到目标容器的下一个兄弟前(jQuery插入方法)
    }

    function insertErrorMsg(targetContainer, msg) {
        const errorElem = document.createElement("div");
        errorElem.style.cssText = `
        margin-left: 16px;
        font-size: 12px;
        color: #9499A0;
        white-space: nowrap;
        `;
        errorElem.textContent = `Bangumi:${msg}`;

        // 利用jQuery的parent()方法获取父元素并设置样式
        targetContainer.parent().css("display", "flex");
        // 插入到targetContainer的下一个兄弟元素之前
        // 先通过next()获取下一个兄弟的jQuery对象,再通过get(0)转为原生DOM元素
        targetContainer.parent().get(0).insertBefore(errorElem, targetContainer.next().get(0));
    }


    async function initBangumiRating() {
        // 1. 获取B站评分容器
        // examepl const gameContainer = await elmGetter.get('div.ivu-tabs-content  div.flex-row.jc-space-flex-start.flex-wrap.w-auto');
        const ratingContainer = await elmGetter.get('[class*="mediainfo_mediaRating__"]');
        if (!ratingContainer) {
            console.error("未找到B站评分容器");
            return;
        }
        const title = await elmGetter.get('[class*="mediainfo_mediaTitle__"]');
        console.log(`current title:${title[0]} ${title[0].href}`);
        const mdId = extractMdId(title[0].href);
        if (!mdId) {
            console.error("未能从链接中提取md号");
            insertErrorMsg(ratingContainer, "未识别md号");
            return;
        }
        const bangumiId = getBangumiIdFromMediaId(mdId);
        if (!bangumiId) {
            console.error("未在映射数据中找到对应的Bangumi ID");
            insertErrorMsg(ratingContainer, "映射数据缺失");
            return;
        }
        // 2. 调用Bangumi API获取评分
        getBangumiRating(bangumiId, (ratingData) => {
            if (ratingData) {
                // 3. 插入Bangumi评分到页面
                window.addEventListener('load', () => {
                    insertBangumiRating(ratingContainer, ratingData);
                });


            } else {
                insertErrorMsg(ratingContainer, "评分加载失败");
            }
        });
    }

    /**
     * 辅助:格式化数字(如1234→1.2万,123456→12.3万)
     * @param {number} num - 原始数字
     * @returns {string} 格式化后的字符串
     */
    function formatNumber(num) {
        if (num >= 10000) {
            return (num / 10000).toFixed(1) + "万";
        }
        return num.toString();
    }

    // 1. 核心函数:判断是否为epxxx路径
    function isBilibiliEpPage() {
        // 获取当前页面的路径(如 "/bangumi/play/ep341249")
        const currentPath = window.location.pathname;
        const epPathReg = /^\/bangumi\/play\/ep\d+(\/|$)/;
        const ssPathReg = /^\/bangumi\/play\/ss\d+(\/|$)/;
        // 返回匹配结果(true=是ep路径,false=不是)
        return epPathReg.test(currentPath) || ssPathReg.test(currentPath);
    }

    function isBiliBiliAnimeIndexPage() {
        const currentPath = window.location.pathname;
        const infoPathReg = /^\/anime\/index\//;
        return infoPathReg.test(currentPath);
    }

    function observePageChanges() {
        const targetContainer = document.querySelector(
            '#app > div.bangumi-index-body.clearfix > div.filter-body'
        );
        if (!targetContainer) return;

        elmGetter.each('.bangumi-item', targetContainer, async (Dom) => {
            // dom is a jquery array
            Dom.each(async (index, dom) => {
                renderBiliScore(dom);
                renderBangumiScore(dom);
            });
        })

        // 标记是否已调度执行
        let isScheduled = false;

        // 创建观察器
        const observer = new MutationObserver(() => {
            // 如果尚未调度,则安排在下一帧执行
            if (!isScheduled) {
                isScheduled = true;
                // 利用requestAnimationFrame确保在DOM更新后、渲染前执行
                requestAnimationFrame(() => {
                    console.log('观察到DOM变化,重新渲染');
                    observer.disconnect();
                    $('.bangumi-item').each((index, dom) => {
                        renderBiliScore(dom);
                        renderBangumiScore(dom);
                    })
                    isScheduled = false; // 
                    observer.observe(targetContainer, options);
                });
            }
        });

        // 配置观察选项(按需调整)
        const options = {
            childList: true,    // 观察子节点变化
            subtree: true,      // 观察所有后代节点
            attributes: true,   // 观察属性变化
            characterData: true // 观察文本内容变化
        };

        // 开始观察
        observer.observe(targetContainer, options);
    }

    elmGetter.selector($)

    // 2. 使用判断结果执行逻辑
    if (isBilibiliEpPage()) {
        console.log("init bgm rating");
        initBangumiRating();
    } else if (isBiliBiliAnimeIndexPage()) {
        // window.addEventListener('load', async () => {
        //     await elmGetter.get('.bangumi-title')
        //     renderBiliScores();
        //     renderBangumiScores();
        // });

        observePageChanges();
    }
})();