您需要先安装一个扩展,例如 篡改猴、Greasemonkey 或 暴力猴,之后才能安装此脚本。
您需要先安装一个扩展,例如 篡改猴 或 暴力猴,之后才能安装此脚本。
您需要先安装一个扩展,例如 篡改猴 或 暴力猴,之后才能安装此脚本。
您需要先安装一个扩展,例如 篡改猴 或 Userscripts ,之后才能安装此脚本。
您需要先安装一款用户脚本管理器扩展,例如 Tampermonkey,才能安装此脚本。
您需要先安装用户脚本管理器扩展后才能安装此脚本。
Greasy Fork is available in English.
可在水源论坛显示仅回复可见的回帖内容(需要手动点击按钮)
当前为
// ==UserScript== // @name 水源显示回复可见 // @namespace CCCC_David // @version 0.2.0 // @description 可在水源论坛显示仅回复可见的回帖内容(需要手动点击按钮) // @author CCCC_David // @match https://shuiyuan.sjtu.edu.cn/* // @grant none // ==/UserScript== (async () => { 'use strict'; // From Font Awesome Free v5.15 by @fontawesome - https://fontawesome.com // License - https://fontawesome.com/license/free (Icons: CC BY 4.0, Fonts: SIL OFL 1.1, Code: MIT License) // Modified class attribute to fit in. const prevReplyIcon = '<svg aria-hidden="true" focusable="false" data-prefix="fas" data-icon="caret-left" class="svg-icon" role="img" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 192 512"><path fill="currentColor" d="M192 127.338v257.324c0 17.818-21.543 26.741-34.142 14.142L29.196 270.142c-7.81-7.81-7.81-20.474 0-28.284l128.662-128.662c12.599-12.6 34.142-3.676 34.142 14.142z"></path></svg>'; const nextReplyIcon = '<svg aria-hidden="true" focusable="false" data-prefix="fas" data-icon="caret-right" class="svg-icon" role="img" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 192 512"><path fill="currentColor" d="M0 384.662V127.338c0-17.818 21.543-26.741 34.142-14.142l128.662 128.662c7.81 7.81 7.81 20.474 0 28.284L34.142 398.804C21.543 411.404 0 402.48 0 384.662z"></path></svg>'; const allowedPolicy = window.trustedTypes?.createPolicy?.('allowedPolicy', {createHTML: (x) => x}); const createTrustedHTML = (html) => (allowedPolicy ? allowedPolicy.createHTML(html) : html); const htmlParser = new DOMParser(); const escapeRegExpOutsideCharacterClass = (s) => s.replace(/[.*+?^${}()|[\]\\]/gu, '\\$&'); const discourseFetch = (url, options) => fetch(url, { method: options?.method ?? 'GET', headers: { 'Discourse-Present': 'true', 'Discourse-Logged-In': 'true', 'X-Requested-With': 'XMLHttpRequest', 'X-CSRF-Token': document.querySelector('meta[name=csrf-token]').content, ...options?.headers, }, body: options?.body, mode: 'same-origin', credentials: 'include', }); const fetchReply = async (topicId, replyId) => { const replyURL = `/t/topic/${topicId}/${replyId}`; const response = await discourseFetch(`/onebox?url=${encodeURIComponent(replyURL)}`); return response.text(); }; const fetchTopicInfo = async (topicId) => { const [topicInfo1, topicInfo2] = await Promise.all([ `/t/${topicId}.json`, `/latest.json?topic_ids=${topicId}`, ].map(async (url) => (await discourseFetch(url, {headers: {Accept: 'application/json'}})).json())); return { isPrivateReply: topicInfo1.private_replies, maxReplyId: topicInfo2.topic_list.topics[0].highest_post_number, participants: topicInfo1.details?.participants ?? [], }; }; const fetchUserInfo = async (username) => { if (!username) { return { username: null, name: null, title: null, }; } const response = await discourseFetch(`/u/${encodeURIComponent(username)}/card.json`, {headers: {Accept: 'application/json'}}); const userInfo = await response.json(); return { username: userInfo.user.username, name: userInfo.user.name, title: userInfo.user.title, }; }; const renderReply = (tree) => { const blockquote = tree.querySelector('aside > blockquote'); if (!blockquote) { return '(帖子已被作者删除)'; } const reply = blockquote.innerHTML.trim(); if (!reply) { return ' '; } // eslint-disable-next-line no-undef return require('discourse/lib/text').cookAsync(reply); }; const getAvatarURLFromReply = (tree) => tree.querySelector('aside > div > img')?.src ?? ''; const tryToFindUserByAvatar = async (avatarURL, participants) => { // Case 1: Extract username from URL if it is user avatar. const username = avatarURL.match(/\/user_avatar\/(?:[^/]+)\/([^/]+)\//u)?.[1]; if (username) { return { username: decodeURIComponent(username), name: null, title: null, }; } // Case 2: Try to match letter avatar with top participants of this topic. const letterAvatarMatch = avatarURL.match(/\/letter_avatar_proxy\/(?:[^/]+)\/letter\/([^/]+)\/([^/]+)\//u); // Letter and color if (!letterAvatarMatch) { // Unexpected format. return { username: null, name: null, title: null, }; } const letterAvatarTag = ['letter', letterAvatarMatch[1], letterAvatarMatch[2]].join('/'); for (const participant of participants) { if ((participant.avatar_template ?? '').includes(letterAvatarTag)) { return { username: participant.username, name: participant.name, title: null, }; } } // Case 3: Search for users with the letter and try to match letter avatar. const searchUsersByLetterResult = await (await discourseFetch(`/directory_items?period=all&order=username&name=${encodeURIComponent(decodeURIComponent(letterAvatarMatch[1]))}`)).json(); for (const user of searchUsersByLetterResult.directory_items) { if ((user.user.avatar_template ?? '').includes(letterAvatarTag)) { return { username: user.user.username, name: user.user.name, title: user.user.title, }; } } // We are unable to figure out the username by avatar. return { username: null, name: null, title: null, }; }; const addViewer = async (parentNode) => { if (!parentNode) { return null; } // Do not add the viewer more than once for a page. if (document.getElementById('show-private-reply-div')) { return null; } const topicId = parseInt(window.location.pathname.match(/^\/t\/topic\/(\d+)(?=\/|$)/u)?.[1], 10); if (Number.isNaN(topicId)) { // Unable to parse topic ID, maybe not a topic page, give up. return null; } const {isPrivateReply, maxReplyId, participants} = await fetchTopicInfo(topicId); // Do not add viewer if current topic is not private reply. // Double check for race condition after await. if (!isPrivateReply || document.getElementById('show-private-reply-div')) { return null; } const viewerContainer = document.createElement('div'); viewerContainer.id = 'show-private-reply-div'; viewerContainer.innerHTML = createTrustedHTML(` <div class="topic-post clearfix regular"> <article role="region" class="boxed"> <div class="row"> <div class="topic-avatar"> <a id="show-private-reply-avatar" class="trigger-user-card main-avatar" href="" data-user-card="" aria-hidden="true"> <img width="45" height="45" src="" class="avatar"> </a> </div> <div class="topic-body clearfix"> <div role="heading" class="topic-meta-data"> <div class="names trigger-user-card"> <span class="first full-name"> <a id="show-private-reply-name" href="" data-user-card=""> </a> </span> <span class="second username"> <a id="show-private-reply-username" href="" data-user-card=""></a> </span> <span id="show-private-reply-user-title" class="user-title"></span> </div> <span style="text-align: right; margin-left: auto;"> <span id="show-private-reply-id" style="font-size: var(--font-up-2); cursor: pointer;">1</span> <button id="show-private-reply-dec-id" class="btn" style="margin-left: 0.5em;">${prevReplyIcon}</button> <button id="show-private-reply-inc-id" class="btn" style="margin-left: 0.5em;">${nextReplyIcon}</button> </span> </div> <div class="regular contents"> <div id="show-private-reply-content" class="cooked">正在加载回复可见内容...</div> </div> </div> </div> </article> </div> `); parentNode.appendChild(viewerContainer); const replyAvatarElement = document.getElementById('show-private-reply-avatar'); const replyNameElement = document.getElementById('show-private-reply-name'); const replyUsernameElement = document.getElementById('show-private-reply-username'); const replyUserTitleElement = document.getElementById('show-private-reply-user-title'); const replyIdElement = document.getElementById('show-private-reply-id'); const replyContentElement = document.getElementById('show-private-reply-content'); const updateView = async (replyId) => { replyIdElement.textContent = replyId.toString(); const reply = await fetchReply(topicId, replyId); const tree = htmlParser.parseFromString(reply, 'text/html'); const avatarURL = getAvatarURLFromReply(tree); // eslint-disable-next-line no-shadow let {username, name, title} = await tryToFindUserByAvatar(avatarURL, participants); if (title === null) { ({username, name, title} = await fetchUserInfo(username)); } const nameToShow = name || username; replyAvatarElement.href = replyNameElement.href = replyUsernameElement.href = username ? `/u/${encodeURIComponent(username)}` : ''; for (const userCardElement of [replyAvatarElement, replyNameElement, replyUsernameElement]) { userCardElement.setAttribute('data-user-card', username ?? ''); // eslint-disable-next-line no-undef jQuery(userCardElement).data('user-card', username ?? ''); } replyAvatarElement.children[0].src = avatarURL; replyAvatarElement.style = `visibility: ${avatarURL ? 'visible' : 'hidden'}; cursor: ${username ? 'pointer' : 'default'};`; replyNameElement.textContent = nameToShow || '未知用户的回复'; replyNameElement.style = `cursor: ${nameToShow ? 'pointer' : 'text'};`; replyUsernameElement.textContent = username; replyUsernameElement.parentNode.style = name && name !== username ? '' : 'display: none;'; replyUserTitleElement.textContent = title; replyUserTitleElement.style = title ? '' : 'display: none;'; replyContentElement.innerHTML = createTrustedHTML(await renderReply(tree)); }; for (const userCardElement of [replyAvatarElement, replyNameElement, replyUsernameElement]) { userCardElement.addEventListener('click', (e) => { if (!userCardElement.getAttribute('data-user-card')) { e.preventDefault(); e.stopPropagation(); } }); } document.getElementById('show-private-reply-inc-id').addEventListener('click', () => { let replyId = parseInt(replyIdElement.textContent, 10); replyId = replyId < maxReplyId ? replyId + 1 : 1; return updateView(replyId); }); document.getElementById('show-private-reply-dec-id').addEventListener('click', () => { let replyId = parseInt(replyIdElement.textContent, 10); replyId = replyId > 1 ? replyId - 1 : maxReplyId; return updateView(replyId); }); replyIdElement.addEventListener('click', () => { const newReplyIdText = prompt('跳转到回复...', replyIdElement.textContent); if (!newReplyIdText) { return null; } let newReplyId = parseInt(newReplyIdText, 10); if (Number.isNaN(newReplyId)) { newReplyId = 1; } newReplyId = Math.min(Math.max(newReplyId, 1), maxReplyId); return updateView(newReplyId); }); return updateView(parseInt(replyIdElement.textContent, 10)); }; const appendToTargetClass = 'post-stream'; const observer = new MutationObserver(async (mutationsList) => { for (const mutation of mutationsList) { if (mutation.type === 'childList') { for (const el of mutation.addedNodes) { if (el.classList?.contains(appendToTargetClass)) { // eslint-disable-next-line no-await-in-loop -- addViewer should ideally only happen once await addViewer(el); } } } else if (mutation.type === 'attributes') { if (!mutation.oldValue?.match(new RegExp(`(?:^|\\s)${escapeRegExpOutsideCharacterClass(appendToTargetClass)}(?:\\s|$)`, 'u')) && mutation.target.classList?.contains(appendToTargetClass)) { // eslint-disable-next-line no-await-in-loop -- addViewer should ideally only happen once await addViewer(mutation.target); } } } }); observer.observe(document.body, { subtree: true, childList: true, attributeFilter: ['class'], attributeOldValue: true, }); await addViewer(document.getElementsByClassName(appendToTargetClass)[0]); })();