LinuxSB Comment Tree

为 linux.sb 帖子页提供评论树/平铺切换、跨页合并、目标楼层定位与实时更新。

คุณจะต้องติดตั้งส่วนขยาย เช่น Tampermonkey, Greasemonkey หรือ Violentmonkey เพื่อติดตั้งสคริปต์นี้

You will need to install an extension such as Tampermonkey or Violentmonkey to install this script.

คุณจะต้องติดตั้งส่วนขยาย เช่น Tampermonkey หรือ Violentmonkey เพื่อติดตั้งสคริปต์นี้

You will need to install an extension such as Tampermonkey or Userscripts to install this script.

You will need to install an extension such as Tampermonkey to install this script.

You will need to install a user script manager extension to install this script.

(I already have a user script manager, let me install it!)

You will need to install an extension such as Stylus to install this style.

You will need to install an extension such as Stylus to install this style.

You will need to install an extension such as Stylus to install this style.

You will need to install a user style manager extension to install this style.

You will need to install a user style manager extension to install this style.

You will need to install a user style manager extension to install this style.

(I already have a user style manager, let me install it!)

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