// ==UserScript== // @name 起点图表格筛选 (v7.4 懒加载优化) // @namespace http://tampermonkey.net/ // @version 7.4 // @description 为起点图(qidiantu.com)增加强大的表格筛选和数据分析功能。支持分类和等级的多选过滤、书名热词分析与筛选,并完美兼容网站的懒加载机制。新增书单收录数显示(支持点击刷新)和智能容错功能。 // @author Gemini // @homepageURL https://github.com/liucong2013/qidiantu-filter // @downloadURL https://cdn.jsdelivr.net/gh/liucong2013/qidiantu-filter/qidiantu-filter.user.js // @updateURL https://cdn.jsdelivr.net/gh/liucong2013/qidiantu-filter/qidiantu-filter.user.js // @match https://www.qidiantu.com/shouding/* // @grant GM_addStyle // @grant GM_setValue // @grant GM_getValue // @run-at document-start // ==/UserScript== (function() { 'use strict'; const SCRIPT_NAME = '起点图表格筛选'; const log = (...args) => console.log(`[${SCRIPT_NAME}]`, ...args); function addViewportMeta() { if (document.querySelector('meta[name="viewport"]')) return; const meta = document.createElement('meta'); meta.name = 'viewport'; meta.content = 'width=device-width, initial-scale=1.0'; document.head.appendChild(meta); } if (document.head) { addViewportMeta(); } else { const observer = new MutationObserver(() => { if (document.head) { addViewportMeta(); observer.disconnect(); } }); observer.observe(document.documentElement, { childList: true }); } GM_addStyle(` .gm-sticky-toolbar { position: sticky; top: 0; background-color: #ffffff; padding: 8px 10px; border-bottom: 1px solid #e0e0e0; box-shadow: 0 2px 4px rgba(0,0,0,0.05); z-index: 1001; } #gm-hotword-display-area { padding-top: 8px; border-top: 1px dashed #ccc; margin-top: 8px; text-align: center; } .gm-hotword-tag { display: inline-block; padding: 3px 8px; margin: 2px; background-color: #e9f5ff; border: 1px solid #d0eaff; border-radius: 4px; cursor: pointer; } .gm-hotword-tag.active { background-color: #007bff; color: white; border-color: #0056b3; } #gm-hotword-clear-btn { display: none; width: 98%; box-sizing: border-box; margin: 8px auto 0 auto; padding: 4px; background-color: #ffe9e9; border: 1px solid #ffd0d0; border-radius: 4px; cursor: pointer; } .gm-multiselect-container { position: relative; display: inline-block; margin-left: 8px; font-weight: normal; vertical-align: middle; } .gm-multiselect-button, #gm-analyze-hotwords-btn { padding: 4px 10px; border: 1px solid #ccc; border-radius: 4px; background-color: #f8f8f8; cursor: pointer; } .gm-multiselect-button.active { background-color: #007bff; color: white; border-color: #0056b3; } .gm-multiselect-dropdown { display: none; position: absolute; background-color: white; border: 1px solid #ccc; border-radius: 4px; padding: 5px; z-index: 1002; max-height: 300px; overflow-y: auto; text-align: left; } .gm-multiselect-dropdown.show { display: block; } .gm-multiselect-dropdown label { display: block; padding: 3px 5px; white-space: nowrap; } .gm-multiselect-dropdown label:hover { background-color: #f0f0f0; } .gm-multiselect-clear-btn { position: sticky; top: -5px; z-index: 1; width: calc(100% + 10px); margin: -5px -5px 5px -5px; box-sizing: border-box; padding: 5px; text-align: center; border: none; border-bottom: 1px solid #ddd; background: #f5f5f5; cursor: pointer; } .booklist-count { font-size: 12px; color: #ff6a00; margin-left: 8px; background-color: #fff3e0; padding: 1px 5px; border-radius: 3px; border: 1px solid #ffe0b2; display: inline-flex; align-items: center; cursor: pointer; } .booklist-count:hover { background-color: #ffe0b2; } .booklist-spinner { width: 12px; height: 12px; border: 2px solid #ffab40; border-top-color: transparent; border-radius: 50%; display: inline-block; animation: spin 1s linear infinite; margin-right: 4px; } @keyframes spin { to { transform: rotate(360deg); } } @media (max-width: 768px) { body, .table-bordered { font-size: 14px; } .gm-sticky-toolbar { padding: 5px; display: flex; flex-direction: column; align-items: stretch; } #gm-analyze-hotwords-btn { margin-bottom: 5px; } .gm-multiselect-container { margin: 5px 0; display: block; } .gm-multiselect-button { width: 100%; box-sizing: border-box; text-align: center; } .gm-multiselect-dropdown { width: 98%; box-sizing: border-box; left: 1%; } .table-bordered th, .table-bordered td { padding: 4px; white-space: normal !important; } .table-bordered th:nth-child(1), .table-bordered td:nth-child(1) { min-width: 30px; width: 30px; } .table-bordered th:nth-child(3), .table-bordered td:nth-child(3), .table-bordered th:nth-child(5), .table-bordered td:nth-child(5) { min-width: 50px; } .table-bordered th:nth-child(2), .table-bordered td:nth-child(2) { min-width: 150px; } .table-bordered th { font-size: 13px; } #gm-hotword-display-area { text-align: left; } .booklist-count { display: block; margin-left: 0; margin-top: 4px; text-align: center; justify-content: center; } } `); let allTableRows = []; let categoryFilterControl = null; let levelFilterControl = null; let activeHotword = null; let tableBodyObserver = null; let booklistFetchQueue = []; let isFetching = false; let consecutiveFailureCount = 0; const MAX_CONSECUTIVE_FAILURES = 5; let fetchLimit = 100; const CACHE_KEY_PREFIX = 'booklist_count_v5_'; // Invalidate old cache v4 const CACHE_EXPIRATION_MS = 30 * 24 * 60 * 60 * 1000; // 30天 function getRandomDelay(min = 200, max = 500) { return Math.floor(Math.random() * (max - min + 1)) + min; } async function fetchBooklistCount(bookUrl, displayElement, force = false) { const bookIdMatch = bookUrl.match(/\/info\/(\d+)/); const row = displayElement.closest('tr'); if (!bookIdMatch) { log('Could not extract book ID from URL:', bookUrl); displayElement.textContent = 'ID错误'; if (row) row.dataset.booklistState = 'failed'; return 'error'; } const bookId = bookIdMatch[1]; const cacheKey = `${CACHE_KEY_PREFIX}${bookId}`; if (!force) { try { const cachedData = await GM_getValue(cacheKey); if (cachedData && (Date.now() - cachedData.timestamp < CACHE_EXPIRATION_MS)) { log(`Book ID ${bookId}: Found valid cache. Count: ${cachedData.count}`); displayElement.innerHTML = `书单: ${cachedData.count}`; if (row) row.dataset.booklistState = 'fetched'; consecutiveFailureCount = 0; // Reset on cache hit return 'cache'; } } catch (e) { log(`Error reading cache for Book ID ${bookId}:`, e); } } log(`Book ID ${bookId}: ${force ? 'Forced fetch' : 'No cache, fetching from network'}.`); displayElement.innerHTML = '
查询中...'; try { const response = await fetch(bookUrl, { method: 'GET', credentials: 'include', headers: { "User-Agent": "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/123.0.0.0 Safari/537.36", "Referer": "https://www.qidiantu.com/shouding/" } }); if (response.ok) { const text = await response.text(); const match = text.match(/共被(\d+)份书单收录过/); const rawMatchText = match ? `'${match[0]}'` : 'N/A'; const count = match ? match[1] : '0'; log(`Book ID ${bookId}: Raw match: ${rawMatchText}, Extracted count: ${count}, URL: ${bookUrl}`); displayElement.innerHTML = `书单: ${count}`; if (row) row.dataset.booklistState = 'fetched'; consecutiveFailureCount = 0; // Reset on success try { await GM_setValue(cacheKey, { count: count, timestamp: Date.now() }); } catch (e) { log(`Error saving cache for Book ID ${bookId}:`, e); } return 'network'; } else { log(`Book ID ${bookId}: Fetch failed with status ${response.status}`); displayElement.textContent = '查询失败'; if (row) row.dataset.booklistState = 'failed'; consecutiveFailureCount++; return 'error'; } } catch (error) { log(`Book ID ${bookId}: Fetch error.`, error); displayElement.textContent = '查询失败'; if (row) row.dataset.booklistState = 'failed'; consecutiveFailureCount++; return 'error'; } } async function processFetchQueue() { if (isFetching) return; if (booklistFetchQueue.length === 0) return; isFetching = true; log('Starting to process fetch queue...'); while (booklistFetchQueue.length > 0) { if (consecutiveFailureCount >= MAX_CONSECUTIVE_FAILURES) { log(`连续获取失败${MAX_CONSECUTIVE_FAILURES}次,自动停止获取。`); booklistFetchQueue.length = 0; // Clear the queue break; } const item = booklistFetchQueue.shift(); log(`Processing Book URL: ${item.url}. Queue size: ${booklistFetchQueue.length}`); const result = await fetchBooklistCount(item.url, item.element, item.force); if (result === 'network') { await new Promise(resolve => setTimeout(resolve, getRandomDelay())); } } log('All booklist fetch tasks completed or stopped.'); isFetching = false; } function enqueueBooklistFetch(row, index) { if (row.dataset.booklistState) return; const link = row.cells[1]?.querySelector('a'); if (!link || !link.href) return; let countSpan = row.querySelector('.booklist-count'); if (!countSpan) { countSpan = document.createElement('span'); countSpan.className = 'booklist-count'; link.parentNode.appendChild(countSpan); } if (index < fetchLimit) { row.dataset.booklistState = 'queued'; countSpan.title = '点击刷新'; countSpan.addEventListener('click', (e) => { e.stopPropagation(); e.preventDefault(); fetchBooklistCount(link.href, countSpan, true); }); booklistFetchQueue.push({ url: link.href, element: countSpan, force: false }); } else { row.dataset.booklistState = 'unfetched'; countSpan.textContent = '未获取'; countSpan.title = '点击获取'; countSpan.addEventListener('click', (e) => { e.stopPropagation(); e.preventDefault(); fetchLimit = index + 20; log(`Fetch limit extended to: ${fetchLimit}`); updateDisplay(); }, { once: true }); } } function updateDisplay() { if (!categoryFilterControl || !levelFilterControl) return; const selectedCategories = categoryFilterControl.getSelected(); const selectedLevels = levelFilterControl.getSelected(); categoryFilterControl.container.querySelector('.gm-multiselect-button').classList.toggle('active', selectedCategories.length > 0); levelFilterControl.container.querySelector('.gm-multiselect-button').classList.toggle('active', selectedLevels.length > 0); allTableRows = Array.from(document.querySelectorAll('.table-bordered tbody tr')); let visibleRowIndex = 0; allTableRows.forEach(row => { const category = row.cells[1]?.textContent.trim().match(/\[(.*?)\]/)?.[1] || ''; const level = row.cells[4]?.textContent.trim() || ''; const title = row.cells[1]?.querySelector('a')?.textContent.trim() || ''; const categoryMatch = selectedCategories.length === 0 || selectedCategories.includes(category); const levelMatch = selectedLevels.length === 0 || selectedLevels.includes(level); const hotwordMatch = !activeHotword || title.includes(activeHotword); const isVisible = categoryMatch && levelMatch && hotwordMatch; row.style.display = isVisible ? '' : 'none'; if (isVisible) { enqueueBooklistFetch(row, visibleRowIndex); visibleRowIndex++; } }); log('Display updated. Triggering booklist fetch queue.'); processFetchQueue(); } function initializeControls(table) { if (document.querySelector('.gm-sticky-toolbar')) return; log('Initializing controls...'); const thead = table.querySelector('thead'); const tbody = table.querySelector('tbody'); if (!thead || !tbody) return; allTableRows = Array.from(tbody.querySelectorAll('tr')); if (allTableRows.length === 0) return; const toolbar = document.createElement('div'); toolbar.className = 'gm-sticky-toolbar'; table.parentNode.insertBefore(toolbar, table); const analyzeBtn = document.createElement('button'); analyzeBtn.id = 'gm-analyze-hotwords-btn'; analyzeBtn.textContent = '书名热词分析'; toolbar.appendChild(analyzeBtn); const headerRow = thead.querySelector('tr'); if (!headerRow || headerRow.children.length < 5) return; const titleHeader = headerRow.children[1]; const levelHeader = headerRow.children[4]; const categoryCounts = {}; const levelCounts = {}; allTableRows.forEach(r => { const category = r.cells[1]?.textContent.trim().match(/\[(.*?)\]/)?.[1]; const level = r.cells[4]?.textContent.trim(); if (category) categoryCounts[category] = (categoryCounts[category] || 0) + 1; if (level) levelCounts[level] = (levelCounts[level] || 0) + 1; }); const categoriesWithOptions = Object.entries(categoryCounts).map(([name, count]) => ({ name, count })); const levelsWithOptions = Object.entries(levelCounts).map(([name, count]) => ({ name, count })); categoryFilterControl = createMultiSelect(categoriesWithOptions, "分类", updateDisplay); levelFilterControl = createMultiSelect(levelsWithOptions, "等级", updateDisplay); titleHeader.appendChild(categoryFilterControl.container); levelHeader.appendChild(levelFilterControl.container); analyzeBtn.addEventListener('click', analyzeAndDisplayHotwords); log('Enqueuing initial visible books for booklist count fetch.'); updateDisplay(); // Initial call to setup everything if (tableBodyObserver) tableBodyObserver.disconnect(); tableBodyObserver = new MutationObserver((mutations) => { const hasAddedNodes = mutations.some(m => m.addedNodes.length > 0); if (hasAddedNodes) { log('New rows detected, re-applying filter and fetching booklist data.'); updateDisplay(); } }); tableBodyObserver.observe(tbody, { childList: true }); log('Controls initialized and table observer is running.'); } function analyzeAndDisplayHotwords() { const toolbar = document.querySelector('.gm-sticky-toolbar'); let hotwordArea = document.getElementById('gm-hotword-display-area'); if (hotwordArea) hotwordArea.remove(); const wordCounts = {}; const stopWords = new Set(['的', '了', '我', '你', '他', '她', '之', '什么', '一个', '这个', '那个']); allTableRows.forEach(r => { const originalTitle = r.cells[1]?.querySelector('a')?.textContent.trim(); if (!originalTitle) return; const cleanedTitle = originalTitle.replace(/[^\u4e00-\u9fa5a-zA-Z0-9]/g, ''); for (let i = 0; i < cleanedTitle.length - 1; i += 2) { const word = cleanedTitle.substring(i, i + 2); if (word.length === 2 && !stopWords.has(word) && !/\d/.test(word)) { wordCounts[word] = (wordCounts[word] || 0) + 1; } } }); const sortedWords = Object.entries(wordCounts).sort((a, b) => b[1] - a[1]).slice(0, 30); hotwordArea = document.createElement('div'); hotwordArea.id = 'gm-hotword-display-area'; toolbar.appendChild(hotwordArea); sortedWords.forEach(([word, count]) => { const tag = document.createElement('span'); tag.className = 'gm-hotword-tag'; tag.textContent = `${word} (${count})`; tag.dataset.word = word; hotwordArea.appendChild(tag); }); const clearBtn = document.createElement('div'); clearBtn.id = 'gm-hotword-clear-btn'; clearBtn.textContent = '清除热词筛选'; hotwordArea.appendChild(clearBtn); hotwordArea.addEventListener('click', e => { const target = e.target; if (target.classList.contains('gm-hotword-tag')) { const word = target.dataset.word; if (target.classList.contains('active')) { activeHotword = null; target.classList.remove('active'); } else { hotwordArea.querySelectorAll('.gm-hotword-tag.active').forEach(t => t.classList.remove('active')); target.classList.add('active'); activeHotword = word; } } else if (target.id === 'gm-hotword-clear-btn') { activeHotword = null; hotwordArea.querySelectorAll('.gm-hotword-tag.active').forEach(t => t.classList.remove('active')); } clearBtn.style.display = activeHotword ? 'block' : 'none'; updateDisplay(); }); } function createMultiSelect(options, label, onChangeCallback) { const container = document.createElement('div'); container.className = 'gm-multiselect-container'; container.addEventListener('mousedown', e => { e.preventDefault(); e.stopPropagation(); }); const button = document.createElement('button'); button.className = 'gm-multiselect-button'; button.textContent = label; container.appendChild(button); const dropdown = document.createElement('div'); dropdown.className = 'gm-multiselect-dropdown'; container.appendChild(dropdown); dropdown.innerHTML = `${options.map(opt => ``).join('')}`; button.addEventListener('click', e => { e.stopPropagation(); document.querySelectorAll('.gm-multiselect-dropdown.show').forEach(d => { if (d !== dropdown) d.classList.remove('show'); }); dropdown.classList.toggle('show'); }); dropdown.addEventListener('change', () => { onChangeCallback(); }); dropdown.querySelector('.gm-multiselect-clear-btn').addEventListener('click', e => { e.stopPropagation(); dropdown.querySelectorAll('input:checked').forEach(c => { c.checked = false; }); onChangeCallback(); }); document.addEventListener('click', () => dropdown.classList.remove('show')); dropdown.addEventListener('click', e => e.stopPropagation()); return { container, getSelected: () => Array.from(dropdown.querySelectorAll('input:checked')).map(input => input.value) }; } const initialObserver = new MutationObserver((mutationsList, observer) => { const table = document.querySelector('.table-bordered'); if (table) { log('Table element found, initializing script.'); observer.disconnect(); initializeControls(table); } }); initialObserver.observe(document.body, { childList: true, subtree: true }); log('Script loaded. Waiting for table element to appear...'); })();