能把题库捐赠给我们吗?

· · 科技·工程

前言

有时候就算真给了也无福消受啊,需要一个很好的 Online Judge 平台,即在线评测平台,简称 OJ。

虽然现在已经有很多现成 OJ 框架了,不过对普通人来说可能缺的还是硬件设施:没有公网 ip。用 GitHub Pages 只能显示静态网页。别说在线评测平台了,做一个单纯的留言板或者在线联机小游戏都困难。

内网穿透是一个办法,不过有一种更加行为艺术的方案:用 SupaBase 这种在线数据库。只要规模不是很大,SupaBase 提供的免费额度完全足够一些玩具项目的使用了。

本文将介绍如何由自己写出一个能评测程序的在线评测网站,而且在外界也可以访问。

前端

SupaBase 准备工作

这个平台可以用 GitHub 账号登录,所以只要有个 GitHub 账号,关联上去就可以快速地登进去使用了。

界面元素都很清晰,这里就不放图了,按照下面的指示做:

数据表准备工作

创建一张数据表,用来处理提交进来的代码。数据表顾名思义就是一张表格,有表头和数据行,要在表头写出每一列的名字和值的类型。之后,每一行就是一条数据记录。

一次提交需要携带的信息有:评测 ID(用户需要根据评测 ID 才能得到结果)、题号、待评测的代码。此外为了方便统计,可以带上用户 UID 和提交评测的时间信息。这张表就叫 submissions。

携带这么多信息,有两种方案:

-- 先不要运行这份代码
CREATE TABLE submissions (
  uid TEXT NOT NULL,
  id BIGINT PRIMARY KEY,
  problem_id TEXT NOT NULL,
  code_text TEXT NOT NULL,
  created_at TIMESTAMP WITH TIME ZONE DEFAULT NOW(),
  status TEXT NOT NULL, -- 评测状态
);
CREATE TABLE submissions (
  id BIGINT PRIMARY KEY,
  status TEXT NOT NULL, -- 评测状态
  info TEXT NOT NULL -- 也可以用 JSONB 但是无所谓了
);

我属于第二个集团。

在 SQL Editor 的右上角那片区域写入指令并点击运行即可。下面显示 Success 就是成功创建了(刚创建的空表一行记录都没有,显示 No rows returned 正常)。

类似地,创建一个用于保存评测结果的数据表 results。包含的数据就可以简单一点了:一个评测 ID,一个 json 字符串评测结果。用 json 是因为每道题的评测点数量不一样,结构也比较复杂,建列很麻烦,不如直接变成 json。同样,运行这条指令创建表格。

CREATE TABLE results (
  id BIGINT PRIMARY KEY,
  result TEXT NOT NULL
);

然后创建一个题目列表,也创建一个数据表 problems,包含的列有题库 ID、题名(用来更方便地按名查题目)、以及万能 json。这么做的原因下面很快就会讲到。

CREATE TABLE problems (
  id TEXT PRIMARY KEY,
  name TEXT NOT NULL,
  info TEXT NOT NULL
);

前端解析

如果想要托管 html 页面的话,一个选择是自己开一个有公网 IP 的服务器,不过已经说了没有;另一个选择是 GitHub 托管。代价是一些动态内容实现需要多花一些精力。

首先先要理解为什么要创建数据库。以题库这个功能来举例:problem/P1001.html 导航到 P1001,problem/P1002.html 导航到 P1002,这没什么问题,在 GitHub 上创建一个 problem 文件夹,里面放上 P1001.htmlP1002.html 就可以正常访问。现在要多加一道 P1003,就单独创建一个 P1003.html 放进去,也可以,但是这就限制了必须是有 GitHub 仓库管理权限的人才能增删改题目。而且,每次增删改题目都需要直接接触底层文件。这不太方便。

再举一个更直接的例子:讨论区。难道每个人的每一次发言,都要修改一次讨论区的 html 文件吗?这显然是不可承受的。最好的办法还是数据和显示分离,前端只包含一个模板,而每道题目的题面 / 每条发言都从数据库获取,获取到之后动态渲染到页面上(而非直接写在 html 文件里)。具体而言,用类似 problem.html?id=P1001 的方式携带一个参数表示题目 ID,然后动态从 ID 获取题面内容渲染就行了。

