Greasy Fork is available in English.
分析 linux.do 被举报隐藏帖子,面板内查看楼层、人员、图表并支持导出 Excel
// ==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, '&') .replace(/</g, '<') .replace(/>/g, '>') .replace(/"/g, '"') .replace(/'/g, '''); } function escapeAttr(value) { return escapeHtml(value); } })();