您需要先安装一个扩展,例如 篡改猴、Greasemonkey 或 暴力猴,之后才能安装此脚本。
您需要先安装一个扩展,例如 篡改猴 或 暴力猴,之后才能安装此脚本。
您需要先安装一个扩展,例如 篡改猴 或 暴力猴,之后才能安装此脚本。
您需要先安装一个扩展,例如 篡改猴 或 Userscripts ,之后才能安装此脚本。
您需要先安装一款用户脚本管理器扩展,例如 Tampermonkey,才能安装此脚本。
您需要先安装用户脚本管理器扩展后才能安装此脚本。
记录30天阅读挑战的打卡情况,自动统计阅读时长,数据保存在本地,显示日期、挑战周期、进度条及周分布,仅在页面激活时计时,每分钟更新一次,无需刷新
// ==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(); })();