// ==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 += `
| ${index + 1} |
${escapeHtml(animation.subjectName)} |
${escapeHtml(animation.subjectNameCN)} |
${animation.friendRatings.size} |
${animation.averageScore.toFixed(4)} |
${animation.weightedScore.toFixed(4)} |
查看详情 |
`;
});
tableHTML += `
当前显示: ${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();
}
})();