// ==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 秒启动
})();