主页显示收藏题单
a_small_OIer · · 科技·工程
如果你是一名经常在刷题的 OIer,一定收藏了不少题单。
但是现在的题单收藏功能实在是有点逊色,每次必须点进个人主页,还是有点麻烦的。因此我写了一个脚本,它能在侧边栏直接展示所有收藏题单的完成进度,并用彩色圆环直观反映题单的整体难度——这一切都只依赖本地缓存,不会在你打开首页时自动爬取题单详情。本文将简单介绍这个脚本的技术细节。
主要功能
- 首页侧边栏展示:在洛谷首页右侧的“任务计划”上方,会出现一个“收藏题单”卡片,列出你收藏的所有题单。
- 圆形进度环:每个题单左侧有一个小巧的圆环,显示“已完成数/总题数”。全部完成时,圆环会变成一个绿色的 ✓。
- 难度众数颜色:圆环的颜色不是固定的,而是由题单内出现次数最多的题目难度决定。1~8 级难度分别对应红、橙、黄、绿、青、蓝、紫、黑,让你一眼就能判断这道题单的大致难度。
- 绝对无自动爬取:进度数据只有在你自己打开某个题单详情页时才会被缓存,脚本不会在后台默默发起任何网络请求。避免了对洛谷服务器造成不必要的负担。
- 本地缓存持久化:进度和难度众数存储在浏览器 中,下次打开首页直接读取,无需重复操作。
一点技术细节
这个脚本完全基于浏览器端实现,不依赖任何第三方服务。它的核心原理是:
- 数据来源:洛谷在题目页的 HTML 中嵌入了一个
id="lentille-context"的 JSON,里面包含了该题单的所有题目及其完成状态。脚本在题单页加载时直接读取这个 JSON,无需额外 API 请求。 - 难度众数计算:遍历题目列表,统计各难度等级的出现次数,取最多的一项作为众数。颜色映射采用了精确的色值(红橙黄绿青蓝紫黑)。
- 进度环绘制:用 SVG 画出两个叠加的圆环,底层浅灰色,上层根据进度百分比计算
stroke-dashoffset来裁剪弧长。当进度为 100% 时,切换为一个带半透明背景的勾形图标。 - 数据刷新:只有在访问
/training/*页面时才会更新缓存,主页只读不写。这也意味着首次安装时进度环会显示“?”,只要去点开一个题单,数据就会被记录下来。
如何开始使用
- 安装 Tampermonkey。
- 将脚本代码添加到浏览器中。
- 打开任意你收藏的题单页面(例如从主页的“收藏题单”点击进入),脚本会自动缓存该题单的完成进度和难度众数。
- 回到洛谷首页,即可。
声明
只有在用户手动打开题单页面才会保存做题数和题目信息,绝对不存在“爬取”做题数和题目信息等会对洛谷服务器发起 高频请求 的可能。
源代码
// ==UserScript==
// @name 洛谷收藏题单 + 进度
// @namespace http://tampermonkey.net/
// @version 3.3
// @description 主页显示收藏题单及完成进度,题单页自动更新缓存
// @author a_small_OIer
// @match https://www.luogu.com.cn/
// @match https://www.luogu.com.cn/training/*
// @grant none
// @run-at document-end
// ==/UserScript==
(function(){
'use strict';
const path = window.location.pathname;
//难度1-8对应颜色 (RGB)
const DIFFICULTY_COLORS = [
'rgb(254,76,97)', //1 红
'rgb(243,156,17)', //2 橙
'rgb(255,193,22)', //3 黄
'rgb(82,196,26)', //4 绿
'rgb(19,194,194)', //5 青
'rgb(52,152,219)', //6 蓝
'rgb(157,61,207)', //7 紫
'rgb(14,29,105)' //8 黑
];//额啊……搬运ing……
if (path.startsWith('/training/') && path !== '/training/list') {
try{
const scripts = document.querySelectorAll('script[id="lentille-context"]');
if(scripts.length === 0) return;
const json = JSON.parse(scripts[0].textContent);
const training = json.data?.training;
if(!training) return;
const id = training.id;
const total = training.problemCount || 0;
const problems = training.problems || [];
const solved = problems.filter(p => p.accepted === true).length;
let modeDifficulty = -1;
if(problems.length > 0){
const freq = {};
let maxCount = 0;
problems.forEach(p =>{
if(p.difficulty){
freq[p.difficulty] = (freq[p.difficulty] || 0) + 1;
if(freq[p.difficulty] > maxCount){
maxCount = freq[p.difficulty];
modeDifficulty = p.difficulty;
}
}
});
}
if(id && total > 0){
localStorage.setItem(`luogu_training_progress_${id}`,
JSON.stringify({ solved, total, modeDifficulty }));
console.log(`[进度缓存] 题单 ${id}:${solved}/${total},难度众数:${modeDifficulty}`);
}
}catch(e){
console.warn('[进度缓存] 更新失败', e);
}
return;
}
if(path !== '/')return;
console.log('[收藏题单] 开始加载...');
initHome();
async function initHome(){
try{
const resp = await fetch('https://www.luogu.com.cn/user/mine/trainingFav', {
headers: { 'Accept': 'text/html,application/xhtml+xml,application/xml;q=0.9,*/*;q=0.8' },
credentials: 'same-origin'
});
if(!resp.ok) throw new Error('状态码 ' + resp.status);
const html = await resp.text();
const match = html.match(/<script id="lentille-context" type="application\/json">([\s\S]*?)<\/script>/);
if(!match) throw new Error('未登录或无数据');
const json = JSON.parse(match[1]);
const trainings = json.data?.trainings?.result;
if (!Array.isArray(trainings) || trainings.length === 0) throw new Error('题单为空');
console.log(`[收藏题单] 获取到 ${trainings.length} 个题单`);
const target = await waitForTarget();
if(!target){
console.warn('[收藏题单] 未找到插入位置');
return;
}
const card = buildCard(trainings);
target.parentNode.insertBefore(card, target);
//仅从缓存加载进度,不发起任何网络请求
loadProgressFromCache(trainings);
}catch(e){
console.error('[收藏题单] Error:', e.message);
}
}
function waitForTarget(timeout = 8000){
return new Promise(resolve => {
const start = Date.now();
const check = () => {
const h2s = document.querySelectorAll('#app-old h2');
for (const h2 of h2s) {
if (h2.textContent.includes('任务计划') || h2.textContent.includes('本站公告')) {
resolve(h2.closest('.lg-article'));
return;
}
}
if (Date.now() - start > timeout) resolve(null);
else setTimeout(check, 200);
};
check();
});
}
function buildCard(trainings){
if(document.getElementById('tfc-card')) return document.getElementById('tfc-card');
const itemsHtml = trainings.map(item => {
const name = item.name || '未知题单';
const link = `/training/${item.id}`;
const count = item.problemCount || 0;
const provider = item.provider?.name || '';
const id = item.id;
return `
<div style="display:flex; align-items:center; padding:8px 0; border-bottom:1px solid #f5f5f5;">
<div class="tfc-progress-ring" id="tfc-ring-${id}" style="width:24px; height:24px; margin-right:8px; flex-shrink:0;">
<svg width="24" height="24" viewBox="0 0 24 24">
<circle cx="12" cy="12" r="9" fill="none" stroke="#eee" stroke-width="3"></circle>
<text x="12" y="16" text-anchor="middle" font-size="8" fill="#999">?</text>
</svg>
</div>
<div style="flex:1; min-width:0;">
<a href="${link}" target="_blank" style="font-size:14px; font-weight:500; color:#2c3e50; text-decoration:none; word-break:break-word;">${name}</a>
<div style="font-size:12px; color:#999; margin-top:3px; line-height:1.4;">
<span>${count}题</span>
${provider ? `<span style="margin-left:6px;">${provider}</span>` : ''}
</div>
</div>
</div>`;
}).join('');
const card = document.createElement('div');
card.id = 'tfc-card';
card.className = 'lg-article';
card.style.cssText = 'margin-bottom:15px; padding:15px 15px 10px; word-break:break-word; overflow:hidden;';
card.innerHTML = `
<h2 style="lg-article"> 收藏题单</h2>
<div>${itemsHtml}</div>
<div style="font-size:12px; color:#bbb; margin-top:8px; text-align:right;">共 ${trainings.length} 个题单</div>
`;
return card;
}
function loadProgressFromCache(trainings){
for (const item of trainings) {
const id = item.id;
const container = document.getElementById(`tfc-ring-${id}`);
if(!container) continue;
const cacheKey = `luogu_training_progress_${id}`;
const cached = localStorage.getItem(cacheKey);
if(!cached) continue;//???神秘,真的会触发这个吗
try{
const data = JSON.parse(cached);
const solved = data.solved;
const total = data.total;
const modeDifficulty = data.modeDifficulty || 1;
const color = DIFFICULTY_COLORS[modeDifficulty - 1] || DIFFICULTY_COLORS[0];
updateRing(container, solved, total, color);
} catch(e) {}
}
}
function updateRing(container, solved, total, color){
const percent = Math.min(solved / total, 1);
const circumference = 2 * Math.PI * 9; // 半径9
const offset = circumference * (1 - percent);
if(solved === total){
container.innerHTML = `
<svg width="24" height="24" viewBox="0 0 24 24">
<circle cx="12" cy="12" r="9" fill="${color}" opacity="0.15" stroke="${color}" stroke-width="3"></circle>
<path d="M7 12 l3 3 6-7" stroke="${color}" stroke-width="2.5" fill="none" stroke-linecap="round" stroke-linejoin="round"/>
</svg>`;
}else{
container.innerHTML = `
<svg width="24" height="24" viewBox="0 0 24 24">
<circle cx="12" cy="12" r="9" fill="none" stroke="#eee" stroke-width="3"></circle>
<circle cx="12" cy="12" r="9" fill="none" stroke="${color}" stroke-width="3"
stroke-dasharray="${circumference}" stroke-dashoffset="${offset}"
stroke-linecap="round" transform="rotate(-90 12 12)"></circle>
<text x="12" y="15" text-anchor="middle" font-size="8" fill="#333" font-weight="bold">${solved}/${total}</text>
</svg>`;
}
}
})();
等我以后一定要把所有我写的插件整合到一起。