说多花一些精力是因为要在前端实现请求的解析。比如 problem?id=P1001,大部分时候用户的浏览器(前端)只负责把请求发给服务器(后端),而后端再根据请求获取数据库的内容,生成 html 之后返回给用户的浏览器。GitHub 托管的页面没有提供自定义后端处理逻辑的功能,所以获取数据库内容这一步只能放在前端了。

题目页面及 utils.js

首先,在 GitHub 上面创建一个项目。用来托管所有页面。

然后创建一个基本的用来显示题目的 problem.html 文件,并放到仓库去。这个文件就是模板,将会把题目内容动态渲染到里面去。注意到 <script src="https://cdn.jsdelivr.net/npm/@supabase/supabase-js"></script> 这一行了吗?这一行类似 C++ 的 #include 指示,浏览器会自动从这个地方下载 SupaBase 官方提供的 JavaScript 源代码,而这份源代码里包装了很多操作 SupaBase 需要的库函数。

同样的,也可以创建自己的 JavaScript 库文件供引用,只要能够找得到就可以。上面说过,后端处理的逻辑现在搬到了前端,会有非常可以重复利用的代码,所以可以在自己项目根目录下创建一个 utils.js 文件,包含进来就可以了。

据此,先创建的那个 problem.html 文件内容如下:

<!DOCTYPE html>
<html lang="en">
<head>
    <meta charset="UTF-8">
    <title>Problem List</title>
    <script src="https://cdn.jsdelivr.net/npm/@supabase/supabase-js"></script>
    <link rel="stylesheet" href="https://cdn.jsdelivr.net/npm/[email protected]/dist/katex.min.css">
    <script src="https://cdn.jsdelivr.net/npm/[email protected]/dist/markdown-it.min.js"></script>
    <script src="https://cdn.jsdelivr.net/npm/[email protected]/dist/katex.min.js"></script>
    <script src="https://cdn.jsdelivr.net/npm/[email protected]/texmath.min.js"></script>
</head>
<body>
    <h1 id="problem_name">Loading...</h1>
    <p>
        <div id="options"></div>
    </p>
    <div id="problem_container"></div>
    <script src="utils.js 地址"></script>
</body>
</html>

开头那些引用的 JavaScript 包以及一个样式表是为了支持 MarkDown 和 \KaTeX(支持这两个看上去很复杂的东西是不是比想象的要简单)。

关于地址,如果把 utils.js 创建在根目录下,那么就是 utils.js;如果创建了一个文件夹(比如叫做 js_code)再在里面创建 utils.js,那么就是 js_code/utils.js。更复杂的请问 AI。

utils.js 里要放很多常用的代码:

let supabaseClient = null;
function getClient() {
    if (!supabaseClient) {
        const supabaseUrl = 'SupaBase 项目地址';
        const supabaseKey = 'SupaBase 匿名密钥';
        supabaseClient = supabase.createClient(supabaseUrl, supabaseKey);
    }
    return supabaseClient;
}
function getArgs(key) {
    const args = {};
    for (const [k, v] of new URLSearchParams(window.location.search).entries()) {
        args[k] = v;
    }
    return key ? args[key] : args;
}
document.addEventListener('DOMContentLoaded', function() {
    const currentPath = window.location.pathname;
    let scriptPath = currentPath.replace(/\.html$/, '.js');
    if (!scriptPath.endsWith(".js")) scriptPath += ".js";
    const script = document.createElement('script');
    script.src = scriptPath;
    document.head.appendChild(script);
});

SupaBase 项目地址,去 SupaBase 的工作界面,左边菜单栏最下面的 Project Settings 里,选择 Data API,可以看到 Project URL。它就是这个项目的地址,也是数据库地址。在 Data API 的下面有个 API Keys,目前只需要 Legacy 的就够了,有个 anonpublic 的密钥,复制下来,它就是匿名密钥。把这两项信息填入上面的源代码里。

