您需要先安装一个扩展,例如 篡改猴、Greasemonkey 或 暴力猴,之后才能安装此脚本。
您需要先安装一个扩展,例如 篡改猴 或 暴力猴,之后才能安装此脚本。
您需要先安装一个扩展,例如 篡改猴 或 暴力猴,之后才能安装此脚本。
您需要先安装一个扩展,例如 篡改猴 或 Userscripts ,之后才能安装此脚本。
您需要先安装一款用户脚本管理器扩展,例如 Tampermonkey,才能安装此脚本。
您需要先安装用户脚本管理器扩展后才能安装此脚本。
在 Bangumi 角色页面生成多层关系图(支持1~5层,自动合并双向关系,支持隐藏节点功能,支持关系扩展,支持强关系模式,支持剧透显示开关)
// ==UserScript== // @name BGM 角色关系图生成器 // @namespace http://tampermonkey.net/ // @version 4.8 // @description 在 Bangumi 角色页面生成多层关系图(支持1~5层,自动合并双向关系,支持隐藏节点功能,支持关系扩展,支持强关系模式,支持剧透显示开关) // @author age_anime // @match https://bgm.tv/character/* // @match https://bangumi.tv/character/* // @match https://chii.in/character/* // @grant none // @require https://cdnjs.cloudflare.com/ajax/libs/vis/4.21.0/vis.min.js // @license MIT // ==/UserScript== (function () { 'use strict'; const base_url = window.location.origin; let attempts = 0; const maxAttempts = 50; const checkExist = setInterval(() => { attempts++; const contentInner = document.querySelector('#columnCrtB .content_inner.clearit'); const charNameElement = document.querySelector('#headerSubject h1.nameSingle a'); if (contentInner && charNameElement) { clearInterval(checkExist); insertButton(contentInner, charNameElement); } else if (attempts >= maxAttempts) { clearInterval(checkExist); } }, 300); function insertButton(contentInner, charNameElement) { try { const container = document.createElement('div'); container.style.cssText = ` display: flex !important; align-items: center !important; gap: 10px !important; margin-bottom: 15px !important; `; const input = document.createElement('input'); input.type = 'number'; input.min = '1'; input.max = '5'; input.value = '3'; input.style.cssText = ` width: 50px !important; padding: 5px !important; text-align: center !important; border: 1px solid #ccc !important; border-radius: 4px !important; color: black !important; background-color: white !important; `; // 剧透开关按钮 const spoilerToggle = document.createElement('button'); spoilerToggle.textContent = '👁️ 剧透: 关'; spoilerToggle.style.cssText = ` padding: 6px 10px !important; background-color: #9C27B0 !important; color: white !important; border: none !important; border-radius: 4px !important; cursor: pointer !important; font-size: 12px !important; `; spoilerToggle.dataset.spoilerActive = 'false'; spoilerToggle.addEventListener('click', () => { const isActive = spoilerToggle.dataset.spoilerActive === 'true'; if (isActive) { spoilerToggle.textContent = '👁️ 剧透: 关'; spoilerToggle.style.backgroundColor = '#9C27B0'; spoilerToggle.dataset.spoilerActive = 'false'; } else { spoilerToggle.textContent = '👁️ 剧透: 开'; spoilerToggle.style.backgroundColor = '#4CAF50'; spoilerToggle.dataset.spoilerActive = 'true'; } }); const button = document.createElement('button'); button.textContent = '🖼️ 生成关系图'; button.style.cssText = ` padding: 6px 10px !important; background-color: #4CAF50 !important; color: white !important; border: none !important; border-radius: 4px !important; cursor: pointer !important; font-size: 12px !important; font-weight: bold !important; display: block !important; box-shadow: 0 1px 2px rgba(0,0,0,0.2) !important; `; button.onmouseover = () => { button.style.backgroundColor = '#45a049 !important'; }; button.onmouseout = () => { button.style.backgroundColor = '#4CAF50 !important'; }; container.appendChild(input); container.appendChild(spoilerToggle); container.appendChild(button); contentInner.parentNode.insertBefore(container, contentInner); const charName = charNameElement.textContent.trim(); const charId = location.pathname.split('/').pop(); button.addEventListener('click', async () => { const layers = parseInt(input.value) || 3; if (layers < 1 || layers > 5) { alert('❌ 层数必须在 1 到 5 之间'); return; } const includeSpoiler = spoilerToggle.dataset.spoilerActive === 'true'; try { button.disabled = true; button.textContent = '🔄 正在生成...'; const nodes = new Map(); const edges = []; let initialCharImage = getInitialCharacterImage(); nodes.set(charId, { id: charId, label: charName, shape: 'image', image: initialCharImage, brokenImage: base_url + '/img/no_icon.jpg', title: charName, font: { color: '#000' } }); await buildRelationGraph(charId, layers, nodes, edges, new Map(), 1, includeSpoiler); const mergedEdges = mergeBidirectionalEdges(edges); renderGraph([...nodes.values()], mergedEdges, charId, layers, includeSpoiler); } catch (error) { alert('❌ 生成关系网失败: ' + error.message); console.error('生成关系网失败:', error); } finally { button.disabled = false; button.textContent = '🖼️ 生成关系图'; } }); } catch (e) { alert('❌ 插入按钮时出错: ' + e.message); } } function getInitialCharacterImage() { const img = document.querySelector('#columnCrtA .infobox img'); return img ? img.src : base_url + '/img/no_icon.jpg'; } const fetchedCache = new Map(); async function buildRelationGraph(charId, layers, nodes, edges, visited = new Map(), currentLayer = 1, includeSpoiler = false) { if (currentLayer > layers) return; if (visited.has(charId) && visited.get(charId) <= currentLayer) { return; } visited.set(charId, currentLayer); const relations = await fetchRelations(charId, includeSpoiler); for (const rel of relations) { const { id, name, relation, image, isSpoiler } = rel; if (!nodes.has(id)) { nodes.set(id, { id: id, label: name, shape: 'image', image: image || base_url + '/img/no_icon.jpg', brokenImage: base_url + '/img/no_icon.jpg', title: name, font: { color: '#000' }, isSpoiler: isSpoiler }); } else if (includeSpoiler && isSpoiler) { // 剧透开启时,更新节点的剧透状态 const node = nodes.get(id); node.isSpoiler = true; nodes.set(id, node); } edges.push({ from: charId, to: id, label: relation, arrows: 'to', isSpoiler: isSpoiler }); await buildRelationGraph(id, layers, nodes, edges, visited, currentLayer + 1, includeSpoiler); } } async function fetchRelations(charId, includeSpoiler = false) { const cacheKey = `${charId}-${includeSpoiler}`; if (fetchedCache.has(cacheKey)) { return fetchedCache.get(cacheKey); } const url = `${base_url}/character/${charId}`; try { const response = await fetch(url, { credentials: 'include' }); if (!response.ok) throw new Error(`HTTP ${response.status}`); const html = await response.text(); const parser = new DOMParser(); const doc = parser.parseFromString(html, 'text/html'); const container = doc.querySelector('#columnCrtB .crt_relations.content_inner.clearit'); if (!container) return []; const relations = []; const items = container.querySelectorAll('ul.browserCoverMedium > li'); items.forEach((li, index) => { try { const isSpoiler = li.classList.contains('spoiler'); // 剧透关闭时跳过 spoiler 项 if (!includeSpoiler && isSpoiler) return; const subElement = li.querySelector('p.sub.spoilerable'); let relationType = ''; if (subElement) { const badge = subElement.querySelector('.badge_job_tip'); const badgeText = badge ? badge.textContent.trim() : ''; const mainTextNodes = Array.from(subElement.childNodes) .filter(node => node.nodeType === Node.TEXT_NODE) .map(node => node.textContent.trim()) .join('') .trim(); relationType = mainTextNodes + (badgeText ? `(${badgeText})` : ''); } const link = li.querySelector('.info a'); if (!link) return; const name = link.textContent.trim(); const href = link.getAttribute('href'); const idMatch = href?.match(/\/character\/(\d+)/); if (!idMatch) return; const id = idMatch[1]; let image = base_url + '/img/no_icon.jpg'; const imgElement = li.querySelector('.avatar span'); if (imgElement && imgElement.style.backgroundImage) { const bg = imgElement.style.backgroundImage; const urlMatch = bg.match(/url\(['"]?(.*?)['"]?\)/); if (urlMatch) image = urlMatch[1]; } relations.push({ id, name, relation: relationType, image, isSpoiler }); } catch (e) { console.warn(`❌ 解析第${index}项关系失败`, e); } }); fetchedCache.set(cacheKey, relations); return relations; } catch (e) { console.warn('❌ 获取关系失败:', e); return []; } } function mergeBidirectionalEdges(edges) { const edgeMap = new Map(); edges.forEach(edge => { const key = [edge.from, edge.to].sort().join('-'); if (!edgeMap.has(key)) { edgeMap.set(key, { forward: [], backward: [] }); } const group = edgeMap.get(key); if (edge.from < edge.to) { group.forward.push(edge); } else if (edge.from > edge.to) { group.backward.push(edge); } else { group.forward.push(edge); } }); const result = []; for (const [nodePair, { forward, backward }] of edgeMap.entries()) { const forwardLabels = [...new Set(forward.map(e => e.label || ''))].filter(Boolean).sort(); const backwardLabels = [...new Set(backward.map(e => e.label || ''))].filter(Boolean).sort(); const forwardLabelStr = forwardLabels.join(', '); const backwardLabelStr = backwardLabels.join(', '); if (forwardLabelStr === backwardLabelStr && forwardLabelStr !== '') { const [nodeA, nodeB] = nodePair.split('-'); result.push({ from: nodeA, to: nodeB, label: forwardLabelStr, arrows: 'from, to', color: { color: '#FF5722' }, isSpoiler: forward[0]?.isSpoiler || backward[0]?.isSpoiler }); } else { if (forward.length > 0) { result.push({ from: forward[0].from, to: forward[0].to, label: forwardLabelStr || ' ', arrows: 'to', color: { color: '#666666' }, isSpoiler: forward[0].isSpoiler }); } if (backward.length > 0) { result.push({ from: backward[0].from, to: backward[0].to, label: backwardLabelStr || ' ', arrows: 'to', color: { color: '#666666' }, isSpoiler: backward[0].isSpoiler }); } } } return result; } // 查找连通图 function findConnectedComponents(nodes, edges) { const nodeMap = new Map(nodes.map(node => [node.id, node])); const edgeMap = new Map(); // 构建邻接表 edges.forEach(edge => { if (!edgeMap.has(edge.from)) edgeMap.set(edge.from, []); if (!edgeMap.has(edge.to)) edgeMap.set(edge.to, []); edgeMap.get(edge.from).push(edge.to); edgeMap.get(edge.to).push(edge.from); }); const visited = new Set(); const components = []; nodes.forEach(node => { if (!visited.has(node.id)) { const component = []; const queue = [node.id]; visited.add(node.id); while (queue.length > 0) { const currentId = queue.shift(); component.push(currentId); const neighbors = edgeMap.get(currentId) || []; neighbors.forEach(neighborId => { if (!visited.has(neighborId)) { visited.add(neighborId); queue.push(neighborId); } }); } components.push(component); } }); return components; } function renderGraph(nodes, edges, initialCharId, layers, includeSpoiler) { const existingModal = document.getElementById('relation-graph-modal'); if (existingModal) document.body.removeChild(existingModal); const modal = document.createElement('div'); modal.id = 'relation-graph-modal'; modal.style.cssText = ` position: fixed !important; top: 40px !important; left: 40px !important; right: 40px !important; bottom: 40px !important; background-color: #fff !important; z-index: 10000 !important; border: 2px solid #ccc !important; box-shadow: 0 0 20px rgba(0,0,0,0.5) !important; padding: 15px !important; `; const closeBtn = document.createElement('button'); closeBtn.textContent = '×'; closeBtn.style.cssText = ` position: absolute !important; top: 10px !important; right: 10px !important; width: 30px !important; height: 30px !important; background-color: #f44336 !important; color: white !important; border: none !important; border-radius: 50% !important; cursor: pointer !important; font-size: 18px !important; font-weight: bold !important; `; closeBtn.onclick = () => document.body.removeChild(modal); modal.appendChild(closeBtn); const title = document.createElement('h3'); title.textContent = '角色关系图 (' + nodes.length + '个节点, ' + edges.length + '条关系)'; title.style.marginTop = '0'; title.style.marginBottom = '10px'; modal.appendChild(title); const graphContainer = document.createElement('div'); graphContainer.id = 'relation-graph'; graphContainer.style.cssText = ` width: 100% !important; height: calc(100% - 50px) !important; border: 1px solid #ddd !important; `; modal.appendChild(graphContainer); document.body.appendChild(modal); const hideBtn = document.createElement('button'); hideBtn.textContent = '👁️ 隐藏模式'; hideBtn.style.cssText = ` position: absolute !important; bottom: 15px !important; right: 15px !important; padding: 8px 15px !important; background-color: #2196F3 !important; color: white !important; border: none !important; border-radius: 4px !important; cursor: pointer !important; font-size: 12px !important; z-index: 10001 !important; `; hideBtn.dataset.active = 'false'; modal.appendChild(hideBtn); // 添加强关系模式复选框 const strongRelationCheckbox = document.createElement('input'); strongRelationCheckbox.type = 'checkbox'; strongRelationCheckbox.id = 'strong-relation-mode'; strongRelationCheckbox.style.cssText = ` position: absolute !important; bottom: 20px !important; right: 230px !important; z-index: 10001 !important; `; const strongRelationLabel = document.createElement('label'); strongRelationLabel.setAttribute('for', 'strong-relation-mode'); strongRelationLabel.textContent = '强关系模式'; strongRelationLabel.style.cssText = ` position: absolute !important; bottom: 20px !important; right: 250px !important; color: #333 !important; font-size: 12px !important; z-index: 10001 !important; `; modal.appendChild(strongRelationCheckbox); modal.appendChild(strongRelationLabel); // 添加关系扩展按钮 const expandBtn = document.createElement('button'); expandBtn.textContent = '🔍 关系扩展'; expandBtn.style.cssText = ` position: absolute !important; bottom: 15px !important; right: 130px !important; padding: 8px 15px !important; background-color: #FF9800 !important; color: white !important; border: none !important; border-radius: 4px !important; cursor: pointer !important; font-size: 12px !important; z-index: 10001 !important; `; expandBtn.dataset.active = 'false'; expandBtn.dataset.includeSpoiler = includeSpoiler; // 保存初始剧透设置 modal.appendChild(expandBtn); try { const data = { nodes: new vis.DataSet(nodes), edges: new vis.DataSet(edges) }; const options = { nodes: { shape: 'image', size: 50, font: { color: '#000', size: 12, face: 'Arial, sans-serif', strokeColor: '#fff', strokeWidth: 2 } }, edges: { arrows: { to: { enabled: true, scaleFactor: 0.7 } }, font: { align: 'horizontal', size: 10, background: 'rgba(255,255,255,0.8)' }, color: { color: '#666666' }, smooth: { type: 'continuous' }, scaling: { label: { enabled: true } } }, physics: { enabled: true, stabilization: { iterations: 100, fit: true }, barnesHut: { gravitationalConstant: -3000, springLength: 225, springConstant: 0.01, damping: 0.5, avoidOverlap: 1 } }, interaction: { dragNodes: true, dragView: true, zoomView: true } }; const network = new vis.Network(graphContainer, data, options); // 保存初始状态的数据 const initialNodes = new Map(nodes.map(node => [node.id, node])); // 将初始的合并边转换为原始边格式,用于后续扩展时的统一处理 let allRawEdges = []; // 从初始合并边中提取原始边数据 edges.forEach(edge => { if (edge.arrows === 'from, to') { // 双向合并边拆分成两个原始单向边 allRawEdges.push({ from: edge.from, to: edge.to, label: edge.label, arrows: 'to', isSpoiler: edge.isSpoiler }); allRawEdges.push({ from: edge.to, to: edge.from, label: edge.label, arrows: 'to', isSpoiler: edge.isSpoiler }); } else if (edge.arrows === 'to') { // 单向边直接保存 allRawEdges.push({ from: edge.from, to: edge.to, label: edge.label, arrows: 'to', isSpoiler: edge.isSpoiler }); } }); let hiddenNodes = new Set(); let hiddenEdges = new Set(); let expandModeActive = false; let currentLayers = layers; // 切换扩展模式 expandBtn.addEventListener('click', function () { expandModeActive = !expandModeActive; if (expandModeActive) { this.style.backgroundColor = '#FF5722'; this.textContent = '🔍 点击角色扩展'; } else { this.style.backgroundColor = '#FF9800'; this.textContent = '🔍 关系扩展'; } }); network.on("click", async function (params) { const hideModeActive = hideBtn.dataset.active === 'true'; if (hideModeActive && params.nodes.length > 0) { const nodeId = params.nodes[0]; hiddenNodes.add(nodeId); // 关键修复:删除与该节点相关的所有边(包括原始边) const connectedEdgesToRemove = []; const remainingRawEdges = []; // 从当前显示的边中找出要删除的边 data.edges.forEach(edge => { if (edge.from === nodeId || edge.to === nodeId) { connectedEdgesToRemove.push(edge.id); hiddenEdges.add(edge.id); } }); // 从原始边数组中移除与该节点相关的边 allRawEdges = allRawEdges.filter(edge => { if (edge.from === nodeId || edge.to === nodeId) { // 这些边需要被隐藏,不保留在原始边中 return false; } return true; }); // 从图中移除节点和边 data.nodes.remove(nodeId); data.edges.remove(connectedEdgesToRemove); // 重新合并剩余的原始边 const remainingMergedEdges = mergeBidirectionalEdges(allRawEdges); // 更新图的边 data.edges.clear(); data.edges.add(remainingMergedEdges); const nodeCount = data.nodes.length; const edgeCount = data.edges.length; title.textContent = `角色关系图 (${nodeCount}个节点, ${edgeCount}条关系)`; } else if (expandModeActive && params.nodes.length > 0) { const nodeId = params.nodes[0]; try { expandBtn.disabled = true; expandBtn.textContent = '🔄 加载中...'; // 使用初始的剧透设置 const newRelations = await fetchRelations(nodeId, includeSpoiler); const newNodes = []; const newRawEdges = []; for (const rel of newRelations) { const { id, name, relation, image, isSpoiler } = rel; // 关键修复:检查节点是否真的可以在 DataSet 中添加 // data.nodes.get(id) 返回 null 表示节点不存在或已被删除 const canAddNode = data.nodes.get(id) === null; // 如果是强关系模式且节点被隐藏,尝试恢复 if (strongRelationCheckbox.checked && hiddenNodes.has(id)) { hiddenNodes.delete(id); if (canAddNode) { newNodes.push({ id: id, label: name, shape: 'image', image: image || base_url + '/img/no_icon.jpg', brokenImage: base_url + '/img/no_icon.jpg', title: name, font: { color: '#000' }, isSpoiler: isSpoiler }); } } // 普通情况:节点真的不存在时才添加 else if (canAddNode && !initialNodes.has(id) && !hiddenNodes.has(id)) { newNodes.push({ id: id, label: name, shape: 'image', image: image || base_url + '/img/no_icon.jpg', brokenImage: base_url + '/img/no_icon.jpg', title: name, font: { color: '#000' }, isSpoiler: isSpoiler }); } // 添加新边到原始边列表 newRawEdges.push({ from: nodeId, to: id, label: relation, arrows: 'to', isSpoiler: isSpoiler }); } // 添加新节点到图中(使用 update 替代 add,可以避免重复 ID 错误) if (newNodes.length > 0) { data.nodes.update(newNodes); } // 将新边添加到所有原始边中(关键修复:确保正确维护原始边结构) allRawEdges = allRawEdges.concat(newRawEdges); // 重新合并所有原始边 const allMergedEdges = mergeBidirectionalEdges(allRawEdges); // 更新图的边 data.edges.clear(); data.edges.add(allMergedEdges); const nodeCount = data.nodes.length; const edgeCount = data.edges.length; title.textContent = `角色关系图 (${nodeCount}个节点, ${edgeCount}条关系)`; } catch (err) { console.error("扩展失败:", err); alert("❌ 扩展失败:" + err.message); } finally { expandBtn.disabled = false; expandBtn.textContent = '🔍 点击角色扩展'; } } else if (!hideModeActive && params.nodes.length > 0) { const nodeId = params.nodes[0]; window.open(`${base_url}/character/${nodeId}`, '_blank'); } }); // 隐藏模式切换 hideBtn.addEventListener('click', function () { const isActive = this.dataset.active === 'true'; if (isActive) { this.textContent = '👁️ 隐藏模式'; this.style.backgroundColor = '#2196F3'; this.dataset.active = 'false'; } else { this.textContent = '👁️ 点击角色隐藏'; this.style.backgroundColor = '#FF9800'; this.dataset.active = 'true'; } }); } catch (e) { graphContainer.innerHTML = '<p style="text-align:center;color:red;">渲染图表失败: ' + e.message + '</p>'; } } })();