Greasy Fork is available in English.
为 linux.sb 帖子页提供评论树/平铺切换、跨页合并、目标楼层定位与实时更新。
// ==UserScript==
// @name LinuxSB Comment Tree
// @namespace https://linux.sb/
// @version 1.3.0
// @description 为 linux.sb 帖子页提供评论树/平铺切换、跨页合并、目标楼层定位与实时更新。
// @author ROYWANG(sb.sb) · 二次适配(linux.sb):Evan
// @match *://linux.sb/topic/*
// @run-at document-idle
// @grant none
// @noframes
// @license MIT
// ==/UserScript==
(function () {
'use strict';
// 本脚本仅用于 linux.sb 的 /topic/<主题ID> 页面。
const STORE_KEY = 'linuxsb-comment-tree-mode-v3';
const STYLE_ID = 'linuxsb-comment-tree-style';
const TOPIC_RE = /^\/topic\/(\d+)\/?$/;
const PAGE_PARAM = 'p';
const DEBOUNCE_MS = 320;
const ITEM_SELECTOR = '.post-item.post-entry';
const CLONE_CLASS = 'lsct-split';
const CHILDREN_CLASS = 'lsct-children';
let mode = 'tree';
let observer = null;
let observeTarget = null;
let debounceTimer = null;
let lastSignature = '';
let loadingPages = false;
let didInitialScroll = false;
// 多重引用时保存原正文节点;切回平铺时原样恢复,避免丢失站点已有监听器与内容状态。
const savedContents = new Map();
try { mode = localStorage.getItem(STORE_KEY) || 'tree'; } catch (_) { /* 隐私模式 */ }
const $ = (selector, root = document) => root.querySelector(selector);
const $$ = (selector, root = document) => Array.from(root.querySelectorAll(selector));
function getTopicId() {
const match = location.pathname.match(TOPIC_RE);
return match ? match[1] : null;
}
function getList(root = document) {
return $('.topic-post-list.post-list, .topic-post-list', root);
}
function isClone(el) {
return el.classList.contains(CLONE_CLASS);
}
// linux.sb 的楼层元素为 .post-floor;同时兼容主题模板中可能存在的数据属性。
function getFloor(el) {
const candidates = [
el.dataset.replyNo,
el.dataset.floor,
el.dataset.replyId,
el.getAttribute('data-reply-no'),
el.getAttribute('data-floor'),
el.getAttribute('data-reply-id')
];
for (const value of candidates) {
const n = parseInt(value, 10);
if (Number.isFinite(n) && n > 0) return n;
}
const floorEl = $('.post-floor, [data-post-floor]', el);
if (!floorEl) return null;
const match = (floorEl.dataset.floor || floorEl.dataset.replyNo || floorEl.textContent || '').match(/(?:#|楼|回复\s*)?(\d+)/);
const n = match ? parseInt(match[1], 10) : NaN;
return Number.isFinite(n) && n > 0 ? n : null;
}
// 帖子实体 ID 并不一定等同于楼层号,保留它可解析 #post-<id> 形式的引用锚点。
function getPostId(el) {
const direct = el.dataset.postId || el.dataset.replyId || el.getAttribute('data-post-id');
if (direct && /^\d+$/.test(direct)) return direct;
const match = (el.id || '').match(/^post-(\d+)$/);
return match ? match[1] : null;
}
function allOriginalEntries(list) {
return $$(ITEM_SELECTOR, list).filter(el => !isClone(el));
}
function directOriginalEntries(list) {
return Array.from(list.children).filter(el => el.matches?.(ITEM_SELECTOR) && !isClone(el));
}
function replyEntries(list) {
return allOriginalEntries(list).filter(el => getFloor(el) !== null);
}
function buildNodeIndex(items) {
const byFloor = new Map();
const byPostId = new Map();
const nodes = items.map(li => {
const node = {
li,
floor: getFloor(li),
postId: getPostId(li),
parents: [],
children: [],
refs: [],
mounted: false,
segments: null
};
if (node.floor !== null) byFloor.set(String(node.floor), node);
if (node.postId) byPostId.set(String(node.postId), node);
return node;
});
return { nodes, byFloor, byPostId };
}
// 读取一条评论内容中的有效引用。只接受当前主题、前序楼层或已知帖子锚点。
// 站点若以 ?reply=N、?floor=N、#reply-N、#floor-N 或 #post-ID 生成引用,均可识别。
function parseReferenceLinks(li, topicId, ownFloor, byFloor, byPostId) {
const content = $('.post-content', li);
if (!content) return [];
const result = [];
const seen = new Set();
const anchors = $$('a[href], [data-reply-no], [data-floor]', content);
for (const anchor of anchors) {
let target = null;
const attrFloor = anchor.dataset.replyNo || anchor.dataset.floor || anchor.getAttribute('data-reply-no') || anchor.getAttribute('data-floor');
if (attrFloor && /^\d+$/.test(attrFloor)) target = byFloor.get(String(parseInt(attrFloor, 10))) || null;
const href = anchor.getAttribute('href');
if (!target && href) {
let url;
try { url = new URL(href, location.origin); } catch (_) { url = null; }
if (url) {
const sameTopic = url.pathname.match(TOPIC_RE)?.[1] === String(topicId);
const replyNo = url.searchParams.get('reply') || url.searchParams.get('floor') || url.searchParams.get('reply_no');
if (sameTopic && replyNo && /^\d+$/.test(replyNo)) {
target = byFloor.get(String(parseInt(replyNo, 10))) || null;
}
if (!target) {
const hash = url.hash.match(/^#(?:reply|floor)-(\d+)$/i);
if (hash) target = byFloor.get(hash[1]) || null;
}
if (!target) {
const postHash = url.hash.match(/^#post-(\d+)$/i);
if (postHash) target = byPostId.get(postHash[1]) || null;
}
}
}
if (!target || target.li === li) continue;
// 楼层引用只挂到更早的楼层;帖子 ID 锚点则依赖 DOM 中的既有条目。
if (target.floor !== null && ownFloor !== null && target.floor >= ownFloor) continue;
if (seen.has(target)) continue;
seen.add(target);
result.push({ target, link: anchor.matches('a') ? anchor : null });
}
return result;
}
function blockOf(content, el) {
let block = el;
while (block.parentNode && block.parentNode !== content) block = block.parentNode;
return block;
}
function onlyWhitespaceBefore(el, content) {
let node = el;
while (node && node !== content) {
const parent = node.parentNode;
for (let sibling = parent.firstChild; sibling && sibling !== node; sibling = sibling.nextSibling) {
if (sibling.nodeType === Node.TEXT_NODE) {
if (sibling.nodeValue.trim()) return false;
} else {
return false;
}
}
node = parent;
}
return true;
}
// 只有每个父引用均对应可定位的链接时,才拆分正文,避免因不完整引用破坏原文。
function buildSegments(content, refs) {
if (refs.length < 2 || refs.some(ref => !ref.link)) return null;
const segments = [];
for (let i = 0; i < refs.length; i++) {
const range = document.createRange();
const link = refs[i].link;
if (i === 0) range.setStart(content, 0);
else range.setStartBefore(link);
if (i + 1 < refs.length) {
const next = refs[i + 1].link;
if (onlyWhitespaceBefore(next, content)) range.setEndBefore(blockOf(content, next));
else range.setEndBefore(next);
} else {
range.setEnd(content, content.childNodes.length);
}
segments.push(range.cloneContents());
}
return segments;
}
function restoreFlat(list) {
// 先恢复原正文。Map 中保存的是原生节点,因此站点绑定在节点上的监听器不会丢失。
for (const [li, children] of savedContents) {
const content = $('.post-content', li);
if (!content) continue;
content.replaceChildren(...children);
}
savedContents.clear();
// 在移除子树之前取得全部原条目,防止嵌套回复随着包装层一起被丢弃。
const originals = allOriginalEntries(list);
$$('.' + CLONE_CLASS, list).forEach(el => el.remove());
$$('.' + CHILDREN_CLASS, list).forEach(el => el.remove());
originals.sort((a, b) => (getFloor(a) || 0) - (getFloor(b) || 0));
for (const entry of originals) list.appendChild(entry);
list.classList.remove('lsct-tree');
}
function renderTree(list, topicId) {
// renderTree 总是在 restoreFlat 后调用,只使用列表的直接子项,确保每次均从服务器顺序重建。
const entries = directOriginalEntries(list).filter(el => getFloor(el) !== null);
if (!entries.length) return;
const { nodes, byFloor, byPostId } = buildNodeIndex(entries);
for (const node of nodes) {
const refs = parseReferenceLinks(node.li, topicId, node.floor, byFloor, byPostId);
for (const ref of refs) {
if (node.parents.includes(ref.target)) continue;
node.parents.push(ref.target);
node.refs.push(ref);
ref.target.children.push(node);
}
}
for (const node of nodes) {
if (node.parents.length <= 1) continue;
const content = $('.post-content', node.li);
const segments = content ? buildSegments(content, node.refs) : null;
if (!segments) continue;
node.segments = segments;
savedContents.set(node.li, Array.from(content.childNodes));
}
const mount = (node, container, depth, parentNode) => {
let element = node.li;
if (node.mounted) {
element = node.li.cloneNode(true);
element.removeAttribute('id');
element.classList.add(CLONE_CLASS);
$$(`.${CHILDREN_CLASS}`, element).forEach(child => child.remove());
} else {
node.mounted = true;
}
if (node.segments) {
const index = node.parents.indexOf(parentNode);
const segment = index >= 0 ? node.segments[index] : null;
const content = $('.post-content', element);
if (content && segment) content.replaceChildren(segment.cloneNode(true));
}
container.appendChild(element);
if (!node.children.length) return;
const sublist = document.createElement('ul');
sublist.className = `${CHILDREN_CLASS}${depth >= 5 ? ' lsct-deep' : ''}${depth >= 9 ? ' lsct-max' : ''}`;
element.appendChild(sublist);
for (const child of node.children) mount(child, sublist, depth + 1, node);
};
for (const root of nodes.filter(node => node.parents.length === 0)) mount(root, list, 0, null);
list.classList.add('lsct-tree');
}
function getFirstReply(list) {
const replies = replyEntries(list);
return replies.find(entry => getFloor(entry) === 1) || replies[0] || null;
}
function updateToggleUI() {
$$('.lsct-toggle button').forEach(button => button.classList.toggle('is-active', button.dataset.mode === mode));
}
function showStatus(text) {
const toggle = $('.lsct-toggle');
if (!toggle) return null;
let status = $('.lsct-status', toggle);
if (!status) {
status = document.createElement('span');
status.className = 'lsct-status';
toggle.appendChild(status);
}
status.textContent = text;
return status;
}
function buildToggle(list) {
const firstReply = getFirstReply(list);
// 首选挂在 #1 的 .post-ops 中,位于原有操作按钮的左侧;模板缺少操作区时再退回到该回复本身。
const host = firstReply ? ($('.post-ops', firstReply) || firstReply) : null;
if (!host || $('.lsct-toggle', host)) return;
// 避免站点局部重绘后遗留的旧控件出现在非 #1 位置。
$$('.lsct-toggle').forEach(toggle => toggle.remove());
const wrap = document.createElement('div');
wrap.className = 'lsct-toggle';
wrap.title = '评论视图';
wrap.dataset.lsctOwned = '1';
for (const [value, label] of [['tree', '树形'], ['flat', '平铺']]) {
const button = document.createElement('button');
button.type = 'button';
button.dataset.mode = value;
button.textContent = label;
button.addEventListener('click', () => setMode(value));
wrap.appendChild(button);
}
host.prepend(wrap);
updateToggleUI();
}
function pauseObserver() {
if (observer) observer.disconnect();
}
function resumeObserver() {
if (observer && observeTarget) observer.observe(observeTarget, { childList: true, subtree: true });
}
function signature(list = getList()) {
if (!list) return '';
const direct = directOriginalEntries(list).length;
const floors = replyEntries(list).map(getFloor).filter(n => n !== null).sort((a, b) => a - b);
return `${direct}|${floors.join(',')}`;
}
function applyMode() {
const list = getList();
const topicId = getTopicId();
if (!list || !topicId || !replyEntries(list).length) return;
pauseObserver();
restoreFlat(list);
if (mode === 'tree') renderTree(list, topicId);
updateToggleUI();
lastSignature = signature(list);
resumeObserver();
}
function setMode(nextMode) {
if (mode === nextMode) return;
mode = nextMode;
try { localStorage.setItem(STORE_KEY, mode); } catch (_) { /* 隐私模式 */ }
applyMode();
}
function pageLinksFrom(container, out, topicId) {
if (!container || !topicId) return;
for (const anchor of $$('a[href]', container)) {
let url;
try { url = new URL(anchor.getAttribute('href'), location.origin); } catch (_) { continue; }
if (url.pathname.match(TOPIC_RE)?.[1] !== String(topicId)) continue;
const page = parseInt(url.searchParams.get(PAGE_PARAM) || '1', 10);
if (Number.isFinite(page) && page > 0) out.set(url.href, page);
}
}
async function loadAllPages(list) {
if (loadingPages) return;
const topicId = getTopicId();
if (!topicId) return;
const queue = new Map();
pageLinksFrom($('.pagination-bar', list.parentElement || document), queue, topicId);
if (!queue.size) {
scrollToReply();
return;
}
loadingPages = true;
pauseObserver();
const status = showStatus('正在加载分页…');
const knownFloors = new Set(replyEntries(list).map(getFloor).filter(n => n !== null).map(String));
const visited = new Set();
let merged = 0;
let failed = 0;
while (queue.size) {
const [url] = Array.from(queue.entries()).sort((a, b) => a[1] - b[1])[0];
queue.delete(url);
if (visited.has(url)) continue;
visited.add(url);
try {
const response = await fetch(url, { credentials: 'same-origin' });
if (!response.ok) {
failed++;
continue;
}
const doc = new DOMParser().parseFromString(await response.text(), 'text/html');
pageLinksFrom($('.pagination-bar', doc), queue, topicId);
const remoteList = getList(doc);
if (!remoteList) continue;
for (const entry of directOriginalEntries(remoteList)) {
const floor = getFloor(entry);
if (floor === null || knownFloors.has(String(floor))) continue;
knownFloors.add(String(floor));
list.appendChild(document.adoptNode(entry));
merged++;
}
} catch (_) {
failed++;
}
}
const bar = $('.pagination-bar', list.parentElement || document);
if (merged && bar) bar.remove();
if (status) {
if (merged) status.textContent = `已合并 ${visited.size} 页`;
else if (failed) status.textContent = '分页加载未完成';
else status.remove();
}
loadingPages = false;
applyMode();
scrollToReply();
resumeObserver();
}
function scrollToReply() {
if (didInitialScroll || mode !== 'tree') return;
didInitialScroll = true;
const search = new URLSearchParams(location.search);
const requested = parseInt(search.get('reply') || search.get('floor') || '', 10);
if (!Number.isFinite(requested) || requested < 1) return;
const list = getList();
const target = list ? replyEntries(list).find(el => getFloor(el) === requested) : null;
if (!target) return;
setTimeout(() => {
target.scrollIntoView({ block: 'start', behavior: 'smooth' });
target.classList.add('lsct-target');
setTimeout(() => target.classList.remove('lsct-target'), 2500);
}, 60);
}
function onMutations() {
debounceTimer = null;
if (loadingPages) return;
const list = getList();
if (!list || !replyEntries(list).length) return;
buildToggle(list);
const current = signature(list);
if (current !== lastSignature) applyMode();
}
function injectStyle() {
if (document.getElementById(STYLE_ID)) return;
const style = document.createElement('style');
style.id = STYLE_ID;
style.textContent = `
/* 挂在 #1 的操作区内,置于回复、点赞、编辑与楼层号的左侧。 */
.post-ops > .lsct-toggle {
display: inline-flex;
align-items: center;
gap: 4px;
margin-right: 8px;
vertical-align: middle;
}
.post-entry > .lsct-toggle {
position: absolute;
top: 0;
right: 92px;
display: inline-flex;
align-items: center;
gap: 4px;
}
/* 轻量文字控件:更小、更精致,并始终保持无填充背景。 */
.lsct-toggle button {
appearance: none;
min-height: 18px;
padding: 0 2px;
border: 0;
border-radius: 0;
background: transparent;
color: var(--text-subtle, #7a8290);
font: 500 11px/18px var(--font-sans, inherit);
letter-spacing: .02em;
cursor: pointer;
transition: color .14s ease, opacity .14s ease;
}
.lsct-toggle button:hover { background: transparent; color: var(--brand, #516185); }
.lsct-toggle button.is-active {
background: transparent;
color: var(--brand, #516185);
font-weight: 650;
}
.lsct-toggle button + button::before {
content: '·';
display: inline-block;
margin-right: 4px;
color: var(--line-strong, #c7ccd5);
font-weight: 400;
}
.lsct-status { display: none; }
.topic-post-list.lsct-tree .post-entry { position: relative; }
.topic-post-list.lsct-tree > .post-entry:first-child { margin-bottom: 0; }
/* 每条子评论只保留自己的 └ 形引导线;不再使用贯穿整组评论的竖线。 */
.lsct-children {
grid-column: 1 / -1;
width: auto;
list-style: none;
margin: 8px 0 0 22px;
padding: 0 0 0 14px;
}
.lsct-children > .post-entry { margin-top: 8px; }
.lsct-children.lsct-deep { margin-left: 12px; padding-left: 10px; }
.lsct-children.lsct-max { margin-left: 4px; padding-left: 6px; }
.lsct-children > .post-entry::before {
content: '';
position: absolute;
top: 0;
left: -15px;
width: 15px;
height: 31px;
box-sizing: border-box;
border-left: 2px solid var(--line-soft, #edf0f4);
border-bottom: 2px solid var(--line-soft, #edf0f4);
border-radius: 0;
pointer-events: none;
}
.lsct-children.lsct-max > .post-entry::before { display: none; }
.post-entry.lsct-target { animation: lsct-flash 2.4s ease-out; }
@keyframes lsct-flash {
0%, 54% { background: color-mix(in srgb, var(--brand-soft, #e1eaff) 88%, transparent); }
100% { background: transparent; }
}
@media (max-width: 720px) {
.post-ops > .lsct-toggle { margin-right: 5px; gap: 3px; }
.post-entry > .lsct-toggle { right: 78px; }
.lsct-children { margin-left: 10px; padding-left: 9px; }
.lsct-children.lsct-deep { margin-left: 6px; padding-left: 7px; }
.lsct-children > .post-entry::before { left: -10px; width: 10px; }
.lsct-status { display: none; }
}
`;
document.head.appendChild(style);
}
function init() {
if (!getTopicId()) return;
const list = getList();
// 未登录“评论登录可见”和零回复主题均没有可重排楼层,保持静默,不干扰原页面。
if (!list || !replyEntries(list).length) return;
injectStyle();
buildToggle(list);
applyMode();
observeTarget = list.closest('.main-panel') || list.parentElement || list;
observer = new MutationObserver(() => {
if (debounceTimer) clearTimeout(debounceTimer);
debounceTimer = setTimeout(onMutations, DEBOUNCE_MS);
});
resumeObserver();
loadAllPages(list);
}
if (document.readyState === 'loading') {
document.addEventListener('DOMContentLoaded', init, { once: true });
} else {
init();
}
})();