Linux.do Discourse Hidden Posts Analyzer

分析 linux.do 被举报隐藏帖子,面板内查看楼层、人员、图表并支持导出 Excel

您需要先安装一款用户脚本管理器扩展,例如 Tampermonkey 篡改猴Greasemonkey 油猴子Violentmonkey 暴力猴,才能安装此脚本。

您需要先安装一款用户脚本管理器扩展,例如 Tampermonkey 篡改猴,才能安装此脚本。

您需要先安装一款用户脚本管理器扩展,例如 Tampermonkey 篡改猴Violentmonkey 暴力猴,才能安装此脚本。

您需要先安装一款用户脚本管理器扩展,例如 Tampermonkey 篡改猴Userscripts ,才能安装此脚本。

您需要先安装一款用户脚本管理器扩展,例如 Tampermonkey 篡改猴,才能安装此脚本。

您需要先安装一款用户脚本管理器扩展后才能安装此脚本。

(我已经安装了用户脚本管理器,让我安装!)

Advertisement:

您需要先安装一款用户样式管理器扩展,比如 Stylus,才能安装此样式。

您需要先安装一款用户样式管理器扩展,比如 Stylus,才能安装此样式。

您需要先安装一款用户样式管理器扩展,比如 Stylus,才能安装此样式。

您需要先安装一款用户样式管理器扩展后才能安装此样式。

您需要先安装一款用户样式管理器扩展后才能安装此样式。

您需要先安装一款用户样式管理器扩展后才能安装此样式。

(我已经安装了用户样式管理器,让我安装!)

Advertisement:

// ==UserScript==
// @name         Linux.do Discourse Hidden Posts Analyzer
// @namespace    http://tampermonkey.net/
// @version      1.1
// @license      MIT
// @description  分析 linux.do 被举报隐藏帖子,面板内查看楼层、人员、图表并支持导出 Excel
// @match        https://linux.do/t/*
// @grant        GM_addStyle
// @grant        GM_registerMenuCommand
// @grant        GM_getValue
// @grant        GM_setValue
// @require      https://code.jquery.com/jquery-3.7.1.min.js
// @require      https://cdn.jsdelivr.net/npm/echarts@5/dist/echarts.min.js
// @require      https://cdn.jsdelivr.net/npm/[email protected]/dist/xlsx.full.min.js
// ==/UserScript==

