🎉 欢迎访问GreasyFork.Org 镜像站!本镜像站由公众号【爱吃馍】搭建,用于分享脚本。联系邮箱📮

Greasy fork 爱吃馍镜像

Greasy Fork is available in English.

📂 缓存分发状态(共享加速已生效)
🕒 页面同步时间:2026/01/07 05:05:17
🔄 下次更新时间:2026/01/07 06:05:17
手动刷新缓存

微信读书30天阅读挑战打卡记录(本地版)

记录30天阅读挑战的打卡情况,自动统计阅读时长,数据保存在本地,显示日期、挑战周期、进度条及周分布,仅在页面激活时计时,每分钟更新一次,无需刷新

You will need to install an extension such as Tampermonkey, Greasemonkey or Violentmonkey to install this script.

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

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

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

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

You will need to install a user script manager extension to install this script.

(I already have a user script manager, let me install it!)

🚀 安装遇到问题?关注公众号获取帮助

公众号二维码

扫码关注【爱吃馍】

回复【脚本】获取最新教程和防失联地址

You will need to install an extension such as Stylus to install this style.

You will need to install an extension such as Stylus to install this style.

You will need to install an extension such as Stylus to install this style.

You will need to install a user style manager extension to install this style.

You will need to install a user style manager extension to install this style.

You will need to install a user style manager extension to install this style.

(I already have a user style manager, let me install it!)

🚀 安装遇到问题?关注公众号获取帮助

公众号二维码

扫码关注【爱吃馍】

回复【脚本】获取最新教程和防失联地址

// ==UserScript==
// @name         微信读书30天阅读挑战打卡记录(本地版)
// @version      0.24
// @description  记录30天阅读挑战的打卡情况,自动统计阅读时长,数据保存在本地,显示日期、挑战周期、进度条及周分布,仅在页面激活时计时,每分钟更新一次,无需刷新
// @icon         https://i.miji.bid/2025/03/15/560664f99070e139e28703cf92975c73.jpeg
// @author       Grok
// @match        https://weread.qq.com/web/reader/*
// @grant        GM_registerMenuCommand
// @grant        GM_getValue
// @grant        GM_setValue
// @run-at       document-end
// @license      MIT
// @namespace http://tampermonkey.net/
// ==/UserScript==

