我们教练要我们的提交记录表格,我就调教了会AI弄出来了这个。

支持洛谷、Codeforces、AtCoder 的提交记录爬取并导出为 Excel。

把代码导入篡改猴,点击网页左下角导出即可。

// ==UserScript==
// @name         CP 提交记录导出器
// @namespace    http://tampermonkey.net/
// @version      2.3
// @description  导出 AC 记录。
// @author       Awatesolo
// @match        https://www.luogu.com.cn/*
// @match        https://codeforces.com/*
// @match        https://mirror.codeforces.com/*
// @match        https://atcoder.jp/*
// @require      https://cdn.sheetjs.com/xlsx-0.19.3/package/dist/xlsx.full.min.js
// @grant        none
// ==/UserScript==

(function() {
    'use strict';

    // ==========================================
    // 1. 公共工具
    // ==========================================

    function createButton(text, color = '#3498db') {
        const btn = document.createElement('button');
        btn.innerText = text;
        btn.style.position = 'fixed';
        btn.style.bottom = '20px';
        btn.style.left = '20px';
        btn.style.zIndex = '999999';
        btn.style.padding = '10px 15px';
        btn.style.backgroundColor = color;
        btn.style.color = 'white';
        btn.style.border = 'none';
        btn.style.borderRadius = '5px';
        btn.style.cursor = 'pointer';
        btn.style.boxShadow = '0 2px 5px rgba(0,0,0,0.3)';
        btn.style.fontWeight = 'bold';
        btn.style.fontSize = '14px';
        document.body.appendChild(btn);
        return btn;
    }

    function exportToExcel(data, filename, sheetName = "Accepted Records") {
        if (data.length === 0) {
            alert('❌ 当前日期范围内没有找到 AC 记录!');
            return;
        }
        const worksheet = XLSX.utils.json_to_sheet(data);
        const workbook = XLSX.utils.book_new();
        XLSX.utils.book_append_sheet(workbook, worksheet, sheetName);
        XLSX.writeFile(workbook, filename);
        alert(`✅ 成功导出 ${data.length} 条记录!\n文件已保存为: ${filename}`);
    }

    // 获取并解析日期
    function getStartDate() {
        const input = prompt("请输入最早爬取日期 (格式: YYYY-MM-DD)\n留空则爬取所有历史记录:", "2023-01-01");
        if (!input) return 0; // 0 表示 1970年,即爬取所有
        const timestamp = new Date(input).getTime() / 1000; // 转换为秒级时间戳
        if (isNaN(timestamp)) {
            alert("日期格式错误,将爬取所有记录。");
            return 0;
        }
        return timestamp;
    }

    // ==========================================
    // 2. 路由逻辑
    // ==========================================
    const host = window.location.hostname;

    if (host.includes('luogu')) {
        runLuogu();
    } else if (host.includes('codeforces')) {
        runCodeforces();
    } else if (host.includes('atcoder')) {
        runAtCoder();
    }

    // ==========================================
    // 3. 洛谷 (Luogu) 逻辑
    // ==========================================
    function runLuogu() {
        const btn = createButton('📥 导出洛谷 AC', '#e74c3c');

        const difficultyMap = {
            0: "暂无评定", 1: "入门", 2: "普及-", 3: "普及/提高-",
            4: "普及+/提高", 5: "提高+/省选-", 6: "省选/NOI-", 7: "NOI/NOI+/CTSC"
        };

        btn.onclick = async () => {
            // 1. 获取UID
            let uid = null;
            if (window._feInjection && window._feInjection.currentUser) {
                uid = window._feInjection.currentUser.uid;
            } else {
                const userLink = document.querySelector('a[href^="/user/"]');
                if (userLink) uid = userLink.href.match(/\/user\/(\d+)/)[1];
            }
            let inputUid = prompt("请输入要爬取的洛谷UID:", uid || "");
            if (!inputUid) return;
            uid = inputUid.trim();

            // 2. 获取日期
            const startTime = getStartDate();

            let page = 1;
            let rawRecords = [];
            let keepRunning = true;

            btn.innerText = '⏳ 爬取中...';
            btn.disabled = true;

            try {
                // 3. 爬取数据 (带时间截断)
                while (keepRunning) {
                    btn.innerText = `⏳ 第 ${page} 页...`;
                    const url = `https://www.luogu.com.cn/record/list?user=${uid}&page=${page}&_contentOnly=1`;
                    const response = await fetch(url);
                    const json = await response.json();

                    if (!json.currentData || !json.currentData.records.result.length) {
                        break;
                    }

                    const pageRecords = json.currentData.records.result;

                    // 洛谷每一页是从新到旧排序的
                    // 如果这一页最旧的一条记录 比 startTime 还早,说明下一页肯定不需要爬了
                    const lastRecordTime = pageRecords[pageRecords.length - 1].submitTime;

                    // 筛选符合日期的记录加入数组
                    const validRecords = pageRecords.filter(r => r.submitTime >= startTime);
                    rawRecords.push(...validRecords);

                    // 如果本页最后一条记录已经小于起始时间,停止翻页
                    if (lastRecordTime < startTime) {
                        console.log("已到达指定日期,停止爬取旧记录。");
                        keepRunning = false;
                    }

                    page++;
                    await new Promise(r => setTimeout(r, 800));
                }

                // 4. 处理数据
                btn.innerText = '⏳ 处理数据...';
                rawRecords.sort((a, b) => a.submitTime - b.submitTime);

                const processedData = [];
                const problemMap = {};

                for (const rec of rawRecords) {
                    const pid = rec.problem.pid;
                    if (!problemMap[pid]) problemMap[pid] = { solved: false, attempts: 0 };
                    if (problemMap[pid].solved) continue;

                    problemMap[pid].attempts++;

                    if (rec.status === 12) {
                        problemMap[pid].solved = true;
                        const diffText = difficultyMap[rec.problem.difficulty] || "未知";

                        processedData.push({
                            'ID': rec.id,
                            '题目': `${rec.problem.pid} ${rec.problem.title}`,
                            '难度': diffText,
                            '尝试次数': problemMap[pid].attempts,
                            '分数': rec.score,
                            '语言': rec.language,
                            '时间': new Date(rec.submitTime * 1000).toLocaleString(),
                            '链接': `https://www.luogu.com.cn/record/${rec.id}`
                        });
                    }
                }

                exportToExcel(processedData, `Luogu_AC_${uid}.xlsx`);

            } catch (err) {
                console.error(err);
                alert('❌ 发生错误');
            } finally {
                btn.innerText = '📥 导出洛谷 AC';
                btn.disabled = false;
            }
        };
    }

    // ==========================================
    // 4. Codeforces 逻辑
    // ==========================================
    function runCodeforces() {
        const btn = createButton('📥 导出 CF AC', '#f39c12');

        btn.onclick = async () => {
            let handle = null;
            const profileLink = document.querySelector('a[href^="/profile/"]');
            if (profileLink) handle = profileLink.href.split('/').pop();
            let inputHandle = prompt("请输入 Codeforces Handle:", handle || "");
            if (!inputHandle) return;
            handle = inputHandle.trim();

            const startTime = getStartDate();

            btn.innerText = '⏳ 获取中...';
            btn.disabled = true;

            try {
                const url = `https://codeforces.com/api/user.status?handle=${handle}&from=1&count=100000`;
                const response = await fetch(url);
                const json = await response.json();

                if (json.status !== 'OK') {
                    alert('API Error: ' + json.comment);
                    return;
                }

                // 过滤日期并排序 (旧 -> 新)
                const rawRecords = json.result
                    .filter(r => r.creationTimeSeconds >= startTime)
                    .reverse();

                const processedData = [];
                const problemMap = {};

                for (const rec of rawRecords) {
                    const pid = `${rec.problem.contestId}${rec.problem.index}`;
                    if (!problemMap[pid]) problemMap[pid] = { solved: false, attempts: 0 };
                    if (problemMap[pid].solved) continue;

                    problemMap[pid].attempts++;

                    if (rec.verdict === 'OK') {
                        problemMap[pid].solved = true;
                        const tags = rec.problem.tags ? rec.problem.tags.join(', ') : '';

                        processedData.push({
                            'ID': rec.id,
                            '题目': `${pid} - ${rec.problem.name}`,
                            '算法标签': tags,
                            '难度评分': rec.problem.rating || '',
                            '尝试次数': problemMap[pid].attempts,
                            '语言': rec.programmingLanguage,
                            '时间': new Date(rec.creationTimeSeconds * 1000).toLocaleString(),
                            '链接': `https://codeforces.com/contest/${rec.contestId}/submission/${rec.id}`
                        });
                    }
                }

                exportToExcel(processedData, `Codeforces_AC_${handle}.xlsx`);

            } catch (e) {
                console.error(e);
                alert('❌ 网络错误');
            } finally {
                btn.innerText = '📥 导出 CF AC';
                btn.disabled = false;
            }
        };
    }

    // ==========================================
    // 5. AtCoder 逻辑
    // ==========================================
    function runAtCoder() {
        const btn = createButton('📥 导出 AC AC', '#2c3e50');

        btn.onclick = async () => {
            let user = typeof userScreenName !== 'undefined' ? userScreenName : "";
            const inputUser = prompt("请输入 AtCoder 用户名:", user);
            if (!inputUser) return;
            user = inputUser.trim();

            const startTime = getStartDate();

            btn.innerText = '⏳ 获取题目数据...';
            btn.disabled = true;

            try {
                const problemsUrl = 'https://kenkoooo.com/atcoder/resources/problem-models.json';
                const problemsResp = await fetch(problemsUrl);
                const problemModels = await problemsResp.json();

                btn.innerText = '⏳ 获取提交记录...';
                const url = `https://kenkoooo.com/atcoder/atcoder-api/v3/user/submissions?user=${user}&from_second=${startTime}`;
                const response = await fetch(url);
                if (response.status !== 200) throw new Error("API Failed");

                let rawRecords = await response.json();

                // Kenkoooo API 可以直接用 from_second 参数过滤,但为了保险,前端再 filter 一次
                rawRecords = rawRecords.filter(r => r.epoch_second >= startTime);
                rawRecords.sort((a, b) => a.epoch_second - b.epoch_second);

                const processedData = [];
                const problemMap = {};

                for (const rec of rawRecords) {
                    const pid = rec.problem_id;
                    if (!problemMap[pid]) problemMap[pid] = { solved: false, attempts: 0 };
                    if (problemMap[pid].solved) continue;

                    problemMap[pid].attempts++;

                    if (rec.result === 'AC') {
                        problemMap[pid].solved = true;
                        let difficulty = '-';
                        if (problemModels[pid] && problemModels[pid].difficulty !== undefined) {
                            difficulty = problemModels[pid].difficulty;
                        }

                        processedData.push({
                            'ID': rec.id,
                            '题目ID': rec.problem_id,
                            '比赛ID': rec.contest_id,
                            '难度分': difficulty,
                            '尝试次数': problemMap[pid].attempts,
                            '语言': rec.language,
                            '时间': new Date(rec.epoch_second * 1000).toLocaleString(),
                            '链接': `https://atcoder.jp/contests/${rec.contest_id}/submissions/${rec.id}`
                        });
                    }
                }

                exportToExcel(processedData, `AtCoder_AC_${user}.xlsx`);

            } catch (e) {
                console.error(e);
                alert('❌ 获取失败');
            } finally {
                btn.innerText = '📥 导出 AC AC';
                btn.disabled = false;
            }
        };
    }

})();

1 条评论

  1. 日月神棱

    严肃批判!
    你这难度不对呀!
    0: "暂无评定", 1: "入门", 2: "普及-", 3: "普及/提高-",

            4: "普及+/提高", 5: "提高+/省选-", 6: "省选/NOI-", 7: "NOI/NOI+/CTSC"

    洛谷香香软软的青题呢?
    [qq:发怒]

添加新评论

文章目录