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 的全部功能更新与修复。

\n

3.6.1:代码对比显示修复(#1006,修复 #1004)

\n\n

3.6.2:提交记录布局修复(#1013)

\n\n

3.6.3:获取数据功能升级(#1015)

\n\n

3.6.4:提交验证码支持(#1019)

\n\n

3.6.5:比赛结束后回退提交修复(#1021)

\n\n

3.6.6:验证码自动更换与重试(#1023)

\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": {