匿名密钥顾名思义,任何人都可以通过这个密钥访问数据库——当然会有安全性的问题,不过可以放心,之后在 SupaBase 上进行一些配置之后就能很方便地控制匿名密钥的权限。

注意:在 anon 密钥下面还有一个 service_role 的密钥,它属于拥有最高权限的密钥。不要泄露它。

题目界面 JavaScript

先确定一下题目界面,即 problem.html 的大概逻辑:

提交代码的页面就命名为 submit.html,这个文件之后补上。

按照如上的逻辑,在根目录下创建 problem.js,写出代码(这一部分比较简单,可以直接找 AI 帮忙写):

const md = window.markdownit();
md.use(window.texmath.use(window.katex), {
  engine: window.katex,
  delimiters: 'dollars',
  katexOptions: { macros: { "\\RR": "\\mathbb{R}" } }
});

async function render() {
    const supabase = getClient();
    const problemNameEl = document.getElementById('problem_name');
    const optionsEl = document.getElementById('options');
    const containerEl = document.getElementById('problem_container');
    const id = getArgs('id');
    if (id) {
        const { data: problem, error } = await supabase
            .from('problems')
            .select('*')
            .eq('id', id)
            .single();
        if (error) {
            problemNameEl.innerHTML = ``;
            containerEl.innerHTML = `<p>No such problem</p>`;
            return;
        }
        problemNameEl.textContent = problem.name;
        optionsEl.innerHTML = `
            <a href="submit?id=${id}">Submit</a>
            <a href="problem">Problem List</a>
        `;
        try {
            const info = JSON.parse(problem.info);
            containerEl.innerHTML = md.render(info.description) || '<p>No description</p>';
        } catch (e) {
            containerEl.innerHTML = '<p>No description</p>';
        }
    } else {
        const { data: problems, error } = await supabase
            .from('problems')
            .select('id, name')
            .order('id');

        if (error) {
            containerEl.innerHTML = `<p>Load problem list failed</p>`;
            return;
        }
        problemNameEl.textContent = "Problem List";
        optionsEl.innerHTML = '';
        let tableHTML = `
            <table border="1" style="width: 100%; border-collapse: collapse;">
                <thead>
                    <tr>
                        <th>Problem ID</th>
                        <th>Problem Name</th>
                    </tr>
                </thead>
                <tbody>
        `;
        problems.forEach(problem => {
            tableHTML += `
                <tr>
                    <td><a href="problem?id=${problem.id}">${problem.id}</a></td>
                    <td><a href="problem?id=${problem.id}">${problem.name}</a></td>
                </tr>
            `;
        });
        tableHTML += `
                </tbody>
            </table>
        `;
        containerEl.innerHTML = tableHTML;
    }
}
render();

事实上这里有非常多可以优化的地方,从单纯的观感方面到性能方面——这些是前端工程师们需要考虑的问题。不过既然这个项目只是作为示例,那么懒惰一些也无妨。

配置 RLS

之前说过,持有匿名密钥可以对数据库进行任何操作,如果不加防护的话。启用行防护,即 Row Level Security,就可以很大程度上解决问题。它可以限制不同身份(简单理解成不同密钥)可以执行的操作。

既然已经下发了匿名密钥,那么匿名密钥的权限就是普通用户的权限,所以:

在 SupaBase 的 SQL Editor 里执行下面的命令,即可为上面三个表开启 RLS 防护并且设置权限限制:

ALTER TABLE problems ENABLE ROW LEVEL SECURITY;
ALTER TABLE submissions ENABLE ROW LEVEL SECURITY;
ALTER TABLE results ENABLE ROW LEVEL SECURITY;
CREATE POLICY "Allow anon read problems" ON problems FOR SELECT USING (true);
CREATE POLICY "Allow anon insert submissions" ON submissions FOR INSERT WITH CHECK (true);
CREATE POLICY "Allow anon read submissions" ON submissions FOR SELECT USING (true);
CREATE POLICY "Allow anon read results" ON results FOR SELECT USING (true);

简单测试

先去 GitHub 上把目前的页面给部署了。部署完之后,访问 /problem.html,应该会在加载之后显示出一个空的表格。

