简易洛谷插件

· · 科技·工程

20250206 upd:修复了由前端更新导致的题目颜色爬取失败的 bug。

20240809 upd:对新版题解审核界面的题解超链接进行了特化。

20240709 upd:现在主页会先渲染讨论区的题目颜色再渲染任务列表的题目颜色。

20240707 upd:被人锐评博客写太严肃了,怎么回事呢。其实我也不想的,是不是有管理员包袱导致的(

提交记录页面和主页练习页面的题目颜色不再通过暴力爬取的方式获得,提高了速度也减小了服务器访问压力。

对于其它需要暴力爬取题目颜色的页面,给两次爬取之间增加了时间间隔限制,应该不会因为过度访问洛谷而出事了。但这个时间间隔是针对单个页面的,因此如果同时开了多个需要大量爬取的页面(比如主页或者部分题单),仍然有可能因为过度访问洛谷而被洛谷暂时屏蔽。

20240705 upd:修了一些 bug,并改良了提交记录页面的颜色爬取方法。

上学期选了门 JavaScript 课,大作业没 idea 于是就写了个洛谷小插件。写完之后发现居然不完全是废物,于是发出来供大家使用。

使用说明

该插件是一个 Tampermonkey 插件。如果你不知道什么是 Tampermonkey,可以在搜索引擎中搜索或在它的官网中学习。

源码在文章最下方。

功能

侧边栏中会增加“插件设置”选项(第一次打开可能需要刷新)。

点击进入后会出现设置界面,共有五个功能,默认全部开启(不要问界面为什么这么丑,这是 ChatGPT 写的,能用就不错了)。

具体功能不解释了,对着设置里的名称自己试一下就知道了。

值得注意的是“显示题目颜色”功能,它会把所有以题目编号开头的链接都加上颜色,具体效果如下:

此外,我还对提交记录页面的颜色显示进行了特化,解决了 exlg 的著名 bug:提交记录翻页后原内容会残留。

// ==UserScript==
// @name         luogu_bot1
// @namespace    http://tampermonkey.net/
// @version      1.8.2
// @description  自制洛谷插件
// @author       realskc
// @match        https://www.luogu.com.cn/*
// @grant        none
// @run-at       document-end
// ==/UserScript==

'use strict';
(function(){
    const featureDict = new Map([
        ['lgbot1RemoveAd', '屏蔽广告'],
        ['lgbot1Removeback', '移除网页出错跳转'],
        ['lgbot1AddMessageLink', '私信界面 Ctrl+Click 打开用户主页'],
        ['lgbot1RemoveCover', '移除主页遮盖'],
        ['lgbot1AddProblemsColor', '显示题目颜色']
    ]);
    const problemDifficultyToColor = [[191,191,191],[254,76,97],[243,156,17],[255,193,22],[82,196,26],[52,152,219],[157,61,207],[14,29,105]];
    function addSettingButton(){//增加插件设置
        const navElement = document.querySelector('nav.lfe-body');
        if(!navElement){
            console.log("未找到侧边导航栏");
            console.log(document);
            return;
        }
        const settingElement = document.createElement('a');//外壳,链接功能
        settingElement.setAttribute('data-v-0640126c', '');
        settingElement.setAttribute('data-v-639bc19b', '');
        settingElement.setAttribute('data-v-33633d7e', '');
        settingElement.className = 'color-none';
        settingElement.style.color = 'inherit';
        const settingText = document.createElement('span');
        settingText.setAttribute('data-v-639bc19b', '');
        settingText.className = 'text';
        settingText.textContent = '插件设置';
        settingElement.appendChild(settingText);
        navElement.appendChild(settingElement);
        settingElement.addEventListener('click', () => {//点击后进入临时页面
            function initializeSettings(){
                for(let i of featureDict.keys()){
                    if(localStorage.getItem(i) === null){
                        localStorage.setItem(i, 'true');
                    }
                }
            }
            initializeSettings();
            const pageContent = `
                <!DOCTYPE html>
                <html lang="en">
                <head>
                    <meta charset="UTF-8">
                    <meta name="viewport" content="width=device-width, initial-scale=1.0">
                    <title>luogu_bot1 功能设置</title>
                    <style>
                        body { font-family: Arial, sans-serif; }
                        #featureBox {
                            position: fixed;
                            top: 20%;
                            left: 50%;
                            transform: translate(-50%, -50%);
                            background-color: white;
                            border: 1px solid #ccc;
                            padding: 20px;
                            z-index: 10001;
                        }
                    </style>
                </head>
                <body>
                    <div id="featureBox">
                        <h3>选择插件功能</h3>
                        ${Array.from(featureDict).map(([key, description]) => `
                            <div>
                                <label><input type="checkbox" id="${key}">${description}</label>
                            </div>
                        `).join('')}
                        <button id="selectAll">全选</button>
                        <button id="saveFeatures">保存</button>
                    </div>
                    <script>
                        document.addEventListener('DOMContentLoaded', () => {//加载设置
                            ${Array.from(featureDict.keys()).map(key => `
                                const ${key} = localStorage.getItem('${key}') === 'true';
                                document.getElementById('${key}').checked = ${key};
                            `).join('')}
                        });
                        document.getElementById('saveFeatures').addEventListener('click', () => {//保存设置
                            ${Array.from(featureDict.keys()).map(key => `
                                const ${key} = document.getElementById('${key}').checked;
                                localStorage.setItem('${key}', String(${key}));
                            `).join('')}
                            alert('功能已保存');
                        });
                        document.getElementById('selectAll').addEventListener('click', () => {//全选
                            ${Array.from(featureDict.keys()).map(key => `
                                document.getElementById('${key}').checked = true;
                            `).join('')}
                        });
                    </script>
                </body>
                </html>
            `;
            const newWindow = window.open();
            newWindow.document.write(pageContent);
            newWindow.document.close();
        });
    }
    function removeAd(){//屏蔽广告
        const adElement = document.querySelector('div[data-v-0a593618][data-v-1143714b]');
        if(adElement) {
            adElement.remove();
            console.log('广告已被删除');
        }
        else console.log('没有找到广告');
    }
    function removeBack(){//移除网页跳转
        function disableRedirect(){
            window.history.go = function() {// 根据实际测试,洛谷使用的是 history.go
                console.log("luogu_bot1:已为您屏蔽 history.go()");
            };
        }
        function checkError(){
            if(document.title === '错误 - 洛谷 | 计算机科学教育新生态'){
                disableRedirect();
                return true;
            }
            return false;
        }
        const observer = new MutationObserver((mutations, obs) => {
            checkError();
        });
        const config = {
            childList: true,
            subtree: true,
        };
        observer.observe(document, config);// 监视 title 变化
        if(document.readyState === 'complete' || document.readyState === 'interactive'){
            checkError();
        }
    }
    async function getUidByUsername(username){// 获取 uid
        const apiUrl = `https://www.luogu.com.cn/api/user/search?keyword=${username}`;
        return await fetch(apiUrl)
        .then(response => response.json())
        .then(data => {
            if(data.users && data.users.length > 0){
                return data.users[0].uid;
            }
        })
        .catch(error => {
            console.error('Error fetching user data:', error);
        });
    }
    async function processItem(item) { // 添加 eventListener
        item.parentElement.parentElement.addEventListener('click', (event) => {
            if(event.ctrlKey){
                getUidByUsername(item.textContent.trim())
                .then(uid => {
                    const userLink = `/user/${uid}`;
                    if(userLink){
                        window.open(userLink, '_blank');
                    }
                })
            }
        });
    }
    function addMessageLink(){ // Ctrl+Click 触发,动态修改网页
        let items = document.querySelectorAll('span[data-v-5b9e5f50] > span[slot="trigger"]');
        for(let i of items){
            processItem(i);
        }
        const callback = async function(mutationsList, observer) {
            for (const mutation of mutationsList) {
                if (mutation.type === 'childList') {
                    for (const addedNode of mutation.addedNodes) {
                        if (addedNode.nodeType === Node.ELEMENT_NODE) {
                            const newItems = addedNode.querySelectorAll('span[data-v-5b9e5f50] > span[slot="trigger"]');
                            for (const newItem of newItems) {
                                processItem(newItem);
                            }
                        }
                    }
                }
            }
        };
        const observer = new MutationObserver(callback);
        observer.observe(document, { childList: true, subtree: true });
    }
    function removeCover(){
        let profile = document.querySelector(".introduction.marked");
        if(profile && profile.style.display === "none"){
            profile.style.display = "block";
            for(let i=0;i<profile.parentElement.children.length;++i){
                if(profile.parentElement.children[i].innerText === "系统维护,该内容暂不可见。"){
                    profile.parentElement.children[i].remove();
                }
            }
        }
    }
    function alwaysRemoveCover(){
        const observer = new MutationObserver(() => removeCover());
        observer.observe(document,{
            childList: true,
            subtree: true
        });
        removeCover();
    }
    const problemToColorMap = new Map();
    class FetchRateLimiter{
        constructor(limit) { // limit 以毫秒为单位,表示相邻两次 fetch 操作间的最小间隔
            this.limit = limit;
            this.queue = [];
            this.queuePrior = [];
            this.active = false;
            this.requestCache = new Map();
        }
        process() {
            this.active = 1;
            let resolve, reject, url;
            if(this.queuePrior.length > 0){
                ({resolve, reject, url} = this.queuePrior.shift());
            }
            else if(this.queue.length > 0){
                ({resolve, reject, url} = this.queue.shift());
            }
            else{
                this.active = 0;
                return;
            }
            fetch(url)
            .then(resolve)
            .catch(reject);
            setTimeout(this.process.bind(this), this.limit);
        }
        push(url, prior) {
            if(this.requestCache.has(url)) return this.requestCache.get(url);
            const request = new Promise((resolve, reject) => {
                if(prior) this.queuePrior.push({url, resolve, reject});
                else this.queue.push({url, resolve, reject});
                if(!this.active) this.process();
            })
            .then((response) => {
                return response.text();
            });
            this.requestCache.set(url,request);
            return request;
        }
    }
    const limiter = new FetchRateLimiter(300);
    async function getProblemColor(problemid, prior=false){
        if(window.location.href.startsWith('https://www.luogu.com.cn/record/list')){
            const resultList = _feInstance.currentData.records.result;
            if(!resultList.lgbot1Visited){
                for(const item of resultList){
                    problemToColorMap.set(item.problem.pid,`rgb(${problemDifficultyToColor[item.problem.difficulty].join(',')})`);
                }
                resultList.lgbot1Visited = true;
            }
        }
        if(/^https:\/\/www\.luogu\.com\.cn\/user\/\d+#practice$/.test(window.location.href)) {
            let problemList = _feInstance.currentData.submittedProblems;
            if(!problemList.lgbot1Visited){
                for(const item of problemList){
                    problemToColorMap.set(item.pid,`rgb(${problemDifficultyToColor[item.difficulty].join(',')})`);
                }
                problemList.lgbot1Visited = true;
            }
            problemList = _feInstance.currentData.passedProblems;
            if(!problemList.lgbot1Visited){
                for(const item of problemList){
                    problemToColorMap.set(item.pid,`rgb(${problemDifficultyToColor[item.difficulty].join(',')})`);
                }
                problemList.lgbot1Visited = true;
            }
        }
        if(problemToColorMap.has(problemid)) return problemToColorMap.get(problemid);
        const url = '/problem/'+problemid;
        let data;
        try{
            data = await limiter.push(url, prior);
        } catch (error) {
            console.error('Error fetching user data:', error);
            console.log(problemid);
            return;
        }
        const parser = new DOMParser();
        const doc = parser.parseFromString(data, 'text/html');
        let scriptText = '';
        for(const i of doc.querySelectorAll("script")){
            scriptText += i.textContent;
        }
        scriptText = scriptText.match(/"difficulty":\d/)[0];
        debugger;
        if(!scriptText) return;
        const problemDifficulty = Number(scriptText[scriptText.length-1]);
        problemToColorMap.set(problemid,`rgb(${problemDifficultyToColor[problemDifficulty].join(',')})`);
        return problemToColorMap.get(problemid);
    }
    function isProblemId(problemid){
        if(problemid.startsWith('AT_')) return true;
        if(!/[a-zA-Z]/.test(problemid)) return false;
        if(!/[0-9]/.test(problemid)) return false;
        return true;
    }
    async function addProblemColor(item){
        let problemid = item.href.split('/').pop();
        let prior = false;
        if(problemid.includes('?forum=')){
            problemid = problemid.split('=').pop();
            prior = true;
        }
        problemid = problemid.split('?')[0];
        problemid = problemid.split('=').pop();
        if(item.matches('a[data-v-bade3303][data-v-4842157a]'))
            if(problemid === "javascript:void 0")
                problemid = item.innerText.split(' ')[0];
        if(!isProblemId(problemid)) return;
        if(item.innerText.startsWith(problemid)){
            const spanItem = item.children[0];
            if(spanItem && spanItem.matches('span.pid') && spanItem.innerText === problemid){
                const color = await getProblemColor(problemid, prior);
                spanItem.style.color = color;
                spanItem.style.fontWeight = 'bold';
            }
            else{
                const color = await getProblemColor(problemid, prior);
                const content = item.innerHTML;
                item.innerHTML = content.replace(problemid,`<b style="color: ${color};">${problemid}</b>`);
            }
        }
    }
    async function addProblemsColor(){
        const observer = new MutationObserver(async (mutationsList) => {
            for (const mutation of mutationsList) {
                if (mutation.type === 'childList') {
                    for (const addedNode of mutation.addedNodes) {
                        if (addedNode.nodeType === Node.ELEMENT_NODE) {
                            const newItems = addedNode.querySelectorAll('a[href]');
                            if(addedNode.matches('a[href]')) addProblemColor(addedNode);
                            for (const newItem of newItems) {
                                addProblemColor(newItem);
                            }
                        }
                    }
                }
                else if(mutation.type === 'characterData') {
                    if(mutation.target.parentElement.matches('span.pid')){
                        mutation.target.parentElement.style.color = await getProblemColor(mutation.target.textContent);
                        mutation.target.parentElement.style.fontWeight = 'bold';
                    }
                }
            }
        });
        observer.observe(document, {
            childList: true,
            subtree: true,
            characterData: true,
        });
        const nodelist = document.querySelectorAll('a[href]');
        for(const i of nodelist){
            addProblemColor(i);
        }
    }
    setTimeout(addSettingButton, 500);
    if(localStorage.getItem('lgbot1RemoveAd') === 'true'){
        setTimeout(removeAd, 500);
    }
    if(localStorage.getItem('lgbot1Removeback') === 'true'){
        removeBack();
    }
    if(localStorage.getItem('lgbot1AddMessageLink') === 'true'){
        if(window.location.href.startsWith('https://www.luogu.com.cn/chat')){
            setTimeout(addMessageLink,500);
        }
    }
    if(localStorage.getItem('lgbot1RemoveCover') === 'true'){
        setTimeout(alwaysRemoveCover, 500);
    }
    if(localStorage.getItem('lgbot1AddProblemsColor') === 'true'){
        setTimeout(addProblemsColor, 500);
    }
})();

如果发现 bug,欢迎私聊我或在评论区反馈。