// ==UserScript==
// @name Bangumi Staff作品速览
// @namespace http://tampermonkey.net/
// @version 1.0
// @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 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;
function createTooltip() {
if (tooltip) return;
tooltip = document.createElement('div');
tooltip.id = 'bangumi-staff-tooltip-v1';
tooltip.style.cssText = `
position: fixed;
z-index: 9999;
width: ${TOOLTIP_WIDTH}px;
max-height: 600px;
overflow-y: auto;
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.addEventListener('mouseenter', () => {
if (hideTimeout) {
clearTimeout(hideTimeout);
hideTimeout = null;
}
});
tooltip.addEventListener('mouseleave', (e) => {
if (!e.relatedTarget || !e.relatedTarget.closest('a[href*="/person/"]')) {
scheduleHideTooltip();
}
});
const style = document.createElement('style');
style.textContent = `
#bangumi-staff-tooltip-v1 .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 .tooltip-name {
font-size: 20px;
font-weight: 700;
color: #0066cc;
flex: 1;
min-width: 0;
}
#bangumi-staff-tooltip-v1 .tooltip-name-text {
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
#bangumi-staff-tooltip-v1 .tooltip-stats {
font-size: 13px;
color: #666;
white-space: nowrap;
}
#bangumi-staff-tooltip-v1 .tooltip-bio {
font-size: 14px;
color: #555;
margin-bottom: 16px;
line-height: 1.5;
}
#bangumi-staff-tooltip-v1 .tooltip-works-list {
max-height: 400px;
overflow-y: auto;
padding-right: 8px;
list-style: none;
padding: 0;
margin: 0 0 10px 0;
}
#bangumi-staff-tooltip-v1 .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 .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 .tooltip-work-cover {
width: 100%;
height: 100%;
object-fit: cover;
}
#bangumi-staff-tooltip-v1 .tooltip-work-content {
flex: 1;
margin-left: 16px;
min-width: 0;
}
#bangumi-staff-tooltip-v1 .tooltip-work-title-row {
display: flex;
align-items: center;
margin-bottom: 6px;
}
#bangumi-staff-tooltip-v1 .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 .tooltip-work-title:hover {
color: #0056b3;
text-decoration: underline;
}
#bangumi-staff-tooltip-v1 .tooltip-work-meta {
display: flex;
flex-wrap: wrap;
margin-bottom: 6px;
font-size: 13px;
color: #666;
gap: 8px;
}
#bangumi-staff-tooltip-v1 .tooltip-work-year,
#bangumi-staff-tooltip-v1 .tooltip-work-rating-count {
background: #f0f0f0;
padding: 2px 6px;
border-radius: 4px;
flex-shrink: 0;
}
#bangumi-staff-tooltip-v1 .tooltip-work-role {
font-size: 13px;
color: #555;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
#bangumi-staff-tooltip-v1 .loading {
text-align: center;
padding: 30px 20px;
color: #666;
}
#bangumi-staff-tooltip-v1 .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 .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 .tooltip-works-list::-webkit-scrollbar {
width: 6px;
}
#bangumi-staff-tooltip-v1 .tooltip-works-list::-webkit-scrollbar-track {
background: #f1f1f1;
border-radius: 10px;
}
#bangumi-staff-tooltip-v1 .tooltip-works-list::-webkit-scrollbar-thumb {
background: #c1c1c1;
border-radius: 10px;
}
#bangumi-staff-tooltip-v1 .tooltip-works-list::-webkit-scrollbar-thumb:hover {
background: #a8a8a8;
}
`;
document.head.appendChild(style);
}
function updateTooltipPosition(x, y) {
if (!tooltip) return;
const rect = tooltip.getBoundingClientRect();
let finalX = x;
let finalY = y + 10;
if (finalX + rect.width > window.innerWidth) {
finalX = window.innerWidth - rect.width - 10;
}
if (finalY + rect.height > window.innerHeight) {
finalY = y - rect.height - 10;
}
tooltip.style.left = `${finalX}px`;
tooltip.style.top = `${finalY}px`;
}
function showTooltip(htmlContent, x, y) {
if (!tooltip) createTooltip();
if (hideTimeout) {
clearTimeout(hideTimeout);
hideTimeout = null;
}
tooltip.innerHTML = htmlContent;
tooltip.style.display = 'block';
updateTooltipPosition(x, y);
}
function scheduleHideTooltip() {
if (hideTimeout) {
clearTimeout(hideTimeout);
}
hideTimeout = setTimeout(() => {
hideTooltip();
}, 300); // 300ms 延迟隐藏
}
function hideTooltip() {
if (tooltip) {
tooltip.style.display = 'none';
}
if (currentFetchRequest) {
currentFetchRequest.abort();
currentFetchRequest = null;
}
if (hideTimeout) {
clearTimeout(hideTimeout);
hideTimeout = null;
}
}
function sortWorksByRatingCount(works) {
if (!Array.isArray(works)) return works;
return [...works].sort((a, b) => {
// 安全地获取评分人数,默认为0
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; // 如果总数很少,全部显示
}
// 动态计算:取总数的30%,但不超过最大值和总数
return Math.min(Math.max(MIN_WORKS_TO_SHOW, Math.floor(totalWorks * 0.3)), MAX_WORKS_TO_SHOW, totalWorks);
}
function fetchPersonInfo(personId, personName, mouseX, mouseY) {
const cachedData = cache.get(`person_${personId}`);
if (cachedData) {
displayPersonInfo(cachedData, personName, mouseX, mouseY);
return;
}
showTooltip('<div class="loading">加载中...</div>', mouseX, mouseY);
// 添加一个随机延迟以减轻服务器压力
const delay = Math.random() * (REQUEST_DELAY_MAX - REQUEST_DELAY_MIN) + REQUEST_DELAY_MIN;
setTimeout(() => {
// 再次检查 tooltip 是否仍然需要显示
if (!tooltip || tooltip.style.display === 'none') {
return;
}
// 获取人物基本信息
currentFetchRequest = GM_xmlhttpRequest({
method: "GET",
url: `https://api.bgm.tv/v0/persons/${personId}`,
headers: {
"Accept": "application/json",
"User-Agent": "BangumiStaffInfoTooltip/1.0"
},
onload: function(response) {
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"
},
onload: function(response) {
try {
const worksData = JSON.parse(response.responseText);
const combinedData = {
person: personData,
works: worksData
};
cache.set(`person_${personId}`, combinedData);
displayPersonInfo(combinedData, personName, mouseX, mouseY);
} catch (e) {
console.error("Error parsing works data:", e, response.responseText);
showTooltip('<div class="error">解析作品数据时出错</div>', mouseX, mouseY);
}
},
onerror: function(error) {
console.error("Works request failed:", error);
showTooltip('<div class="error">获取作品信息失败</div>', mouseX, mouseY);
}
});
} catch (e) {
console.error("Error parsing person data:", e, response.responseText);
showTooltip('<div class="error">解析人物数据时出错</div>', mouseX, mouseY);
}
},
onerror: function(error) {
console.error("Person request failed:", error);
showTooltip('<div class="error">获取人物信息失败</div>', mouseX, mouseY);
}
});
}, delay);
}
function displayPersonInfo(data, personName, mouseX, mouseY) {
if (!data || !data.person || !data.works) {
showTooltip('<div class="error">数据不完整</div>', 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);
// --- 构建 Tooltip 内容 ---
const nameToShow = person.name_cn || person.name || personName;
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>';
}
const htmlContent = `
<div class="tooltip-header">
<div class="tooltip-name tooltip-name-text">${nameToShow}</div>
<span class="tooltip-stats">(共 ${totalWorks} 部作品)</span>
</div>
${bio}
<ul class="tooltip-works-list">
${worksHtml}
</ul>
`;
showTooltip(htmlContent, mouseX, mouseY);
}
function init() {
createTooltip();
document.addEventListener('mouseover', debounce(function(e) {
const link = e.target.closest('a[href*="/person/"]');
if (link) {
// 如果鼠标移入 tooltip 区域,取消可能的隐藏定时器
if (hideTimeout) {
clearTimeout(hideTimeout);
hideTimeout = null;
}
const href = link.getAttribute('href');
const match = href.match(/\/person\/(\d+)/);
if (match) {
const personId = match[1];
// 使用链接文本作为备用名称
const personName = link.textContent.trim() || '未知';
const rect = link.getBoundingClientRect();
// 计算鼠标相对于视口的位置
const x = rect.left + window.scrollX;
const y = rect.top + window.scrollY;
fetchPersonInfo(personId, personName, x, y);
}
}
}, 200)); // 200ms 防抖
// 处理鼠标移出链接的情况
document.addEventListener('mouseout', function(e) {
const link = e.target.closest('a[href*="/person/"]');
// 如果鼠标是从链接移出,并且没有进入 tooltip (或下一个链接),则计划隐藏
if (link && (!tooltip || !tooltip.contains(e.relatedTarget))) {
scheduleHideTooltip();
}
});
}
// 等待页面加载完成
if (document.readyState === 'loading') {
document.addEventListener('DOMContentLoaded', init);
} else {
init();
}
})();