表格当然是空的,目前题库里一道题也没有。那么就创建几道测试用的题目。在 SQL Editor 里运行下面的命令创建两道题目:

INSERT INTO problems (id, name, info) VALUES (
  'P1001',
  'A+B Problem',
  '{"description":"Read two integers $a,b$ from **standard input**. Output $a+b$.\n\nThe absolute values of $a,b$ do not exceed $10^7$."}'
);
INSERT INTO problems (id, name, info) VALUES (
  'P1002',
  'Wolves Catch Bunnies',
  '{"description":"自行寻找题面"}'
);

然后就可以在题目列表里看到题目,点进去也可以看到题干了。

提交页面

提交页面可以设置一个文本框,让用户可以设置要提交的题目 ID。这里采用一个比较人性化的设计:如果页面带有 id 参数(比如 submit.html?id=P1001),就自动帮助用户填入题目 ID。

同样地,先创建 submit.html

<!DOCTYPE html>
<html lang="en">
<head>
    <meta charset="UTF-8">
    <title>Submit Code</title>
    <script src="https://cdn.jsdelivr.net/npm/@supabase/supabase-js"></script>
</head>
<body>
    <h1>Submit Code</h1>
    <div>
        <label for="problem_id">Problem ID: </label>
        <input type="text" id="problem_id">
        <a id="back_link" href="/problem">Back</a>
    </div>
    <br>
    <button id="submit_btn">Submit</button>
    <br><br>
    <textarea id="code_text" rows="20" cols="80" placeholder="Your code"></textarea>
    <script src="utils.js 的地址"></script>
</body>
</html>

然后创建 submit.js,向 SupaBase 的 submissions 数据表里插入一条包含提交信息的行,并把状态设置为 pending 表示等待评测。

简便起见,这里的评测 ID 使用的是当前是时间戳,精确到毫秒;提交信息只包含题号和用户的代码。如果提交成功,就自动导航到对应的评测结果页等待评测结果。评测结果页就命名为 result.html

function render() {
    const problemIdInput = document.getElementById('problem_id');
    const backLink = document.getElementById('back_link');
    const submitBtn = document.getElementById('submit_btn');
    const codeText = document.getElementById('code_text');
    const id = getArgs('id');
    if (id) {
        problemIdInput.value = id;
        backLink.href = `problem?id=${id}`;
    } else {
        backLink.href = 'problem';
    }
    submitBtn.addEventListener('click', async function() {
        const problemId = problemIdInput.value.trim();
        const code = codeText.value.trim();
        if (!problemId) {
            alert('Please enter Problem ID');
            return;
        }
        if (!code) {
            alert('Code is empty');
            return;
        }
        try {
            const supabase = getClient();
            const submissionId = Date.now();
            const submissionData = {
                problem: problemId,
                code: code,
            };
            const { data, error } = await supabase
                .from('submissions')
                .insert([
                    { 
                        id: submissionId, 
                        status: 'pending',
                        info: JSON.stringify(submissionData)
                    }
                ]);
            if (error) {
                alert('Submission failed: ' + error.message);
            } else {
                location.href = "result?id=" + submissionId;
            }
        } catch (error) {
            alert('Error: ' + error.message);
        }
    });
}
render();

结果页面

结果页面要显示的基本内容至少包括:提交时间、提交的代码、提交的题目和代码,然后是评测结果。为了方便使用,还要有一个返回题目的超链接。创建 result.html

<!DOCTYPE html>
<html lang="en">
<head>
    <meta charset="UTF-8">
    <title>Submission Result</title>
    <script src="https://cdn.jsdelivr.net/npm/@supabase/supabase-js"></script>
</head>
<body>
    <h1 id="title">Submission Result</h1>
    <div id="header">
        <span id="submit_time"></span>
        <a id="back_link" href="/problem">Back</a>
    </div>
    <div id="result_container">Fetching results...</div>
    <div>
        <h3>Submitted Code:</h3>
        <pre id="code_display"></pre>
    </div>
    <script src="utils.js 地址"></script>
</body>
</html>

