我发现关于 domjudge 的搭建的教程挺多,但是导入数据的教程不太好找。我结合了一下已有的资料和官方文档自己折腾了一下,搞出来一套差不多能用的方案。
domserver 版本号是 8.3.2 运行环境是 ubuntu 24.04。运行脚本的电脑是 Macbook Pro M5。这篇博客主要是给自己干过的活留个档,如果版本号或者运行环境不一致可能需要微调。
同步设置
jury interface -> Configuration Settings -> External Systems -> Data Source,改成 configuration data external,这样操作用的就是自己设的 external_id 了,可以避免很多强迫症的问题。
domserver 的前端的导入页在

所有的通过图形化界面的导入方式都在这里。
用户导入
找到 Teams & groups,在 Import JSON / YAML 下导入。
导入 Organizations
对于校赛来说,组织可以设置成所有的学院。一共只有 id, icpc_id, name, formal_name, country 五个字段,我们不需要 icpc_id,我用了下面的脚本生成。
import json
COLLEGE_MAP = { "地球科学与技术学院": "c01", "石油工程学院": "c02", "化学化工学院": "c03", "机电工程学院": "c04", "储运与建筑工程学院": "c05", "材料科学与工程学院": "c06", "石大山能新能源学院": "c07", "海洋与空间信息学院": "c08", "控制科学与工程学院": "c09", "青岛软件学院、计算机科学与技术学院": "c10", "理学院": "c11", "经济管理学院": "c12", "外国语学院": "c13", "文法学院": "c14", "马克思主义学院": "c15", "体育教学部": "c16",}
organizations = []
for name, id in COLLEGE_MAP.items(): organizations.append({ "id": id, "name": name, "formal_name": name, "country": "CHN" })
with open("organizations.json", "w") as f: json.dump(organizations, f, indent=2)导入 teams 和 accounts
teams 官方要求的格式是 json,accounts 官方要求的格式是 yaml. 我们的报名表的表头是这样的。
提交者(自动) 提交时间(自动) 学号(必填) 姓名(必填) 性别(必填) 学院(必填) 专业班级(必填) QQ(必填) 联系电话(必填)打星import jsonimport yamlimport pandas as pdimport secretsimport string
def generate_password(length=12, use_upper=True, use_digits=True, use_symbols=True): # Gemini 写的 chars = string.ascii_lowercase
if use_upper: chars += string.ascii_uppercase if use_digits: chars += string.digits if use_symbols: chars += string.punctuation
password = ''.join(secrets.choice(chars) for _ in range(length)) return password
DATA_PATH = "final.csv"BEGIN_ID = 1001COLLEGE_MAP = { "地球科学与技术学院": "c01", "石油工程学院": "c02", "化学化工学院": "c03", "机电工程学院": "c04", "储运与建筑工程学院": "c05", "材料科学与工程学院": "c06", "石大山能新能源学院": "c07", "海洋与空间信息学院": "c08", "控制科学与工程学院": "c09", "青岛软件学院、计算机科学与技术学院": "c10", "理学院": "c11", "经济管理学院": "c12", "外国语学院": "c13", "文法学院": "c14", "马克思主义学院": "c15", "体育教学部": "c16",}
wl110 = pd.read_excel("文理楼110信息统计.xlsx", sheet_name="编号-ip") # 文理楼 110 的序号和 ip 的对应关系极其混乱,所以单独统计了一张键值对的表df = pd.read_csv(DATA_PATH, dtype=str)df["打星"] = df["打星"] == "True" # 转成一个 bool 型user_table = []teams = []accounts = []
for idx, row in df.iterrows(): id = str(BEGIN_ID + idx) if row["打星"] == True: name = '*' + row["姓名(必填)"] group_ids = ["observers"] else: name = row["姓名(必填)"] if row["性别(必填)"] == "女": group_ids = ["girl-participants"] else: group_ids = ["participants"] if idx < 72: # 前 72 个人排到 102 pos = idx * 2 + 2 location = f"文理楼102-{pos}" ip = f"172.20.6.{pos}" else: # 后面 54 个人排到 110 location = f"文理楼110-{wl110.iloc[idx - 72]["编号"]}" ip = f"172.20.10.{wl110.iloc[idx - 72]["ip"]}" teams.append({ "id": id, "group_ids": group_ids, "name": row["学号(必填)"], "display_name": name, "organization_id": COLLEGE_MAP[row["学院(必填)"]], "location": {"description": location} }) # 生成了个密码备用,但是并没有派上用场,因为出问题的时候调座直接修改 ip 好像比数一遍密码快 password = generate_password(use_symbols=False) accounts.append({ "id": id, "username": row["学号(必填)"], "password": password, "type": "team", "name": name, "team_id": id, "ip": ip, }) user_table.append({ "username": row["学号(必填)"], "passowrd": password, "姓名": name, "学院": row["学院(必填)"], "班级": row["专业班级(必填)"], "打星": row["打星"], "位置": location })
# domserver 导入(给 domserver 看的)with open("output/teams.json", "w") as f: json.dump(teams, f, indent=2, ensure_ascii=False)
with open("output/accounts.yaml", "w") as f: yaml.dump(accounts, f, allow_unicode=True)
# 用户名单(给人看的)pd.DataFrame(user_table).to_excel("user_table.xlsx")竞赛导入
导入竞赛
在 Contests 下导入,但是我感觉这个不如用图形界面创建。
题目导入
最好保证先有一个竞赛。
题目格式
首先过一遍题目的数据格式,每道题都是一个压缩包,以正式赛的 I 题为例,I.zip 里面的内容如下
I# 这个目录下可以再放一个 problem.pdf 表示题面# 也可以在 I/submissions/accepted/ 下面放标程,交上去之后会自动测一遍这个标程├── data│ ├── sample # 样例,用户可以打包下载这里的文件│ │ ├── 1.ans│ │ └── 1.in│ └── secret # 测试数据,只需要 in 和 out 的 stem 对应即可│ ├── 1.ans│ ├── 1.in│ ├── 2.ans│ ├── 2.in│ ├── 3.ans│ ├── 3.in│ ├── 4.ans│ ├── 4.in│ ├── 5.ans│ └── 5.in├── domjudge-problem.ini├── output_validators # (可选)交互库 / spj│ └── I_cmp│ ├── interactor.cpp│ └── testlib.h└── problem.yaml
6 directories, 16 filestimelimit='2' # 单位是 sspecial_run='I_cmp' # (可选)如果不需要 spj 或者交互器可以无视,发现导出的里面有个这条,但是不加好像也没事color='#ab812c' # (可选)气球颜色name: Liuxx怎么这么能跑啊validation: 'custom interactive' # (可选)验证方法,交互题 custem interactive,一般 spj 用 custom# (可选)浮点数误差 validator_flags: 'float_tolerance 1E-6'limits: memory: 256 # 单位是 MB维护者的无奈
不过一般题不是一个人出的,所以每个人都不好好看文档就会造成很大的混乱,比如
- domjudge-problem.ini 或者 problem.yaml 缺失或者格式有问题
- 测试数据里面的换行用
\r\n而不是\n,说几次都不听 - 不传样例,只传测试数据
最后还得是搭建 domjudge 的人在擦屁股,我是真无语……如果你没有遇到这种恶习的情况可以直接看 打包 了。
于是我想到一个折中的办法,只要他们的测试数据,其他的都从题面里解析,所有测试数据都过滤一遍 \r\n,之后如果需要 spj,interactive 或者浮点数精度等调整自己手动在加一下。我们的题面统一用了 olymp.sty,参考了前前任部长的教程 ACM/ICPC/CCPC等算法竞赛规范题面撰写详细教程(全网最详细,看完包懂)。
解析 latex 文件需要 texsoup 库
pip install texsoup这里模式也很固定,的很多代码也都是先 ai 在微调的。
"""解析 tex 用"""from TexSoup import TexSoupfrom TexSoup.data import TexNode, TexArgs
def tex_info(content: str): # 解析LaTeX内容 soup = TexSoup(content) # 初始化返回值 title = "" timelimit = "" memlimit = "" samples = []
# 1. 提取problem环境的参数(时间限制、内存限制) problem_env = soup.find("problem") if problem_env and hasattr(problem_env, 'args') and len(problem_env.args) >= 5: # problem环境参数:{Title}{standard input}{standard output}{2 seconds}{256 megabytes} title = str(problem_env.args[0]).strip("{}") time_arg = str(problem_env.args[3]).strip("{}") # 第4个参数是时间限制 mem_arg = str(problem_env.args[4]).strip("{}") # 第5个参数是内存限制
timelimit = int(_get_arg_text(time_arg).split()[0]) memlimit = int(_get_arg_text(mem_arg).split()[0])
# 2. 提取example环境中的样例输入输出 example_envs = soup.find_all("example") for example in example_envs: # 找到exmp命令(样例输入输出的容器) exmp_cmd = example.find("exmp") if exmp_cmd and hasattr(exmp_cmd, 'args') and len(exmp_cmd.args) >= 2: # exmp的第一个参数是输入,第二个是输出 in_arg = exmp_cmd.args[0] ans_arg = exmp_cmd.args[1]
# 提取输入输出文本并清理格式 in_text = _clean_sample_text(_get_arg_text(in_arg)) ans_text = _clean_sample_text(_get_arg_text(ans_arg))
if in_text or ans_text: samples.append({ "in": in_text, "ans": ans_text })
return title, timelimit, memlimit, samples
def _get_arg_text(arg) -> str: """辅助函数:提取TexArgs/TexNode的文本内容""" if isinstance(arg, (TexArgs, TexNode)): # 直接取text属性,若没有则转为字符串 return getattr(arg, 'text', str(arg)) return str(arg)
def _clean_sample_text(text: str) -> str: """辅助函数:清理样例文本中的多余符号和空白""" return text.strip("{}%\n").replace("\r\n", "\n")from pathlib import Pathfrom shutil import rmtreefrom tex_info import tex_info
basedir = Path("/Users/wangyafei/Documents/学校事务/社团管理/2025社团管理/2026校赛/contests/正式赛/problems")tex_path = basedir / "LaTeX" # tex 的目录,每道题是一个单独的 tex,文件名如 A.texstd_path = basedir / "std" # 标程的目录,文件名如 std-a.cppinput_path = basedir / '数据' # 测试数据output_path = basedir / 'norm_output'output_path.mkdir(exist_ok=True) # 创建目录
for dir in input_path.iterdir(): if dir.is_dir(): label = dir.stem.upper() print(f"Processing Problem {label}") out_dir = output_path / label rmtree(out_dir, ignore_errors=True) out_dir.mkdir(exist_ok=True) content = (tex_path / f"{label}.tex").read_text() title, timelimit, memlimit, samples = tex_info(content)
# domjudge-problem.ini print("Writing domjudge-problem.ini") (out_dir / 'domjudge-problem.ini').write_text(f"timelimit = {timelimit}")
# problem.yaml print("Writing problem.yaml") (out_dir / 'problem.yaml').write_text(f"name: \"{title}\"\nlimits:\n memory: {memlimit}")
# submissions/accepted try: print("Writing submissions/correct/solve.cpp") std_dir = out_dir / 'submissions' / 'accepted' std_dir.mkdir(parents=True, exist_ok=True) (std_dir / 'solve.cpp').write_text((std_path / f"std-{label.lower()}.cpp").read_text()) except Exception as e: print(f'error: {e}')
# data/sample print("Writing samples") sample_dir = out_dir / 'data' / 'sample' sample_dir.mkdir(parents=True) for i, data in enumerate(samples): (sample_dir / f"{i}.in").write_text(data.get("in")) (sample_dir / f"{i}.ans").write_text(data.get("ans"))
# data/secret print("Copying testcases") data_dir = out_dir / 'data' / 'secret' data_dir.mkdir(parents=True) for file in (dir / 'data' / 'secret').iterdir(): filename = file.name if file.suffix == ".in" else file.stem + ".ans" # 没错这都有人不看,用了 .out,所以不是 in 都认为是输出了 out_file_path = data_dir / filename out_file_path.write_text(file.read_text().replace('\r\n', '\n'))打包
注意:压缩包根路径应该是 data, domjudge-problem.ini, problem.yaml …,而不是一个目录 I!
这个任务比较好描述,所以我丢给 ai 直接就得到了一个能用的脚本,把 output 目录下的所有题目的目录打包输出到 archives。
import osimport zipfilefrom pathlib import Path
def zip_folders_in_output(output_dir: str = "output", archives_dir: str = "archives"): """ 遍历output路径下的所有文件夹,将文件夹内容打包为zip包(散列内容),输出到archives路径
Args: output_dir (str): 源文件夹路径,默认"output" archives_dir (str): 压缩包输出路径,默认"archives" """ # 转换为Path对象,方便路径操作 output_path = Path(output_dir) archives_path = Path(archives_dir)
# 检查output目录是否存在 if not output_path.exists(): print(f"错误:{output_dir} 目录不存在!") return
# 创建archives目录(若不存在) archives_path.mkdir(exist_ok=True)
# 遍历output下的所有一级子目录 for item in output_path.iterdir(): if item.is_dir(): folder_name = item.name # 文件夹名称(作为zip包名) zip_file_path = archives_path / f"{folder_name}.zip" # zip包路径
# 创建zip文件(压缩模式为DEFLATED,即有压缩效果) with zipfile.ZipFile(zip_file_path, 'w', zipfile.ZIP_DEFLATED) as zipf: # 遍历文件夹内的所有文件和子目录 for root, dirs, files in os.walk(item): # 计算相对路径(去掉外层文件夹,实现散列打包) relative_root = os.path.relpath(root, item)
# 先添加子目录(确保空目录也被打包) for dir_name in dirs: dir_path = os.path.join(root, dir_name) relative_dir_path = os.path.join(relative_root, dir_name) # 写入目录到zip包 zipf.write(dir_path, relative_dir_path)
# 再添加文件 for file_name in files: file_path = os.path.join(root, file_name) relative_file_path = os.path.join(relative_root, file_name) # 写入文件到zip包(使用相对路径,避免外层文件夹) zipf.write(file_path, relative_file_path)
print(f"成功打包:{zip_file_path}")
if __name__ == "__main__": # 调用函数,可自定义output和archives路径 zip_folders_in_output(output_dir="output", archives_dir="archives")导入题目
在 Problems 下,但是一般题目很多,一个一个手动导入很麻烦,另外我用图形界面导入有时候还一直报错,所以我选择用 api 导入。
import requests
API_URL = "<替换成domjudge的ip或域名>/api/v4"USERNAME = "admin"PASSWORD = "<替换成管理员密码>"CONTEST_ID = "upcpc2026"
def upload_problem(zip_path): endpoint = f"{API_URL}/contests/{CONTEST_ID}/problems"
with open(zip_path, 'rb') as f: files = {'zip': f} response = requests.post( endpoint, auth=(USERNAME, PASSWORD), files=files )
if response.status_code == 200: print("Successfully uploaded!") print("Response:", response.json()) else: print(f"Failed! Status code: {response.status_code}") print("Error:", response.text)
for i in range(0, 13): upload_problem(f'./{chr(i + ord('A'))}.zip') # 如果这样写就必须要 cd 到压缩包那个目录下跑,也可以写死目录或者写好相对路径部分信息可能已经过时







