Bangumi 组件代码对比器

在Bangumi组件页中对比两个版本的代码

当前为 2025-06-03 提交的版本,查看 最新版本

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

You will need to install an extension such as Tampermonkey to install this script.

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

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

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

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

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

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

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

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

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

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

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

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

// ==UserScript==
// @name         Bangumi 组件代码对比器
// @namespace    http://tampermonkey.net/
// @version      1.0.4
// @description  在Bangumi组件页中对比两个版本的代码
// @author       Bios (improved by Grok)
// @match        *://bgm.tv/dev/app/*
// @match        *://bangumi.tv/dev/app/*
// @match        *://chii.in/dev/app/*
// @icon         https://lain.bgm.tv/pic/icon/l/000/00/41/4180.jpg
// @grant        GM_addStyle
// @license      MIT
// ==/UserScript==

(function () {
    'use strict';

    // 尝试从文档中提取指定ID的字段内容。
    // 优先从具有特定ID的元素中获取值,如果不存在,则尝试从常见代码编辑器或pre标签中获取文本内容。
    function extractField(doc, id) {
        const el = doc.querySelector(`#${id}`);
        if (el) {
            return el.value || el.innerText || el.getAttribute('data-value') || el.textContent || '';
        }
        // 兼容处理:如果找不到特定ID的元素,尝试查找常见的代码编辑区域或pre标签
        const alt = doc.querySelector('.CodeMirror, .cm-editor, pre');
        return alt ? alt.textContent : '';
    }

    // 比较两个字符串的字符差异。
    // 返回一个数组,每个元素表示一个差异块:[类型, 内容],
    // 类型为 0 表示相同,-1 表示删除,1 表示新增。
    function diffChars(a, b) {
        const MAX_DISTANCE = 100;

        if (!a && !b) return [[0, '']];
        if (!a) return [[1, b]];
        if (!b) return [[-1, a]];
        if (a === b) return [[0, a]];

        let start = 0;
        // 查找共同的前缀
        while (start < a.length && start < b.length && a[start] === b[start]) {
            start++;
        }

        let end = 0;
        // 查找共同的后缀
        while (
            end < a.length - start &&
            end < b.length - start &&
            a[a.length - 1 - end] === b[b.length - 1 - end]
        ) {
            end++;
        }

        const diffs = [];
        // 添加共同的前缀
        if (start > 0) {
            diffs.push([0, a.substring(0, start)]);
        }

        // 获取中间部分的字符串
        const aMid = a.substring(start, a.length - end);
        const bMid = b.substring(start, b.length - end);

        // 处理中间部分的差异
        if (aMid.length > 0 || bMid.length > 0) {
            // 如果中间部分长度在MAX_DISTANCE内,则进行行内字符差异比较
            if (aMid.length <= MAX_DISTANCE && bMid.length <= MAX_DISTANCE) {
                const midDiffs = findInlineDiffs(aMid, bMid);
                diffs.push(...midDiffs);
            } else {
                // 否则,将中间部分直接标记为删除或新增
                if (aMid) diffs.push([-1, aMid]);
                if (bMid) diffs.push([1, bMid]);
            }
        }

        // 添加共同的后缀
        if (end > 0) {
            diffs.push([0, a.substring(a.length - end)]);
        }

        return diffs;
    }

    // 查找两个字符串的行内字符差异。
    // 用于在diffChars中处理较短的中间部分。
    function findInlineDiffs(oldStr, newStr) {
        if (oldStr === newStr) return [[0, oldStr]];
        const result = [];
        let commonStart = 0;
        // 查找共同的前缀
        while (
            commonStart < oldStr.length &&
            commonStart < newStr.length &&
            oldStr[commonStart] === newStr[commonStart]
        ) {
            commonStart++;
        }

        if (commonStart > 0) {
            result.push([0, oldStr.substring(0, commonStart)]);
        }

        let commonEnd = 0;
        // 查找共同的后缀
        while (
            commonEnd < oldStr.length - commonStart &&
            commonEnd < newStr.length - commonStart &&
            oldStr[oldStr.length - 1 - commonEnd] === newStr[newStr.length - 1 - commonEnd]
        ) {
            commonEnd++;
        }

        // 获取中间部分
        const oldMiddle = oldStr.substring(commonStart, oldStr.length - commonEnd);
        const newMiddle = newStr.substring(commonStart, newStr.length - commonEnd);

        // 添加中间部分的删除和新增标记
        if (oldMiddle.length > 0) {
            result.push([-1, oldMiddle]);
        }
        if (newMiddle.length > 0) {
            result.push([1, newMiddle]);
        }

        // 添加共同的后缀
        if (commonEnd > 0) {
            result.push([0, oldStr.substring(oldStr.length - commonEnd)]);
        }

        return result;
    }

    // 比较两段文本的行差异。
    // 使用最长公共子序列(LCS)算法来确定相同、修改、删除和新增的行。
    function diffLines(oldText, newText) {
        const oldLines = oldText.split('\n');
        const newLines = newText.split('\n');
        const lcs = computeLCS(oldLines, newLines);
        const result = [];
        let oldIdx = 0, newIdx = 0, lcsIdx = 0;

        while (oldIdx < oldLines.length || newIdx < newLines.length) {
            // 如果当前行是LCS的一部分(即在两个版本中都相同)
            if (
                lcsIdx < lcs.length &&
                oldIdx < oldLines.length &&
                newIdx < newLines.length &&
                oldLines[oldIdx] === lcs[lcsIdx] &&
                newLines[newIdx] === lcs[lcsIdx]
            ) {
                result.push({
                    type: 'same',
                    oldLine: oldLines[oldIdx],
                    newLine: newLines[newIdx],
                    oldIndex: oldIdx,
                    newIndex: newIdx,
                    charDiffs: [[0, oldLines[oldIdx]]]
                });
                oldIdx++;
                newIdx++;
                lcsIdx++;
            }
            // 如果当前行在两个版本中都不同(被修改)
            else if (
                oldIdx < oldLines.length &&
                newIdx < newLines.length &&
                (lcsIdx >= lcs.length ||
                 (oldLines[oldIdx] !== lcs[lcsIdx] && newLines[newIdx] !== lcs[lcsIdx]))
            ) {
                const charDiffs = diffChars(oldLines[oldIdx], newLines[newIdx]);
                result.push({
                    type: 'modify',
                    oldLine: oldLines[oldIdx],
                    newLine: newLines[newIdx],
                    oldIndex: oldIdx,
                    newIndex: newIdx,
                    charDiffs: charDiffs
                });
                oldIdx++;
                newIdx++;
            }
            // 如果当前行只存在于旧版本中(被删除)
            else if (
                oldIdx < oldLines.length &&
                (lcsIdx >= lcs.length || oldLines[oldIdx] !== lcs[lcsIdx] || newIdx >= newLines.length)
            ) {
                result.push({
                    type: 'delete',
                    oldLine: oldLines[oldIdx],
                    newLine: '',
                    oldIndex: oldIdx,
                    newIndex: -1,
                    charDiffs: [[-1, oldLines[oldIdx]]]
                });
                oldIdx++;
            }
            // 如果当前行只存在于新版本中(被新增)
            else if (
                newIdx < newLines.length &&
                (lcsIdx >= lcs.length || newLines[newIdx] !== lcs[lcsIdx] || oldIdx >= oldLines.length)
            ) {
                result.push({
                    type: 'add',
                    oldLine: '',
                    newLine: newLines[newIdx],
                    oldIndex: -1,
                    newIndex: newIdx,
                    charDiffs: [[1, newLines[newIdx]]]
                });
                newIdx++;
            }
        }

        return result;
    }

    // 计算两个数组(行)的最长公共子序列(LCS)。
    // 用于行差异比较。
    function computeLCS(a, b) {
        // 初始化一个二维数组,用于存储LCS的长度
        const lengths = Array(a.length + 1)
        .fill()
        .map(() => Array(b.length + 1).fill(0));

        // 填充lengths数组
        for (let i = 0; i < a.length; i++) {
            for (let j = 0; j < b.length; j++) {
                if (a[i] === b[j]) {
                    lengths[i + 1][j + 1] = lengths[i][j] + 1;
                } else {
                    lengths[i + 1][j + 1] = Math.max(lengths[i + 1][j], lengths[i][j + 1]);
                }
            }
        }

        // 从lengths数组中回溯,构建LCS
        const result = [];
        let i = a.length, j = b.length;
        while (i > 0 && j > 0) {
            if (a[i - 1] === b[j - 1]) {
                result.unshift(a[i - 1]);
                i--;
                j--;
            } else if (lengths[i][j - 1] > lengths[i - 1][j]) {
                j--;
            } else {
                i--;
            }
        }

        return result;
    }

    // 对HTML特殊字符进行转义,防止XSS。
    function escapeHTML(str) {
        return str.replace(/&/g, '&amp;').replace(/</g, '&lt;').replace(/>/g, '&gt;');
    }

    // 判断字符是否为空白字符。
    function isWhitespace(char) {
        return /\s/.test(char);
    }

    // 处理行,分离缩进和内容。
    function processLineWithIndent(line) {
        if (!line) return { indent: '', content: '' };
        let indentLength = 0;
        while (indentLength < line.length && isWhitespace(line[indentLength])) {
            indentLength++;
        }

        const indent = line.substring(0, indentLength);
        const content = line.substring(indentLength);
        return { indent, content };
    }

    // 将字符串分割成代码和空白字符段。
    // 用于行内差异高亮,确保只高亮非空白字符。
    function splitCodeAndWhitespace(str) {
        const result = [];
        let currentSegment = '';
        let currentIsSpace = false;
        let started = false;

        for (let i = 0; i < str.length; i++) {
            const char = str[i];
            const isSpace = isWhitespace(char);

            if (!started) {
                currentSegment = char;
                currentIsSpace = isSpace;
                started = true;
                continue;
            }

            if (isSpace === currentIsSpace) {
                currentSegment += char;
            } else {
                result.push({ text: currentSegment, isSpace: currentIsSpace });
                currentSegment = char;
                currentIsSpace = isSpace;
            }
        }

        if (currentSegment) {
            result.push({ text: currentSegment, isSpace: currentIsSpace });
        }

        return result;
    }

    // 渲染行内差异的HTML。
    // 将差异类型(相同、删除、新增)应用于内容。
    function renderInlineDiff(diff) {
        let html = '';
        for (const [type, content] of diff.charDiffs) {
            if (type === 0) {
                html += escapeHTML(content);
            } else if (type === -1 || type === 1) {
                const segments = splitCodeAndWhitespace(content);
                const styleClass = type === -1 ? 'diff-del' : 'diff-add';
                for (const segment of segments) {
                    if (segment.isSpace) {
                        html += escapeHTML(segment.text);
                    } else {
                        html += `<span class="${styleClass}">${escapeHTML(segment.text)}</span>`;
                    }
                }
            }
        }
        return html || '&nbsp;'; // 使用不间断空格作为占位符,避免空行高度问题
    }

    // 准备带有缩进的行,并根据类型应用样式。
    function prepareLineWithIndent(line, type) {
        if (!line) return '&nbsp;';
        const processed = processLineWithIndent(line);
        const styleClass = type === 'delete' ? 'diff-del' : type === 'add' ? 'diff-add' : '';
        let html = escapeHTML(processed.indent);

        if (styleClass && processed.content) {
            const segments = splitCodeAndWhitespace(processed.content);
            for (const segment of segments) {
                if (segment.isSpace) {
                    html += escapeHTML(segment.text);
                } else {
                    html += `<span class="${styleClass}">${escapeHTML(segment.text)}</span>`;
                }
            }
        } else {
            html += escapeHTML(processed.content);
        }

        return html;
    }

    // 渲染差异到指定的容器中。
    // 生成旧版本和新版本的HTML代码,并将其插入到DOM中。
    function renderDiff(container, oldText, newText, title) {
        const lineDiffs = diffLines(oldText, newText);
        const oldHtml = [], newHtml = [];
        let oldLineCount = 1;
        let newLineCount = 1;

        for (const diff of lineDiffs) {
            let oldLineHtml = '';
            let newLineHtml = '';
            let oldLineNumber = '';
            let newLineNumber = '';

            if (diff.type === 'same') {
                oldLineHtml = escapeHTML(diff.oldLine);
                newLineHtml = escapeHTML(diff.newLine);
                oldLineNumber = oldLineCount++;
                newLineNumber = newLineCount++;
            } else if (diff.type === 'modify') {
                // 对于修改行,只显示删除和新增的部分
                oldLineHtml = renderInlineDiff({
                    charDiffs: diff.charDiffs.map(([type, content]) => (type === 1 ? [0, ''] : [type, content]))
                });
                newLineHtml = renderInlineDiff({
                    charDiffs: diff.charDiffs.map(([type, content]) => (type === -1 ? [0, ''] : [type, content]))
                });
                oldLineNumber = oldLineCount++;
                newLineNumber = newLineCount++;
            } else if (diff.type === 'delete') {
                oldLineHtml = prepareLineWithIndent(diff.oldLine, 'delete');
                newLineHtml = '&nbsp;'; // 使用不间断空格作为占位符
                oldLineNumber = oldLineCount++;
                newLineNumber = '';
            } else if (diff.type === 'add') {
                oldLineHtml = '&nbsp;'; // 使用不间断空格作为占位符
                newLineHtml = prepareLineWithIndent(diff.newLine, 'add');
                oldLineNumber = '';
                newLineNumber = newLineCount++;
            }

            oldHtml.push(`<div class="line"><span class="line-number">${oldLineNumber}</span>${oldLineHtml}</div>`);
            newHtml.push(`<div class="line"><span class="line-number">${newLineNumber}</span>${newLineHtml}</div>`);
        }

        container.innerHTML = `
            <h2>${title} 差异</h2>
            <div class="diff-grid">
                <div><h4>旧版本</h4><div class="code">${oldHtml.join('')}</div></div>
                <div><h4>新版本</h4><div class="code">${newHtml.join('')}</div></div>
            </div>
        `;
    }

    // 比较两个版本的代码。
    // 异步获取两个URL的HTML内容,解析出脚本和样式,然后渲染差异。
    async function compareVersions(urlA, urlB) {
        // 解析URL,确保oldUrl和newUrl是按版本号升序排列的
        const a = parseInt(urlA.match(/\/(\d+)(\?.*)?$/)?.[1] ?? '0', 10);
        const b = parseInt(urlB.match(/\/(\d+)(\?.*)?$/)?.[1] ?? '0', 10);
        const [oldUrl, newUrl] = a < b ? [urlA, urlB] : [urlB, urlA];

        // 获取旧版本和新版本的HTML内容
        const oldHtml = await (await fetch(oldUrl)).text();
        const newHtml = await (await fetch(newUrl)).text();

        // 解析HTML字符串为DOM对象
        const oldDoc = new DOMParser().parseFromString(oldHtml, 'text/html');
        const newDoc = new DOMParser().parseFromString(newHtml, 'text/html');

        // 提取脚本和样式内容
        const oldScript = extractField(oldDoc, 'formScript');
        const newScript = extractField(newDoc, 'formScript');
        const oldStyle = extractField(oldDoc, 'formStyle');
        const newStyle = extractField(newDoc, 'formStyle');

        // 创建模态框
        const modal = document.createElement('div');
        modal.className = 'diff-modal';
        modal.innerHTML = `
            <div class="modal-content">
                <div class="modal-header">
                    <h2>Bangumi 代码对比</h2>
                    <button class="modal-close">关闭</button>
                </div>
                <div class="container">
                    <div id="script"></div>
                    <div id="style"></div>
                </div>
            </div>
        `;
        document.body.appendChild(modal);

        // 渲染脚本和样式差异
        renderDiff(modal.querySelector('#script'), oldScript, newScript, '脚本');
        renderDiff(modal.querySelector('#style'), oldStyle, newStyle, '样式');

        // 添加关闭按钮功能
        modal.querySelector('.modal-close').addEventListener('click', () => {
            modal.remove();
        });
    }

    // 初始化函数,在页面加载后添加复选框和对比按钮。
    function init() {
        // 获取所有版本链接
        const versionLinks = [...document.querySelectorAll('a.l[href*="/gadget/"]')];
        if (versionLinks.length === 0) return;

        // 为每个版本链接添加复选框
        versionLinks.forEach(link => {
            const box = document.createElement('input');
            box.type = 'checkbox';
            box.className = 'version-box';
            box.dataset.url = link.href;
            link.before(box);
        });

        // 创建对比按钮
        const btn = document.createElement('button');
        btn.textContent = '版本对比';
        btn.className = 'compare-btn';
        btn.disabled = true;
        btn.style.marginLeft = '10px';
        document.querySelector('h2.subtitle')?.appendChild(btn);

        // 监听复选框变化,控制按钮的禁用状态
        document.querySelectorAll('.version-box').forEach(box => {
            box.addEventListener('change', () => {
                const checked = document.querySelectorAll('.version-box:checked');
                btn.disabled = checked.length !== 2;
            });
        });

        // 监听对比按钮点击事件
        btn.addEventListener('click', () => {
            const [a, b] = [...document.querySelectorAll('.version-box:checked')].map(b => b.dataset.url);
            compareVersions(a, b);
        });
    }

    // 页面加载完成后执行初始化
    window.addEventListener('load', () => {
        if (/https:\/\/(bgm\.tv|bangumi\.tv|chii\.in)\/dev\/app\/\d+$/.test(window.location.href)) {
            init();
        }
    });

    // 添加GM_addStyle样式
    GM_addStyle(`
        .version-box {
            margin-right: 4px;
        }
        .compare-btn {
            background-color: #F09199;
            color: white;
            border: none;
            padding: 5px 10px;
            border-radius: 4px;
            cursor: pointer;
            font-weight: bold;
        }
        .compare-btn:disabled {
            background-color: #cccccc;
            cursor: not-allowed;
        }
        .compare-btn:not(:disabled):hover {
            background-color: #e07b83;
        }
        .diff-modal {
            position: fixed;
            top: 0;
            left: 0;
            width: 100vw;
            height: 100vh;
            display: flex;
            align-items: center;
            justify-content: center;
            z-index: 1000;
        }
        .modal-content {
            background: #ffffff;
            width: 90vw;
            height: 90vh;
            overflow: auto;
            padding: 20px;
            border-radius: 8px;
            border: 1px solid #ddd;
            position: relative;
            display: flex;
            flex-direction: column;
        }
        .modal-header {
            display: flex;
            justify-content: space-between;
            align-items: center;
            position: sticky;
            top: 0;
            background: #ffffff;
            padding: 10px;
            z-index: 1001;
            border-bottom: 1px solid #ddd;
        }
        .modal-header h2 {
            margin: 0;
        }
        .modal-close {
            background: #F09199;
            color: white;
            border: none;
            padding: 5px 10px;
            border-radius: 4px;
            cursor: pointer;
            font-weight: bold;
        }
        .modal-close:hover {
            background: #e07b83;
        }
        .container {
            flex: 1;
            overflow: auto;
        }
        .diff-grid {
            display: grid;
            grid-template-columns: 1fr 1fr;
            gap: 10px;
            width: 100%;
            height: 100%;
            overflow-x: auto;
            overflow-y: auto;
        }
        .diff-grid > div {
            overflow: auto;
        }
        .diff-grid .code {
            font-family: 'Source Code Pro', 'Fira Code', 'Courier New', monospace;
            background: #f7f7f7;
            padding: 10px;
            border: 1px solid #ddd;
            min-height: 300px;
            overflow-x: auto;
            font-size: 14px !important;
            tab-size: 4;
        }
        .diff-grid h4 {
            font-size: 16px !important;
        }
        .diff-add {
            background: #eaffea;
            color: #228822;
        }
        .diff-del {
            background: #ffecec;
            color: #cc0000;
        }
        .line {
            position: relative;
            white-space: pre;
            padding-left: 3em;
        }
        .line-number {
            position: absolute;
            left: 0;
            width: 2.5em;
            text-align: right;
            color: #999;
            user-select: none;
        }
        .line + .line {
            border-top: 1px solid #f0f0f0;
        }
        h2, h4 {
            margin: 0.5em 0;
        }
        @media (max-width: 768px) {
            .diff-grid {
                grid-template-columns: 1fr;
            }
        }
    `);
})();