然后是获取评测结果的逻辑。直接获取是比较难的,因为评测有队列,需要花时间。而让用户在结果页面一直刷新等待刷出评测结果的体验并不太好。所以设计一个“自动获取是否有评测结果”的逻辑如下:

这里的“时刻关注”可以用多次发起查询来完成,这里简单实现为每三秒查询一次数据库(当然有个更简便也更高效的办法是 SupaBase 已经实现的 subscribe 功能)。具体请看如下代码,文件名为 result.js

由于评测结果也是用 json 保存的,需要先设计好评测结果的 json 结构。按照如下格式(verdict 是一个数组,可以包含多个测试点数据):

{
  "code": "用户代码",
  "id": "题号",
  "brief": "总体情况(比如 100 Accepted)",
  "verdict": [
    {
      "case": "测试点编号",
      "status": "测试结果",
      "details": "测试细节(比如时间空间消耗)",
      "point": "测试点得分",
      "info": "附加信息"
    }
  ]
}

然后就可以写出对应的 JavaScript 逻辑。

function formatTime(timestamp) {
    const date = new Date(parseInt(timestamp));
    const year = date.getFullYear();
    const month = String(date.getMonth() + 1).padStart(2, '0');
    const day = String(date.getDate()).padStart(2, '0');
    const hours = String(date.getHours()).padStart(2, '0');
    const minutes = String(date.getMinutes()).padStart(2, '0');
    const seconds = String(date.getSeconds()).padStart(2, '0');
    const milliseconds = String(date.getMilliseconds()).padStart(3, '0');
    return `${year}-${month}-${day} ${hours}:${minutes}:${seconds}.${milliseconds}`;
}
function renderResult(resultObj) {
    const container = document.getElementById('result_container');
    const backLink = document.getElementById('back_link');
    if (resultObj.id) {
        backLink.href = `problem?id=${resultObj.id}`;
    }
    if (!resultObj.verdict || !Array.isArray(resultObj.verdict)) {
        container.innerHTML = '<p>No test results available</p>';
        return;
    }
    let tableHTML = `
        <table border="1" style="width: 100%; border-collapse: collapse;">
            <thead>
                <tr>
                    <th>Case</th>
                    <th>Status</th>
                    <th>Details</th>
                    <th>Point</th>
                    <th>Info</th>
                </tr>
            </thead>
            <tbody>
    `;
    resultObj.verdict.forEach(test => {
        tableHTML += `
            <tr>
                <td>${test.case || ''}</td>
                <td>${test.status || ''}</td>
                <td>${test.details || ''}</td>
                <td>${test.point || ''}</td>
                <td>${test.info || ''}</td>
            </tr>
        `;
    });
    tableHTML += `
            </tbody>
        </table>
    `;
    container.innerHTML = "<h3>" + resultObj.brief + "</h3>";
    container.innerHTML += tableHTML;
}
function setCodeDisplay(code) {
    const codeDisplay = document.getElementById('code_display');
    codeDisplay.textContent = code || 'No code available';
}
async function process() {
    const supabase = getClient();
    const submissionId = parseInt(getArgs('id'));
    const title = document.getElementById('title');
    const submitTime = document.getElementById('submit_time');
    const backLink = document.getElementById('back_link');
    const container = document.getElementById('result_container');
    if (!submissionId) {
        title.textContent = 'No submission ID provided';
        return;
    }
    title.textContent = 'Submission Result #' + submissionId;
    submitTime.textContent = `Submit Time: ${formatTime(submissionId)}`;
    const { data: resultData, error: resultError } = await supabase
        .from('results')
        .select('*')
        .eq('id', submissionId)
        .single();
    if (resultData) {
        try {
            const resultObj = JSON.parse(resultData.result);
            setCodeDisplay(resultObj.code);
            renderResult(resultObj);
        } catch (e) {
            container.innerHTML = '<p>Error parsing result data</p>';
        }
        return;
    }
    const { data: submissionData, error: submissionError } = await supabase
        .from('submissions')
        .select('*')
        .eq('id', submissionId)
        .single();

    if (!submissionData) {
        title.textContent = 'No such submission';
        container.innerHTML = '<p>Redirecting back in 3 seconds...</p>';
        setTimeout(() => {
            window.history.back();
        }, 30000);
        return;
    }
    container.innerHTML = 'Pending for tests';
    try {
        const submissionInfo = JSON.parse(submissionData.info);
        setCodeDisplay(submissionInfo.code);
        if (submissionInfo.problem) {
            backLink.href = `/problem?id=${submissionInfo.problem}`;
        }
        const pollInterval = setInterval(async () => {
            const { data: resultData, error } = await supabase
                .from('results')
                .select('*')
                .eq('id', submissionId)
                .single();

            if (resultData) {
                try {
                    const resultObj = JSON.parse(resultData.result);
                    setCodeDisplay(resultObj.code);
                    renderResult(resultObj);
                    clearInterval(pollInterval);
                } catch (e) {
                    container.innerHTML = '<p>Error parsing result data</p>';
                    clearInterval(pollInterval);
                }
            }
        }, 3000);
    } catch (e) {
        container.innerHTML = '<p>Error parsing submission data</p>';
    }
}
process();

