起点图表格筛选 (v7.4 懒加载优化)

为起点图(qidiantu.com)增加强大的表格筛选和数据分析功能。支持分类和等级的多选过滤、书名热词分析与筛选,并完美兼容网站的懒加载机制。新增书单收录数显示(支持点击刷新)和智能容错功能。

// ==UserScript==
// @name         起点图表格筛选 (v7.4 懒加载优化)
// @namespace    http://tampermonkey.net/
// @version      7.4
// @description  为起点图(qidiantu.com)增加强大的表格筛选和数据分析功能。支持分类和等级的多选过滤、书名热词分析与筛选,并完美兼容网站的懒加载机制。新增书单收录数显示(支持点击刷新)和智能容错功能。
// @author       Gemini
// @homepageURL  https://github.com/liucong2013/qidiantu-filter
// @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 = '<div class="booklist-spinner"></div>查询中...';

        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 = `<button class="gm-multiselect-clear-btn">全部取消</button>${options.map(opt => `<label><input type="checkbox" value="${opt.name}"> ${opt.name} (${opt.count})</label>`).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...');

})();