Greasy Fork

Greasy Fork is available in English.

优学院讨论区匿名替换真实姓名 v0.1

让我看看都是谁在评论!

// ==UserScript==
// @name         优学院讨论区匿名替换真实姓名 v0.1
// @namespace    http://tampermonkey.net/
// @version      0.1
// @description  让我看看都是谁在评论!
// @author       青柍kk
// @match        https://discussion.ulearning.cn/pc.html*
// @grant        GM_xmlhttpRequest
// @grant        GM_addStyle
// @connect      discussion.ulearning.cn
// @connect courseapi.ulearning.cn
// @connect      api.ulearning.cn
// @connect      www.ulearning.cn
// @license MIT
// ==/UserScript==

(function() {
    'use strict';

    // --- --- 配置区 (根据提供信息填写) --- ---

    // 1. 成员列表 API 的完整 URL (获取全班/全课程用户名单的分页接口基础URL)
    const MEMBER_API_URL = 'https://courseapi.ulearning.cn/student/list?ocId=147496&isDesc=1&pn=1&ps=10&lang=zh'; // 成员列表API基础URL

    // 1a. 成员列表 API 分页相关字段名确认
    const MEMBER_API_PAGE_PARAM = 'pn';// 页码参数名 (来自 URL)
    const MEMBER_API_PAGESIZE_PARAM = 'ps';// 页面大小参数名 (来自 URL)
    const MEMBER_API_TOTAL_FIELD = 'total';// 返回JSON中总数字段 (来自成员列表JSON)
    const MEMBER_API_PAGESIZE_FIELD = 'pageSize';// 返回JSON中页面大小字段 (来自成员列表JSON)
    const MEMBER_API_LIST_FIELD = 'list';// 返回JSON中用户数字段 (来自成员列表JSON)
    const MEMBER_API_USERID_FIELD = 'userId';// 用户对象中用户ID字段 (来自成员列表JSON)
    const MEMBER_API_USERNAME_FIELD = 'name';// 用户对象中姓名字段 (来自成员列表JSON)

    // 1b. **尝试** 成员列表 API 页面大小 (尝试一次性获取更多,减少请求次数, API可能有限制)
    const MEMBER_API_PREFERRED_PAGESIZE = 200; // 尝试每页获取200个,如果不行API会按自己的限制来

    // 2. 讨论帖子列表 API 的 URL 模板 (获取当前讨论页面帖子内容的接口模板)
    const TOPIC_INFO_API_TEMPLATE = 'https://courseapi.ulearning.cn/topic/topicInfo?pn={pageNum}&ps={pageSize}&ocId={ocId}&discussionId={discussionId}&teacherId=&classId=&orderType=&mine=false&keyword='; // 讨论帖子API模板

    // 2a. 讨论帖子 API 结果解析确认
    const TOPIC_INFO_LIST_PATH = ['result', 'pageInfo', 'list']; // 帖子列表在JSON中的路径
    const TOPIC_INFO_USERID_FIELD = 'userID';// 帖子对象中用户ID字段

    // 3. 评论区容器的 CSS 选择器 (包含所有评论和分页控件)
    const COMMENT_CONTAINER_SELECTOR = '.contentDetails'; // 包含所有评论和分页的容器

    // 4. 单个评论项/块的 CSS 选择器
    const COMMENT_ITEM_SELECTOR = '.infoText'; // 单个评论块

    // 5. 用户名元素相对于单个评论项的 CSS 选择器 (显示"匿名"的地方)
    const USERNAME_SELECTOR = '.teacherTipsTop .span1'; // 显示用户名的元素

    // 6. 当前页码的 CSS 选择器 (分页控件中高亮的页码)
    const PAGINATION_CURRENT_PAGE_SELECTOR = '.pagination-wrap .active'; // 分页控件当前页码

    // 7. 讨论区每页帖子数量 (必须与 topicInfo API 的 ps 参数一致)
    const DISCUSSION_PAGE_SIZE = 20; // 讨论区每页帖子数

    // --- --- 配置区结束 --- ---


    let userIdToNameMap = null; // userID -> 真实姓名的映射
    let isFetchingUserMap = false; // 是否正在获取用户映射的标记
    let userMapPromise = null; // 保存获取用户映射的 Promise
    let currentOcId = null; // 当前课程/组织 ID
    let currentDiscussionId = null; // 当前讨论 ID
    let authToken = null; // 认证 Token
    let lastProcessedPage = -1; // 记录上次处理的页码,防止重复处理

    // --- 样式 ---
    GM_addStyle(`
        .real-name-revealed {
            color: #007bff !important; /* 蓝色 */
            font-weight: bold !important;
            cursor: help; /* 添加一个鼠标悬停提示 */
            position: relative;
            background-color: rgba(0, 123, 255, 0.08); /* 更淡的蓝色背景 */
            padding: 0px 3px;
            border-radius: 3px;
            border: 1px solid rgba(0, 123, 255, 0.2); /* 浅蓝色边框 */
        }
        /* 可选:在名字后面加个标记 */
        .real-name-revealed::after {
            content: ' (R)'; /* R = Revealed / Real */
            font-size: 0.8em;
            color: #6c757d; /* 灰色 */
            margin-left: 3px;
            font-weight: normal; /* 标记不需要加粗 */
        }
        /* 可选: 鼠标悬停显示原始 UserID */
        .real-name-revealed:hover::before {
            content: "ID:" attr(data-original-userid); /* 显示 "ID:xxxx" */
            position: absolute;
            bottom: 105%; /* 稍微向上一点 */
            left: 50%;
            transform: translateX(-50%);
            background-color: #444; /* 深灰色背景 */
            color: white;
            padding: 3px 6px;
            border-radius: 4px;
            font-size: 10px; /* 稍小一点 */
            line-height: 1.2;
            white-space: nowrap;
            z-index: 1000; /* 确保在顶层 */
            box-shadow: 0 1px 3px rgba(0,0,0,0.2);
        }
    `);

    // --- 工具函数 ---

    function getParamsFromUrl() {
        const params = {};
        const hash = window.location.hash;
        if (hash.includes('?')) {
            const urlParams = new URLSearchParams(hash.substring(hash.indexOf('?')));
            params.ocId = urlParams.get('ocId');
            params.discussionId = urlParams.get('discussionId');
        }
        return params;
    }

    function getTokenFromCookie() {
        const cookies = document.cookie;
        const tokenMatch = /token=([^;]+)/.exec(cookies);
        if (tokenMatch && tokenMatch[1]) {
            return tokenMatch[1];
        }
        console.error("未能从 Cookie 中找到 token!");
        return null;
    }

    function getCurrentPageNumber() {
        try {
            const activePageElement = document.querySelector(PAGINATION_CURRENT_PAGE_SELECTOR);
            if (activePageElement) {
                const pageNum = parseInt(activePageElement.textContent.trim(), 10);
                return (isNaN(pageNum) || pageNum < 1) ? 1 : pageNum;
            }
        } catch (e) {
            console.error("获取当前页码时出错:", e);
        }
        console.warn(`无法找到当前页码元素 (${PAGINATION_CURRENT_PAGE_SELECTOR}),默认返回页码 1`);
        return 1;
    }

     // 安全地从嵌套对象获取值
    function getNestedValue(obj, pathArray) {
        return pathArray.reduce((currentValue, key) => {
            return (currentValue && typeof currentValue === 'object' && key in currentValue) ? currentValue[key] : undefined;
        }, obj);
    }


    // --- API 请求函数 ---

    // 获取 *所有* 成员列表 (处理分页)
    function fetchAllUsers() {
        if (isFetchingUserMap) return userMapPromise;
        if (userIdToNameMap) return Promise.resolve(userIdToNameMap);
        if (!authToken) return Promise.reject("缺少认证 Token (fetchAllUsers)");

        isFetchingUserMap = true;
        userIdToNameMap = {};
        console.log("[成员列表] 开始获取所有成员数据...");

        function fetchPage(pageNumber) {
            return new Promise((resolvePage) => { // 改为只 resolve,在 Promise.all 外处理失败
                let pageUrl;
                try {
                    pageUrl = new URL(MEMBER_API_URL);
                    pageUrl.searchParams.set(MEMBER_API_PAGE_PARAM, pageNumber);
                    pageUrl.searchParams.set(MEMBER_API_PAGESIZE_PARAM, MEMBER_API_PREFERRED_PAGESIZE); // 尝试设置期望的页面大小
                } catch (e) {
                    console.error("[成员列表] 处理 URL 时出错:", e);
                    resolvePage({ success: false, error: "URL 处理错误" }); return;
                }
                const urlForPage = pageUrl.toString();
                console.log(`  [成员列表] 获取第 ${pageNumber} 页 (尝试 ps=${MEMBER_API_PREFERRED_PAGESIZE})...`);

                GM_xmlhttpRequest({
                    method: 'GET', url: urlForPage, responseType: 'json', timeout: 30000,
                    headers: { 'AUTHORIZATION': authToken },
                    onload: function(response) {
                        if (response.status >= 200 && response.status < 300 && response.response) {
                            const data = response.response;
                            const userList = getNestedValue(data, [MEMBER_API_LIST_FIELD]); // 使用确认的字段名
                            if (userList && Array.isArray(userList)) {
                                userList.forEach(user => {
                                    const userId = getNestedValue(user, [MEMBER_API_USERID_FIELD]);
                                    const userName = getNestedValue(user, [MEMBER_API_USERNAME_FIELD]);
                                    if (userId && userName) {
                                        userIdToNameMap[userId] = userName.trim();
                                    }
                                });
                                resolvePage({ success: true, data: data }); // 传递原始数据
                            } else {
                                console.error(`  [成员列表] 第 ${pageNumber} 页数据结构不符 (${MEMBER_API_LIST_FIELD} 路径错误):`, data);
                                resolvePage({ success: false, error: "数据结构错误" });
                            }
                        } else {
                            console.error(`  [成员列表] 获取第 ${pageNumber} 页失败,状态码: ${response.status}`);
                            resolvePage({ success: false, error: `HTTP ${response.status}` });
                        }
                    },
                    onerror: (error) => { console.error(`  [成员列表] 第 ${pageNumber} 页网络错误:`, error); resolvePage({ success: false, error: "网络错误" }); },
                    ontimeout: () => { console.error(`  [成员列表] 第 ${pageNumber} 页请求超时`); resolvePage({ success: false, error: "请求超时" }); }
                });
            });
        }

        userMapPromise = new Promise(async (resolve, reject) => {
            try {
                console.log("  [成员列表] 获取第一页以确定总页数...");
                const firstPageResult = await fetchPage(1);

                if (!firstPageResult.success || !firstPageResult.data) {
                     reject(`获取第一页成员列表失败: ${firstPageResult.error || '未知错误'}`);
                     isFetchingUserMap = false; return;
                }

                const firstPageData = firstPageResult.data;
                const totalUsers = parseInt(getNestedValue(firstPageData, [MEMBER_API_TOTAL_FIELD]), 10);
                const actualPageSize = parseInt(getNestedValue(firstPageData, [MEMBER_API_PAGESIZE_FIELD]), 10);

                if (isNaN(totalUsers) || isNaN(actualPageSize) || actualPageSize <= 0) {
                    console.error("  [成员列表] 无法从第一页获取有效分页信息。", firstPageData);
                    if (Object.keys(userIdToNameMap).length > 0) {
                        console.warn("  [成员列表] 将仅使用第一页数据。"); resolve(userIdToNameMap);
                    } else { reject("分页信息无效"); }
                    isFetchingUserMap = false; return;
                }

                const totalPages = Math.ceil(totalUsers / actualPageSize); // 使用API返回的实际页面大小计算总页数
                console.log(`  [成员列表] 总用户数: ${totalUsers}, 实际每页大小: ${actualPageSize}, 总页数: ${totalPages}`);

                if (totalPages > 1) {
                    const pagePromises = [];
                    for (let i = 2; i <= totalPages; i++) {
                        pagePromises.push(fetchPage(i));
                    }
                    console.log(`  [成员列表] 并发获取第 2 页到第 ${totalPages} 页...`);
                    const results = await Promise.all(pagePromises); // 等待所有请求完成
                    // 检查是否有失败的请求
                    const failedPages = results.filter(r => !r.success).length;
                    if (failedPages > 0) {
                        console.warn(`  [成员列表] ${failedPages} 个页面获取失败或处理出错。`);
                    }
                    console.log("  [成员列表] 所有页面请求完成。");
                }

                console.log(`[成员列表] 最终处理了 ${Object.keys(userIdToNameMap).length} 个用户映射 (API报告总数 ${totalUsers})。`);
                isFetchingUserMap = false;
                resolve(userIdToNameMap);

            } catch (error) {
                console.error("[成员列表] 获取所有分页数据时出错:", error);
                isFetchingUserMap = false;
                if (Object.keys(userIdToNameMap).length > 0) {
                    console.warn("[成员列表] 过程出错,将仅使用部分数据。"); resolve(userIdToNameMap);
                } else { reject(error); }
            }
        });
        return userMapPromise;
    }

    // 获取 *指定页* 的讨论帖子列表数据
    function fetchTopicInfoPageData(ocId, discussionId, pageNum, pageSize) {
         if (!authToken) return Promise.reject("缺少认证 Token (fetchTopicInfoPageData)");
         if (!ocId || !discussionId) return Promise.reject("缺少 ocId 或 discussionId");

        let apiUrl = TOPIC_INFO_API_TEMPLATE
            .replace('{ocId}', encodeURIComponent(ocId))
            .replace('{discussionId}', encodeURIComponent(discussionId))
            .replace('{pageNum}', encodeURIComponent(pageNum))
            .replace('{pageSize}', encodeURIComponent(pageSize));

        console.log(`[讨论帖子] 获取第 ${pageNum} 页 (ps=${pageSize})...`);

        return new Promise((resolve, reject) => {
            GM_xmlhttpRequest({
                method: 'GET', url: apiUrl, responseType: 'json', timeout: 15000,
                headers: { 'AUTHORIZATION': authToken },
                onload: function(response) {
                    if (response.status >= 200 && response.status < 300 && response.response) {
                        const data = response.response;
                        // 使用确认的路径和字段名解析
                        const postList = getNestedValue(data, TOPIC_INFO_LIST_PATH);
                        if (data && data.code === 1 && postList && Array.isArray(postList)) {
                             console.log(`  [讨论帖子] 第 ${pageNum} 页获取成功,帖子数: ${postList.length}`);
                             resolve(postList); // 返回帖子列表数组
                        } else {
                             console.error(`  [讨论帖子] 第 ${pageNum} 页数据结构不符或 code 不为 1:`, data);
                             reject(`讨论帖子API数据错误 (code:${data?.code})`);
                        }
                    } else {
                         console.error(`  [讨论帖子] 获取第 ${pageNum} 页失败,状态码: ${response.status}`);
                         reject(`获取讨论帖子失败,状态码: ${response.status}`);
                    }
                },
                onerror: (error) => { console.error(`  [讨论帖子] 第 ${pageNum} 页网络错误:`, error); reject("网络错误"); },
                ontimeout: () => { console.error(`  [讨论帖子] 第 ${pageNum} 页请求超时`); reject("请求超时"); }
            });
        });
    }


    // --- 核心处理逻辑 ---

    // 处理当前可见的评论及其回复
    function processVisibleComments(topicInfoPageData, userMap) {
         if (!topicInfoPageData || topicInfoPageData.length === 0) {
             console.log("没有当前页的讨论帖子数据可供处理。"); return;
         }
         if (!userMap) {
             console.warn("用户映射数据尚未准备好,无法进行名称替换。");
         }

         console.log(`准备使用 ${topicInfoPageData.length} 条主帖子数据处理页面元素...`);
         const commentElements = document.querySelectorAll(COMMENT_ITEM_SELECTOR); // 获取所有主评论元素
         console.log(`在页面上找到 ${commentElements.length} 个主评论元素 (${COMMENT_ITEM_SELECTOR})。`);

         const processLength = Math.min(topicInfoPageData.length, commentElements.length);
         if (processLength === 0) { console.log("没有可匹配的主帖子数据和 DOM 元素。"); return; }
         if (topicInfoPageData.length !== commentElements.length) {
              console.warn(`警告:API主帖子数 (${topicInfoPageData.length}) 与页面元素数 (${commentElements.length}) 不匹配!将只处理前 ${processLength} 个。`);
         }

         let totalReplaced = 0;
         for (let i = 0; i < processLength; i++) {
             const mainPostData = topicInfoPageData[i];       // 当前主帖的 API 数据
             const mainCommentElement = commentElements[i]; // 页面上对应的主评论元素

             // 1. 处理主评论作者
             try {
                 const usernameElement = mainCommentElement.querySelector(USERNAME_SELECTOR); // 主评论用户名元素的选择器
                 if (usernameElement && usernameElement.textContent.trim() === '匿名') {
                     const userId = getNestedValue(mainPostData, [TOPIC_INFO_USERID_FIELD]); // 主帖的用户 ID
                     if (userId && userMap && userMap[userId]) {
                         const realName = userMap[userId];
                         usernameElement.textContent = realName;
                         usernameElement.classList.add('real-name-revealed');
                         usernameElement.setAttribute('data-original-userid', userId);
                         totalReplaced++;
                     }
                 } else if (usernameElement && usernameElement.textContent.trim() !== '匿名' && !usernameElement.classList.contains('real-name-revealed')) {
                      const userId = getNestedValue(mainPostData, [TOPIC_INFO_USERID_FIELD]);
                      if (userId) {
                           usernameElement.classList.add('real-name-revealed');
                           usernameElement.setAttribute('data-original-userid', userId);
                      }
                 }
             } catch (e) {
                  console.error(`处理第 ${i} 个主评论作者时出错:`, e, mainCommentElement);
             }

             // 2. 处理该主评论下的回复 (二级评论)
             const subListData = getNestedValue(mainPostData, ['subList', 'list']); // 从主帖数据获取回复列表
             if (subListData && Array.isArray(subListData) && subListData.length > 0) {
                 // 查找当前主评论元素下的所有回复元素
                 const replyElements = mainCommentElement.querySelectorAll('.secondText'); // 选择器是 .secondText
                 console.log(`  主帖 #${i + 1}: 找到 ${subListData.length} 条回复数据 和 ${replyElements.length} 个回复元素。`);

                 const replyProcessLength = Math.min(subListData.length, replyElements.length);
                 if (subListData.length !== replyElements.length) {
                      console.warn(`  主帖 #${i + 1}: 回复数据数 (${subListData.length}) 与回复元素数 (${replyElements.length}) 不匹配!将只处理前 ${replyProcessLength} 个。`);
                 }

                 for (let j = 0; j < replyProcessLength; j++) {
                     const replyData = subListData[j];// 当前回复的 API 数据
                     const replyElement = replyElements[j];// 页面上对应的回复元素

                     try {
                         // 查找回复元素中的名字 span
                         const replyNameElement = replyElement.querySelector('.name'); // 回复名字的选择器是 .name
                         if (replyNameElement && replyNameElement.textContent.trim() === '匿名') {
                             const replyUserId = getNestedValue(replyData, [TOPIC_INFO_USERID_FIELD]); // 回复者的 UserID
                             if (replyUserId && userMap && userMap[replyUserId]) {
                                 const realName = userMap[replyUserId];
                                 // console.log(`    回复 #${j + 1}: UserID ${replyUserId} -> ${realName}`);
                                 replyNameElement.textContent = realName;
                                 replyNameElement.classList.add('real-name-revealed');
                                 replyNameElement.setAttribute('data-original-userid', replyUserId);
                                 totalReplaced++;
                             } else if (replyUserId && !userMap) {
                                 // 等待映射加载
                             } else if (replyUserId) {
                                 // console.log(`    回复 #${j + 1}: 未在映射中找到 UserID ${replyUserId}`);
                             }
                         } else if (replyNameElement && replyNameElement.textContent.trim() !== '匿名' && !replyNameElement.classList.contains('real-name-revealed')) {
                              const replyUserId = getNestedValue(replyData, [TOPIC_INFO_USERID_FIELD]);
                              if (replyUserId) {
                                  replyNameElement.classList.add('real-name-revealed');
                                  replyNameElement.setAttribute('data-original-userid', replyUserId);
                              }
                         }
                     } catch (e) {
                           console.error(`处理主帖 #${i + 1} 的第 ${j} 条回复时出错:`, e, replyElement);
                     }
                 }
             }
         }
         console.log(`名称替换完成,本轮共替换了 ${totalReplaced} 个"匿名"(包括主评论和回复)。`);
    }

    // --- 主执行函数和监听器 ---
    let processingScheduled = false; // 防抖标记

    async function runReplacement() {
        const currentPage = getCurrentPageNumber();
        // 如果页面和上次处理的相同,则不执行,除非用户映射尚未加载
        if (currentPage === lastProcessedPage && userIdToNameMap) {
            // console.log(`页面 ${currentPage} 已处理过,跳过。`);
            return;
        }

        if (processingScheduled) { console.log("任务已在计划中,跳过本次触发。"); return; }
        processingScheduled = true;
        console.log(`--- 开始执行第 ${currentPage} 页名称替换流程 ---`);

        const params = getParamsFromUrl();
        if (!params.ocId || !params.discussionId) {
            console.error("无法从 URL 获取 ocId 或 discussionId。");
            processingScheduled = false; return;
        }
        currentOcId = params.ocId; currentDiscussionId = params.discussionId;
        if (!authToken) authToken = getTokenFromCookie();
        if (!authToken) { console.error("无法获取 Token。"); processingScheduled = false; return; }

        try {
             // 确保用户映射已加载或正在加载
             const userMap = await fetchAllUsers(); // 获取或等待用户映射 Promise

             // 获取当前页的帖子数据
             const topicInfoPageData = await fetchTopicInfoPageData(currentOcId, currentDiscussionId, currentPage, DISCUSSION_PAGE_SIZE);

             // 处理可见评论
             processVisibleComments(topicInfoPageData, userMap);
             lastProcessedPage = currentPage; // 记录已成功处理的页码

        } catch (error) {
            console.error(`执行第 ${currentPage} 页替换时发生错误:`, error);
            // 出错时不清空 lastProcessedPage,允许下次重试
        } finally {
             console.log(`--- 第 ${currentPage} 页名称替换流程结束 ---`);
             setTimeout(() => { processingScheduled = false; }, 300); // 300ms 防抖
        }
    }

    // --- 启动与监听 ---

    console.log("优学院匿名替换脚本 (v0.5) 启动...");

    // 1. 获取初始 Token
    authToken = getTokenFromCookie();
    if (!authToken) {
        console.warn("脚本启动时未获取到 Token,请确保已登录。");
    }

    // 2. 监听 DOM 变化 (用于处理翻页或动态加载)
    function observeContentChanges() {
        const targetNode = document.querySelector(COMMENT_CONTAINER_SELECTOR);
        if (!targetNode) {
            console.error(`无法找到评论容器 ${COMMENT_CONTAINER_SELECTOR} 进行监听,1秒后重试...`);
            setTimeout(observeContentChanges, 1000);
            return;
        }

        console.log(`开始监听 ${COMMENT_CONTAINER_SELECTOR} 的 DOM 变化以触发更新...`);
        const observer = new MutationObserver((mutationsList) => {
            let relevantChange = false;
            for (const mutation of mutationsList) {
                // 主要检测子节点变化(评论列表更新)或分页控件激活状态变化
                if (mutation.type === 'childList' ||
                    (mutation.type === 'attributes' && mutation.attributeName === 'class' && mutation.target.matches && mutation.target.matches(PAGINATION_CURRENT_PAGE_SELECTOR)))
                {
                     // 如果检测到分页控件的 active class 变化,强制重置上次处理页码
                     if (mutation.type === 'attributes' && mutation.target.matches(PAGINATION_CURRENT_PAGE_SELECTOR)) {
                         const newPageNum = getCurrentPageNumber();
                         if (newPageNum !== lastProcessedPage) {
                             console.log(`检测到分页控件活动页码变化为: ${newPageNum}`);
                             lastProcessedPage = -1; // 强制重新处理
                             relevantChange = true;
                             break;
                         }
                     } else if (mutation.type === 'childList') {
                          // 更宽松的检测:只要有子节点变化就认为可能需要更新
                          relevantChange = true;
                          break;
                     }
                }
            }

            if (relevantChange) {
                 console.log("检测到相关 DOM 变化,计划运行名称替换...");
                 runReplacement(); // 调用主替换函数(内部有防抖)
            }
        });

        observer.observe(targetNode, {
            childList: true, subtree: true, attributes: true, attributeFilter: ['class']
        });
    }

    // 3. 延迟启动首次运行和监听器,给页面足够时间加载初始内容
    setTimeout(() => {
        runReplacement(); // 首次运行
        observeContentChanges(); // 启动监听器
    }, 1500); // 延迟 1.5 秒启动

})();