Greasy Fork

Greasy Fork is available in English.

友评排行榜

生成好友共同评分排行榜,支持加权评分和多种排序方式

您需要先安装一款用户脚本管理器扩展,例如 Tampermonkey 篡改猴Greasemonkey 油猴子Violentmonkey 暴力猴,才能安装此脚本。

您需要先安装一款用户脚本管理器扩展,例如 Tampermonkey 篡改猴,才能安装此脚本。

您需要先安装一款用户脚本管理器扩展,例如 Tampermonkey 篡改猴Violentmonkey 暴力猴,才能安装此脚本。

您需要先安装一款用户脚本管理器扩展,例如 Tampermonkey 篡改猴Userscripts ,才能安装此脚本。

您需要先安装一款用户脚本管理器扩展,例如 Tampermonkey 篡改猴,才能安装此脚本。

您需要先安装一款用户脚本管理器扩展后才能安装此脚本。

(我已经安装了用户脚本管理器,让我安装!)

您需要先安装一款用户样式管理器扩展,比如 Stylus,才能安装此样式。

您需要先安装一款用户样式管理器扩展,比如 Stylus,才能安装此样式。

您需要先安装一款用户样式管理器扩展,比如 Stylus,才能安装此样式。

您需要先安装一款用户样式管理器扩展后才能安装此样式。

您需要先安装一款用户样式管理器扩展后才能安装此样式。

您需要先安装一款用户样式管理器扩展后才能安装此样式。

(我已经安装了用户样式管理器,让我安装!)

// ==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
// ==/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 = `
            <h3>友评排行榜</h3>
            <div class="ranking-controls">
                <div class="control-group">
                    <span class="control-label">并发线程数:</span>
                    <input type="number" id="threadCount" min="1" max="10" value="1">
                </div>
                <button id="generateRanking">生成评分榜</button>
                <div id="status"></div>
            </div>
            <div id="rankingResults" class="hidden">
                <div class="ranking-controls">
                    <div class="control-group">
                        <span class="control-label">加权参数m:</span>
                        <input type="number" id="mThreshold" min="0" value="1">
                    </div>
                    <div class="control-group">
                        <span class="control-label">排序方式:</span>
                        <select id="sortMethod">
                            <option value="weighted">加权评分</option>
                            <option value="average">平均分</option>
                            <option value="votes">评分人数</option>
                        </select>
                    </div>
                    <button id="updateRanking">更新排名</button>
                    <button id="filterByVotes">隐藏低人数条目</button>
                    <button id="showAll">显示所有条目</button>
                    <button id="saveToFile">保存到文件</button>
                </div>
                <div id="rankingTableContainer"></div>
            </div>
        `;
        
        // 插入到页面中
        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 = '<div class="loading">正在获取好友列表...</div>';
        
        try {
            // 获取好友列表
            const friendIds = await getFriendIds(userId);
            statusEl.innerHTML = `<div class="loading">发现 ${friendIds.length} 位用户,正在获取评分数据...</div>`;
            
            // 获取评分数据
            const animationMap = await fetchAllUserRatings(friendIds);
            
            // 计算平均分并排序
            let animations = calculateAndSortAverage(animationMap);
            
            // 显示结果
            displayRanking(animations);
            document.getElementById('rankingResults').classList.remove('hidden');
            statusEl.innerHTML = `<div class="status-info">数据处理完成!有效动画条目数量: ${animations.length}</div>`;
            
        } catch (error) {
            statusEl.innerHTML = `<div class="error">错误: ${error.message}</div>`;
            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 = `<div class="loading">正在获取评分数据... ${progress}/${friendIds.length}</div>`;
        }
        
        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 = `
            <table class="ranking-table">
                <thead>
                    <tr>
                        <th>加权排名</th>
                        <th>条目原名</th>
                        <th>中文名称</th>
                        <th>评分人数</th>
                        <th>平均分</th>
                        <th>加权分数</th>
                        <th>条目链接</th>
                    </tr>
                </thead>
                <tbody>
        `;
        
        visibleAnimations.forEach((animation, index) => {
tableHTML += `
    <tr data-id="${animation.subjectId}">
        <td>${index + 1}</td>
        <td>${escapeHtml(animation.subjectName)}</td>
        <td>${escapeHtml(animation.subjectNameCN)}</td>
        <td>${animation.friendRatings.size}</td>
        <td>${animation.averageScore.toFixed(4)}</td>
        <td>${animation.weightedScore.toFixed(4)}</td>
        <td><a href="https://${window.location.hostname}/subject/${animation.subjectId}" target="_blank">查看详情</a></td>
    </tr>
`;
        });
        
        tableHTML += `
                </tbody>
            </table>
            <div class="status-info">当前显示: ${visibleAnimations.length} / ${animations.length} 个条目</div>
        `;
        
        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, '&amp;')
            .replace(/</g, '&lt;')
            .replace(/>/g, '&gt;')
            .replace(/"/g, '&quot;')
            .replace(/'/g, '&#39;');
    }

    // 辅助函数: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();
    }
})();