LinuxDo 论坛自动阅读器

自动阅读LinuxDo论坛话题,支持自定义浏览速度和话题数量,支持控制面板展开/收缩,记录已阅读话题避免重复,支持跳过前面几篇话题,智能识别短话题并立即跳转

// ==UserScript==
// @name         LinuxDo 论坛自动阅读器
// @namespace    http://tampermonkey.net/
// @version      1.9
// @description  自动阅读LinuxDo论坛话题,支持自定义浏览速度和话题数量,支持控制面板展开/收缩,记录已阅读话题避免重复,支持跳过前面几篇话题,智能识别短话题并立即跳转
// @author       A嘉技术
// @match        https://linux.do/*
// @grant        GM_setValue
// @grant        GM_getValue
// @grant        GM_deleteValue
// @license      MIT
// ==/UserScript==

(function() {
    'use strict';

    // 配置选项 - 使用GM存储来持久化状态
    let config = {
        readSpeed: GM_getValue('readSpeed', 3000),
        scrollSpeed: GM_getValue('scrollSpeed', 1000),
        maxTopics: GM_getValue('maxTopics', 10),
        skipCount: GM_getValue('skipCount', 0), // 跳过前面几篇话题
        currentCount: GM_getValue('currentCount', 0),
        isRunning: GM_getValue('isRunning', false),
        topicList: GM_getValue('topicList', []),
        currentIndex: GM_getValue('currentIndex', 0),
        startUrl: GM_getValue('startUrl', ''),
        isCollapsed: GM_getValue('isCollapsed', false),
        readTopicIds: GM_getValue('readTopicIds', []), // 已阅读的话题ID列表
        actualTopicCount: GM_getValue('actualTopicCount', 0) // 实际要阅读的话题数量
    };

    // 保存配置到GM存储
    function saveConfig() {
        GM_setValue('readSpeed', config.readSpeed);
        GM_setValue('scrollSpeed', config.scrollSpeed);
        GM_setValue('maxTopics', config.maxTopics);
        GM_setValue('skipCount', config.skipCount);
        GM_setValue('currentCount', config.currentCount);
        GM_setValue('isRunning', config.isRunning);
        GM_setValue('topicList', config.topicList);
        GM_setValue('currentIndex', config.currentIndex);
        GM_setValue('startUrl', config.startUrl);
        GM_setValue('isCollapsed', config.isCollapsed);
        GM_setValue('readTopicIds', config.readTopicIds);
        GM_setValue('actualTopicCount', config.actualTopicCount);
    }

    // 清除运行状态
    function clearRunningState() {
        config.isRunning = false;
        config.currentCount = 0;
        config.currentIndex = 0;
        config.topicList = [];
        config.startUrl = '';
        config.actualTopicCount = 0;
        saveConfig();
    }

    // 从URL提取话题ID
    function extractTopicId(url) {
        const match = url.match(/\/t\/topic\/(\d+)/);
        return match ? parseInt(match[1]) : null;
    }

    // 检查话题是否已阅读
    function isTopicRead(topicId) {
        return config.readTopicIds.includes(topicId);
    }

    // 标记话题为已阅读
    function markTopicAsRead(topicId) {
        if (!config.readTopicIds.includes(topicId)) {
            config.readTopicIds.push(topicId);
            // 限制已阅读列表最大长度为1000,避免存储过多数据
            if (config.readTopicIds.length > 1000) {
                config.readTopicIds = config.readTopicIds.slice(-1000);
            }
            saveConfig();
        }
    }

    // 清除已阅读记录
    function clearReadHistory() {
        config.readTopicIds = [];
        saveConfig();
        updateStatus('已清除阅读记录');
        setTimeout(() => {
            createControlPanel();
        }, 1000);
    }

    // 切换面板展开/收缩状态
    function togglePanel() {
        config.isCollapsed = !config.isCollapsed;
        saveConfig();
        updatePanelDisplay();
    }

    // 更新面板显示状态
    function updatePanelDisplay() {
        const panel = document.getElementById('linuxdo-auto-reader-panel');
        const content = document.getElementById('panel-content');
        const toggleBtn = document.getElementById('toggle-btn');
        const header = document.getElementById('panel-header');

        if (!panel || !content || !toggleBtn || !header) return;

        if (config.isCollapsed) {
            content.style.display = 'none';
            toggleBtn.innerHTML = '📖';
            toggleBtn.title = '展开控制面板';
            header.style.cursor = 'pointer';
            panel.style.width = '60px';
            panel.style.height = '60px';
            panel.style.borderRadius = '50%';
            // 隐藏标题文字
            const titleEl = header.querySelector('.panel-title');
            if (titleEl) titleEl.style.display = 'none';
        } else {
            content.style.display = 'block';
            toggleBtn.innerHTML = '📕';
            toggleBtn.title = '收缩控制面板';
            header.style.cursor = 'default';
            panel.style.width = '320px';
            panel.style.height = 'auto';
            panel.style.borderRadius = '8px';
            // 显示标题文字
            const titleEl = header.querySelector('.panel-title');
            if (titleEl) titleEl.style.display = 'block';
        }
    }

    // 安全地移除元素
    function safeRemoveElement(element) {
        try {
            if (element && element.parentNode) {
                element.parentNode.removeChild(element);
            }
        } catch (error) {
            console.log('移除元素时出错,但已忽略:', error);
        }
    }

    // 创建控制面板
    function createControlPanel() {
        // 如果面板已存在,先安全地删除
        const existingPanel = document.getElementById('linuxdo-auto-reader-panel');
        if (existingPanel) {
            safeRemoveElement(existingPanel);
        }

        const panel = document.createElement('div');
        panel.id = 'linuxdo-auto-reader-panel';
        panel.style.cssText = `
            position: fixed;
            top: 20px;
            right: 20px;
            width: 320px;
            background: #fff;
            border: 2px solid #007cba;
            border-radius: 8px;
            box-shadow: 0 4px 12px rgba(0,0,0,0.15);
            z-index: 10000;
            font-family: Arial, sans-serif;
            font-size: 14px;
            transition: all 0.3s ease;
            overflow: hidden;
        `;

        // 修复进度显示逻辑
        const totalTopics = config.isRunning ? config.actualTopicCount : Math.min(config.maxTopics, config.topicList.length);
        const statusText = config.isRunning ?
            (isTopicPage() ? '正在阅读话题内容...' : '准备跳转到下一个话题...') :
            '未运行';

        panel.innerHTML = `
            <div id="panel-header" style="display: flex; justify-content: space-between; align-items: center; padding: 10px 15px; background: #007cba; color: white;">
                <div class="panel-title" style="font-weight: bold; font-size: 16px;">🤖 LinuxDo 自动阅读器</div>
                <button id="toggle-btn" style="background: none; border: none; color: white; font-size: 18px; cursor: pointer; padding: 2px 6px; border-radius: 3px; transition: background 0.2s;" title="收缩控制面板">📕</button>
            </div>
            <div id="panel-content" style="padding: 15px;">
                <div style="margin-bottom: 10px;">
                    <label style="display: inline-block; width: 100px;">阅读速度 (秒):</label>
                    <input type="number" id="readSpeed" value="${config.readSpeed/1000}" min="1" max="60" style="width: 60px; padding: 2px; border: 1px solid #ddd; border-radius: 3px;">
                </div>
                <div style="margin-bottom: 10px;">
                    <label style="display: inline-block; width: 100px;">滚动速度 (秒):</label>
                    <input type="number" id="scrollSpeed" value="${config.scrollSpeed/1000}" min="0.5" max="10" step="0.5" style="width: 60px; padding: 2px; border: 1px solid #ddd; border-radius: 3px;">
                </div>
                <div style="margin-bottom: 10px;">
                    <label style="display: inline-block; width: 100px;">话题数量:</label>
                    <input type="number" id="maxTopics" value="${config.maxTopics}" min="1" max="100" style="width: 60px; padding: 2px; border: 1px solid #ddd; border-radius: 3px;">
                </div>
                <div style="margin-bottom: 15px;">
                    <label style="display: inline-block; width: 100px;">跳过前面:</label>
                    <input type="number" id="skipCount" value="${config.skipCount}" min="0" max="50" style="width: 60px; padding: 2px; border: 1px solid #ddd; border-radius: 3px;">
                    <span style="font-size: 12px; color: #666; margin-left: 5px;">篇</span>
                </div>
                <div style="margin-bottom: 15px;">
                    <button id="startBtn" style="background: #28a745; color: white; border: none; padding: 8px 16px; border-radius: 4px; cursor: pointer; margin-right: 10px; transition: background 0.2s;" ${config.isRunning ? 'disabled' : ''}>▶️ 开始阅读</button>
                    <button id="stopBtn" style="background: #dc3545; color: white; border: none; padding: 8px 16px; border-radius: 4px; cursor: pointer; transition: background 0.2s;" ${!config.isRunning ? 'disabled' : ''}>⏹️ 停止阅读</button>
                </div>
                <div style="margin-bottom: 15px;">
                    <button id="backToListBtn" style="background: #6c757d; color: white; border: none; padding: 6px 12px; border-radius: 4px; cursor: pointer; font-size: 12px; margin-right: 10px; transition: background 0.2s;" ${config.isRunning ? 'disabled' : ''}>🔙 返回列表</button>
                    <button id="clearHistoryBtn" style="background: #ffc107; color: #212529; border: none; padding: 6px 12px; border-radius: 4px; cursor: pointer; font-size: 12px; transition: background 0.2s;" ${config.isRunning ? 'disabled' : ''}>🗑️ 清除记录</button>
                </div>
                <div style="font-size: 12px; color: #666; border-top: 1px solid #eee; padding-top: 10px;">
                    <div>状态: <span id="status" style="font-weight: bold; color: ${config.isRunning ? '#28a745' : '#6c757d'};">${statusText}</span></div>
                    <div>进度: <span id="progress" style="font-weight: bold; color: #007cba;">${config.currentCount}/${totalTopics}</span></div>
                    <div>已读话题: <span style="font-weight: bold; color: #17a2b8;">${config.readTopicIds.length}</span> 个</div>
                    <div style="margin-top: 5px; font-size: 11px; color: #999;">
                        💡 提示:短话题滚动到底部后会立即跳转
                    </div>
                </div>
            </div>
        `;

        document.body.appendChild(panel);

        // 绑定事件
        bindEvents();

        // 应用当前的展开/收缩状态
        updatePanelDisplay();
    }

    // 绑定事件
    function bindEvents() {
        const startBtn = document.getElementById('startBtn');
        const stopBtn = document.getElementById('stopBtn');
        const backToListBtn = document.getElementById('backToListBtn');
        const clearHistoryBtn = document.getElementById('clearHistoryBtn');
        const toggleBtn = document.getElementById('toggle-btn');
        const header = document.getElementById('panel-header');

        if (startBtn) startBtn.addEventListener('click', startReading);
        if (stopBtn) stopBtn.addEventListener('click', stopReading);
        if (backToListBtn) backToListBtn.addEventListener('click', backToList);
        if (clearHistoryBtn) clearHistoryBtn.addEventListener('click', clearReadHistory);

        // 修复CSP问题:使用addEventListener而不是内联事件处理
        if (toggleBtn) {
            toggleBtn.addEventListener('click', (e) => {
                e.stopPropagation(); // 阻止事件冒泡
                togglePanel();
            });
        }

        // 为整个标题栏添加点击事件(仅在收缩状态下生效)
        if (header) {
            header.addEventListener('click', (e) => {
                // 如果点击的是按钮,不处理
                if (e.target.id === 'toggle-btn') return;

                // 只有在收缩状态下才允许通过点击标题栏展开
                if (config.isCollapsed) {
                    togglePanel();
                }
            });
        }

        // 监听配置变化
        const readSpeedInput = document.getElementById('readSpeed');
        const scrollSpeedInput = document.getElementById('scrollSpeed');
        const maxTopicsInput = document.getElementById('maxTopics');
        const skipCountInput = document.getElementById('skipCount');

        if (readSpeedInput) {
            readSpeedInput.addEventListener('change', (e) => {
                config.readSpeed = parseInt(e.target.value) * 1000;
                saveConfig();
            });
        }
        if (scrollSpeedInput) {
            scrollSpeedInput.addEventListener('change', (e) => {
                config.scrollSpeed = parseFloat(e.target.value) * 1000;
                saveConfig();
            });
        }
        if (maxTopicsInput) {
            maxTopicsInput.addEventListener('change', (e) => {
                config.maxTopics = parseInt(e.target.value);
                saveConfig();
            });
        }
        if (skipCountInput) {
            skipCountInput.addEventListener('change', (e) => {
                config.skipCount = parseInt(e.target.value) || 0;
                saveConfig();
                console.log('跳过前面话题数量已更新为:', config.skipCount);
            });
        }
    }

    // 更新状态显示
    function updateStatus(status, progress = null) {
        const statusEl = document.getElementById('status');
        const progressEl = document.getElementById('progress');
        if (statusEl) {
            statusEl.textContent = status;
            statusEl.style.color = config.isRunning ? '#28a745' : '#6c757d';
        }
        if (progressEl && progress) {
            progressEl.textContent = progress;
        }
    }

    // 获取话题列表(过滤已阅读的话题并应用跳过设置)
    function getTopicList() {
        // 尝试多种选择器来获取话题链接
        const selectors = [
            'a.title',
            'a[href*="/t/topic/"]',
            '.topic-list-item a',
            '.topic-title a'
        ];

        let topicLinks = [];
        for (const selector of selectors) {
            topicLinks = Array.from(document.querySelectorAll(selector));
            if (topicLinks.length > 0) break;
        }

        const allTopics = topicLinks
            .map(link => link.href)
            .filter(href => href && href.includes('/t/topic/'))
            .slice(0, 100); // 限制最多100个话题

        console.log(`原始话题列表长度: ${allTopics.length}`);
        console.log(`跳过前面话题数量: ${config.skipCount}`);

        // 应用跳过设置:跳过前面指定数量的话题
        const topicsAfterSkip = allTopics.slice(config.skipCount);
        console.log(`跳过后话题列表长度: ${topicsAfterSkip.length}`);

        // 过滤掉已阅读的话题
        const unreadTopics = topicsAfterSkip.filter(url => {
            const topicId = extractTopicId(url);
            return topicId && !isTopicRead(topicId);
        });

        console.log(`找到 ${allTopics.length} 个话题,跳过前 ${config.skipCount} 个后剩余 ${topicsAfterSkip.length} 个,其中 ${unreadTopics.length} 个未读`);
        return unreadTopics;
    }

    // 开始阅读
    async function startReading() {
        if (config.isRunning) return;

        config.isRunning = true;
        config.currentCount = 0;
        config.currentIndex = 0;
        config.startUrl = window.location.href;

        updateStatus('正在获取话题列表...');

        // 如果不在话题列表页面,先跳转到latest页面
        if (!isTopicListPage()) {
            updateStatus('跳转到话题列表页面...');
            saveConfig();
            window.location.href = 'https://linux.do/latest';
            return;
        }

        // 获取话题列表(已过滤已阅读的话题并应用跳过设置)
        config.topicList = getTopicList();
        if (config.topicList.length === 0) {
            const skipText = config.skipCount > 0 ? `,跳过前 ${config.skipCount} 个话题后` : '';
            updateStatus(`未找到未读话题${skipText},请刷新页面或调整设置`);
            clearRunningState();
            createControlPanel();
            return;
        }

        // 计算实际要阅读的话题数量:用户设置的数量和实际可用话题数量的较小值
        config.actualTopicCount = Math.min(config.maxTopics, config.topicList.length);
        console.log(`用户设置话题数量: ${config.maxTopics}, 实际可用话题数量: ${config.topicList.length}, 实际要阅读: ${config.actualTopicCount}`);

        const skipText = config.skipCount > 0 ? `(已跳过前 ${config.skipCount} 个话题)` : '';
        updateStatus(`开始自动阅读...${skipText}`, `0/${config.actualTopicCount}`);

        // 保存状态
        saveConfig();

        // 更新按钮状态
        createControlPanel();

        // 开始阅读循环
        setTimeout(() => {
            readNextTopic();
        }, 1000);
    }

    // 停止阅读
    function stopReading() {
        clearRunningState();
        updateStatus('已停止');
        createControlPanel();
    }

    // 返回列表页面
    function backToList() {
        if (config.startUrl) {
            window.location.href = config.startUrl;
        } else {
            window.location.href = 'https://linux.do/latest';
        }
    }

    // 判断是否在话题列表页面
    function isTopicListPage() {
        const url = window.location.href;
        return url.includes('/latest') ||
               url.includes('/top') ||
               url.includes('/hot') ||
               url === 'https://linux.do/' ||
               url.includes('/c/') ||
               url.includes('/categories');
    }

    // 判断是否在话题页面
    function isTopicPage() {
        return window.location.href.includes('/t/topic/');
    }

    // 阅读下一个话题
    async function readNextTopic() {
        if (!config.isRunning) return;

        // 使用实际要阅读的话题数量进行判断
        if (config.currentCount >= config.actualTopicCount || config.currentIndex >= config.topicList.length) {
            const skipText = config.skipCount > 0 ? `(已跳过前 ${config.skipCount} 个话题)` : '';
            updateStatus(`阅读完成!${skipText}`, `${config.currentCount}/${config.actualTopicCount}`);
            clearRunningState();
            createControlPanel();
            // 自动返回列表页面
            setTimeout(() => {
                backToList();
            }, 2000);
            return;
        }

        const topicUrl = config.topicList[config.currentIndex];
        config.currentIndex++;
        config.currentCount++;

        updateStatus(`跳转到话题 ${config.currentCount}...`, `${config.currentCount}/${config.actualTopicCount}`);

        // 保存状态
        saveConfig();

        // 跳转到话题页面
        window.location.href = topicUrl;
    }

    // 改进的检查是否已滚动到页面底部函数
    function isScrolledToBottom() {
        const scrollTop = window.pageYOffset || document.documentElement.scrollTop;
        const windowHeight = window.innerHeight || document.documentElement.clientHeight;
        const documentHeight = document.documentElement.scrollHeight;

        // 减少误差范围到30px,提高检测精度
        const threshold = 30;
        const isAtBottom = scrollTop + windowHeight >= documentHeight - threshold;

        console.log(`滚动检测: scrollTop=${scrollTop}, windowHeight=${windowHeight}, documentHeight=${documentHeight}, isAtBottom=${isAtBottom}`);

        return isAtBottom;
    }

    // 检查页面是否为短话题(内容较少)
    function isShortTopic() {
        const documentHeight = document.documentElement.scrollHeight;
        const windowHeight = window.innerHeight;

        // 如果页面高度小于等于2倍窗口高度,认为是短话题
        const isShort = documentHeight <= windowHeight * 2;
        console.log(`短话题检测: documentHeight=${documentHeight}, windowHeight=${windowHeight}, isShort=${isShort}`);

        return isShort;
    }

    // 在话题页面自动滚动到底部
    async function autoScrollToBottom() {
        if (!config.isRunning || !isTopicPage()) return;

        // 获取当前话题ID并标记为已读
        const currentTopicId = extractTopicId(window.location.href);
        if (currentTopicId) {
            markTopicAsRead(currentTopicId);
            console.log(`标记话题 ${currentTopicId} 为已读`);
        }

        console.log('开始自动滚动,当前话题:', config.currentCount);

        updateStatus(`正在阅读话题 ${config.currentCount}...`, `${config.currentCount}/${config.actualTopicCount}`);

        // 等待页面加载
        await sleep(2000);

        // 检查是否为短话题
        const isShort = isShortTopic();
        if (isShort) {
            console.log('检测到短话题,将使用快速阅读模式');
        }

        // 获取页面高度信息
        let scrollHeight = document.documentElement.scrollHeight;
        const windowHeight = window.innerHeight;
        const scrollStep = Math.max(windowHeight / 3, 200); // 每次滚动至少200px
        let currentScroll = window.pageYOffset;

        // 先滚动到顶部
        window.scrollTo(0, 0);
        await sleep(500);

        // 记录开始滚动的时间
        const startTime = Date.now();
        let hasReachedBottom = false;
        let consecutiveBottomChecks = 0; // 连续检测到底部的次数

        // 逐步滚动到底部
        while (currentScroll < scrollHeight - windowHeight && config.isRunning && !hasReachedBottom) {
            currentScroll += scrollStep;
            window.scrollTo({
                top: Math.min(currentScroll, scrollHeight),
                behavior: 'smooth'
            });

            await sleep(config.scrollSpeed);

            // 重新获取页面高度,因为可能有动态内容加载
            scrollHeight = document.documentElement.scrollHeight;

            // 检查是否已经滚动到底部
            if (isScrolledToBottom()) {
                consecutiveBottomChecks++;
                console.log(`连续检测到底部 ${consecutiveBottomChecks} 次`);

                // 连续3次检测到底部才确认真的到底了
                if (consecutiveBottomChecks >= 3) {
                    hasReachedBottom = true;
                    console.log('确认已滚动到底部,准备跳转');
                    break;
                }
            } else {
                consecutiveBottomChecks = 0; // 重置计数器
            }
        }

        // 确保滚动到最底部
        if (!hasReachedBottom) {
            window.scrollTo({
                top: document.documentElement.scrollHeight,
                behavior: 'smooth'
            });
            await sleep(1000);

            // 最后再检查一次是否到底部
            if (isScrolledToBottom()) {
                hasReachedBottom = true;
                console.log('最终确认已滚动到底部');
            }
        }

        // 计算已经花费的时间
        const elapsedTime = Date.now() - startTime;
        const remainingTime = Math.max(0, config.readSpeed - elapsedTime);

        // 如果已经滚动到底部,根据话题长度决定等待时间
        let waitTime;
        if (hasReachedBottom) {
            if (isShort) {
                // 短话题:最少等待1秒,最多等待3秒
                waitTime = Math.max(1000, Math.min(3000, remainingTime));
            } else {
                // 长话题:最少等待2秒
                waitTime = Math.max(2000, remainingTime);
            }
        } else {
            // 没有到底部,等待完整的阅读时间
            waitTime = remainingTime;
        }

        console.log(`滚动完成,等待 ${waitTime/1000} 秒后跳转到下一个话题`);

        await sleep(waitTime);

        // 继续下一个话题
        if (config.isRunning) {
            updateStatus('准备下一个话题...');
            setTimeout(() => {
                readNextTopic();
            }, 1000);
        }
    }

    // 工具函数:延时
    function sleep(ms) {
        return new Promise(resolve => setTimeout(resolve, ms));
    }

    // 页面加载完成后初始化
    function init() {
        console.log('LinuxDo自动阅读器初始化,当前URL:', window.location.href);
        console.log('运行状态:', config.isRunning, '当前话题:', config.currentCount);
        console.log('已阅读话题数量:', config.readTopicIds.length);
        console.log('跳过话题数量:', config.skipCount);
        console.log('实际要阅读话题数量:', config.actualTopicCount);

        // 等待页面完全加载
        setTimeout(() => {
            createControlPanel();

            // 如果在话题页面且正在运行,开始滚动
            if (config.isRunning && isTopicPage()) {
                console.log('检测到正在运行且在话题页面,开始自动滚动');
                setTimeout(autoScrollToBottom, 1000);
            }
        }, 1000);
    }

    // 监听页面变化(用于SPA应用)
    let lastUrl = location.href;
    function checkForUrlChange() {
        const url = location.href;
        if (url !== lastUrl) {
            console.log('页面URL变化:', lastUrl, '->', url);
            lastUrl = url;

            // 页面变化后重新初始化
            setTimeout(() => {
                init();
            }, 1500);
        }
    }

    // 页面加载完成后执行
    if (document.readyState === 'loading') {
        document.addEventListener('DOMContentLoaded', init);
    } else {
        init();
    }

    // 定期检查URL变化
    setInterval(checkForUrlChange, 1000);

    // 监听DOM变化,但更加谨慎
    const observer = new MutationObserver(() => {
        // 如果控制面板不存在,重新创建
        if (!document.getElementById('linuxdo-auto-reader-panel')) {
            setTimeout(() => {
                try {
                    createControlPanel();
                } catch (error) {
                    console.log('创建控制面板时出错:', error);
                }
            }, 500);
        }
    });

    observer.observe(document.body, {
        childList: true,
        subtree: false // 只监听直接子元素变化,减少性能开销
    });

    console.log('LinuxDo自动阅读器脚本已加载');

})();

QingJ © 2025

镜像随时可能失效,请加Q群300939539或关注我们的公众号极客氢云获取最新地址