Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
11 changes: 11 additions & 0 deletions Update.json
Original file line number Diff line number Diff line change
Expand Up @@ -3727,6 +3727,17 @@
}
],
"Notes": "### 修复提交界面不显示验证码\n\n评测队列繁忙时 XMOJ 会启用图片验证码,但提交界面是脚本自行渲染的,服务端的验证码字段被丢弃,导致提交静默失败。现在提交界面会显示验证码图片与输入框(点击图片可更换),并在提交时携带验证码。\n\n另新增 `AutoCaptcha` 开关(默认开启):在浏览器本地用模板匹配识别 4 位数字验证码,不联网、不调用任何 AI 服务。把握不大时会留空由您填写,不会填错。"
},
"3.6.5": {
"UpdateDate": 1789778010947,
"Prerelease": true,
"UpdateContents": [
{
"PR": 1021,
"Description": "fix: 比赛结束后回退提交不再被静默丢弃"
}
],
"Notes": "修复比赛结束后自动向原题提交的回退功能。此前回退提交的响应被丢弃,遇到提交冷却时会静默失败并显示\"提交失败\",状态页里也没有任何提交记录。"
}
}
}
190 changes: 124 additions & 66 deletions XMOJ.user.js
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
// ==UserScript==
// @name XMOJ
// @version 3.6.4
// @version 3.6.5

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P3: This PR manually bumps @version to 3.6.5, but repository automation (Update/UpdateVersion.js via the UpdateVersion workflow) is supposed to be the only writer of version numbers on PRs to dev, and it re-derives the patch from package.json. A manual bump can conflict with that automation (double-bump or enforced re-sync), and the version is enforced to stay consistent across package.json, XMOJ.user.js, and Update.json. Recommend leaving the version line untouched and letting the workflow bump it.

Prompt for AI agents
Check if this issue is valid — if so, understand the root cause and fix it. At XMOJ.user.js, line 3:

<comment>This PR manually bumps `@version` to 3.6.5, but repository automation (`Update/UpdateVersion.js` via the UpdateVersion workflow) is supposed to be the only writer of version numbers on PRs to `dev`, and it re-derives the patch from `package.json`. A manual bump can conflict with that automation (double-bump or enforced re-sync), and the version is enforced to stay consistent across `package.json`, `XMOJ.user.js`, and `Update.json`. Recommend leaving the version line untouched and letting the workflow bump it.</comment>

<file context>
@@ -1,6 +1,6 @@
 // ==UserScript==
 // @name         XMOJ
-// @version      3.6.4
+// @version      3.6.5
 // @description  XMOJ增强脚本
 // @author       @XMOJ-Script-dev, @langningchen and the community
</file context>
Suggested change
// @version 3.6.5
// @version 3.6.4

// @description XMOJ增强脚本
// @author @XMOJ-Script-dev, @langningchen and the community
// @namespace https://github/langningchen
Expand Down Expand Up @@ -4473,6 +4473,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

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

有效,已在 157e9b2 修掉。

确认了一遍控制流:整条链上唯一的 try/catch 是包 _xmoj_disposeErrorMessageEditors() 的那两处,fetch 链上没有 .catch()。所以 reject 会穿出 SubmitToEndedContestProblem、穿出那个 async .then 回调(回调是 async,抛出会让 .then 返回的 promise reject)、再穿出 PassCheckawait,最后变成事件监听器里的 unhandled rejection。ShowSubmitStatus 和恢复按钮的两行都不执行,而 ErrorElement.style.display = "none" 在监听器开头就已经设过了——按钮卡在"正在提交...",且没有任何提示。确实就是本 PR 声称修掉的那个症状。

除了你指的 contest.php,循环里 submit.php 的那次 fetch 是同一个问题,所以两处一起包了 try/catch。contest.php 失败复用原来的"无法读取比赛页面"提示(对用户来说是同一种情况);submit.php 失败返回"网络错误,请稍后重试"。.text() 也可能 reject,一并包在里面了。

回归脚本加了三条:contest.php fetch reject、contest.php .text() reject、回退 submit reject,都断言函数返回错误信息而不是抛出。反向 mutation(MUT=6,去掉 contest.php 的 guard)不是让某条断言失败,而是直接让测试进程带着 uncaught TypeError: Failed to fetch 崩掉——和它在浏览器里干掉点击监听器的方式一模一样。现在 15/15。

另外提一下,外层 PassCheck 里那次 submit.php 的 fetch(第 4586 行左右)有同样的暴露面,不过那是 dev 上既有的、和回退路径无关,本 PR 没动。要不要顺手在监听器上加个兜底的 try/catch,我听 maintainer 的。

// 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];

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2: For malformed pid values such as length, this indexes array properties instead of validating an integer position, so the fallback can submit the row count as a real problem ID. Validate pid as a numeric index before indexing so malformed URLs take the existing failure path.

Prompt for AI agents
Check if this issue is valid — if so, understand the root cause and fix it. At XMOJ.user.js, line 4518:

<comment>For malformed `pid` values such as `length`, this indexes array properties instead of validating an integer position, so the fallback can submit the row count as a real problem ID. Validate `pid` as a numeric index before indexing so malformed URLs take the existing failure path.</comment>

<file context>
@@ -4473,6 +4473,102 @@ async function main() {
+                                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);
</file context>
Suggested change
RealPID = ContestProblems[ProblemNumber];
const ProblemIndex = /^\d+$/.test(ProblemNumber || "") ? Number(ProblemNumber) : -1;
RealPID = ProblemIndex >= 0 ? ContestProblems[ProblemIndex] : undefined;

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));
Comment on lines +4586 to +4587

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P3: After the fifth cooldown response, this code displays a retry message and sleeps for three seconds, then returns without another submission. Guard the status update and delay with Attempt < 4.

Prompt for AI agents
Check if this issue is valid — if so, understand the root cause and fix it. At XMOJ.user.js, line 4566:

<comment>After the fifth cooldown response, this code displays a retry message and sleeps for three seconds, then returns without another submission. Guard the status update and delay with `Attempt < 4`.</comment>

<file context>
@@ -4473,6 +4473,102 @@ async function main() {
+                                }
+                                return {Success: false, Message: "向题目 " + RealPID + " 提交失败!" + (ServerMessage === "" ? "请关闭脚本后重试!" : ServerMessage)};
+                            }
+                            ReportStatus("提交过于频繁, 3 秒后重新尝试向题目 " + RealPID + " 提交");
+                            await new Promise((Resolve) => setTimeout(Resolve, 3000));
+                        }
</file context>
Suggested change
ReportStatus("提交过于频繁, 3 秒后重新尝试向题目 " + RealPID + " 提交");
await new Promise((Resolve) => setTimeout(Resolve, 3000));
if (Attempt < 4) {
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.
Expand Down Expand Up @@ -4515,76 +4631,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 = "提交";
}
Expand Down
2 changes: 1 addition & 1 deletion package.json
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
{
"name": "xmoj-script",
"version": "3.6.4",
"version": "3.6.5",
"description": "an improvement script for xmoj.tech",
"main": "AddonScript.js",
"scripts": {
Expand Down
Loading