// ==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, '&').replace(/</g, '<').replace(/>/g, '>');
}
// 判断字符是否为空白字符。
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 || ' '; // 使用不间断空格作为占位符,避免空行高度问题
}
// 准备带有缩进的行,并根据类型应用样式。
function prepareLineWithIndent(line, type) {
if (!line) return ' ';
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 = ' '; // 使用不间断空格作为占位符
oldLineNumber = oldLineCount++;
newLineNumber = '';
} else if (diff.type === 'add') {
oldLineHtml = ' '; // 使用不间断空格作为占位符
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;
}
}
`);
})();