总体测试

题目显示、提交代码、查看结果三个界面都已经完成了。总体测试一下前端:

INSERT INTO results values(
  评测 ID,
  '{"code":"// nothing","brief":"Score: 100, Wrong Answer","id":"P1001","verdict":[{"case":"1","status":"Accepted","details":"100ms 256MB","point":"100","info":""},{"case":"Extra test","status":"Wrong Answer","details":"200ms 512MB","point":"0","info":""}]}'
);

后端

后端比前端要处理的细节更少,任务主要是评测提交过来的程序,并生成报告。只要有一台能够访问到 SupaBase 数据的电脑(不过需要 24 小时不关机,因为一旦关机了就没法评测任务了),就能够当做评测后端。不必有公网 ip。如果有足够多的精力和设备,可以设计一套分布式评测,也就是把各个测试点分到多台机器上评测,这样可以更快返回结果。这里就只设计一套单点评测,并且只支持 C++。

评测系统的逻辑很简单:持续关注 submissions 数据表,如果有状态是 pending 的行,取出来进行评测,并把状态设置为 testing;评测完成之后,往 results 数据表里插入评测结果(上面手动插入,这里用代码自动插入),再把状态改为 finished

关于测试数据,每道题根据题号创建一个文件夹,内部包含若干数据文件,简单起见强制要求输入文件命名为为 in1.txtin2.txt……输入文件为 out1.txtout2.txt……。以及一个测试点配置文件 config.json。配置文件的格式也是可以自己设计的,这里设计成这样:

{
  "subtasks": [
    {
        "type": Subtask 评分方式,
        "cases": [{
            "case": 测试点编号,
            "time": 时间限制(毫秒),
            "memory": 内存限制(KB),
            "points": 得分,
        }]
    }
  ]
}

subtaskscases 都是数组,可以往里面放入多个 Subtask / 评测点。Subtask 评分方式也是自己设计。这里设计两个:"sum" 表示所有测试点加和计算、"min" 表示取测试点中得分最小的。至于总体评测信息(即 brief),格式设计为 Score: 分数, 状态。状态取所有测试点中第一个非 Accepted 的点的状态(全都通过了就直接设置为 Accepted)。

评测系统

用 Python 实现后端,需要先安装 SupaBase 的 Python 支持库,运行命令 pip install supabase 安装;然后电脑上需要安装 g++,安装方式自行上网查询。

评测系统的逻辑很清晰,只是有点长(但是仍然可以用 AI 写出来),这里只放出使用时需要比较注意的部分的代码(如果用 AI 生成或者自己写,那自行类比逻辑即可):

if __name__ == "__main__":
    SUPABASE_URL = "SupaBase 地址"
    SUPABASE_KEY = "SupaBase 的 service key"

    judge = CppJudge(SUPABASE_URL, SUPABASE_KEY, "./testdata") # 所有题目的文件夹放在 testdata 文件夹下

    try:
        judge.start_judging()
    except KeyboardInterrupt:
        judge.stop()
        print("Judge stopped.")

还记得上面说过的 anon key 吗?与之对应的就是 SupaBase 的 service key,拥有全部权限的密钥。毕竟这是自己的主机,赋予最高权限也没什么问题,只要不乱来。

