// ==UserScript==
// @name 微信读书30天阅读挑战打卡记录(本地版)
// @namespace http://tampermonkey.net/
// @version 0.15
// @description 记录30天阅读挑战的打卡情况,自动统计阅读时长,数据保存在本地,显示日期、挑战周期、进度条及周分布
// @icon https://i.miji.bid/2025/03/08/990e81d6e8ebc90d181e091cc0c99699.jpeg
// @author Charlie
// @match https://weread.qq.com/web/reader/*
// @grant GM_registerMenuCommand
// @grant GM_getValue
// @grant GM_setValue
// @run-at document-end
// @license MIT
// ==/UserScript==
(function() {
'use strict';
// ===== 常量定义 =====
const TOTAL_DAYS = 30; // 挑战总天数
const TOTAL_GOAL_HOURS = 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 = Date.now(); // 记录阅读开始时间
// 获取下拉隐藏开关状态,默认隐藏
const hideOnScrollDown = GM_getValue('hideOnScrollDown', true);
// ===== 时间记录相关函数 =====
/**
* 记录阅读时长并保存到本地存储
*/
function recordReadingTime() {
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));
console.log(`记录阅读时长:本次${sessionTime.toFixed(2)}分钟,今日${challengeData.dailyReadingTimes[todayIndex].toFixed(0)}分钟`);
} catch (e) {
console.error('记录阅读时长失败:', e);
}
}
// ===== 工具函数 =====
/**
* 格式化日期为 YYYY-MM-DD
*/
function formatDate(date) {
return date.toISOString().split('T')[0];
}
/**
* 计算总阅读时间、剩余时间和进度
*/
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 progress = Math.min((totalMinutes / goalMinutes) * 100, 100);
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}分钟`,
progress: progress,
average: `${Math.floor(avgMinutes / 60)}小时${Math.floor(avgMinutes % 60)}分钟`
};
} catch (e) {
console.error('计算总时长失败:', e);
return { total: '0小时0分钟', remaining: '30小时0分钟', progress: 0, 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(formatDate(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(''), total: '0小时0分钟' };
}
}
// ===== UI 创建函数 =====
/**
* 创建并更新挑战 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: 2147483648;
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, height 0.3s ease; /* 确保过渡动画生效 */
overflow: hidden; /* 防止内容溢出 */
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);
container.innerHTML = `
<h1 style="font-size: 1.2em; margin: 0 0 6px 0; color: #333;">30天阅读挑战</h1>
<div style="font-size: 1em; color: #666; margin-bottom: 10px;">
<div>🏅 挑战时间:</div>
<div>\u00A0\u00A0\u00A0\u00A0 ${formatDate(startDate)} 至 ${formatDate(endDate)}</div>
</div>
<div style="font-size: 1em; color: #666; text-align: left;">
<div>⌚ 本周期目标时长:</div>
<div>\u00A0\u00A0\u00A0\u00A0 ${totalTime.total} / 还需${totalTime.remaining}</div>
</div>
<div style="margin-left: 22px; margin-top: 10px; width: 86%; height: 10px; background: #ebedf0; border-radius: 4px; overflow: hidden;">
<div style="width: ${totalTime.progress}%; height: 100%; background: #30AAFD; border-radius: 4px; transition: width 0.3s ease;"></div>
</div>
<div style="display: grid; grid-template-columns: repeat(6, 1fr); gap: 4px; margin-top: 10px;">
${Array.from({ length: TOTAL_DAYS }, (_, i) => {
const date = new Date(startDate);
date.setDate(date.getDate() + i);
return `<div style="width: 28px; height: 28px; background-color: ${challengeData.completedDays[i] ? '#30AAFD' : '#ebedf0'}; border-radius: 4px; display: flex; align-items: center; justify-content: center; font-size: 14px; color: ${challengeData.completedDays[i] ? '#fff' : '#666'};">${date.getDate()}</div>`;
}).join('')}
</div>
<div style="font-size: 1em; color: #666; margin-top: 12px; 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; flex-direction: column; gap: 8px; height: 160px; width: 100%; padding: 5px;">
${weeklyData.times.map((minutes, i) => `
<div style="display: flex; align-items: center; width: 100%;">
<div style="font-size: 0.8em; color: #666; width: 50px; text-align: left; flex-shrink: 0;">${weeklyData.dates[i].slice(-5)}</div>
<div style="flex-grow: 1; height: 10px; background: #ebedf0; border-radius: 2px; overflow: hidden;">
<div style="width: ${(minutes / maxWeeklyMinutes) * 100}%; height: 100%; background: #30AAFD; border-radius: 2px; transition: width 0.3s ease;"></div>
</div>
<div style="font-size: 0.8em; color: #666; margin-left: 5px; flex-shrink: 0;">${Math.floor(minutes)}分</div>
</div>
`).join('')}
</div>
</div>
`;
// 动态设置初始高度
container.style.height = `${container.scrollHeight}px`;
document.body.appendChild(container);
console.log('UI 创建完成');
} 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();
console.log('挑战已重置');
}
}
// ===== 初始化和事件监听 =====
/**
* 初始化脚本
*/
function initialize() {
console.log('脚本开始初始化...');
if (!document.body) {
console.log('等待 DOM 加载...');
const observer = new MutationObserver(() => {
if (document.body) {
observer.disconnect();
setup();
}
});
observer.observe(document.documentElement, { childList: true, subtree: true });
return;
}
setup();
}
/**
* 设置脚本功能
*/
function setup() {
console.log('DOM 已加载,开始设置...');
createChallengeUI();
window.addEventListener('beforeunload', recordReadingTime);
setInterval(() => {
recordReadingTime();
startTime = Date.now();
createChallengeUI();
}, 60 * 1000);
const observer = new MutationObserver(() => {
if (!document.getElementById('challenge-container')) {
console.log('UI 丢失,重新创建...');
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;
window.addEventListener('scroll', () => {
let scrollS = window.scrollY;
let container = document.getElementById('challenge-container');
if (scrollS > windowTop && scrollS > 50) {
// 下拉时根据开关决定是否隐藏
if (hideOnScrollDown) {
container.style.height = '0';
container.style.opacity = '0';
}
} else {
// 向上滚动时始终显示
if (container) {
container.style.height = `${container.scrollHeight}px`; // 动态恢复高度
container.style.opacity = '1';
}
}
windowTop = scrollS;
});
}
console.log('30天阅读挑战脚本加载中...');
initialize();
})();