(function () {
  'use strict';

  const PAGE_SIZE = 20;
  const MAX_CONCURRENCY = 20;
  const HIDDEN_NOTICE = '此帖子已被社区举报,现已被临时隐藏';
  const EMPTY_TEXT = '暂无可展示数据';
  const PERSIST_VERSION = 1;
  const STORAGE_PREFIX = 'linuxdo-hidden-posts-analyzer';

  const state = {
    activeTab: 'overview',
    charts: {
      pages: null,
      users: null
    },
    errors: [],
    hiddenPosts: [],
    isAnalyzing: false,
    keyword: '',
    loadedPages: 0,
    pageStats: {},
    persistedAt: '',
    selectedUser: '',
    topicMeta: null,
    totalPages: 0,
    totalPosts: 0,
    userStats: {}
  };

  addStyles();
  registerMenuCommands();
  const restoredFromCache = restorePersistedData();
  createPanel();
  renderAll();

  if (restoredFromCache) {
    setStatus(`已加载缓存 ${formatCacheTime(state.persistedAt)}`);
  }

  /***********************
   * 样式
   ***********************/
  function addStyles() {
    const styles = `
      #ld-panel,
      #ld-panel * {
        box-sizing: border-box;
      }

      #ld-panel {
        position: fixed;
        top: 18px;
        right: 18px;
        z-index: 99999;
        width: min(620px, calc(100vw - 36px));
        max-height: calc(100vh - 36px);
        overflow: hidden;
        color: #17212b;
        background: #fbfcfd;
        border: 1px solid #dfe5eb;
        border-radius: 8px;
        box-shadow: 0 18px 50px rgba(20, 34, 48, 0.22);
        font: 13px/1.45 -apple-system, BlinkMacSystemFont, "Segoe UI", "PingFang SC", "Microsoft YaHei", sans-serif;
      }

      #ld-panel a {
        color: inherit;
        text-decoration: none;
      }

      .ld-header {
        display: flex;
        align-items: flex-start;
        justify-content: space-between;
        gap: 12px;
        padding: 14px 14px 12px;
        background: #fff;
        border-bottom: 1px solid #e5ebf0;
        cursor: move;
        touch-action: none;
        user-select: none;
      }

      #ld-panel.ld-dragging {
        transition: none;
      }

      #ld-panel.ld-dragging,
      #ld-panel.ld-dragging * {
        user-select: none;
      }

      .ld-kicker {
        color: #667481;
        font-size: 11px;
        letter-spacing: 0.04em;
        text-transform: uppercase;
      }

      .ld-header h2 {
        margin: 2px 0 0;
        color: #17212b;
        font-size: 18px;
        font-weight: 720;
        letter-spacing: 0;
      }

      .ld-window-actions {
        display: flex;
        flex: 0 0 auto;
        gap: 6px;
        cursor: default;
      }

      .ld-icon-btn {
        display: inline-flex;
        width: 28px;
        height: 28px;
        align-items: center;
        justify-content: center;
        color: #465664;
        background: #f4f7f9;
        border: 1px solid #dfe6ec;
        border-radius: 6px;
        cursor: pointer;
      }

      .ld-icon-btn:hover {
        background: #eaf0f4;
      }

      #ld-body {
        max-height: calc(100vh - 96px);
        overflow: auto;
        padding: 12px 14px 14px;
      }

      #ld-panel.ld-collapsed #ld-body {
        display: none;
      }

      #ld-body,
      .ld-list {
        scrollbar-color: #aab8c4 transparent;
        scrollbar-width: thin;
      }

      #ld-body::-webkit-scrollbar,
      .ld-list::-webkit-scrollbar {
        width: 8px;
        height: 8px;
      }

      #ld-body::-webkit-scrollbar-thumb,
      .ld-list::-webkit-scrollbar-thumb {
        background: #aab8c4;
        border-radius: 999px;
      }

      .ld-actions,
      .ld-filter {
        display: grid;
        grid-template-columns: 1fr 1fr;
        gap: 8px;
      }

      .ld-btn {
        min-height: 34px;
        padding: 0 12px;
        border: 1px solid #cfd8df;
        border-radius: 6px;
        cursor: pointer;
        font-weight: 650;
      }

      .ld-btn:disabled {
        cursor: not-allowed;
        opacity: 0.54;
      }

      .ld-btn-primary {
        color: #fff;
        background: #1f7a62;
        border-color: #1f7a62;
      }

      .ld-btn-primary:hover:not(:disabled) {
        background: #17664f;
      }

      .ld-btn-plain {
        color: #243443;
        background: #fff;
      }

      .ld-btn-plain:hover:not(:disabled) {
        background: #f2f6f8;
      }

      .ld-statusbar {
        display: flex;
        align-items: center;
        justify-content: space-between;
        gap: 10px;
        margin-top: 10px;
        color: #627180;
      }

      .ld-status {
        display: inline-flex;
        align-items: center;
        min-height: 24px;
        padding: 0 9px;
        color: #17664f;
        background: #e7f4ef;
        border: 1px solid #b8ddd0;
        border-radius: 999px;
        font-weight: 650;
      }

      .ld-status.ld-status-error {
        color: #9a3412;
        background: #fff1e8;
        border-color: #ffc7a3;
      }

      .ld-topic {
        min-width: 0;
        overflow: hidden;
        text-overflow: ellipsis;
        white-space: nowrap;
      }

      .ld-progress {
        height: 6px;
        margin-top: 10px;
        overflow: hidden;
        background: #ecf1f4;
        border-radius: 999px;
      }

      #ld-progress-bar {
        width: 0%;
        height: 100%;
        background: linear-gradient(90deg, #1f7a62, #3867d6);
        transition: width 180ms ease;
      }

      .ld-loading {
        display: none;
        grid-template-columns: auto 1fr auto;
        align-items: center;
        gap: 10px;
        margin-top: 10px;
        padding: 10px;
        color: #1f3f36;
        background: #f0faf6;
        border: 1px solid #c9e7dc;
        border-radius: 8px;
      }

      #ld-panel.ld-is-loading .ld-loading {
        display: grid;
      }

      .ld-spinner {
        width: 18px;
        height: 18px;
        border: 2px solid #b8ddd0;
        border-top-color: #1f7a62;
        border-radius: 50%;
        animation: ld-spin 780ms linear infinite;
      }

      .ld-loading-title {
        font-weight: 720;
      }

      #ld-progress-label,
      #ld-progress-percent {
        color: #5e716a;
        font-size: 12px;
        font-variant-numeric: tabular-nums;
      }

      @keyframes ld-spin {
        to {
          transform: rotate(360deg);
        }
      }

      .ld-summary {
        display: grid;
        grid-template-columns: repeat(4, minmax(0, 1fr));
        gap: 8px;
        margin-top: 12px;
      }

      .ld-topic-meta {
        margin-top: 10px;
        padding: 10px;
        background: #fff;
        border: 1px solid #e2e8ee;
        border-radius: 8px;
      }

      .ld-topic-meta-title {
        color: #17212b;
        font-size: 14px;
        font-weight: 760;
        overflow-wrap: anywhere;
      }

      .ld-topic-meta-title a {
        color: #17212b;
      }

      .ld-topic-meta-grid {
        display: grid;
        grid-template-columns: repeat(3, minmax(0, 1fr));
        gap: 8px;
        margin-top: 8px;
      }

      .ld-topic-meta-item {
        min-width: 0;
      }

      .ld-topic-meta-label {
        color: #657380;
        font-size: 11px;
      }

      .ld-topic-meta-value {
        margin-top: 2px;
        color: #253544;
        font-weight: 680;
        overflow: hidden;
        text-overflow: ellipsis;
        white-space: nowrap;
      }

      .ld-metric {
        min-width: 0;
        padding: 9px 10px;
        background: #fff;
        border: 1px solid #e2e8ee;
        border-radius: 8px;
      }

      .ld-metric-label {
        color: #657380;
        font-size: 11px;
      }

      .ld-metric-value {
        margin-top: 2px;
        color: #111a22;
        font-size: 18px;
        font-weight: 760;
      }

      .ld-tabs {
        display: grid;
        grid-template-columns: repeat(3, minmax(0, 1fr));
        gap: 6px;
        margin-top: 12px;
        padding: 4px;
        background: #edf2f5;
        border-radius: 8px;
      }

      .ld-tab {
        min-height: 30px;
        color: #536271;
        background: transparent;
        border: 0;
        border-radius: 6px;
        cursor: pointer;
        font-weight: 650;
      }

      .ld-tab.ld-active {
        color: #15202a;
        background: #fff;
        box-shadow: 0 1px 2px rgba(15, 23, 42, 0.08);
      }

      .ld-filter {
        grid-template-columns: 1fr auto;
        margin-top: 10px;
      }

      #ld-search {
        width: 100%;
        min-height: 34px;
        padding: 0 10px;
        color: #17212b;
        background: #fff;
        border: 1px solid #d7e0e7;
        border-radius: 6px;
        outline: none;
      }

      #ld-search:focus {
        border-color: #1f7a62;
        box-shadow: 0 0 0 3px rgba(31, 122, 98, 0.12);
      }

      .ld-filter-state {
        min-height: 20px;
        margin-top: 8px;
        color: #647482;
      }

      .ld-filter-chip {
        display: inline-flex;
        align-items: center;
        gap: 6px;
        padding: 2px 8px;
        color: #174d42;
        background: #e7f4ef;
        border: 1px solid #b8ddd0;
        border-radius: 999px;
      }

      .ld-filter-chip button {
        padding: 0;
        color: inherit;
        background: transparent;
        border: 0;
        cursor: pointer;
      }

      .ld-view {
        display: none;
        margin-top: 12px;
      }

      .ld-view.ld-active {
        display: block;
      }

      .ld-chart-grid {
        display: grid;
        grid-template-columns: 1.25fr 0.9fr;
        gap: 10px;
      }

      .ld-section {
        padding: 10px;
        background: #fff;
        border: 1px solid #e2e8ee;
        border-radius: 8px;
      }

      .ld-section-title,
      .ld-list-head {
        display: flex;
        align-items: center;
        justify-content: space-between;
        gap: 8px;
        color: #22313f;
        font-weight: 720;
      }

      .ld-section-subtitle {
        color: #657380;
        font-size: 12px;
        font-weight: 500;
      }

      #chart-pages,
      #chart-users {
        width: 100%;
        height: 220px;
      }

      .ld-hot-users {
        margin-top: 10px;
      }

      .ld-list {
        display: grid;
        gap: 8px;
        max-height: max(180px, min(430px, calc(100vh - 360px)));
        min-height: 130px;
        margin-top: 8px;
        overflow: auto;
        padding-right: 2px;
      }

      .ld-row {
        display: grid;
        grid-template-columns: 1fr auto;
        gap: 10px;
        padding: 10px;
        background: #fff;
        border: 1px solid #e2e8ee;
        border-radius: 8px;
      }

      .ld-row-title {
        display: flex;
        flex-wrap: wrap;
        align-items: center;
        gap: 6px;
        color: #17212b;
        font-weight: 720;
        overflow-wrap: anywhere;
      }

      .ld-floor {
        color: #3867d6;
        font-variant-numeric: tabular-nums;
      }

      .ld-muted {
        color: #6a7987;
        font-weight: 500;
      }

      .ld-excerpt {
        display: -webkit-box;
        margin: 6px 0 0;
        overflow: hidden;
        color: #334454;
        overflow-wrap: anywhere;
        -webkit-box-orient: vertical;
        -webkit-line-clamp: 2;
      }

      .ld-meta {
        display: flex;
        flex-wrap: wrap;
        gap: 7px;
        margin-top: 7px;
        color: #6a7987;
        font-size: 12px;
      }

      .ld-pill {
        display: inline-flex;
        align-items: center;
        min-height: 20px;
        padding: 0 7px;
        background: #f1f5f8;
        border: 1px solid #dce5ec;
        border-radius: 999px;
        overflow-wrap: anywhere;
      }

      .ld-row-actions {
        display: flex;
        flex-direction: column;
        gap: 6px;
        min-width: 72px;
      }

      .ld-link {
        display: inline-flex;
        min-height: 28px;
        align-items: center;
        justify-content: center;
        padding: 0 9px;
        color: #174d42;
        background: #f3faf7;
        border: 1px solid #c9e7dc;
        border-radius: 6px;
        cursor: pointer;
        font-weight: 650;
      }

      .ld-link:hover {
        background: #e7f4ef;
      }

      .ld-user-row {
        grid-template-columns: auto 1fr auto;
        align-items: center;
      }

      .ld-avatar {
        display: inline-flex;
        width: 34px;
        height: 34px;
        flex: 0 0 auto;
        align-items: center;
        justify-content: center;
        overflow: hidden;
        color: #fff;
        background: #31485c;
        border-radius: 50%;
        font-weight: 760;
      }

      .ld-avatar img {
        width: 100%;
        height: 100%;
        object-fit: cover;
      }

      .ld-empty,
      .ld-alert {
        padding: 14px;
        color: #647482;
        background: #fff;
        border: 1px dashed #ccd8e2;
        border-radius: 8px;
        text-align: center;
      }

      .ld-alert {
        margin-top: 10px;
        color: #9a3412;
        background: #fff8f3;
        border-style: solid;
        border-color: #ffd2b5;
        text-align: left;
      }

      @media (max-width: 720px) {
        #ld-panel {
          top: 10px;
          right: 10px;
          left: 10px;
          width: auto;
        }

        .ld-summary,
        .ld-chart-grid {
          grid-template-columns: 1fr 1fr;
        }

        .ld-row {
          grid-template-columns: 1fr;
        }

        .ld-row-actions {
          flex-direction: row;
        }
      }
    `;

    if (typeof GM_addStyle === 'function') {
      GM_addStyle(styles);
      return;
    }

    $('<style>').text(styles).appendTo(document.head);
  }

  /***********************
   * UI 面板
   ***********************/
  function createPanel() {
    if (document.getElementById('ld-panel')) {
      return;
    }

    const panel = $(`
      <div id="ld-panel">
        <div class="ld-header">
          <div>
            <div class="ld-kicker">Linux.do Topic Audit</div>
            <h2>隐藏楼层分析器</h2>
          </div>
          <div class="ld-window-actions">
            <button id="ld-minimize" class="ld-icon-btn" type="button" title="收起或展开">-</button>
            <button id="ld-close" class="ld-icon-btn" type="button" title="关闭">×</button>
          </div>
        </div>

        <div id="ld-body">
          <div class="ld-actions">
            <button id="ld-run" class="ld-btn ld-btn-primary" type="button">开始分析</button>
            <button id="ld-export" class="ld-btn ld-btn-plain" type="button" disabled>导出 Excel</button>
          </div>

          <div class="ld-statusbar">
            <span id="ld-status" class="ld-status">待分析</span>
            <span id="ld-topic" class="ld-topic">${escapeHtml(topicTitle())}</span>
          </div>

          <div class="ld-progress">
            <div id="ld-progress-bar"></div>
          </div>

          <div id="ld-loading" class="ld-loading">
            <span class="ld-spinner"></span>
            <div>
              <div class="ld-loading-title">正在分析楼层</div>
              <div id="ld-progress-label">准备中</div>
            </div>
            <span id="ld-progress-percent">0%</span>
          </div>

          <div id="ld-topic-meta" class="ld-topic-meta"></div>

          <div id="ld-summary" class="ld-summary"></div>

          <div class="ld-tabs">
            <button id="ld-tab-overview" class="ld-tab ld-active" type="button" data-tab="overview">概览</button>
            <button id="ld-tab-posts" class="ld-tab" type="button" data-tab="posts">楼层</button>
            <button id="ld-tab-users" class="ld-tab" type="button" data-tab="users">人员</button>
          </div>

          <div class="ld-filter">
            <input id="ld-search" type="search" placeholder="搜索用户、楼层、隐藏原因或内容">
            <button id="ld-clear-filter" class="ld-btn ld-btn-plain" type="button">清除</button>
          </div>
          <div id="ld-filter-state" class="ld-filter-state"></div>

          <section id="ld-view-overview" class="ld-view ld-active">
            <div class="ld-chart-grid">
              <div class="ld-section">
                <div class="ld-section-title">
                  <span>页面隐藏分布</span>
                  <span class="ld-section-subtitle">按 API 页聚合</span>
                </div>
                <div id="chart-pages"></div>
              </div>

              <div class="ld-section">
                <div class="ld-section-title">
                  <span>人员命中 Top</span>
                  <span class="ld-section-subtitle">按隐藏楼层数</span>
                </div>
                <div id="chart-users"></div>
              </div>
            </div>

            <div id="ld-hot-users" class="ld-hot-users"></div>
          </section>

          <section id="ld-view-posts" class="ld-view">
            <div class="ld-list-head">
              <span>楼层明细</span>
              <span id="ld-post-count" class="ld-section-subtitle"></span>
            </div>
            <div id="ld-post-list" class="ld-list"></div>
          </section>

          <section id="ld-view-users" class="ld-view">
            <div class="ld-list-head">
              <span>人员统计</span>
              <span id="ld-user-count" class="ld-section-subtitle"></span>
            </div>
            <div id="ld-user-list" class="ld-list"></div>
          </section>
        </div>
      </div>
    `);

    $('body').append(panel);
    restorePanelPosition();
    initDragPanel();

    $('#ld-close').on('click', hidePanel);
    $('#ld-minimize').on('click', () => $('#ld-panel').toggleClass('ld-collapsed'));
    $('#ld-run').on('click', analyze);
    $('#ld-export').on('click', exportExcel);

    $('.ld-tab').on('click', function () {
      setActiveTab($(this).data('tab'));
    });

    $('#ld-search').on('input', function () {
      state.keyword = String($(this).val() || '').trim();
      renderLists();
    });

    $('#ld-clear-filter').on('click', () => {
      state.keyword = '';
      state.selectedUser = '';
      $('#ld-search').val('');
      renderLists();
    });

    $('#ld-user-list').on('click', '.ld-user-filter', function () {
      state.selectedUser = String($(this).data('user') || '');
      setActiveTab('posts');
      renderLists();
    });

    $('#ld-filter-state').on('click', '.ld-clear-user-filter', () => {
      state.selectedUser = '';
      renderLists();
    });
  }

  function registerMenuCommands() {
    if (typeof GM_registerMenuCommand !== 'function') {
      return;
    }

    GM_registerMenuCommand('显示/隐藏隐藏楼层分析器', togglePanel);
  }

  function showPanel() {
    if (!document.getElementById('ld-panel')) {
      createPanel();
      renderAll();
    } else {
      restorePanelPosition();
    }

    $('#ld-panel').show();
    resizeCharts();
  }

  function hidePanel() {
    $('#ld-panel').hide();
  }

  function togglePanel() {
    const panel = document.getElementById('ld-panel');

    if (!panel || panel.style.display === 'none') {
      showPanel();
      return;
    }

    hidePanel();
  }

  function initDragPanel() {
    const panel = document.getElementById('ld-panel');
    const header = panel && panel.querySelector('.ld-header');

    if (!panel || !header) {
      return;
    }

    let dragState = null;

    header.addEventListener('pointerdown', (event) => {
      const target = event.target;
      const isWindowAction = target && target.closest && target.closest('.ld-window-actions');

      if (event.button !== 0 || isWindowAction) {
        return;
      }

      const rect = panel.getBoundingClientRect();
      dragState = {
        offsetX: event.clientX - rect.left,
        offsetY: event.clientY - rect.top
      };

      panel.classList.add('ld-dragging');
      header.setPointerCapture(event.pointerId);
      event.preventDefault();
    });

    header.addEventListener('pointermove', (event) => {
      if (!dragState) {
        return;
      }

      const position = clampPanelPosition(
        event.clientX - dragState.offsetX,
        event.clientY - dragState.offsetY
      );

      applyPanelPosition(position);
    });

    header.addEventListener('pointerup', (event) => {
      if (!dragState) {
        return;
      }

      dragState = null;
      panel.classList.remove('ld-dragging');

      if (header.hasPointerCapture(event.pointerId)) {
        header.releasePointerCapture(event.pointerId);
      }

      savePanelPosition();
    });

    header.addEventListener('pointercancel', () => {
      dragState = null;
      panel.classList.remove('ld-dragging');
      savePanelPosition();
    });
  }

  function restorePanelPosition() {
    const raw = readStoredValue(panelPositionKey(), '');

    if (!raw) {
      return;
    }

    try {
      const position = typeof raw === 'string' ? JSON.parse(raw) : raw;
      applyPanelPosition(clampPanelPosition(Number(position.left), Number(position.top)));
    } catch (error) {
      console.warn('Linux.do hidden posts panel position restore failed:', error);
    }
  }

  function savePanelPosition() {
    const panel = document.getElementById('ld-panel');

    if (!panel) {
      return;
    }

    const rect = panel.getBoundingClientRect();
    writeStoredValue(panelPositionKey(), JSON.stringify({
      left: Math.round(rect.left),
      top: Math.round(rect.top)
    }));
  }

  function applyPanelPosition(position) {
    const panel = document.getElementById('ld-panel');

    if (!panel) {
      return;
    }

    panel.style.left = `${position.left}px`;
    panel.style.top = `${position.top}px`;
    panel.style.right = 'auto';
  }

  function clampPanelPosition(left, top) {
    const panel = document.getElementById('ld-panel');
    const rect = panel ? panel.getBoundingClientRect() : { width: 620, height: 420 };
    const margin = 8;
    const maxLeft = Math.max(margin, window.innerWidth - rect.width - margin);
    const maxTop = Math.max(margin, window.innerHeight - Math.min(rect.height, window.innerHeight - margin * 2) - margin);

    return {
      left: Math.min(Math.max(Number.isFinite(left) ? left : margin, margin), maxLeft),
      top: Math.min(Math.max(Number.isFinite(top) ? top : margin, margin), maxTop)
    };
  }

  function setActiveTab(tab) {
    state.activeTab = tab;
    $('.ld-tab').removeClass('ld-active');
    $(`.ld-tab[data-tab="${tab}"]`).addClass('ld-active');
    $('.ld-view').removeClass('ld-active');
    $(`#ld-view-${tab}`).addClass('ld-active');
    resizeCharts();
  }

  /***********************
   * 数据请求与分析
   ***********************/
  function topicId() {
    const parts = location.pathname.split('/').filter(Boolean);
    const topicIndex = parts.indexOf('t');
    const idCandidate = topicIndex >= 0 ? parts[topicIndex + 2] : parts[2];

    if (/^\d+$/.test(idCandidate || '')) {
      return idCandidate;
    }

    return '';
  }

  function topicTitle() {
    const parts = location.pathname.split('/').filter(Boolean);
    return decodeURIComponent(parts[1] || '当前主题');
  }

  function topicBasePath() {
    const parts = location.pathname.split('/').filter(Boolean);
    const id = topicId();
    const idIndex = parts.indexOf(id);

    if (idIndex < 0) {
      return location.pathname.replace(/\/$/, '');
    }

    return `/${parts.slice(0, idIndex + 1).join('/')}`;
  }

  async function fetchPage(page, id) {
    const url = `${location.origin}/t/topic/${id}.json?page=${page}`;
    const res = await fetch(url, { credentials: 'include' });

    if (!res.ok) {
      throw new Error(`第 ${page + 1} 页请求失败: ${res.status}`);
    }

    const data = await res.json();
    const posts = data && data.post_stream && data.post_stream.posts;

    if (!Array.isArray(posts)) {
      throw new Error(`第 ${page + 1} 页响应结构异常`);
    }

    return data;
  }

  async function analyze() {
    if (state.isAnalyzing) {
      return;
    }

    const id = topicId();
    if (!id) {
      setStatus('未识别主题 ID', true);
      return;
    }

    resetData();
    setAnalyzing(true);
    setStatus('读取主题信息');
    renderAll();

    try {
      const first = await fetchPage(0, id);
      state.topicMeta = buildTopicMeta(first);
      state.totalPosts = resolveTotalPosts(first);
      state.totalPages = Math.max(1, Math.ceil(state.totalPosts / PAGE_SIZE));

      const queue = Array.from({ length: state.totalPages }, (_, index) => index);
      const concurrency = Math.min(MAX_CONCURRENCY, queue.length);

      async function worker() {
        while (queue.length) {
          const page = queue.shift();

          try {
            const data = page === 0 ? first : await fetchPage(page, id);
            collectPage(data, page);
          } catch (error) {
            state.errors.push(error.message || String(error));
          } finally {
            state.loadedPages += 1;
            renderProgress();
          }
        }
      }

      await Promise.all(Array.from({ length: concurrency }, worker));

      state.hiddenPosts.sort((a, b) => a.post_number - b.post_number);
      savePersistedData();
      setStatus(state.errors.length ? '完成,有部分失败' : '分析完成', Boolean(state.errors.length));
    } catch (error) {
      state.errors.push(error.message || String(error));
      setStatus(`分析失败: ${error.message || error}`, true);
    } finally {
      setAnalyzing(false);
      renderAll();
    }
  }

  function resetData() {
    state.errors = [];
    state.hiddenPosts = [];
    state.loadedPages = 0;
    state.pageStats = {};
    state.persistedAt = '';
    state.selectedUser = '';
    state.topicMeta = null;
    state.totalPages = 0;
    state.totalPosts = 0;
    state.userStats = {};
    $('#ld-search').val('');
    state.keyword = '';
    $('#ld-progress-bar').css('width', '0%');
  }

  function restorePersistedData() {
    const persisted = readPersistedData();

    if (!persisted || persisted.version !== PERSIST_VERSION) {
      return false;
    }

    state.errors = Array.isArray(persisted.errors) ? persisted.errors : [];
    state.hiddenPosts = Array.isArray(persisted.hiddenPosts) ? persisted.hiddenPosts : [];
    state.loadedPages = Number(persisted.totalPages || 0);
    state.pageStats = persisted.pageStats || {};
    state.persistedAt = persisted.persistedAt || '';
    state.topicMeta = persisted.topicMeta || null;
    state.totalPages = Number(persisted.totalPages || 0);
    state.totalPosts = Number(persisted.totalPosts || 0);
    state.userStats = persisted.userStats || {};
    return Boolean(state.totalPosts);
  }

  function savePersistedData() {
    state.persistedAt = new Date().toISOString();

    writePersistedData({
      errors: state.errors,
      hiddenPosts: state.hiddenPosts,
      pageStats: state.pageStats,
      persistedAt: state.persistedAt,
      topicMeta: state.topicMeta,
      totalPages: state.totalPages,
      totalPosts: state.totalPosts,
      userStats: state.userStats,
      version: PERSIST_VERSION
    });
  }

  function readPersistedData() {
    const raw = readStoredValue(storageKey(), '');

    if (!raw) {
      return null;
    }

    try {
      return typeof raw === 'string' ? JSON.parse(raw) : raw;
    } catch (error) {
      console.warn('Linux.do hidden posts cache parse failed:', error);
      return null;
    }
  }

  function writePersistedData(value) {
    writeStoredValue(storageKey(), JSON.stringify(value));
  }

  function storageKey() {
    return `${STORAGE_PREFIX}:${location.origin}:${topicId()}`;
  }

  function panelPositionKey() {
    return `${STORAGE_PREFIX}:panel-position`;
  }

  function readStoredValue(key, fallback) {
    try {
      if (typeof GM_getValue === 'function') {
        return GM_getValue(key, fallback);
      }

      if (typeof localStorage !== 'undefined') {
        return localStorage.getItem(key) || fallback;
      }
    } catch (error) {
      console.warn('Linux.do hidden posts storage read failed:', error);
    }

    return fallback;
  }

  function writeStoredValue(key, value) {
    try {
      if (typeof GM_setValue === 'function') {
        GM_setValue(key, value);
        return;
      }

      if (typeof localStorage !== 'undefined') {
        localStorage.setItem(key, value);
      }
    } catch (error) {
      console.warn('Linux.do hidden posts storage write failed:', error);
    }
  }

  function resolveTotalPosts(data) {
    const firstPost = data.post_stream.posts[0] || {};
    return Number(data.posts_count || firstPost.posts_count || data.highest_post_number || data.post_stream.posts.length || 0);
  }

  function buildTopicMeta(data) {
    const firstPost = data.post_stream.posts[0] || {};
    const title = decodeHtml(data.fancy_title || data.title || firstPost.topic_slug || topicTitle());
    const username = firstPost.username || '';
    const displayName = firstPost.display_username || firstPost.name || username || '-';
    const author = username && displayName !== username ? `${displayName} / ${username}` : displayName;
    const postPath = firstPost.post_url || `${topicBasePath()}/1`;

    return {
      author,
      created_at: firstPost.created_at || '',
      post_count: Number(firstPost.posts_count || data.posts_count || data.highest_post_number || 0),
      title,
      topic_id: firstPost.topic_id || topicId(),
      topic_url: postPath.startsWith('http') ? postPath : `${location.origin}${postPath}`
    };
  }

  function collectPage(data, pageIndex) {
    const posts = data.post_stream.posts;
    const hiddenOnPage = [];

    posts.forEach((post) => {
      if (!isHiddenPost(post)) {
        return;
      }

      const item = buildHiddenPost(post, pageIndex);
      hiddenOnPage.push(item);
      state.hiddenPosts.push(item);
      collectUser(item);
    });

    state.pageStats[String(pageIndex)] = hiddenOnPage.length;
  }

  function collectUser(post) {
    const key = post.username || 'unknown';
    const current = state.userStats[key] || {
      avatar_url: post.avatar_url,
      count: 0,
      name: post.name,
      profile_url: post.profile_url,
      posts: [],
      user_id: post.user_id,
      username: key
    };

    current.avatar_url = current.avatar_url || post.avatar_url;
    current.name = current.name || post.name;
    current.count += 1;
    current.posts.push(post.post_number);
    state.userStats[key] = current;
  }

  function isHiddenPost(post) {
    const cooked = String(post.cooked || '');
    return Boolean(
      post.cooked_hidden === true ||
      post.hidden === true ||
      cooked.includes(HIDDEN_NOTICE)
    );
  }

  function hiddenReason(post) {
    const cooked = String(post.cooked || '');

    if (post.cooked_hidden === true) {
      return 'cooked_hidden';
    }

    if (post.hidden === true) {
      return 'hidden';
    }

    if (cooked.includes(HIDDEN_NOTICE)) {
      return HIDDEN_NOTICE;
    }

    return '未知';
  }

  function buildHiddenPost(post, pageIndex) {
    const username = post.username || 'unknown';

    return {
      avatar_url: avatarUrl(post.avatar_template),
      created_at: post.created_at || '',
      excerpt: excerptFromHtml(post.cooked),
      hidden_reason: hiddenReason(post),
      id: post.id,
      name: post.name || '',
      page: pageIndex,
      page_display: pageIndex + 1,
      page_index: pageIndex,
      post_number: post.post_number,
      post_url: `${location.origin}${topicBasePath()}/${post.post_number}`,
      profile_url: `${location.origin}/u/${encodeURIComponent(username)}`,
      trust_level: post.trust_level == null ? '' : post.trust_level,
      user_id: post.user_id || '',
      user_title: post.user_title || '',
      username
    };
  }

  /***********************
   * 渲染
   ***********************/
  function renderAll() {
    renderTopicMeta();
    renderSummary();
    renderFilterState();
    renderLists();
    renderCharts();
    renderProgress();
    $('#ld-export').prop('disabled', !state.totalPosts || state.isAnalyzing);
  }

  function renderTopicMeta() {
    const meta = state.topicMeta;

    if (!meta) {
      $('#ld-topic-meta').html(`
        <div class="ld-topic-meta-title">统计对象:${escapeHtml(topicTitle())}</div>
        <div class="ld-topic-meta-grid">
          <div class="ld-topic-meta-item">
            <div class="ld-topic-meta-label">作者</div>
            <div class="ld-topic-meta-value">待分析</div>
          </div>
          <div class="ld-topic-meta-item">
            <div class="ld-topic-meta-label">发帖时间</div>
            <div class="ld-topic-meta-value">待分析</div>
          </div>
          <div class="ld-topic-meta-item">
            <div class="ld-topic-meta-label">主题 ID</div>
            <div class="ld-topic-meta-value">${escapeHtml(topicId() || '-')}</div>
          </div>
        </div>
      `);
      return;
    }

    $('#ld-topic-meta').html(`
      <div class="ld-topic-meta-title">
        统计对象:<a href="${escapeAttr(meta.topic_url)}">${escapeHtml(meta.title || '-')}</a>
      </div>
      <div class="ld-topic-meta-grid">
        <div class="ld-topic-meta-item">
          <div class="ld-topic-meta-label">作者</div>
          <div class="ld-topic-meta-value" title="${escapeAttr(meta.author || '-')}">${escapeHtml(meta.author || '-')}</div>
        </div>
        <div class="ld-topic-meta-item">
          <div class="ld-topic-meta-label">发帖时间</div>
          <div class="ld-topic-meta-value">${escapeHtml(formatFullDate(meta.created_at))}</div>
        </div>
        <div class="ld-topic-meta-item">
          <div class="ld-topic-meta-label">主题 ID</div>
          <div class="ld-topic-meta-value">${escapeHtml(String(meta.topic_id || '-'))}</div>
        </div>
      </div>
    `);
  }

  function renderSummary() {
    const userCount = Object.keys(state.userStats).length;
    const hiddenCount = state.hiddenPosts.length;
    const rate = state.totalPosts ? `${((hiddenCount / state.totalPosts) * 100).toFixed(2)}%` : '-';

    $('#ld-summary').html(`
      ${metricHtml('总楼层', state.totalPosts || '-')}
      ${metricHtml('隐藏楼层', hiddenCount)}
      ${metricHtml('影响人员', userCount)}
      ${metricHtml('隐藏占比', rate)}
    `);
  }

  function metricHtml(label, value) {
    return `
      <div class="ld-metric">
        <div class="ld-metric-label">${escapeHtml(label)}</div>
        <div class="ld-metric-value">${escapeHtml(String(value))}</div>
      </div>
    `;
  }

  function renderLists() {
    renderFilterState();
    renderPostList();
    renderUserList();
    renderHotUsers();
  }

  function renderFilterState() {
    if (!state.selectedUser) {
      $('#ld-filter-state').html('');
      return;
    }

    $('#ld-filter-state').html(`
      <span class="ld-filter-chip">
        人员: ${escapeHtml(state.selectedUser)}
        <button class="ld-clear-user-filter" type="button" title="清除人员筛选">×</button>
      </span>
    `);
  }

  function renderPostList() {
    const posts = filteredPosts();
    $('#ld-post-count').text(`${posts.length} / ${state.hiddenPosts.length}`);

    if (!state.totalPosts) {
      $('#ld-post-list').html(emptyHtml('点击“开始分析”后,这里会显示命中的楼层、隐藏原因、用户主页和原帖入口。'));
      return;
    }

    if (!posts.length) {
      $('#ld-post-list').html(emptyHtml(EMPTY_TEXT));
      return;
    }

    $('#ld-post-list').html(posts.map(postRowHtml).join(''));
  }

  function postRowHtml(post) {
    const displayName = post.name ? `${post.username} / ${post.name}` : post.username;

    return `
      <article class="ld-row">
        <div>
          <div class="ld-row-title">
            <span class="ld-floor">#${escapeHtml(String(post.post_number))}</span>
            <span>${escapeHtml(displayName)}</span>
            <span class="ld-muted">第 ${escapeHtml(String(post.page_display))} 页</span>
          </div>
          <p class="ld-excerpt">${escapeHtml(post.excerpt || '内容已隐藏或不可见')}</p>
          <div class="ld-meta">
            <span class="ld-pill">原因: ${escapeHtml(post.hidden_reason)}</span>
            <span class="ld-pill">用户 ID: ${escapeHtml(String(post.user_id || '-'))}</span>
            <span class="ld-pill">信任等级: ${escapeHtml(String(post.trust_level || '-'))}</span>
            <span>${escapeHtml(formatDate(post.created_at))}</span>
          </div>
        </div>
        <div class="ld-row-actions">
          <a class="ld-link" data-nav="same-page" href="${escapeAttr(post.post_url)}">看楼层</a>
          <a class="ld-link" href="${escapeAttr(post.profile_url)}" target="_blank" rel="noopener noreferrer">主页</a>
        </div>
      </article>
    `;
  }

  function renderUserList() {
    const users = filteredUsers();
    const totalUsers = Object.keys(state.userStats).length;
    $('#ld-user-count').text(`${users.length} / ${totalUsers}`);

    if (!state.totalPosts) {
      $('#ld-user-list').html(emptyHtml('点击“开始分析”后,这里会展示人员命中次数、楼层范围和主页入口。'));
      return;
    }

    if (!users.length) {
      $('#ld-user-list').html(emptyHtml(EMPTY_TEXT));
      return;
    }

    $('#ld-user-list').html(users.map(userRowHtml).join(''));
  }

  function userRowHtml(user) {
    const posts = user.posts.slice().sort((a, b) => a - b);
    const previewPosts = posts.slice(0, 8).map((post) => `#${post}`).join('、');
    const remaining = posts.length > 8 ? ` 等 ${posts.length} 个` : '';
    const displayName = user.name ? `${user.username} / ${user.name}` : user.username;

    return `
      <article class="ld-row ld-user-row">
        ${avatarHtml(user)}
        <div>
          <div class="ld-row-title">
            <span>${escapeHtml(displayName)}</span>
            <span class="ld-muted">${escapeHtml(String(user.count))} 个隐藏楼层</span>
          </div>
          <div class="ld-meta">
            <span class="ld-pill">用户 ID: ${escapeHtml(String(user.user_id || '-'))}</span>
            <span class="ld-pill">楼层: ${escapeHtml(previewPosts || '-')}${escapeHtml(remaining)}</span>
          </div>
        </div>
        <div class="ld-row-actions">
          <button class="ld-link ld-user-filter" type="button" data-user="${escapeAttr(user.username)}">看楼层</button>
          <a class="ld-link" href="${escapeAttr(user.profile_url)}" target="_blank" rel="noopener noreferrer">主页</a>
        </div>
      </article>
    `;
  }

  function renderHotUsers() {
    const users = userRows().slice(0, 5);

    if (!state.totalPosts) {
      $('#ld-hot-users').html('');
      return;
    }

    if (!users.length) {
      $('#ld-hot-users').html(emptyHtml('当前主题未发现隐藏楼层。'));
      return;
    }

    $('#ld-hot-users').html(`
      <div class="ld-section">
        <div class="ld-section-title">
          <span>高频人员</span>
          <span class="ld-section-subtitle">可点击进入主页或筛选楼层</span>
        </div>
        <div class="ld-list">
          ${users.map(userRowHtml).join('')}
        </div>
      </div>
    `);
  }

  function renderCharts() {
    renderPageChart();
    renderUserChart();
    resizeCharts();
  }

  function renderPageChart() {
    const chart = ensureChart('pages', 'chart-pages');
    if (!chart) {
      return;
    }

    const pages = Array.from({ length: state.totalPages || 1 }, (_, index) => index);
    const pageLabels = pages.map((page) => String(page + 1));
    const values = pages.map((page) => state.pageStats[String(page)] || 0);

    chart.setOption({
      color: ['#3867d6'],
      grid: { left: 36, right: 12, top: 24, bottom: 34 },
      tooltip: {
        trigger: 'axis',
        formatter: (items) => {
          const item = items[0];
          const pageIndex = Number(item.dataIndex);
          return `第 ${escapeHtml(String(pageIndex + 1))} 页<br>API page: ${escapeHtml(String(pageIndex))}<br>隐藏楼层: ${escapeHtml(String(item.value))}`;
        }
      },
      xAxis: {
        type: 'category',
        data: pageLabels,
        axisLabel: { color: '#6a7987' },
        axisLine: { lineStyle: { color: '#d9e2e8' } },
        axisTick: { show: false }
      },
      yAxis: {
        type: 'value',
        minInterval: 1,
        axisLabel: { color: '#6a7987' },
        splitLine: { lineStyle: { color: '#edf2f5' } }
      },
      series: [{
        name: '隐藏楼层',
        type: 'bar',
        barMaxWidth: 18,
        data: values
      }]
    });
  }

  function renderUserChart() {
    const chart = ensureChart('users', 'chart-users');
    if (!chart) {
      return;
    }

    const users = userRows().slice(0, 8);

    chart.setOption({
      color: ['#1f7a62'],
      grid: { left: 78, right: 10, top: 18, bottom: 24 },
      tooltip: {
        trigger: 'axis',
        formatter: (items) => {
          const item = items[0];
          return `${escapeHtml(String(item.name))}<br>隐藏楼层: ${escapeHtml(String(item.value))}`;
        }
      },
      xAxis: {
        type: 'value',
        minInterval: 1,
        axisLabel: { color: '#6a7987' },
        splitLine: { lineStyle: { color: '#edf2f5' } }
      },
      yAxis: {
        type: 'category',
        data: users.map((user) => user.username).reverse(),
        axisLabel: { color: '#405160', width: 70, overflow: 'truncate' },
        axisLine: { show: false },
        axisTick: { show: false }
      },
      series: [{
        name: '隐藏楼层',
        type: 'bar',
        barMaxWidth: 14,
        data: users.map((user) => user.count).reverse()
      }]
    });
  }

  function ensureChart(key, elementId) {
    const element = document.getElementById(elementId);
    if (!element || typeof echarts === 'undefined') {
      return null;
    }

    if (!state.charts[key]) {
      state.charts[key] = echarts.init(element);
    }

    return state.charts[key];
  }

  function resizeCharts() {
    window.setTimeout(() => {
      Object.values(state.charts).forEach((chart) => {
        if (chart) {
          chart.resize();
        }
      });
    }, 0);
  }

  function renderProgress() {
    const percent = state.totalPages ? Math.round((state.loadedPages / state.totalPages) * 100) : 0;
    $('#ld-progress-bar').css('width', `${Math.min(percent, 100)}%`);
    $('#ld-progress-percent').text(`${Math.min(percent, 100)}%`);
    $('#ld-progress-label').text(state.totalPages ? `已完成 ${state.loadedPages}/${state.totalPages} 页` : '准备中');

    if (state.isAnalyzing && state.totalPages) {
      setStatus(`分析中 ${state.loadedPages}/${state.totalPages}`);
    }
  }

  function emptyHtml(text) {
    return `<div class="ld-empty">${escapeHtml(text)}</div>`;
  }

  /***********************
   * 筛选与导出
   ***********************/
  function filteredPosts() {
    const keyword = state.keyword.toLowerCase();

    return state.hiddenPosts.filter((post) => {
      const matchUser = !state.selectedUser || post.username === state.selectedUser;
      const matchKeyword = !keyword || [
        post.username,
        post.name,
        post.post_number,
        post.page,
        post.hidden_reason,
        post.excerpt
      ].some((value) => String(value || '').toLowerCase().includes(keyword));

      return matchUser && matchKeyword;
    });
  }

  function filteredUsers() {
    const keyword = state.keyword.toLowerCase();

    return userRows().filter((user) => {
      if (!keyword) {
        return true;
      }

      return [
        user.username,
        user.name,
        user.user_id,
        user.posts.join(',')
      ].some((value) => String(value || '').toLowerCase().includes(keyword));
    });
  }

  function userRows() {
    return Object.values(state.userStats).sort((a, b) => {
      if (b.count !== a.count) {
        return b.count - a.count;
      }

      return a.username.localeCompare(b.username);
    });
  }

  function exportExcel() {
    if (!state.totalPosts) {
      window.alert('请先点击“开始分析”。');
      return;
    }

    const wb = XLSX.utils.book_new();

    const postRows = state.hiddenPosts.map((post) => ({
      page: post.page,
      page_display: post.page_display,
      post_number: post.post_number,
      username: post.username,
      name: post.name,
      user_id: post.user_id,
      trust_level: post.trust_level,
      hidden_reason: post.hidden_reason,
      created_at: post.created_at,
      post_url: post.post_url,
      profile_url: post.profile_url,
      excerpt: post.excerpt
    }));

    const userRowsForSheet = userRows().map((user) => ({
      username: user.username,
      name: user.name,
      user_id: user.user_id,
      count: user.count,
      posts: user.posts.slice().sort((a, b) => a - b).join(','),
      profile_url: user.profile_url
    }));

    const pageRows = Array.from({ length: state.totalPages }, (_, index) => ({
      page: index,
      page_display: index + 1,
      hidden_count: state.pageStats[String(index)] || 0
    }));

    const topicRows = state.topicMeta ? [{
      title: state.topicMeta.title,
      author: state.topicMeta.author,
      created_at: state.topicMeta.created_at,
      topic_id: state.topicMeta.topic_id,
      topic_url: state.topicMeta.topic_url,
      total_posts: state.totalPosts,
      hidden_posts: state.hiddenPosts.length,
      hidden_rate: state.totalPosts ? `${((state.hiddenPosts.length / state.totalPosts) * 100).toFixed(2)}%` : ''
    }] : [];

    XLSX.utils.book_append_sheet(wb, XLSX.utils.json_to_sheet(topicRows), 'topic_summary');
    XLSX.utils.book_append_sheet(wb, XLSX.utils.json_to_sheet(postRows), 'hidden_posts');
    XLSX.utils.book_append_sheet(wb, XLSX.utils.json_to_sheet(userRowsForSheet), 'user_stats');
    XLSX.utils.book_append_sheet(wb, XLSX.utils.json_to_sheet(pageRows), 'page_stats');
    XLSX.writeFile(wb, 'linuxdo_hidden_posts.xlsx');
  }

  /***********************
   * 工具函数
   ***********************/
  function setAnalyzing(value) {
    state.isAnalyzing = value;
    $('#ld-panel').toggleClass('ld-is-loading', value);
    $('#ld-run').prop('disabled', value).text(value ? '分析中...' : '开始分析');
    $('#ld-export').prop('disabled', value || !state.totalPosts);
  }

  function setStatus(text, isError) {
    $('#ld-status')
      .toggleClass('ld-status-error', Boolean(isError))
      .text(text);
  }

  function avatarUrl(template) {
    if (!template) {
      return '';
    }

    const url = template.startsWith('http') ? template : `${location.origin}${template}`;
    return url.replace('{size}', '64');
  }

  function avatarHtml(user) {
    if (user.avatar_url) {
      return `<span class="ld-avatar"><img src="${escapeAttr(user.avatar_url)}" alt=""></span>`;
    }

    return `<span class="ld-avatar">${escapeHtml(user.username.slice(0, 1).toUpperCase())}</span>`;
  }

  function excerptFromHtml(html) {
    const box = document.createElement('div');
    box.innerHTML = String(html || '');
    const text = box.textContent.replace(/\s+/g, ' ').trim();
    return text.slice(0, 180);
  }

  function formatDate(value) {
    if (!value) {
      return '-';
    }

    const date = new Date(value);
    if (Number.isNaN(date.getTime())) {
      return value;
    }

    return date.toLocaleString('zh-CN', {
      hour12: false,
      month: '2-digit',
      day: '2-digit',
      hour: '2-digit',
      minute: '2-digit'
    });
  }

  function formatFullDate(value) {
    if (!value) {
      return '-';
    }

    const date = new Date(value);
    if (Number.isNaN(date.getTime())) {
      return value;
    }

    return date.toLocaleString('zh-CN', {
      year: 'numeric',
      month: '2-digit',
      day: '2-digit',
      hour: '2-digit',
      minute: '2-digit',
      second: '2-digit',
      hour12: false
    });
  }

  function decodeHtml(value) {
    const box = document.createElement('textarea');
    box.innerHTML = String(value || '');
    return box.value || box.textContent || '';
  }

  function escapeHtml(value) {
    return String(value == null ? '' : value)
      .replace(/&/g, '&amp;')
      .replace(/</g, '&lt;')
      .replace(/>/g, '&gt;')
      .replace(/"/g, '&quot;')
      .replace(/'/g, '&#39;');
  }

  function escapeAttr(value) {
    return escapeHtml(value);
  }
})();