(function() {
    'use strict';

    // ===== 常量定义 =====
    const TOTAL_DAYS = 30;
    const TOTAL_GOAL_HOURS = 30;
    const CHART_BLUE = '#30AAFD';
    const CALENDAR_DAYS = 30;

    // ===== 数据初始化 =====
    let challengeData = JSON.parse(localStorage.getItem('challengeData')) || {
        startDate: new Date().toISOString().split('T')[0],
        completedDays: Array(TOTAL_DAYS).fill(false),
        dailyReadingTimes: Array(TOTAL_DAYS).fill(0)
    };
    let startTime = null;
    let isPageActive = document.hasFocus();
    const hideOnScrollDown = GM_getValue('hideOnScrollDown', true);
    let globalTooltip = null;
    let eventListeners = [];
    let intervalId = null;
    let todayReadingElement = null; // 保存“今日阅读”元素的引用

    // ===== 时间记录相关函数 =====
    function recordReadingTime() {
        if (!startTime || !isPageActive) return;
        console.log('recordReadingTime triggered'); // 调试日志
        try {
            const endTime = Date.now();
            const sessionTime = (endTime - startTime) / 1000 / 60;
            const todayIndex = Math.min(
                Math.floor((new Date() - new Date(challengeData.startDate)) / (1000 * 60 * 60 * 24)),
                TOTAL_DAYS - 1
            );

            if (todayIndex < 0) return;

            challengeData.dailyReadingTimes[todayIndex] += sessionTime;
            challengeData.completedDays[todayIndex] = challengeData.dailyReadingTimes[todayIndex] >= 30;
            localStorage.setItem('challengeData', JSON.stringify(challengeData));
            startTime = Date.now();
            updateTodayReadingTime(todayIndex);
        } catch (e) {
            console.error('记录阅读时长失败:', e);
        }
    }

    // ===== 更新“今日阅读”时间显示 =====
    function updateTodayReadingTime(todayIndex) {
        console.log('updateTodayReadingTime called'); // 调试日志
        try {
            const todayReadingMinutes = challengeData.dailyReadingTimes[todayIndex];
            const todayReadingHours = Math.floor(todayReadingMinutes / 60);
            const todayReadingMins = Math.floor(todayReadingMinutes % 60);
            const todayReadingTime = `📖 今日阅读:${todayReadingHours}小时${todayReadingMins}分钟`;

            if (todayReadingElement) {
                todayReadingElement.textContent = todayReadingTime;
            } else {
                console.warn('todayReadingElement 未找到,重建 UI');
                createChallengeUI();
            }
        } catch (e) {
            console.error('更新今日阅读时间失败:', e);
            createChallengeUI();
        }
    }

    // ===== 页面激活状态监听 =====
    function handlePageActive() {
        if (document.hasFocus() && document.visibilityState === 'visible') {
            if (!isPageActive) {
                console.log('页面激活,开始计时');
                startTime = Date.now();
                isPageActive = true;
                if (!intervalId) {
                    intervalId = setInterval(recordReadingTime, 60 * 1000);
                    console.log('定时器已启动,ID:', intervalId);
                }
            }
        }
    }

    function handlePageInactive() {
        if (!document.hasFocus() || document.visibilityState === 'hidden') {
            if (isPageActive) {
                console.log('页面失活,暂停计时');
                recordReadingTime();
                startTime = null;
                isPageActive = false;
                if (intervalId) {
                    clearInterval(intervalId);
                    console.log('定时器已清除,ID:', intervalId);
                    intervalId = null;
                }
            }
        }
    }

    // ===== 工具函数 =====
    function formatDate(date) {
        return date.toISOString().split('T')[0].replace(/-/g, '/');
    }

    function formatFullDateWithDay(date) {
        const weekdays = ['星期日', '星期一', '星期二', '星期三', '星期四', '星期五', '星期六'];
        const formattedDate = formatDate(date);
        const dayOfWeek = weekdays[date.getDay()];
        return `${formattedDate} ${dayOfWeek}`;
    }

    function formatTime(minutes) {
        const hours = Math.floor(minutes / 60);
        const mins = Math.floor(minutes % 60);
        return `${hours}小时${mins}分钟`;
    }

    function calculateTotalTime() {
        try {
            const totalMinutes = challengeData.dailyReadingTimes.reduce((sum, time) => sum + (time || 0), 0);
            const goalMinutes = TOTAL_GOAL_HOURS * 60;
            const totalHours = Math.floor(totalMinutes / 60);
            const remainingMinutes = totalMinutes % 60;

            const remainingTotalMinutes = Math.max(0, goalMinutes - totalMinutes);
            const remainingHours = Math.floor(remainingTotalMinutes / 60);
            const remainingMins = Math.floor(remainingTotalMinutes % 60);

            const daysPassed = Math.min(
                Math.floor((new Date() - new Date(challengeData.startDate)) / (1000 * 60 * 60 * 24)) + 1,
                TOTAL_DAYS
            );
            const avgMinutes = daysPassed > 0 ? totalMinutes / daysPassed : 0;

            return {
                total: `${totalHours}小时${Math.floor(remainingMinutes)}分钟`,
                remaining: `${remainingHours}小时${remainingMins}分钟`,
                isGoalReached: remainingTotalMinutes === 0,
                average: `${Math.floor(avgMinutes / 60)}小时${Math.floor(avgMinutes % 60)}分钟`
            };
        } catch (e) {
            console.error('计算总时长失败:', e);
            return { total: '0小时0分钟', remaining: '30小时0分钟', isGoalReached: false, average: '0小时0分钟' };
        }
    }

    function getWeeklyReadingTimes() {
        try {
            const today = new Date();
            const currentDay = today.getDay();
            const startOfWeek = new Date(today);
            startOfWeek.setDate(today.getDate() - (currentDay === 0 ? 6 : currentDay - 1));

            const weeklyTimes = Array(7).fill(0);
            const weeklyDates = [];
            let weeklyTotalMinutes = 0;

            for (let i = 0; i < 7; i++) {
                const day = new Date(startOfWeek);
                day.setDate(startOfWeek.getDate() + i);
                const dayIndex = Math.floor((day - new Date(challengeData.startDate)) / (1000 * 60 * 60 * 24));
                weeklyDates.push(day);
                if (dayIndex >= 0 && dayIndex < TOTAL_DAYS) {
                    weeklyTimes[i] = challengeData.dailyReadingTimes[dayIndex] || 0;
                    weeklyTotalMinutes += weeklyTimes[i];
                }
            }

            return {
                times: weeklyTimes,
                dates: weeklyDates,
                total: `${Math.floor(weeklyTotalMinutes / 60)}小时${Math.floor(weeklyTotalMinutes % 60)}分钟`
            };
        } catch (e) {
            console.error('获取周数据失败:', e);
            return { times: Array(7).fill(0), dates: Array(7).fill(new Date()), total: '0小时0分钟' };
        }
    }

    // ===== UI 创建函数 =====
    function createChallengeUI() {
        try {
            const existingUI = document.getElementById('challenge-container');
            if (existingUI) existingUI.remove();

            if (!document.body) {
                console.warn('document.body 未加载,跳过 UI 创建');
                return;
            }

            const container = document.createElement('div');
            container.id = 'challenge-container';
            container.style.cssText = `
                position: fixed; top: 50px; left: 70px;
                background: rgba(255, 255, 255, 0.5);
                backdrop-filter: blur(10px);
                color: #333; padding: 15px; z-index: 10000;
                width: 250px; font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif;
                border: 1px solid rgba(221, 221, 221, 0.5); border-radius: 8px; font-size: 14px;
                transition: opacity 0.3s ease;
                overflow: visible;
                opacity: 1;
            `;

            const totalTime = calculateTotalTime();
            const weeklyData = getWeeklyReadingTimes();
            const startDate = new Date(challengeData.startDate);
            const endDate = new Date(startDate);
            endDate.setDate(startDate.getDate() + TOTAL_DAYS - 1);
            const todayIndex = Math.min(
                Math.floor((new Date() - new Date(challengeData.startDate)) / (1000 * 60 * 60 * 24)),
                TOTAL_DAYS - 1
            );
            const todayReadingMinutes = todayIndex >= 0 ? challengeData.dailyReadingTimes[todayIndex] : 0;
            const todayReadingHours = Math.floor(todayReadingMinutes / 60);
            const todayReadingMins = Math.floor(todayReadingMinutes % 60);
            const todayReadingTime = `${todayReadingHours}小时${todayReadingMins}分钟`;
            const maxWeeklyMinutes = Math.max(...weeklyData.times, 1);
            const maxDailyMinutes = Math.max(...challengeData.dailyReadingTimes, 1);

            const calendarRows = Math.ceil(CALENDAR_DAYS / 6);
            const calendarHTML = Array.from({ length: CALENDAR_DAYS }, (_, i) => {
                const date = new Date(startDate);
                date.setDate(date.getDate() + i);
                const day = date.getDate();
                const isWithinChallenge = i < TOTAL_DAYS;
                const fullDateWithDay = formatFullDateWithDay(date);
                return `
                    <div class="calendar-cell" data-date="${fullDateWithDay}" style="width: 28px; height: 28px; background-color: ${isWithinChallenge && challengeData.completedDays[i] ? '#30AAFD' : '#ebedf0'}; border-radius: 4px; display: flex; align-items: center; justify-content: center; font-size: 14px; color: ${isWithinChallenge && challengeData.completedDays[i] ? '#fff' : '#666'};">
                        ${day}
                    </div>`;
            }).join('');

            const dailyChartHTML = Array.from({ length: TOTAL_DAYS }, (_, i) => {
                const date = new Date(startDate);
                date.setDate(date.getDate() + i);
                const minutes = challengeData.dailyReadingTimes[i] || 0;
                const heightPercentage = (minutes / maxDailyMinutes) * 100;
                const fullDateWithDay = formatFullDateWithDay(date);
                return `
                    <div style="flex: 1; background: #ebedf0; border-radius: 2px; display: flex; flex-direction: column; justify-content: flex-end; position: relative;" class="chart-bar" data-minutes="${minutes}" data-date="${fullDateWithDay}">
                        <div style="width: 100%; height: ${heightPercentage}%; background: ${CHART_BLUE}; border-radius: 2px; transition: height 0.3s ease;"></div>
                    </div>`;
            }).join('');

            const weeklyChartHTML = weeklyData.times.map((minutes, i) => {
                const date = weeklyData.dates[i];
                const heightPercentage = (minutes / maxWeeklyMinutes) * 100;
                const fullDateWithDay = formatFullDateWithDay(date);
                return `
                    <div style="flex: 1; background: #ebedf0; border-radius: 2px; display: flex; flex-direction: column; justify-content: flex-end; position: relative;" class="chart-bar" data-minutes="${minutes}" data-date="${fullDateWithDay}">
                        <div style="width: 100%; height: ${heightPercentage}%; background: ${CHART_BLUE}; border-radius: 2px; transition: height 0.3s ease;"></div>
                    </div>`;
            }).join('');

            container.innerHTML = `
                <div style="display: flex; align-items: center; justify-content: space-between;">
                    <h1 style="font-size: 1.2em; margin: 0; color: #333;">30天阅读挑战</h1>
                    <div style="position: relative; display: inline-block;">
                        <button style="background: none; border: none; font-size: 1em; color: ${CHART_BLUE}; cursor: pointer; padding: 0;">ℹ️</button>
                        <div class="info-tooltip" style="display: none; position: absolute; top: 100%; right: 0; background: rgba(51, 51, 51, 0.9); color: #fff; padding: 6px 10px; font-size: 0.85em; border-radius: 4px; z-index: 2147483647; box-shadow: 0 2px 4px rgba(0,0,0,0.2); line-height: 1.4; width: 220px; text-align: left;">
                            <div>【挑战时间】:根据每次重置时日期计算</div>
                            <div>【时长更新】:激活阅读页面时开始计时,每分钟更新一次(60秒内切出页面则重新计时)</div>
                            <div>【状态更新】:当天完成30min更新状态(官方5min)</div>
                            <div>【本周期目标时长】:30天总时长需达30小时</div>
                            <div>【日均阅读】:计算挑战周期内的日平均时长</div>
                        </div>
                    </div>
                </div>
                <div style="font-size: 1em; color: #666; margin-top: 10px;">
                    <div>🏅 挑战时间:</div>
                    <div>\u00A0\u00A0\u00A0\u00A0 ${formatDate(startDate)} 至 ${formatDate(endDate)}</div>
                </div>
                <div style="font-size: 1em; color: #666; margin-top: 10px; text-align: left;">
                    <div>⌚ 本周期目标时长:</div>
                    <div>\u00A0\u00A0\u00A0\u00A0 ${totalTime.total} / 还需${totalTime.remaining}</div>
                </div>
                ${totalTime.isGoalReached ? `
                    <div style="font-size: 1em; color: ${CHART_BLUE}; margin-top: 10px; text-align: left;">
                        🎉 已达成目标时长
                    </div>
                ` : ''}
                <div style="display: grid; grid-template-columns: repeat(6, 1fr); grid-template-rows: repeat(${calendarRows}, 1fr); gap: 4px; margin-top: 10px; width: 100%;">
                    ${calendarHTML}
                </div>
                <div id="today-reading" style="font-size: 1em; color: ${CHART_BLUE}; margin-top: 10px; text-align: left;">
                    📖 今日阅读:${todayReadingTime}
                </div>
                <div style="font-size: 1em; color: #666; margin-top: 10px; text-align: left;">
                    📚 日均阅读:${totalTime.average}
                </div>
                <div style="margin-top: 10px;">
                    <div style="font-size: 0.9em; color: #666; margin-bottom: 6px; text-align: left;">
                        📊 本周阅读总时长:${weeklyData.total}
                    </div>
                    <div style="display: flex; gap: 2px; height: 100px; width: 100%; padding: 5px; background: #fff; border-radius: 4px; position: relative;" id="weeklyChart">
                        ${weeklyChartHTML}
                    </div>
                </div>
                <div style="margin-top: 5px;">
                    <div style="font-size: 0.9em; color: #666; margin-bottom: 6px; text-align: left;">
                        📈 本周期阅读分布
                    </div>
                    <div style="display: flex; gap: 2px; height: 100px; width: 100%; padding: 5px; background: #fff; border-radius: 4px; position: relative;" id="dailyChart">
                        ${dailyChartHTML}
                    </div>
                </div>
            `;

            eventListeners.forEach(({ element, type, listener }) => {
                element.removeEventListener(type, listener);
            });
            eventListeners = [];

            if (!globalTooltip) {
                globalTooltip = document.createElement('div');
                globalTooltip.className = 'tooltip';
                globalTooltip.style.cssText = `
                    display: none; position: fixed;
                    background: rgba(51, 51, 51, 0.9); color: #fff;
                    padding: 6px 10px; font-size: 0.9em; border-radius: 4px;
                    white-space: pre-wrap; z-index: 2147483647;
                    pointer-events: none; transform: translateX(-50%);
                    box-shadow: 0 2px 4px rgba(0,0,0,0.2);
                    line-height: 1.4;
                `;
                document.body.appendChild(globalTooltip);
            } else {
                globalTooltip.style.display = 'none';
            }

            document.body.appendChild(container);

            // 保存“今日阅读”元素的引用
            todayReadingElement = document.getElementById('today-reading');
            console.log('todayReadingElement 初始化:', todayReadingElement);

            const dailyChart = container.querySelector('#dailyChart');
            const weeklyChart = container.querySelector('#weeklyChart');
            const calendarCells = container.querySelectorAll('.calendar-cell');
            const infoButton = container.querySelector('button');
            const infoTooltip = container.querySelector('.info-tooltip');

            const showInfoListener = () => infoTooltip.style.display = 'block';
            const hideInfoListener = () => infoTooltip.style.display = 'none';
            infoButton.addEventListener('mouseover', showInfoListener);
            infoButton.addEventListener('mouseout', hideInfoListener);
            eventListeners.push({ element: infoButton, type: 'mouseover', listener: showInfoListener });
            eventListeners.push({ element: infoButton, type: 'mouseout', listener: hideInfoListener });

            function setupChartBars(chart, bars) {
                if (!chart) return;
                bars.forEach((bar) => {
                    const mouseoverListener = (e) => {
                        const minutes = parseFloat(bar.getAttribute('data-minutes')) || 0;
                        const dateWithDay = bar.getAttribute('data-date');
                        globalTooltip.textContent = `${dateWithDay}\n${formatTime(minutes)}`;
                        globalTooltip.style.display = 'block';
                        const rect = bar.getBoundingClientRect();
                        globalTooltip.style.left = `${rect.left + rect.width / 2}px`;
                        globalTooltip.style.top = `${rect.top - globalTooltip.offsetHeight - 5}px`;
                    };
                    const mouseoutListener = () => {
                        globalTooltip.style.display = 'none';
                    };
                    const mousemoveListener = (e) => {
                        const rect = bar.getBoundingClientRect();
                        globalTooltip.style.left = `${rect.left + rect.width / 2}px`;
                        globalTooltip.style.top = `${rect.top - globalTooltip.offsetHeight - 5}px`;
                    };
                    bar.addEventListener('mouseover', mouseoverListener);
                    bar.addEventListener('mouseout', mouseoutListener);
                    bar.addEventListener('mousemove', mousemoveListener);
                    eventListeners.push({ element: bar, type: 'mouseover', listener: mouseoverListener });
                    eventListeners.push({ element: bar, type: 'mouseout', listener: mouseoutListener });
                    eventListeners.push({ element: bar, type: 'mousemove', listener: mousemoveListener });
                });
            }

            function setupCalendarCells(cells) {
                cells.forEach((cell) => {
                    const mouseoverListener = (e) => {
                        const fullDateWithDay = cell.getAttribute('data-date');
                        globalTooltip.textContent = fullDateWithDay;
                        globalTooltip.style.display = 'block';
                        const rect = cell.getBoundingClientRect();
                        globalTooltip.style.left = `${rect.left + rect.width / 2}px`;
                        globalTooltip.style.top = `${rect.top - globalTooltip.offsetHeight - 5}px`;
                    };
                    const mouseoutListener = () => {
                        globalTooltip.style.display = 'none';
                    };
                    const mousemoveListener = (e) => {
                        const rect = cell.getBoundingClientRect();
                        globalTooltip.style.left = `${rect.left + rect.width / 2}px`;
                        globalTooltip.style.top = `${rect.top - globalTooltip.offsetHeight - 5}px`;
                    };
                    cell.addEventListener('mouseover', mouseoverListener);
                    cell.addEventListener('mouseout', mouseoutListener);
                    cell.addEventListener('mousemove', mousemoveListener);
                    eventListeners.push({ element: cell, type: 'mouseover', listener: mouseoverListener });
                    eventListeners.push({ element: cell, type: 'mouseout', listener: mouseoutListener });
                    eventListeners.push({ element: cell, type: 'mousemove', listener: mousemoveListener });
                });
            }

            setupChartBars(dailyChart, dailyChart?.querySelectorAll('.chart-bar') || []);
            setupChartBars(weeklyChart, weeklyChart?.querySelectorAll('.chart-bar') || []);
            setupCalendarCells(calendarCells);

            requestAnimationFrame(() => {
                container.style.height = `${container.scrollHeight}px`;
            });

        } catch (e) {
            console.error('创建 UI 失败:', e);
        }
    }

    // ===== 重置功能 =====
    function resetChallenge() {
        if (confirm('确定要重置挑战吗?所有打卡记录将清空!')) {
            challengeData = {
                startDate: new Date().toISOString().split('T')[0],
                completedDays: Array(TOTAL_DAYS).fill(false),
                dailyReadingTimes: Array(TOTAL_DAYS).fill(0)
            };
            localStorage.setItem('challengeData', JSON.stringify(challengeData));
            createChallengeUI();
        }
    }

    // ===== 初始化和事件监听 =====
    function initialize() {
        if (!document.body) {
            const observer = new MutationObserver(() => {
                if (document.body) {
                    observer.disconnect();
                    setup();
                }
            });
            observer.observe(document.documentElement, { childList: true, subtree: true });
            return;
        }
        setup();
    }

    function setup() {
        let attempts = 0;
        const maxAttempts = 5;

        function tryCreateUI() {
            createChallengeUI();
            if (!document.getElementById('challenge-container') && attempts < maxAttempts) {
                attempts++;
                setTimeout(tryCreateUI, 100 * attempts);
            }
        }

        tryCreateUI();

        window.addEventListener('focus', handlePageActive);
        window.addEventListener('blur', handlePageInactive);
        document.addEventListener('visibilitychange', () => {
            if (document.visibilityState === 'visible') {
                handlePageActive();
            } else {
                handlePageInactive();
            }
        });

        handlePageActive();

        window.addEventListener('beforeunload', recordReadingTime);

        const observer = new MutationObserver(() => {
            if (!document.getElementById('challenge-container')) {
                createChallengeUI();
            }
        });
        observer.observe(document.body, { childList: true, subtree: true });

        GM_registerMenuCommand('重置挑战', resetChallenge);

        GM_registerMenuCommand(`下拉时UI: ${hideOnScrollDown ? '🙈 隐藏' : '👁️ 显示'}`, () => {
            GM_setValue('hideOnScrollDown', !hideOnScrollDown);
            location.reload();
        });

        let windowTop = 0;
        let isVisible = true;
        window.addEventListener('scroll', () => {
            let scrollS = window.scrollY;
            let container = document.getElementById('challenge-container');

            if (!container) return;

            if (scrollS > windowTop && scrollS > 50 && hideOnScrollDown) {
                if (isVisible) {
                    container.style.opacity = '0';
                    isVisible = false;
                    if (globalTooltip) globalTooltip.style.display = 'none';
                }
            } else {
                if (!isVisible) {
                    container.style.opacity = '1';
                    isVisible = true;
                }
            }
            windowTop = scrollS;
        });
    }

    initialize();
})();