// ==UserScript== // @name 友评排行榜 // @version 1.0 // @description 生成好友共同评分排行榜,支持加权评分和多种排序方式 // @author KunimiSaya // @match https://bgm.tv/user/*/friends // @match https://bangumi.tv/user/*/friends // @match https://chii.in/user/*/friends // @namespace KunimiSaya // @grant GM_xmlhttpRequest // @grant GM_setValue // @grant GM_getValue // @grant GM_addStyle // @grant GM_download // @connect api.bgm.tv // @connect bgm.tv // @connect bangumi.tv // @connect chii.in // @license MIT // @require https://code.jquery.com/jquery-3.6.0.min.js // @downloadURL https://update.greasyfork.icu/scripts/552022/%E5%8F%8B%E8%AF%84%E6%8E%92%E8%A1%8C%E6%A6%9C.user.js // @updateURL https://update.greasyfork.icu/scripts/552022/%E5%8F%8B%E8%AF%84%E6%8E%92%E8%A1%8C%E6%A6%9C.meta.js // ==/UserScript== (function() { 'use strict'; // 添加自定义样式 GM_addStyle(` .ranking-container { margin: 15px 0; padding: 15px; border: 1px solid #ddd; border-radius: 5px; background-color: #f9f9f9; font-size: 14px; } .ranking-controls { margin-bottom: 12px; display: flex; flex-wrap: wrap; align-items: center; gap: 8px; } .control-group { display: flex; align-items: center; gap: 5px; } .control-label { font-weight: bold; white-space: nowrap; } .ranking-controls input, .ranking-controls select, .ranking-controls button { padding: 4px 8px; font-size: 14px; } .ranking-table { width: 100%; border-collapse: collapse; margin: 15px 0; font-size: 13px; } .ranking-table th, .ranking-table td { padding: 8px; border: 1px solid #ddd; text-align: left; } .ranking-table th { background-color: #f8f9fa; } .ranking-table tr:nth-child(even) { background-color: #f2f2f2; } .loading { text-align: center; padding: 15px; font-size: 14px; } .error { color: red; padding: 8px; font-size: 14px; } .hidden { display: none; } .status-info { margin-top: 8px; font-size: 13px; } `); // 主函数 function init() { // 获取当前用户ID const currentUrl = window.location.href; const userIdMatch = currentUrl.match(/\/user\/([^\/]+)\/friends/); if (!userIdMatch) return; const userId = userIdMatch[1]; // 创建容器 const container = document.createElement('div'); container.className = 'ranking-container'; container.innerHTML = `

友评排行榜

