diff --git a/Update.json b/Update.json
index c9667873..3bc3bf70 100644
--- a/Update.json
+++ b/Update.json
@@ -3727,6 +3727,59 @@
}
],
"Notes": "### 修复提交界面不显示验证码\n\n评测队列繁忙时 XMOJ 会启用图片验证码,但提交界面是脚本自行渲染的,服务端的验证码字段被丢弃,导致提交静默失败。现在提交界面会显示验证码图片与输入框(点击图片可更换),并在提交时携带验证码。\n\n另新增 `AutoCaptcha` 开关(默认开启):在浏览器本地用模板匹配识别 4 位数字验证码,不联网、不调用任何 AI 服务。把握不大时会留空由您填写,不会填错。"
+ },
+ "3.6.5": {
+ "UpdateDate": 1789778010947,
+ "Prerelease": true,
+ "UpdateContents": [
+ {
+ "PR": 1021,
+ "Description": "fix: 比赛结束后回退提交不再被静默丢弃"
+ }
+ ],
+ "Notes": "修复比赛结束后自动向原题提交的回退功能。此前回退提交的响应被丢弃,遇到提交冷却时会静默失败并显示\"提交失败\",状态页里也没有任何提交记录。"
+ },
+ "3.6.6": {
+ "UpdateDate": 1789779349314,
+ "Prerelease": true,
+ "UpdateContents": [
+ {
+ "PR": 1023,
+ "Description": "feat: 验证码识别不出时自动换一张"
+ }
+ ],
+ "Notes": "自动识别验证码时,若当前这张看不准,脚本会自动换一张重试(最多 5 张),无需手动点击图片。"
+ },
+ "3.7.0": {
+ "UpdateDate": 1789780064668,
+ "Prerelease": false,
+ "UpdateContents": [
+ {
+ "PR": 1006,
+ "Description": "Fix #1004"
+ },
+ {
+ "PR": 1013,
+ "Description": "fix: handle PID 0 submission rows"
+ },
+ {
+ "PR": 1015,
+ "Description": "feat: compress ApplyData payloads with gzip and Base93"
+ },
+ {
+ "PR": 1019,
+ "Description": "fix: 在提交界面显示并提交验证码"
+ },
+ {
+ "PR": 1021,
+ "Description": "fix: 比赛结束后回退提交不再被静默丢弃"
+ },
+ {
+ "PR": 1023,
+ "Description": "feat: 验证码识别不出时自动换一张"
+ }
+ ],
+ "Notes": "
3.7.0 — 自上一正式版 3.6.0 以来的完整更新
\n本次正式版整合 3.6.1、3.6.2、3.6.3、3.6.4、3.6.5 和 3.6.6 的全部功能更新与修复。
\n3.6.1:代码对比显示修复(#1006,修复 #1004)
\n- 修复 Monaco 代码对比区域的高度布局,为合并编辑器容器设置高度,恢复对比内容的正常显示。
\n3.6.2:提交记录布局修复(#1013)
\n- 修复题目 ID 为 0、缺少重新提交链接时提交记录的排版错误,避免相关处理打断页面。
- 安全构造提交记录中的链接,并将缺失链接的判断限定在需要重新提交链接的处理逻辑中。
\n3.6.3:获取数据功能升级(#1015)
\n\n- 页面没有数据申请区域时自动创建获取数据控件。
\n- 默认模式采用 gzip + Base93 压缩传输,逐字节保留任意输入,支持文本、Unicode 和混合内容;无效 UTF-8 以可逆的逐字节转义形式显示。
\n- 新增可选 NSC3 高速数值模式,压缩 long long 整数及每行数值个数;规范数值输入可恢复空格、空行、换行与末尾换行,非规范或混合内容原样回退。
\n- 新增“保留换行”选项,默认开启。关闭后,规范数值输入恢复为单行空格分隔数据,降低随机行长带来的输出开销;仅适用于 cin 或 scanf 等不依赖行边界的读取方式,不适用于 getline 或按行解析。
\n- 界面增加各模式的说明与适用范围,帮助选择通用模式或高速数值模式。
\n- 生成的数据获取程序先向 stderr 输出并刷新,再通过 abort() 触发运行错误以返回数据;同时兼容读取旧版异常包装结果。
\n
\n3.6.4:提交验证码支持(#1019)
\n\n- 修复评测队列繁忙、服务端启用验证码时,脚本提交界面丢失验证码字段而导致提交失败的问题。
\n- 显示验证码图片与输入框,支持点击图片更换、按回车提交,并在提交请求中携带验证码。
\n- 新增 AutoCaptcha 设置,支持在浏览器本地通过模板匹配识别四位数字验证码,不调用外部 AI 服务。识别不确定时交由用户输入;非四位数字类型提示手动填写。
\n- 普通提交与强制提交均检查验证码是否为空,并在实际发送请求前再次检查,避免用户中途清空或刷新图片后仍发送空验证码。
\n- 若服务端在页面加载后才要求验证码,或返回验证码错误,显示并刷新验证码、恢复提交按钮并提示重新填写。
\n
\n3.6.5:比赛结束后回退提交修复(#1021)
\n\n- 修复比赛结束后向原题回退提交时响应被忽略、提交静默丢失的问题。
\n- 从比赛题目列表中解析完整原题编号,避免固定截取四位数字造成题号错误。
\n- 正确处理成功跳转、提交冷却与服务端错误;遇到重复提交限制时按三秒间隔重试,最多尝试五次,并显示进度及失败原因。
\n- 每次回退请求前重新检查验证码并携带当前输入;验证码失效时刷新图片并要求重新填写。
\n- 处理比赛页面读取、题目列表解析及提交网络错误,恢复按钮状态,避免一直停留在“正在提交”。
\n
\n3.6.6:验证码自动更换与重试(#1023)
\n\n- 启用自动识别后,若当前验证码识别不确定,自动更换图片重试,最多尝试五张;仍不确定时提示手动输入或点击换图。
\n- 换图请求期间暂时锁定输入框,避免用户根据旧图片填写;图片加载完成或请求失败后恢复输入。
\n- 防止过期异步请求覆盖较新的验证码状态,并避免自动识别覆盖用户已经输入的内容。
\n- 验证码图片加载失败时给出明确提示,支持点击图片重试。
\n
"
}
}
}
\ No newline at end of file
diff --git a/XMOJ.user.js b/XMOJ.user.js
index 37322f82..494f9a4e 100644
--- a/XMOJ.user.js
+++ b/XMOJ.user.js
@@ -1,6 +1,6 @@
// ==UserScript==
// @name XMOJ
-// @version 3.6.4
+// @version 3.7.0
// @description XMOJ增强脚本
// @author @XMOJ-Script-dev, @langningchen and the community
// @namespace https://github/langningchen
@@ -576,6 +576,8 @@ const CaptchaGlyphs = {
// generated and 40 live captchas, 4 is the point where every wrong answer turns into a decline: it
// still fills in about three quarters of them and never once guessed wrong.
const CaptchaMinMargin = 4;
+// With about three quarters of challenges read, five images leave well under a 1% chance of giving up.
+const CaptchaMaxAttempts = 5;
// Settings that start off rather than on. Both UtilityEnabled and the settings list seed missing
// values, so they have to agree or whichever runs first decides the default.
const DefaultOffSettings = ["DebugMode", "SuperDebug", "ReplaceXM"];
@@ -4395,40 +4397,61 @@ async function main() {
document.querySelector("#CaptchaElement").style.display = "block";
CaptchaInput.value = "";
SetCaptchaStatus(StatusMessage || "");
- let ImageBlob;
- try {
- const CaptchaResponse = await fetch("https://www.xmoj.tech/vcode.php?" + Math.random(), {cache: "no-store"});
- ImageBlob = await CaptchaResponse.blob();
- } catch (e) {
- console.error(e);
- SetCaptchaStatus("验证码加载失败,请点击图片重试");
- return;
- }
- if (RequestID !== CaptchaRequestID) return;
- if (CaptchaObjectURL !== null) URL.revokeObjectURL(CaptchaObjectURL);
- CaptchaObjectURL = URL.createObjectURL(ImageBlob);
- document.querySelector("#CaptchaImage").src = CaptchaObjectURL;
- if (!UtilityEnabled("AutoCaptcha")) return;
- let CaptchaLength = 4;
- try {
- CaptchaLength = await GetCaptchaLength(ImageBlob);
- } catch (e) {
- console.error(e);
- }
- if (CaptchaLength !== 4) {
- SetCaptchaStatus("本次为 " + CaptchaLength + " 位字母验证码,请手动输入");
- return;
- }
- const Answer = await SolveCaptcha(ImageBlob);
- if (RequestID !== CaptchaRequestID) return;
- if (Answer === null) {
- SetCaptchaStatus("这张看不太准,请手动输入,或点击图片换一张");
- return;
+ // Only a submitted answer counts against the session, so fetching another image is
+ // free: when the solver declines one it is cheaper to ask for a fresh challenge than
+ // to make the user type it. Each fetch replaces the answer the session expects, so
+ // this stops if the user starts typing against the image on screen before the next one.
+ for (let Attempt = 1; Attempt <= CaptchaMaxAttempts; Attempt++) {
+ if (Attempt > 1) {
+ await new Promise((Resolve) => setTimeout(Resolve, 300));
+ if (RequestID !== CaptchaRequestID || CaptchaInput.value !== "") return;
+ SetCaptchaStatus("这张看不太准,正在自动换一张(" + Attempt + "/" + CaptchaMaxAttempts + ")");
+ }
+ // From the moment vcode.php is requested the image on screen no longer matches the
+ // session, so anything typed from it would be a guaranteed wrong answer. The box is
+ // locked until the new image replaces it. A newer call that takes over leaves the
+ // lock to be released by that call rather than by this one.
+ CaptchaInput.readOnly = true;
+ let ImageBlob;
+ try {
+ const CaptchaResponse = await fetch("https://www.xmoj.tech/vcode.php?" + Math.random(), {cache: "no-store"});
+ ImageBlob = await CaptchaResponse.blob();
+ } catch (e) {
+ console.error(e);
+ if (RequestID === CaptchaRequestID) {
+ CaptchaInput.readOnly = false;
+ SetCaptchaStatus("验证码加载失败,请点击图片重试");
+ }
+ return;
+ }
+ if (RequestID !== CaptchaRequestID) return;
+ if (CaptchaObjectURL !== null) URL.revokeObjectURL(CaptchaObjectURL);
+ CaptchaObjectURL = URL.createObjectURL(ImageBlob);
+ document.querySelector("#CaptchaImage").src = CaptchaObjectURL;
+ CaptchaInput.value = "";
+ CaptchaInput.readOnly = false;
+ if (!UtilityEnabled("AutoCaptcha")) return;
+ let CaptchaLength = 4;
+ try {
+ CaptchaLength = await GetCaptchaLength(ImageBlob);
+ } catch (e) {
+ console.error(e);
+ }
+ if (CaptchaLength !== 4) {
+ SetCaptchaStatus("本次为 " + CaptchaLength + " 位字母验证码,请手动输入");
+ return;
+ }
+ const Answer = await SolveCaptcha(ImageBlob);
+ if (RequestID !== CaptchaRequestID) return;
+ // Never overwrite what the user has already started typing.
+ if (CaptchaInput.value !== "") return;
+ if (Answer !== null) {
+ CaptchaInput.value = Answer;
+ SetCaptchaStatus("已自动识别,若与图片不符请手动修改");
+ return;
+ }
}
- // Never overwrite what the user has already started typing.
- if (CaptchaInput.value !== "") return;
- CaptchaInput.value = Answer;
- SetCaptchaStatus("已自动识别,若与图片不符请手动修改");
+ SetCaptchaStatus("连续几张都看不太准,请手动输入,或点击图片换一张");
};
document.querySelector("#CaptchaImage").addEventListener("click", () => {
RefreshCaptcha("");
@@ -4473,6 +4496,122 @@ async function main() {
RefreshCaptcha("");
}
+ const ShowSubmitStatus = (Message) => {
+ ErrorElement.style.display = "block";
+ ErrorMessage.style.color = "red";
+ try { _xmoj_disposeErrorMessageEditors(); } catch (e) {
+ console.error(e);
+ if (UtilityEnabled("DebugMode")) {
+ SmartAlert("XMOJ-Script internal error!\n\n" + e + "\n\n" + "If you see this message, please report it to the developer.\nDon't forget to include console logs and a way to reproduce the error!\n\nDon't want to see this message? Disable DebugMode.");
+ }
+ }
+ ErrorMessage.innerText = Message;
+ console.log(Message);
+ };
+
+ // Credit: https://github.com/boomzero/quicksubmit/blob/main/index.ts
+ // Also licensed under GPL-3.0
+ // The contest is over, so submit.php refuses the cid+pid submission. Look up the
+ // real problem number on the contest page and submit to that problem instead.
+ // Returns {Success, Message}; Success means a submission record was created.
+ async function SubmitToEndedContestProblem(Source, O2Switch, ReportStatus) {
+ const ContestID = new URL(location.href).searchParams.get("cid");
+ const ProblemNumber = new URL(location.href).searchParams.get("pid");
+ // A rejected fetch here would unwind all the way out of the click handler, which
+ // has no catch, leaving 提交 stuck on 正在提交... with the error box still hidden.
+ let ContestResponse = undefined;
+ let ContestPage = "";
+ try {
+ ContestResponse = await fetch("https://www.xmoj.tech/contest.php?cid=" + ContestID);
+ ContestPage = await ContestResponse.text();
+ } catch (e) {
+ console.error(e);
+ return {Success: false, Message: "无法读取比赛页面,未能找到原题题号!"};
+ }
+ if (ContestResponse.status !== 200 || ContestPage.indexOf("比赛尚未开始或私有,不能查看题目。") !== -1) {
+ console.error("Failed to get contest page!");
+ return {Success: false, Message: "无法读取比赛页面,未能找到原题题号!"};
+ }
+ let RealPID = undefined;
+ try {
+ const ContestDocument = new DOMParser().parseFromString(ContestPage, "text/html");
+ const ProblemTable = ContestDocument.querySelector("#problemset > tbody");
+ if (ProblemTable === null) {
+ console.error("Failed to find the problem list of the contest!");
+ return {Success: false, Message: "无法解析比赛题目列表,未能找到原题题号!"};
+ }
+ const ContestProblems = [];
+ for (let i = 0; i < ProblemTable.rows.length; i++) {
+ // The 题号 cell is padded with newlines and tabs; a fixed substring(2, 6)
+ // truncates any problem number that is not exactly four digits.
+ const ProblemNumberMatch = ProblemTable.rows[i].children[1].textContent.match(/\d+/);
+ ContestProblems.push(ProblemNumberMatch === null ? "" : ProblemNumberMatch[0]);
+ }
+ RealPID = ContestProblems[ProblemNumber];
+ if (UtilityEnabled("DebugMode")) {
+ console.log("Contest Problems:", ContestProblems);
+ console.log("Real PID:", RealPID);
+ }
+ } catch (e) {
+ console.error(e);
+ return {Success: false, Message: "无法解析比赛题目列表,未能找到原题题号!"};
+ }
+ if (RealPID === undefined || RealPID === "") {
+ return {Success: false, Message: "无法确定原题题号,请手动前往原题提交!"};
+ }
+ // XMOJ rejects anything submitted within a few seconds of the previous submission
+ // with 请勿重复提交, so wait the cooldown out instead of silently dropping the code.
+ for (let Attempt = 0; Attempt < 5; Attempt++) {
+ // The captcha field stays editable while we fetch contest.php and while we wait
+ // out a cooldown, so re-check it before every POST rather than trusting the
+ // check the 提交 handler did. Sending a blank answer would burn the session.
+ // CaptchaIsMissing() already shows its own message and restores the button.
+ if (CaptchaIsMissing()) return {Success: false, Handled: true, Message: ""};
+ ReportStatus("比赛已结束, 正在尝试向题目 " + RealPID + " 提交");
+ let SubmitPage = "";
+ try {
+ const SubmitResponse = await fetch("https://www.xmoj.tech/submit.php", {
+ "headers": {
+ "content-type": "application/x-www-form-urlencoded"
+ },
+ "referrer": location.href,
+ "method": "POST",
+ "body": "id=" + RealPID + "&language=1&" + "source=" + encodeURIComponent(Source) + O2Switch + GetCaptchaParameter()
+ });
+ if (SubmitResponse.redirected) {
+ location.href = SubmitResponse.url;
+ return {Success: true, Message: ""};
+ }
+ SubmitPage = await SubmitResponse.text();
+ } catch (e) {
+ console.error(e);
+ return {Success: false, Message: "向题目 " + RealPID + " 提交失败!网络错误,请稍后重试!"};
+ }
+ if (UtilityEnabled("DebugMode")) {
+ console.log("Direct submission response:", SubmitPage);
+ }
+ // Retrying cannot help here: the answer that was sent has already been spent.
+ if (SubmitPage.indexOf("验证码错误") !== -1) {
+ await RefreshCaptcha("");
+ document.querySelector("#vcode").focus();
+ return {Success: false, Message: "验证码错误!请填写上方的验证码后重新提交。"};
+ }
+ if (SubmitPage.indexOf("请勿重复提交") === -1) {
+ let ServerMessage = "";
+ try {
+ const MessageElement = new DOMParser().parseFromString(SubmitPage, "text/html").querySelector(".jumbotron");
+ if (MessageElement !== null) ServerMessage = MessageElement.textContent.trim();
+ } catch (e) {
+ console.error(e);
+ }
+ return {Success: false, Message: "向题目 " + RealPID + " 提交失败!" + (ServerMessage === "" ? "请关闭脚本后重试!" : ServerMessage)};
+ }
+ ReportStatus("提交过于频繁, 3 秒后重新尝试向题目 " + RealPID + " 提交");
+ await new Promise((Resolve) => setTimeout(Resolve, 3000));
+ }
+ return {Success: false, Message: "向题目 " + RealPID + " 提交失败!提交过于频繁,请稍后手动重试!"};
+ }
+
PassCheck.addEventListener("click", async () => {
// This is the request that actually reaches submit.php, so the captcha is checked
// here as well as in the 提交 handler above.
@@ -4515,76 +4654,18 @@ async function main() {
document.querySelector("#vcode").focus();
return;
}
- if (text.indexOf("没有这个比赛!") !== -1 && new URL(location.href).searchParams.get("pid") !== null) {
- // Credit: https://github.com/boomzero/quicksubmit/blob/main/index.ts
- // Also licensed under GPL-3.0
- const contestReq = await fetch("https://www.xmoj.tech/contest.php?cid=" + new URL(location.href).searchParams.get("cid"));
- const res = await contestReq.text();
- if (
- contestReq.status !== 200 ||
- res.indexOf("比赛尚未开始或私有,不能查看题目。") !== -1
- ) {
- console.error(`Failed to get contest page!`);
- return;
- }
- const parser = new DOMParser();
- const dom = parser.parseFromString(res, "text/html");
- const contestProblems = [];
- const rows = (dom.querySelector(
- "#problemset > tbody",
- )).rows;
- for (let i = 0; i < rows.length; i++) {
- contestProblems.push(
- rows[i].children[1].textContent.substring(2, 6).replaceAll(
- "\t",
- "",
- ),
- );
- }
- rPID = contestProblems[new URL(location.href).searchParams.get("pid")];
- if (UtilityEnabled("DebugMode")) {
- console.log("Contest Problems:", contestProblems);
- console.log("Real PID:", rPID);
- }
- ErrorElement.style.display = "block";
- ErrorMessage.style.color = "red";
- try { _xmoj_disposeErrorMessageEditors(); } catch (e) {
- console.error(e);
- if (UtilityEnabled("DebugMode")) {
- SmartAlert("XMOJ-Script internal error!\n\n" + e + "\n\n" + "If you see this message, please report it to the developer.\nDon't forget to include console logs and a way to reproduce the error!\n\nDon't want to see this message? Disable DebugMode.");
- }
- }
- ErrorMessage.innerText = "比赛已结束, 正在尝试向题目 " + rPID + " 提交";
- console.log("比赛已结束, 正在尝试向题目 " + rPID + " 提交");
- let o2Switch = "&enable_O2=on";
- if (!document.querySelector("#enable_O2").checked) o2Switch = "";
- await fetch("https://www.xmoj.tech/submit.php", {
- "headers": {
- "content-type": "application/x-www-form-urlencoded"
- },
- "referrer": location.href,
- "method": "POST",
- "body": "id=" + rPID + "&language=1&" + "source=" + encodeURIComponent(CodeMirrorElement.getValue()) + o2Switch + GetCaptchaParameter()
- }).then(async (Response) => {
- if (Response.redirected) {
- location.href = Response.url;
- }
- console.log(await Response.text());
- });
-
- }
if (UtilityEnabled("DebugMode")) {
console.log("Submission failed! Response:", text);
}
- ErrorElement.style.display = "block";
- ErrorMessage.style.color = "red";
- try { _xmoj_disposeErrorMessageEditors(); } catch (e) {
- console.error(e);
- if (UtilityEnabled("DebugMode")) {
- SmartAlert("XMOJ-Script internal error!\n\n" + e + "\n\n" + "If you see this message, please report it to the developer.\nDon't forget to include console logs and a way to reproduce the error!\n\nDon't want to see this message? Disable DebugMode.");
+ let FailMessage = "提交失败!请关闭脚本后重试!";
+ if (text.indexOf("没有这个比赛!") !== -1 && SearchParams.get("pid") !== null) {
+ const FallbackResult = await SubmitToEndedContestProblem(CodeMirrorElement.getValue(), o2Switch, ShowSubmitStatus);
+ if (FallbackResult.Success || FallbackResult.Handled) {
+ return;
}
+ FailMessage = FallbackResult.Message;
}
- ErrorMessage.innerText = "提交失败!请关闭脚本后重试!";
+ ShowSubmitStatus(FailMessage);
Submit.disabled = false;
Submit.value = "提交";
}
diff --git a/package.json b/package.json
index 8537fedb..454e50c9 100644
--- a/package.json
+++ b/package.json
@@ -1,6 +1,6 @@
{
"name": "xmoj-script",
- "version": "3.6.4",
+ "version": "3.7.0",
"description": "an improvement script for xmoj.tech",
"main": "AddonScript.js",
"scripts": {