fix: 在提交界面显示并提交验证码
What does this PR aim to accomplish?:
Closes #420
When the judge queue is busy, XMOJ enables its native vcode.php image captcha. The submit page is
re-rendered from scratch by the script, so the server's captcha field was discarded and vcode was
never sent — submissions failed with the generic 提交失败!请关闭脚本后重试!.
Two details from the upstream HUSTOJ source make this worse than it first appears:
submitpage.phprenders the field when pending solutions (result<4) is >10, but
submit.phponly enforces it at >50. The two states drift apart, which is exactly what
@langningchen described in the issue thread.- A missing or wrong answer sets
$_SESSION[vfail], which switchesvcode.phpfrom 4 digits to
8 alphanumeric characters for the remainder of the session. Every failed submission was
therefore poisoning the session into the much harder captcha.
How does this PR accomplish the above?:
- Detect the server-rendered
vcodefield beforeinnerHTMLreplaces the page, and render a
captcha image + input above the Submit button when present. Clicking the image fetches a new one. - Send
vcodeon both submit paths.submit.phpignores the field when the queue is below the
enforcement threshold, so sending it unconditionally is safe and closes the >10/>50 gap. - Detect
验证码错误in the response, reopen the captcha area and re-enable Submit for a retry,
instead of reporting the generic failure message. - Refuse to submit an empty captcha client-side, specifically so the request cannot trip
vfail
and escalate the session to the 8-character challenge. - New
AutoCaptchasetting (green/A, on by default) reads the captcha locally in the browser —
no network call, no Worker, no model:- the challenge is downloaded once and that same copy is both shown and read —
vcode.php
rewrites the session answer on every request, so a separate<img>load would leave the picture
one challenge behind what the server expects; - the GIF header width (15px per character) identifies the 8-character
vfailvariant, which is
never even decoded; - a running solve never overwrites what the user has already started typing.
- the challenge is downloaded once and that same copy is both shown and read —
Why template matching rather than the AI solver
vcode.php renders 4 digits with imagettftext($im, 15, 0, 4, 20, ..., "include/Vera.ttf", $vcode)
— a fixed font at a fixed size and origin, so every glyph is pixel-identical between challenges.
That makes this an OCR problem with 10 known templates, not a vision-model problem.
Vision models were measured first, and all of them failed. On ground truth 7602:
llava-1.5-7b → 600, llama-4-scout → 1834, mistral-small-3.1-24b → 1234. Over 7 labelled
captchas each scored 0/7, before and after isolating the text and upscaling 6× — with perfectly
clean input LLaVA still drops the leading digit (5341→341, 5411→411).
The matcher instead: takes the most common pixel as the background (the text is drawn in its exact
inverse), skips the 1px black border (a near-white background makes the border match the ink), splits
inked columns into glyphs, and scores each against the 10 templates. Scoring rewards covered strokes
and penalises ink the glyph does not explain — without that second term a noisy 0 ties with 9,
which was the one real error found during testing. It answers only when the image splits into exactly
4 glyphs and each winner leads the runner-up by ≥4, otherwise it leaves the box empty.
That threshold is the whole safety argument: a wrong answer trips vfail and escalates the session
from 4 digits to 8 characters, so the matcher is tuned to never guess.
| result | |
|---|---|
300 generated captchas (exact vcode.php algorithm, known labels) | 227 correct, 0 wrong, 73 declined |
| 40 live captchas from xmoj.tech | 31 auto-filled (78%), 9 declined |
| 14 hand-labelled live captchas | 13 correct, 0 wrong, 1 declined |
Because it never answers wrongly, AutoCaptcha ships on by default. captchaSolve and its
@connect are removed — this needs no backend at all.
Two notes for captchaSolve#10, which is already
merged and deployed and is a regression: Llama 4 Scout refuses the current prompt outright
(*I apologize, but I am) because it contains the word CAPTCHA, and returns a constant 8965 when it
does answer; and via the REST API the model returns choices[0].text, so response.response is worth
confirming against the binding. Reverting it restores strictly better (though still poor) behaviour.
Because both UtilityEnabled and the settings list independently seed missing settings, the
default-off list is hoisted into a shared DefaultOffSettings const so the two cannot disagree.
Testing
The queue is idle nearly all the time, so this state is close to unreproducible without flooding the
judge. localStorage["UserScript-ForceCaptcha"] = "true" forces the captcha UI on instead.
What was verified:
node --checkpasses.- The recogniser and the captcha UI block were extracted verbatim from
XMOJ.user.jsand executed
under stubs against real captcha pixels — 18 checks covering recognition, declining on an
ambiguous image, declining on a noise-bridged image, the 8-character skip,AutoCaptchaoff,
vcode.phpunreachable, the type-during-solve race, and "never wrong across 14 labelled captchas". - Accuracy measured as in the table above.
Not verified — please check before merging: nothing has been exercised in a real browser. In
particular createImageBitmap + <canvas> decoding of the GIF is stubbed in the tests, and the
enforcement path (queue >50 → 验证码错误 → recovery) cannot be reached without a busy queue or a
local HUSTOJ with $OJ_VCODE forced on.
By submitting this pull request, I confirm the following:
- ✅ Read and understood the contributor's guide; this PR is based on and targets
dev. - ✅ The changes are commented in-code.
- ⚠️ Partially. Logic is unit-tested under stubs (see above), but nothing was run in a browser.
- ✅
- ✅
- ✅ Squashed into meaningful commits (plus CI's version bumps).
- ✅ No other PR covers this. Note #1018 touches the same submit handler; when it lands, its
SubmitToEndedContestProblemfallback needs+ GetCaptchaParameter()on its request body. - ✅
- ✅
- ✅
- ❌ Cannot verify — needs a human. No browser testing was possible here. Please confirm the
captcha area renders correctly before ticking this.
- I have read the above and my PR is ready for review. Check this box to confirm
Left unchecked deliberately: items 3 and 11 are not fully satisfied. Please tick it after
browser verification.
🤖 Generated with Claude Code