并发线程数:
`; // 插入到页面中 const friendsList = document.querySelector('.user_list'); if (friendsList) { friendsList.parentNode.insertBefore(container, friendsList); } else { document.body.insertBefore(container, document.body.firstChild); } // 添加事件监听 document.getElementById('generateRanking').addEventListener('click', () => { generateRanking(userId); }); document.getElementById('updateRanking').addEventListener('click', updateRanking); document.getElementById('filterByVotes').addEventListener('click', filterByVotes); document.getElementById('showAll').addEventListener('click', showAll); document.getElementById('saveToFile').addEventListener('click', saveToFile); } // 生成评分榜 async function generateRanking(userId) { const statusEl = document.getElementById('status'); statusEl.innerHTML = '
正在获取好友列表...
'; try { // 获取好友列表 const friendIds = await getFriendIds(userId); statusEl.innerHTML = `
发现 ${friendIds.length} 位用户,正在获取评分数据...
`; // 获取评分数据 const animationMap = await fetchAllUserRatings(friendIds); // 计算平均分并排序 let animations = calculateAndSortAverage(animationMap); // 显示结果 displayRanking(animations); document.getElementById('rankingResults').classList.remove('hidden'); statusEl.innerHTML = `
数据处理完成!有效动画条目数量: ${animations.length}
`; } catch (error) { statusEl.innerHTML = `
错误: ${error.message}
`; console.error(error); } } // 获取好友列表 async function getFriendIds(userId) { return new Promise((resolve, reject) => { // 获取当前域名 const currentDomain = window.location.hostname; GM_xmlhttpRequest({ method: 'GET', url: `https://${currentDomain}/user/${userId}/friends`, onload: function(response) { const parser = new DOMParser(); const doc = parser.parseFromString(response.responseText, 'text/html'); const users = doc.querySelectorAll('ul.usersMedium li.user a[href^="/user/"]'); const friendIds = [userId]; // 包括自己 users.forEach(user => { const href = user.getAttribute('href'); const friendId = href.substring(href.lastIndexOf('/') + 1); friendIds.push(friendId); }); resolve(friendIds); }, onerror: function(error) { reject(new Error('获取好友列表失败')); } }); }); } // 获取所有用户的评分数据 async function fetchAllUserRatings(friendIds) { const animationMap = new Map(); const threadCount = parseInt(document.getElementById('threadCount').value) || 1; // 分批处理,控制并发数 for (let i = 0; i < friendIds.length; i += threadCount) { const batch = friendIds.slice(i, i + threadCount); const promises = batch.map(friendId => fetchUserRatings(friendId, animationMap)); await Promise.all(promises); // 更新进度 const statusEl = document.getElementById('status'); const progress = Math.min(i + threadCount, friendIds.length); statusEl.innerHTML = `
正在获取评分数据... ${progress}/${friendIds.length}
`; } return animationMap; } // 获取单个用户的评分数据 async function fetchUserRatings(friendId, animationMap) { const accessToken = await getAccessToken(); if (!accessToken) { throw new Error('请先设置Access Token'); } // 获取所有收藏类型(在看、看过、搁置、抛弃) const types = [2, 3, 4, 5]; for (const type of types) { await fetchUserRatingsByType(friendId, type, accessToken, animationMap); } } // 获取指定类型的用户评分数据 async function fetchUserRatingsByType(friendId, type, accessToken, animationMap) { let offset = 0; let hasNextPage = true; while (hasNextPage) { const url = `https://api.bgm.tv/v0/users/${friendId}/collections?subject_type=2&limit=50&type=${type}&offset=${offset}`; try { const response = await makeApiRequest(url, accessToken); const data = response.data; for (const item of data) { const score = item.rate || 0; if (score !== 0) { const subjectId = item.subject_id; const subjectName = item.subject.name; const subjectNameCN = item.subject.name_cn || ''; let animation = animationMap.get(subjectId); if (!animation) { animation = { subjectId, subjectName, subjectNameCN, friendRatings: new Map() }; animationMap.set(subjectId, animation); } animation.friendRatings.set(friendId, score); } } offset += response.limit; if (offset >= response.total) { hasNextPage = false; } } catch (error) { console.error(`获取用户 ${friendId} 类型 ${type} 数据失败:`, error); hasNextPage = false; } } } // 计算平均分并排序 function calculateAndSortAverage(animationMap) { const animations = Array.from(animationMap.values()); animations.forEach(animation => { const ratings = Array.from(animation.friendRatings.values()); if (ratings.length > 0) { const sum = ratings.reduce((a, b) => a + b, 0); animation.averageScore = sum / ratings.length; } else { animation.averageScore = 0; } }); return animations.sort((a, b) => b.averageScore - a.averageScore); } // 显示评分榜 function displayRanking(animations) { // 计算全局平均分C let totalSum = 0; let totalVotes = 0; animations.forEach(animation => { totalSum += animation.averageScore * animation.friendRatings.size; totalVotes += animation.friendRatings.size; }); const C = totalVotes > 0 ? totalSum / totalVotes : 0; // 存储数据供后续使用 window.rankingData = { animations, C, // 保存原始数据,用于恢复显示 originalAnimations: [...animations] }; // 生成表格 updateRanking(); } // 更新排名 function updateRanking() { if (!window.rankingData) return; const { animations, C } = window.rankingData; const m = parseInt(document.getElementById('mThreshold').value) || 0; const sortMethod = document.getElementById('sortMethod').value; // 计算加权分数 - 使用更精确的计算方法 animations.forEach(animation => { const v = animation.friendRatings.size; const r = animation.averageScore; // 使用更精确的加权公式计算 const weighted = (v / (v + m)) * r + (m / (v + m)) * C; animation.weightedScore = parseFloat(weighted.toFixed(6)); // 保留6位小数减少精度误差 }); // 排序 let sortedAnimations; switch (sortMethod) { case 'weighted': sortedAnimations = [...animations].sort((a, b) => b.weightedScore - a.weightedScore); break; case 'average': sortedAnimations = [...animations].sort((a, b) => b.averageScore - a.averageScore); break; case 'votes': sortedAnimations = [...animations].sort((a, b) => b.friendRatings.size - a.friendRatings.size); break; } // 过滤可见条目 const visibleAnimations = sortedAnimations.filter(animation => !animation.hidden ); // 生成表格HTML let tableHTML = ` `; visibleAnimations.forEach((animation, index) => { tableHTML += ` `; }); tableHTML += `
加权排名 条目原名 中文名称 评分人数 平均分 加权分数 条目链接
${index + 1} ${escapeHtml(animation.subjectName)} ${escapeHtml(animation.subjectNameCN)} ${animation.friendRatings.size} ${animation.averageScore.toFixed(4)} ${animation.weightedScore.toFixed(4)} 查看详情
当前显示: ${visibleAnimations.length} / ${animations.length} 个条目
`; document.getElementById('rankingTableContainer').innerHTML = tableHTML; } // 隐藏低人数条目 function filterByVotes() { if (!window.rankingData) return; const m = parseInt(document.getElementById('mThreshold').value) || 0; const { animations } = window.rankingData; animations.forEach(animation => { animation.hidden = animation.friendRatings.size < m; }); updateRanking(); } // 显示所有条目 function showAll() { if (!window.rankingData) return; const { animations, originalAnimations } = window.rankingData; // 恢复所有条目的显示状态 animations.length = 0; originalAnimations.forEach(anim => { anim.hidden = false; animations.push(anim); }); updateRanking(); } // 保存到文件 function saveToFile() { if (!window.rankingData) return; const { animations, C } = window.rankingData; const m = parseInt(document.getElementById('mThreshold').value) || 0; const sortMethod = document.getElementById('sortMethod').value; // 计算当前可见的动画 const visibleAnimations = animations.filter(animation => !animation.hidden); // 生成CSV内容 let csvContent = "排名,条目原名,中文名称,评分人数,平均分,加权分数,条目链接\n"; visibleAnimations.forEach((animation, index) => { const row = [ index + 1, `"${animation.subjectName.replace(/"/g, '""')}"`, `"${(animation.subjectNameCN || '').replace(/"/g, '""')}"`, animation.friendRatings.size, animation.averageScore.toFixed(4), animation.weightedScore.toFixed(4), `https://${window.location.hostname}/subject/${animation.subjectId}` ].join(","); csvContent += row + "\n"; }); // 添加元数据 csvContent += `\n生成时间,${new Date().toLocaleString()}\n`; csvContent += `加权参数m,${m}\n`; csvContent += `排序方式,${sortMethod}\n`; csvContent += `全局平均分C,${C.toFixed(4)}\n`; csvContent += `条目总数,${animations.length}\n`; csvContent += `显示条目数,${visibleAnimations.length}\n`; // 创建Blob并下载 const blob = new Blob([csvContent], { type: 'text/csv;charset=utf-8;' }); const url = URL.createObjectURL(blob); const filename = `bangumi_ranking_${new Date().toISOString().slice(0, 10)}.csv`; // 使用GM_download下载文件 GM_download({ url: url, name: filename, saveAs: true }); // 清理URL对象 setTimeout(() => URL.revokeObjectURL(url), 1000); } // 辅助函数:HTML转义 function escapeHtml(str) { if (!str) return ''; return str .replace(/&/g, '&') .replace(//g, '>') .replace(/"/g, '"') .replace(/'/g, '''); } // 辅助函数:API请求 function makeApiRequest(url, accessToken) { return new Promise((resolve, reject) => { GM_xmlhttpRequest({ method: 'GET', url: url, headers: { 'User-Agent': '650688/friends-rating-ranking', 'Authorization': `Bearer ${accessToken}` }, onload: function(response) { if (response.status === 200) { try { const data = JSON.parse(response.responseText); resolve(data); } catch (e) { reject(new Error('解析API响应失败')); } } else { reject(new Error(`API请求失败: ${response.status}`)); } }, onerror: function(error) { reject(new Error('网络请求失败')); } }); }); } // 获取Access Token async function getAccessToken() { let accessToken = GM_getValue('bgm_access_token'); if (!accessToken) { accessToken = prompt('请输入您的Bangumi Access Token:\n(请在该网址获取: https://next.bgm.tv/demo/access-token)'); if (accessToken) { GM_setValue('bgm_access_token', accessToken); } } return accessToken; } // 页面加载完成后初始化 if (document.readyState === 'loading') { document.addEventListener('DOMContentLoaded', init); } else { init(); } })();