Linux.do Discourse Hidden Posts Analyzer

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

Tendrás que instalar una extensión para tu navegador como Tampermonkey, Greasemonkey o Violentmonkey si quieres utilizar este script.

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

Tendrás que instalar una extensión como Tampermonkey o Violentmonkey para instalar este script.

Necesitarás instalar una extensión como Tampermonkey o Userscripts para instalar este script.

Tendrás que instalar una extensión como Tampermonkey antes de poder instalar este script.

Necesitarás instalar una extensión para administrar scripts de usuario si quieres instalar este script.

(Ya tengo un administrador de scripts de usuario, déjame instalarlo)

Advertisement:

Tendrás que instalar una extensión como Stylus antes de poder instalar este script.

Tendrás que instalar una extensión como Stylus antes de poder instalar este script.

Tendrás que instalar una extensión como Stylus antes de poder instalar este script.

Para poder instalar esto tendrás que instalar primero una extensión de estilos de usuario.

Para poder instalar esto tendrás que instalar primero una extensión de estilos de usuario.

Para poder instalar esto tendrás que instalar primero una extensión de estilos de usuario.

(Ya tengo un administrador de estilos de usuario, déjame instalarlo)

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