主页显示收藏题单

· · 科技·工程

如果你是一名经常在刷题的 OIer,一定收藏了不少题单。

但是现在的题单收藏功能实在是有点逊色,每次必须点进个人主页,还是有点麻烦的。因此我写了一个脚本,它能在侧边栏直接展示所有收藏题单的完成进度,并用彩色圆环直观反映题单的整体难度——这一切都只依赖本地缓存,不会在你打开首页时自动爬取题单详情。本文将简单介绍这个脚本的技术细节。

主要功能

一点技术细节

这个脚本完全基于浏览器端实现,不依赖任何第三方服务。它的核心原理是:

  1. 数据来源:洛谷在题目页的 HTML 中嵌入了一个 id="lentille-context" 的 JSON,里面包含了该题单的所有题目及其完成状态。脚本在题单页加载时直接读取这个 JSON,无需额外 API 请求。
  2. 难度众数计算:遍历题目列表,统计各难度等级的出现次数,取最多的一项作为众数。颜色映射采用了精确的色值(红橙黄绿青蓝紫黑)。
  3. 进度环绘制:用 SVG 画出两个叠加的圆环,底层浅灰色,上层根据进度百分比计算 stroke-dashoffset 来裁剪弧长。当进度为 100% 时,切换为一个带半透明背景的勾形图标。
  4. 数据刷新:只有在访问 /training/* 页面时才会更新缓存,主页只读不写。这也意味着首次安装时进度环会显示“?”,只要去点开一个题单,数据就会被记录下来。

如何开始使用

  1. 安装 Tampermonkey。
  2. 将脚本代码添加到浏览器中。
  3. 打开任意你收藏的题单页面(例如从主页的“收藏题单”点击进入),脚本会自动缓存该题单的完成进度和难度众数。
  4. 回到洛谷首页,即可。

声明

只有在用户手动打开题单页面才会保存做题数和题目信息,绝对不存在“爬取”做题数和题目信息等会对洛谷服务器发起 高频请求 的可能。

源代码

// ==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>`;
        }
    }
})();

等我以后一定要把所有我写的插件整合到一起。