运行程序,程序会自动从 SupaBase 尝试获取评测请求,只要一切配置正确就能成功评测并返回结果。常见的错误:

上传题面

如果扩展了用户系统和控制权限的功能,可以把这个功能也做成一个页面。不过如果没有,那么还是从主机上同步题面比较好。

思路是,检测每个题目文件夹下有没有 problem.md 文件,有的话就把里面的内容当做题面上传到 problems 数据表里。这个程序可以设计成定时自动运行,就能从本地同步题面到云端了。手动运行也可以。

import os
import json
from supabase import create_client

def sync_problems_from_markdown(supabase_url: str, supabase_key: str, testdata_base_path: str = "./testdata"):
    supabase = create_client(supabase_url, supabase_key)
    for problem_id in os.listdir(testdata_base_path):
        problem_path = os.path.join(testdata_base_path, problem_id)
        if not os.path.isdir(problem_path):
            continue
        md_file = os.path.join(problem_path, "problem.md")
        if not os.path.exists(md_file):
            continue
        try:
            with open(md_file, 'r', encoding='utf-8') as f:
                content = f.read().strip()
            lines = content.split('\n')
            problem_name = problem_id
            if lines and lines[0].startswith('# '):
                problem_name = lines[0][2:].strip()
                description = '\n'.join(lines[1:]).strip()
            else:
                description = content
            info = {
                "description": description
            }
            response = supabase.table('problems').select('id').eq('id', problem_id).execute()
            if response.data:
                supabase.table('problems').update({
                    'name': problem_name,
                    'info': json.dumps(info, ensure_ascii=False)
                }).eq('id', problem_id).execute()
                print(f"Updated problem: {problem_id} - {problem_name}")
            else:
                supabase.table('problems').insert({
                    'id': problem_id,
                    'name': problem_name,
                    'info': json.dumps(info, ensure_ascii=False)
                }).execute()
                print(f"Inserted problem: {problem_id} - {problem_name}")
        except Exception as e:
            print(f"Error processing problem {problem_id}: {e}")
    print("Sync completed!")

if __name__ == "__main__":
    SUPABASE_URL = "SupaBase 地址"
    SUPABASE_KEY = "SupaBase 的 service key"
    TESTDATA_PATH = "./testdata"
    sync_problems_from_markdown(SUPABASE_URL, SUPABASE_KEY, TESTDATA_PATH)

总结

还能做什么

SupaBase 这样的平台有点像内网穿透——没有公网 ip 的电脑也可以作为主机对外界提供服务。GitHub Pages 没有存储功能,但是 SupaBase 有;SupaBase 没有太强的数据处理(比如评测程序)功能,但是自己的电脑有;自己的电脑没有公网 ip 无法直接从外界访问,但是 Github Pages 有。三个在一起就可以实现一个丐版的服务器。

没错,是一个服务器。有数据交换能力就能实现几乎所有东西。如果不嫌延迟大,用 SupaBase 开 Minecraft 服务器都是可以的(把报文包装好放进任务队列一个一个处理,服务器处理完之后放进结果队列分发给客户端)。

当然如果这样玩,还有一些好玩的事情可以做(而且很简单):

SupaBase 还提供了一些类似云函数的功能,这里不展开讲了。云函数就是在云端运行的一个简单函数,用来实现一些普通配置做不到的功能,运用得好甚至可以不使用主机,直接全套云端。

叠甲

试用

本文的 OJ 部署于 https://warfarinbloodanger.github.io/supaoj/problem。源代码在同一个仓库里(backend.zip 是后端代码和文件的压缩包,要下载下来在本地跑的,不要在 GitHub 上改)。

如果愿意的话可以考虑用 GitHub Actions 部署后端。不过需要更多精力。具体可以问 AI。

由于主机运行在本人电脑上,性能有限(加上也没设计沙箱),传过来的评测程序统一返回 Dangerous Syscall。

而且说不定过几天要做点什么事了就把后端关了 / 迁移到 Github Actions。

结语

去吧孩子你是最幫的啸痒