@@ -4197,7 +4254,368 @@ async function main() {
});
}
+ // vcode.php writes the expected answer into the PHP session every time it is
+ // requested, so the challenge must be downloaded exactly once and that same copy
+ // shown to the user: letting the
![]()
load it separately would leave the picture on
+ // screen one challenge behind whatever the session actually expects.
+ let CaptchaObjectURL = null;
+ let CaptchaRequestID = 0;
+ const SetCaptchaStatus = (Message) => {
+ document.querySelector("#CaptchaStatus").innerText = Message;
+ };
+ // Byte 6-7 of a GIF header is the little endian width. vcode.php sizes the image as
+ // 15px per character, so 60px means the easy 4 digit challenge while a wider image is
+ // the 8 character alphanumeric one the server switches to after a failed attempt.
+ const GetCaptchaLength = async (ImageBlob) => {
+ const Header = new Uint8Array(await ImageBlob.slice(0, 8).arrayBuffer());
+ return Math.round((Header[6] | (Header[7] << 8)) / 15);
+ };
+ const ParsedCaptchaGlyphs = Object.entries(CaptchaGlyphs).map(([Digit, Bitmap]) => {
+ const Rows = Bitmap.split("|");
+ return {Digit: Digit, Rows: Rows, Width: Rows[0].length, Height: Rows.length};
+ });
+ // vcode.php fills the background with one random colour and draws the text in its exact
+ // inverse, so the most common pixel identifies the background and 255 minus it is the
+ // ink. Noise dots are a third random colour and mostly fall outside that tolerance. The
+ // 1px black border is skipped because a near white background makes it match the ink.
+ const BuildCaptchaMask = (Pixels, Width, Height) => {
+ const Counts = new Map();
+ for (let Index = 0; Index < Pixels.length; Index += 4) {
+ const Key = (Pixels[Index] << 16) | (Pixels[Index + 1] << 8) | Pixels[Index + 2];
+ Counts.set(Key, (Counts.get(Key) || 0) + 1);
+ }
+ let Background = 0, BestCount = -1;
+ Counts.forEach((Count, Key) => {
+ if (Count > BestCount) { BestCount = Count; Background = Key; }
+ });
+ const InkRed = 255 - ((Background >> 16) & 255);
+ const InkGreen = 255 - ((Background >> 8) & 255);
+ const InkBlue = 255 - (Background & 255);
+ const Mask = [];
+ for (let Row = 0; Row < Height; Row++) {
+ const Line = new Uint8Array(Width);
+ for (let Column = 0; Column < Width; Column++) {
+ if (Row === 0 || Column === 0 || Row === Height - 1 || Column === Width - 1) continue;
+ const Index = (Row * Width + Column) * 4;
+ const Distance = Math.abs(Pixels[Index] - InkRed) +
+ Math.abs(Pixels[Index + 1] - InkGreen) +
+ Math.abs(Pixels[Index + 2] - InkBlue);
+ if (Distance < 90) Line[Column] = 1;
+ }
+ Mask.push(Line);
+ }
+ return Mask;
+ };
+ // Digits never touch in this font, so inked columns split cleanly into one run each.
+ const SplitCaptchaColumns = (Mask, Width, Height) => {
+ const Groups = [];
+ let Current = null;
+ for (let Column = 0; Column < Width; Column++) {
+ let Inked = false;
+ for (let Row = 0; Row < Height && !Inked; Row++) if (Mask[Row][Column]) Inked = true;
+ if (Inked) {
+ if (Current !== null && Column - Current[Current.length - 1] <= 1) Current.push(Column);
+ else { if (Current !== null) Groups.push(Current); Current = [Column]; }
+ }
+ }
+ if (Current !== null) Groups.push(Current);
+ return Groups;
+ };
+ // Rewarding covered ink alone lets a noisy 0 score as well as a 9, so ink the glyph does
+ // not explain is penalised too. The window shifts by a couple of pixels either way to
+ // absorb noise that has stuck to the edge of a digit and moved its bounding box.
+ const MatchCaptchaGlyph = (Mask, Group, Width, Height) => {
+ let Top = Height;
+ for (let Row = 0; Row < Height; Row++) {
+ for (const Column of Group) if (Mask[Row][Column]) { Top = Math.min(Top, Row); break; }
+ }
+ const Left = Group[0];
+ const Scores = ParsedCaptchaGlyphs.map((Glyph) => {
+ let Best = -Infinity;
+ for (let OffsetY = -2; OffsetY <= 2; OffsetY++) {
+ for (let OffsetX = -2; OffsetX <= 2; OffsetX++) {
+ let Score = 0;
+ for (let Row = 0; Row < Glyph.Height; Row++) {
+ for (let Column = 0; Column < Glyph.Width; Column++) {
+ const SampleRow = Top + OffsetY + Row;
+ const SampleColumn = Left + OffsetX + Column;
+ const Inked = SampleRow >= 0 && SampleRow < Height && SampleColumn >= 0 &&
+ SampleColumn < Width && Mask[SampleRow][SampleColumn] === 1;
+ if (Glyph.Rows[Row][Column] === "1") Score += Inked ? 1 : -2;
+ else if (Inked) Score -= 1;
+ }
+ }
+ Best = Math.max(Best, Score);
+ }
+ }
+ return {Digit: Glyph.Digit, Score: Best};
+ }).sort((Left, Right) => Right.Score - Left.Score);
+ return {Digit: Scores[0].Digit, Margin: Scores[0].Score - Scores[1].Score};
+ };
+ // Returns null rather than a guess whenever the image does not split into exactly four
+ // digits or any one of them is a close call, so a wrong answer never reaches submit.php.
+ const SolveCaptcha = async (ImageBlob) => {
+ try {
+ const Bitmap = await createImageBitmap(ImageBlob);
+ const Canvas = document.createElement("canvas");
+ Canvas.width = Bitmap.width;
+ Canvas.height = Bitmap.height;
+ const Context = Canvas.getContext("2d", {willReadFrequently: true});
+ Context.drawImage(Bitmap, 0, 0);
+ const Pixels = Context.getImageData(0, 0, Bitmap.width, Bitmap.height).data;
+ const Mask = BuildCaptchaMask(Pixels, Bitmap.width, Bitmap.height);
+ const Groups = SplitCaptchaColumns(Mask, Bitmap.width, Bitmap.height);
+ if (Groups.length !== 4) {
+ if (UtilityEnabled("DebugMode")) {
+ console.log("Captcha split into", Groups.length, "glyphs, not reading it");
+ }
+ return null;
+ }
+ let Answer = "";
+ for (const Group of Groups) {
+ const Match = MatchCaptchaGlyph(Mask, Group, Bitmap.width, Bitmap.height);
+ if (Match.Margin < CaptchaMinMargin) {
+ if (UtilityEnabled("DebugMode")) {
+ console.log("Captcha glyph too close to call, margin", Match.Margin);
+ }
+ return null;
+ }
+ Answer += Match.Digit;
+ }
+ if (UtilityEnabled("DebugMode")) {
+ console.log("Captcha read locally as", Answer);
+ }
+ return Answer;
+ } catch (e) {
+ console.error(e);
+ return null;
+ }
+ };
+ const RefreshCaptcha = async (StatusMessage) => {
+ const RequestID = ++CaptchaRequestID;
+ const CaptchaInput = document.querySelector("#vcode");
+ document.querySelector("#CaptchaElement").style.display = "block";
+ CaptchaInput.value = "";
+ SetCaptchaStatus(StatusMessage || "");
+ // 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;
+ }
+ }
+ SetCaptchaStatus("连续几张都看不太准,请手动输入,或点击图片换一张");
+ };
+ document.querySelector("#CaptchaImage").addEventListener("click", () => {
+ RefreshCaptcha("");
+ });
+ document.querySelector("#vcode").addEventListener("keydown", (KeyEvent) => {
+ if (KeyEvent.key === "Enter") {
+ KeyEvent.preventDefault();
+ Submit.click();
+ }
+ });
+ // submit.php ignores an unexpected vcode field, so sending it whenever the user has
+ // one costs nothing and covers the case where the queue grew past the enforcement
+ // threshold after this page was rendered.
+ const GetCaptchaParameter = () => {
+ const CaptchaValue = document.querySelector("#vcode").value.trim();
+ return CaptchaValue === "" ? "" : "&vcode=" + encodeURIComponent(CaptchaValue);
+ };
+ // Submitting a blank answer makes the server mark the session as having failed the
+ // check, which swaps the 4 digit challenge for an 8 character one until the session
+ // ends. This has to be re-checked immediately before the POST rather than only when
+ // 提交 is pressed: a warning leaves 强制提交 on screen, and the captcha can be cleared
+ // in between by refreshing the image or emptying the box by hand.
+ const CaptchaIsMissing = () => {
+ if (document.querySelector("#CaptchaElement").style.display === "none") return false;
+ if (document.querySelector("#vcode").value.trim() !== "") return false;
+ PassCheck.style.display = "none";
+ 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 = "当前评测队列繁忙,请先填写上方的验证码。";
+ Submit.disabled = false;
+ Submit.value = "提交";
+ document.querySelector("#vcode").focus();
+ return true;
+ };
+ if (NativeCaptchaShown) {
+ 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.
+ if (CaptchaIsMissing()) return;
ErrorElement.style.display = "none";
document.querySelector("#Submit").disabled = true;
document.querySelector("#Submit").value = "正在提交...";
@@ -4209,43 +4627,19 @@ async function main() {
},
"referrer": location.href,
"method": "POST",
- "body": (SearchParams.get("id") != null ? "id=" + SearchParams.get("id") : "cid=" + SearchParams.get("cid") + "&pid=" + SearchParams.get("pid")) + "&language=1&" + "source=" + encodeURIComponent(CodeMirrorElement.getValue()) + o2Switch
+ "body": (SearchParams.get("id") != null ? "id=" + SearchParams.get("id") : "cid=" + SearchParams.get("cid") + "&pid=" + SearchParams.get("pid")) + "&language=1&" + "source=" + encodeURIComponent(CodeMirrorElement.getValue()) + o2Switch + GetCaptchaParameter()
}).then(async (Response) => {
if (Response.redirected) {
location.href = Response.url;
} else {
const text = await Response.text();
- 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")];
+ // The queue can cross submit.php's enforcement threshold after this page
+ // was rendered, so the field may not have been on screen at all yet.
+ if (text.indexOf("验证码错误") !== -1) {
if (UtilityEnabled("DebugMode")) {
- console.log("Contest Problems:", contestProblems);
- console.log("Real PID:", rPID);
+ console.log("Submission rejected by captcha check.");
}
+ await RefreshCaptcha("");
ErrorElement.style.display = "block";
ErrorMessage.style.color = "red";
try { _xmoj_disposeErrorMessageEditors(); } catch (e) {
@@ -4254,37 +4648,24 @@ async function main() {
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
- }).then(async (Response) => {
- if (Response.redirected) {
- location.href = Response.url;
- }
- console.log(await Response.text());
- });
-
+ ErrorMessage.innerText = "验证码错误!请填写上方的验证码后重新提交。";
+ Submit.disabled = false;
+ Submit.value = "提交";
+ document.querySelector("#vcode").focus();
+ return;
}
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 = "提交";
}
@@ -4296,6 +4677,7 @@ async function main() {
ErrorElement.style.display = "none";
document.querySelector("#Submit").disabled = true;
document.querySelector("#Submit").value = "正在检查...";
+ if (CaptchaIsMissing()) return;
let Source = CodeMirrorElement.getValue();
let PID = 0;
let IOFilename = "";
@@ -4970,6 +5352,10 @@ async function main() {