KunimiSaya / 友评排行榜

// ==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
// @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
// @namespace     KunimiSaya
// @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();
    }
})();