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와 같은 확장 프로그램이 필요합니다.

이 스크립트를 설치하려면 Tampermonkey 또는 Userscripts와 같은 확장 프로그램이 필요합니다.

이 스크립트를 설치하려면 Tampermonkey와 같은 확장 프로그램이 필요합니다.

이 스크립트를 설치하려면 유저 스크립트 관리자 확장 프로그램이 필요합니다.

(이미 유저 스크립트 관리자가 설치되어 있습니다. 설치를 진행합니다!)

이 스타일을 설치하려면 Stylus와 같은 확장 프로그램이 필요합니다.

이 스타일을 설치하려면 Stylus와 같은 확장 프로그램이 필요합니다.

이 스타일을 설치하려면 Stylus와 같은 확장 프로그램이 필요합니다.

이 스타일을 설치하려면 유저 스타일 관리자 확장 프로그램이 필요합니다.

이 스타일을 설치하려면 유저 스타일 관리자 확장 프로그램이 필요합니다.

이 스타일을 설치하려면 유저 스타일 관리자 확장 프로그램이 필요합니다.

(이미 유저 스타일 관리자가 설치되어 있습니다. 설치를 진행합니다!)

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