您需要先安装一个扩展,例如 篡改猴、Greasemonkey 或 暴力猴,之后才能安装此脚本。
您需要先安装一个扩展,例如 篡改猴 或 暴力猴,之后才能安装此脚本。
您需要先安装一个扩展,例如 篡改猴 或 暴力猴,之后才能安装此脚本。
您需要先安装一个扩展,例如 篡改猴 或 Userscripts ,之后才能安装此脚本。
您需要先安装一款用户脚本管理器扩展,例如 Tampermonkey,才能安装此脚本。
您需要先安装用户脚本管理器扩展后才能安装此脚本。
在 Bangumi 条目页面,鼠标悬停在 Staff 名字上时显示其参与作品信息。作品按评分人数排序,动态展示数量。适合看新番的时候快速认知各个staff。
// ==UserScript== // @name Bangumi Staff 作品列表 v1.3 // @namespace http://tampermonkey.net/ // @version 1.3 // @description 在 Bangumi 条目页面,鼠标悬停在 Staff 名字上时显示其参与作品信息。作品按评分人数排序,动态展示数量。适合看新番的时候快速认知各个staff。 // @author You // @match https://bgm.tv/subject/* // @match https://bangumi.tv/subject/* // @match https://chii.in/subject/* // @grant GM_xmlhttpRequest // @connect api.bgm.tv // @license MIT // ==/UserScript== (function() { 'use strict'; // --- 配置 --- const TOOLTIP_WIDTH = 480; const TOOLTIP_HEIGHT = 500; // 预估高度,用于定位 const MAX_WORKS_TO_SHOW = 50; const MIN_WORKS_TO_SHOW = 5; const REQUEST_DELAY_MIN = 200; const REQUEST_DELAY_MAX = 500; const CACHE_EXPIRY = 30 * 60 * 1000; // 30分钟 const IMAGE_SIZE = 'grid'; // --- 工具函数 --- function formatDate(dateStr) { if (!dateStr) return 'N/A'; const parts = dateStr.split('-'); if (parts.length > 0 && parts[0]) { return parts[0]; } return 'N/A'; } function debounce(func, delay) { let timer; return function (...args) { clearTimeout(timer); timer = setTimeout(() => func.apply(this, args), delay); }; } // --- 缓存 --- const cache = { data: {}, set(key, value) { this.data[key] = { value: value, timestamp: Date.now() }; }, get(key) { const item = this.data[key]; if (item && (Date.now() - item.timestamp) < CACHE_EXPIRY) { return item.value; } delete this.data[key]; return null; } }; // --- 核心逻辑 --- let tooltip = null; let currentFetchRequest = null; let hideTimeout = null; let showTimeout = null; // 用于延迟显示Tooltip let currentLink = null; // 记录当前触发 Tooltip 的链接元素 let currentPersonId = null; // 记录当前请求的ID function createTooltip() { if (tooltip) return; tooltip = document.createElement('div'); tooltip.id = 'bangumi-staff-tooltip-v1-8'; // *** 修改1: 将 position 改为 absolute *** tooltip.style.cssText = ` position: absolute; z-index: 9999; width: ${TOOLTIP_WIDTH}px; max-height: 600px; overflow: hidden; /* 外层容器隐藏溢出 */ background: #f9f9f9; border: 1px solid #ddd; border-radius: 8px; box-shadow: 0 8px 24px rgba(0,0,0,0.15); padding: 16px; font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, sans-serif; color: #333; line-height: 1.6; display: none; left: -1000px; top: -1000px; `; document.body.appendChild(tooltip); // 鼠标进入 Tooltip 时取消隐藏计划 tooltip.addEventListener('mouseenter', () => { if (hideTimeout) { clearTimeout(hideTimeout); hideTimeout = null; } }); // 鼠标离开 Tooltip 时计划隐藏 tooltip.addEventListener('mouseleave', (e) => { // 检查鼠标是否移向了触发当前 Tooltip 的链接 const toElement = e.relatedTarget; if (currentLink && currentLink.contains(toElement)) { // 移向了链接,不隐藏 return; } scheduleHideTooltip(); }); const style = document.createElement('style'); style.textContent = ` #bangumi-staff-tooltip-v1-8 .tooltip-header { display: flex; flex-wrap: wrap; align-items: center; margin-bottom: 16px; padding-bottom: 16px; border-bottom: 1px solid #eee; gap: 10px; } #bangumi-staff-tooltip-v1-8 .tooltip-name { font-size: 20px; font-weight: 700; color: #0066cc; flex: 1; min-width: 0; } #bangumi-staff-tooltip-v1-8 .tooltip-name-text { white-space: nowrap; overflow: hidden; text-overflow: ellipsis; } #bangumi-staff-tooltip-v1-8 .tooltip-name-link { color: inherit; text-decoration: none; } #bangumi-staff-tooltip-v1-8 .tooltip-name-link:hover { text-decoration: underline; color: #0056b3; } #bangumi-staff-tooltip-v1-8 .tooltip-stats { font-size: 13px; color: #666; white-space: nowrap; } #bangumi-staff-tooltip-v1-8 .tooltip-bio { font-size: 14px; color: #555; margin-bottom: 16px; line-height: 1.5; } /* *** 修改2: 为滚动区域添加类名和初始样式 *** */ #bangumi-staff-tooltip-v1-8 .tooltip-works-scroll-container { max-height: 400px; overflow-y: auto; /* 初始允许滚动 */ padding-right: 8px; margin: 0 0 10px 0; } #bangumi-staff-tooltip-v1-8 .tooltip-works-list { list-style: none; padding: 0; margin: 0; } #bangumi-staff-tooltip-v1-8 .tooltip-work-item { display: flex; margin-bottom: 16px; padding: 12px; border-radius: 8px; background: white; box-shadow: 0 2px 6px rgba(0,0,0,0.05); } #bangumi-staff-tooltip-v1-8 .tooltip-work-cover-container { width: 60px; height: 80px; flex-shrink: 0; border-radius: 4px; overflow: hidden; background-color: #f0f0f0; display: flex; align-items: center; justify-content: center; } #bangumi-staff-tooltip-v1-8 .tooltip-work-cover { width: 100%; height: 100%; object-fit: cover; } #bangumi-staff-tooltip-v1-8 .tooltip-work-content { flex: 1; margin-left: 16px; min-width: 0; } #bangumi-staff-tooltip-v1-8 .tooltip-work-title-row { display: flex; align-items: center; margin-bottom: 6px; } #bangumi-staff-tooltip-v1-8 .tooltip-work-title { font-weight: 700; color: #222; text-decoration: none; font-size: 15px; white-space: nowrap; overflow: hidden; text-overflow: ellipsis; flex: 1; min-width: 0; } #bangumi-staff-tooltip-v1-8 .tooltip-work-title:hover { color: #0056b3; text-decoration: underline; } #bangumi-staff-tooltip-v1-8 .tooltip-work-meta { display: flex; flex-wrap: wrap; margin-bottom: 6px; font-size: 13px; color: #666; gap: 8px; } #bangumi-staff-tooltip-v1-8 .tooltip-work-year, #bangumi-staff-tooltip-v1-8 .tooltip-work-rating-count { background: #f0f0f0; padding: 2px 6px; border-radius: 4px; flex-shrink: 0; } #bangumi-staff-tooltip-v1-8 .tooltip-work-role { font-size: 13px; color: #555; white-space: nowrap; overflow: hidden; text-overflow: ellipsis; } #bangumi-staff-tooltip-v1-8 .loading { text-align: center; padding: 30px 20px; color: #666; } #bangumi-staff-tooltip-v1-8 .loading::before { content: ""; display: block; width: 40px; height: 40px; margin: 0 auto 15px; border: 3px solid rgba(0,119,204,0.2); border-top: 3px solid #0077cc; border-radius: 50%; animation: spin 1s linear infinite; } #bangumi-staff-tooltip-v1-8 .error { color: #e53935; padding: 15px; text-align: center; background: #fff2f2; border-radius: 8px; } @keyframes spin { 0% { transform: rotate(0deg); } 100% { transform: rotate(360deg); } } /* 自定义滚动条样式 */ #bangumi-staff-tooltip-v1-8 .tooltip-works-scroll-container::-webkit-scrollbar { width: 6px; } #bangumi-staff-tooltip-v1-8 .tooltip-works-scroll-container::-webkit-scrollbar-track { background: #f1f1f1; border-radius: 10px; } #bangumi-staff-tooltip-v1-8 .tooltip-works-scroll-container::-webkit-scrollbar-thumb { background: #c1c1c1; border-radius: 10px; } #bangumi-staff-tooltip-v1-8 .tooltip-works-scroll-container::-webkit-scrollbar-thumb:hover { background: #a8a8a8; } `; document.head.appendChild(style); } // *** 修改3: 优化位置计算函数 (v1.0.8) *** function updateTooltipPosition(linkRect, mouseX, mouseY) { if (!tooltip) return; // 获取页面滚动偏移量 const scrollX = window.pageXOffset || document.documentElement.scrollLeft; const scrollY = window.pageYOffset || document.documentElement.scrollTop; // 获取视口尺寸 const viewportWidth = window.innerWidth; const viewportHeight = window.innerHeight; // 计算链接相对于文档的位置(加上滚动偏移) const linkDocTop = linkRect.top + scrollY; const linkDocLeft = linkRect.left + scrollX; const linkDocBottom = linkRect.bottom + scrollY; const linkDocRight = linkRect.right + scrollX; // --- 优化后的定位逻辑 --- let finalX, finalY; let positionFound = false; // 1. 优先尝试放在链接上方 finalX = linkDocLeft; finalY = linkDocTop - TOOLTIP_HEIGHT - 10; // 上方 10px 间距 // 检查上方空间是否足够且在视口内 if (finalY >= scrollY) { // 上方不超出页面顶部 positionFound = true; } // 2. 如果上方空间不足或超出视口顶部,尝试放在下方 if (!positionFound) { finalY = linkDocBottom + 10; // 下方 10px 间距 // 检查下方空间是否足够且在视口内 if (finalY + TOOLTIP_HEIGHT <= scrollY + viewportHeight) { // 下方不超出页面底部 positionFound = true; } } // 3. 如果上下方都不行,选择离视口边缘更近的一边并进行调整 if (!positionFound) { const spaceAbove = linkDocTop - scrollY; const spaceBelow = (scrollY + viewportHeight) - linkDocBottom; if (spaceAbove > spaceBelow) { // 上方空间相对更大,强制放在上方可见区域 finalY = Math.max(scrollY + 10, linkDocTop - TOOLTIP_HEIGHT - 10); } else { // 下方空间相对更大,强制放在下方可见区域 finalY = Math.min(scrollY + viewportHeight - TOOLTIP_HEIGHT - 10, linkDocBottom + 10); } positionFound = true; // 此时无论如何都确定了Y位置 } // 4. 处理水平方向,确保不超出左右边界 if (finalX + TOOLTIP_WIDTH > scrollX + viewportWidth) { // 右侧空间不足,尝试左对齐链接右侧边缘 finalX = linkDocRight - TOOLTIP_WIDTH; // 确保不会超出左边界 finalX = Math.max(scrollX + 10, finalX); } else { // 确保左侧不会超出边界 finalX = Math.max(scrollX + 10, finalX); } // 5. 最终微调 Y 轴,确保不会超出视口极端边界 (可选,增加鲁棒性) // 这一步在上面的逻辑中已经基本覆盖,但可以再做一次保险检查 finalY = Math.max(scrollY + 10, Math.min(finalY, scrollY + viewportHeight - 200)); // 200是保守估计的最小可视高度 tooltip.style.left = `${finalX}px`; tooltip.style.top = `${finalY}px`; } function showTooltip(htmlContent, linkElement, mouseX, mouseY) { if (!tooltip) createTooltip(); // 清除之前的隐藏计划 if (hideTimeout) { clearTimeout(hideTimeout); hideTimeout = null; } tooltip.innerHTML = htmlContent; tooltip.style.display = 'block'; const linkRect = linkElement.getBoundingClientRect(); updateTooltipPosition(linkRect, mouseX, mouseY); // *** 修改4: 在显示 Tooltip 后,为滚动区域添加事件监听器 *** const scrollContainer = tooltip.querySelector('.tooltip-works-scroll-container'); if (scrollContainer) { // 移除旧的监听器(如果有的话),防止重复绑定 scrollContainer.removeEventListener('wheel', handleScrollWheel); // 添加新的监听器 scrollContainer.addEventListener('wheel', handleScrollWheel, { passive: false }); // passive: false 允许 preventDefault } } // *** 修改5: 处理滚轮事件的函数 (v1.0.7 修正版) *** function handleScrollWheel(event) { const container = event.currentTarget; // 这是 .tooltip-works-scroll-container // 如果容器内容不足以滚动,则不处理,让事件(默认)冒泡到页面 if (container.scrollHeight <= container.clientHeight) { // 不调用 preventDefault,让事件冒泡 return; } // 获取鼠标在容器内的位置 const rect = container.getBoundingClientRect(); const isMouseOverContainer = ( event.clientX >= rect.left && event.clientX <= rect.right && event.clientY >= rect.top && event.clientY <= rect.bottom ); // 只有当鼠标悬停在可滚动容器上时,才处理滚动 if (isMouseOverContainer) { const delta = event.deltaY; const scrollTop = container.scrollTop; const scrollHeight = container.scrollHeight; const clientHeight = container.clientHeight; // 检查是否滚动到了顶部或底部的边界 const isAtTopBoundary = scrollTop === 0 && delta < 0; const isAtBottomBoundary = (scrollTop + clientHeight >= scrollHeight) && delta > 0; // 如果在边界上,不 preventDefault,让页面滚动 if (isAtTopBoundary || isAtBottomBoundary) { // 不调用 preventDefault,让事件冒泡 return; } // 否则,在容器内部滚动,并阻止事件冒泡 event.preventDefault(); // 阻止页面滚动 container.scrollTop += delta; // 手动滚动容器 } // 如果鼠标不在容器上,事件会自然冒泡到页面,无需额外处理 } function scheduleHideTooltip() { if (hideTimeout) { clearTimeout(hideTimeout); } hideTimeout = setTimeout(() => { hideTooltip(); }, 300); // 保持 300ms 延迟 } function hideTooltip() { // 清除所有计划 if (showTimeout) { clearTimeout(showTimeout); showTimeout = null; } if (hideTimeout) { clearTimeout(hideTimeout); hideTimeout = null; } // 隐藏 Tooltip if (tooltip) { tooltip.style.display = 'none'; // *** 修改6: 隐藏时移除滚动监听器 *** const scrollContainer = tooltip.querySelector('.tooltip-works-scroll-container'); if (scrollContainer) { scrollContainer.removeEventListener('wheel', handleScrollWheel); } } // 取消进行中的请求 if (currentFetchRequest) { currentFetchRequest.abort(); currentFetchRequest = null; } // 重置状态 currentLink = null; currentPersonId = null; } function sortWorksByRatingCount(works) { if (!Array.isArray(works)) return works; return [...works].sort((a, b) => { const countA = (a.rating && typeof a.rating.total === 'number') ? a.rating.total : 0; const countB = (b.rating && typeof b.rating.total === 'number') ? b.rating.total : 0; return countB - countA; }); } function determineWorksToShowCount(totalWorks) { if (totalWorks <= MIN_WORKS_TO_SHOW) { return totalWorks; } return Math.min(Math.max(MIN_WORKS_TO_SHOW, Math.floor(totalWorks * 0.3)), MAX_WORKS_TO_SHOW, totalWorks); } function fetchPersonInfo(personId, personName, personUrl, linkElement, mouseX, mouseY) { // 如果有正在进行的请求(针对不同ID),先取消它 if (currentFetchRequest && currentPersonId !== personId) { currentFetchRequest.abort(); currentFetchRequest = null; } // 如果正在请求的是同一个ID,则不重复请求,但可以更新位置 if (currentPersonId === personId) { if (tooltip && tooltip.style.display !== 'none') { const linkRect = linkElement.getBoundingClientRect(); updateTooltipPosition(linkRect, mouseX, mouseY); } return; } // 更新当前状态为新ID currentPersonId = personId; currentLink = linkElement; const cachedData = cache.get(`person_${personId}`); if (cachedData) { displayPersonInfo(cachedData, personName, personUrl, linkElement, mouseX, mouseY); return; } showTooltip('<div class="loading">加载中...</div>', linkElement, mouseX, mouseY); const delay = Math.random() * (REQUEST_DELAY_MAX - REQUEST_DELAY_MIN) + REQUEST_DELAY_MIN; showTimeout = setTimeout(() => { // 再次检查状态,确保请求仍然是针对当前目标 if (currentPersonId !== personId) return; currentFetchRequest = GM_xmlhttpRequest({ method: "GET", url: `https://api.bgm.tv/v0/persons/${personId}`, headers: { "Accept": "application/json", "User-Agent": "BangumiStaffInfoTooltip/1.0.8" }, onload: function(response) { // 确保这是当前需要的数据 if (currentPersonId !== personId) return; try { const personData = JSON.parse(response.responseText); GM_xmlhttpRequest({ method: "GET", url: `https://api.bgm.tv/v0/persons/${personId}/subjects?limit=${Math.min(100, MAX_WORKS_TO_SHOW + 10)}`, headers: { "Accept": "application/json", "User-Agent": "BangumiStaffInfoTooltip/1.0.8" }, onload: function(response) { // 确保这是当前需要的数据 if (currentPersonId !== personId) return; try { const worksData = JSON.parse(response.responseText); const combinedData = { person: personData, works: worksData }; cache.set(`person_${personId}`, combinedData); displayPersonInfo(combinedData, personName, personUrl, linkElement, mouseX, mouseY); } catch (e) { console.error("Error parsing works data:", e, response.responseText); showTooltip('<div class="error">解析作品数据时出错</div>', linkElement, mouseX, mouseY); } }, onerror: function(error) { if (currentPersonId !== personId) return; console.error("Works request failed:", error); showTooltip('<div class="error">获取作品信息失败</div>', linkElement, mouseX, mouseY); } }); } catch (e) { if (currentPersonId !== personId) return; console.error("Error parsing person data:", e, response.responseText); showTooltip('<div class="error">解析人物数据时出错</div>', linkElement, mouseX, mouseY); } }, onerror: function(error) { if (currentPersonId !== personId) return; console.error("Person request failed:", error); showTooltip('<div class="error">获取人物信息失败</div>', linkElement, mouseX, mouseY); } }); }, delay); } function displayPersonInfo(data, personName, personUrl, linkElement, mouseX, mouseY) { // 再次确认是为当前链接显示 if (currentLink !== linkElement) return; if (!data || !data.person || !data.works) { showTooltip('<div class="error">数据不完整</div>', linkElement, mouseX, mouseY); return; } const person = data.person; let works = data.works; if (!Array.isArray(works)) { works = []; } const sortedWorks = sortWorksByRatingCount(works); const totalWorks = works.length; const worksToShowCount = determineWorksToShowCount(totalWorks); const worksToShow = sortedWorks.slice(0, worksToShowCount); const nameToShow = person.name_cn || person.name || personName; const nameLinkHtml = `<a href="${personUrl}" target="_blank" class="tooltip-name-link">${nameToShow}</a>`; const bio = person.summary ? `<div class="tooltip-bio">${person.summary.substring(0, 200)}${person.summary.length > 200 ? '...' : ''}</div>` : ''; let worksHtml = ''; if (worksToShow.length > 0) { worksToShow.forEach(work => { const title = work.name_cn || work.name || '未知标题'; const year = formatDate(work.date); const role = work.staff || '未知'; let coverUrl = ''; if (work.images && work.images[IMAGE_SIZE]) { coverUrl = work.images[IMAGE_SIZE]; } else if (work.image) { coverUrl = work.image; } const subjectUrl = `https://bgm.tv/subject/${work.id}`; let ratingCountText = ''; if (work.rating && typeof work.rating.total === 'number') { ratingCountText = `<span class="tooltip-work-rating-count">${work.rating.total}人评分</span>`; } worksHtml += ` <li class="tooltip-work-item"> <div class="tooltip-work-cover-container"> ${coverUrl ? `<img src="${coverUrl}" alt="${title} 封面" class="tooltip-work-cover" loading="lazy">` : ''} </div> <div class="tooltip-work-content"> <div class="tooltip-work-title-row"> <a href="${subjectUrl}" target="_blank" class="tooltip-work-title">${title}</a> </div> <div class="tooltip-work-meta"> ${year !== 'N/A' ? `<span class="tooltip-work-year">${year}</span>` : ''} ${ratingCountText} </div> <div class="tooltip-work-role">${role}</div> </div> </li> `; }); } else { worksHtml = '<li>暂无作品信息</li>'; } // *** 修改7: 用新的滚动容器包装作品列表 *** const htmlContent = ` <div class="tooltip-header"> <div class="tooltip-name">${nameLinkHtml}</div> <span class="tooltip-stats">(共 ${totalWorks} 部作品)</span> </div> ${bio} <div class="tooltip-works-scroll-container"> <ul class="tooltip-works-list"> ${worksHtml} </ul> </div> `; showTooltip(htmlContent, linkElement, mouseX, mouseY); } function init() { createTooltip(); // 使用防抖处理 mouseover 事件 document.addEventListener('mouseover', debounce(function(e) { const link = e.target.closest('a[href*="/person/"]'); if (link) { const href = link.getAttribute('href'); const match = href.match(/\/person\/(\d+)/); if (match) { const personId = match[1]; const personName = link.textContent.trim() || '未知'; const personUrl = link.href; // 计算鼠标位置 const x = e.clientX; const y = e.clientY; // 如果移入了新的链接,立即取消之前的隐藏和显示计划 if (currentLink && currentLink !== link) { if (hideTimeout) { clearTimeout(hideTimeout); hideTimeout = null; } if (showTimeout) { clearTimeout(showTimeout); showTimeout = null; } // 立即隐藏旧的 Tooltip if (tooltip) { tooltip.style.display = 'none'; // 移除旧的滚动监听器 const oldScrollContainer = tooltip.querySelector('.tooltip-works-scroll-container'); if (oldScrollContainer) { oldScrollContainer.removeEventListener('wheel', handleScrollWheel); } } if (currentFetchRequest) { currentFetchRequest.abort(); currentFetchRequest = null; } // 重置旧状态 currentLink = null; currentPersonId = null; } // 更新当前链接(即使相同也需要,因为可能是从 Tooltip 移回) currentLink = link; fetchPersonInfo(personId, personName, personUrl, link, x, y); } } }, 100)); // 100ms 防抖 // 精细化处理 mouseout 事件 document.addEventListener('mouseout', function(e) { const from = e.target; const to = e.relatedTarget; const fromLink = from.closest('a[href*="/person/"]'); const toLink = to ? to.closest('a[href*="/person/"]') : null; const isTooltipTarget = tooltip && tooltip.contains(to); // 情况1: 鼠标从当前 Staff 链接移出 if (fromLink && fromLink === currentLink) { // 并且没有移到 Tooltip 上,也没有移到另一个 Staff 链接上 if (!isTooltipTarget && toLink !== currentLink) { scheduleHideTooltip(); } // 如果移到了 Tooltip 或另一个链接,则不处理,让对应元素的 mouseenter 来处理 } // 情况2: 鼠标从 Tooltip 移出 // 这个逻辑已经在 Tooltip 的 mouseleave 事件监听器中处理了 }); } if (document.readyState === 'loading') { document.addEventListener('DOMContentLoaded', init); } else { init(); } })();