fix: 在提交界面显示并提交验证码

Merged
BO
2mo ago
+306-7

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.php renders the field when pending solutions (result<4) is >10, but
    submit.php only 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 switches vcode.php from 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 vcode field before innerHTML replaces the page, and render a
    captcha image + input above the Submit button when present. Clicking the image fetches a new one.
  • Send vcode on both submit paths. submit.php ignores 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 AutoCaptcha setting (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 vfail variant, which is
      never even decoded;
    • a running solve never overwrites what the user has already started typing.

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.tech31 auto-filled (78%), 9 declined
14 hand-labelled live captchas13 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 --check passes.
  • The recogniser and the captcha UI block were extracted verbatim from XMOJ.user.js and 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, AutoCaptcha off,
    vcode.php unreachable, 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:

  1. ✅ Read and understood the contributor's guide; this PR is based on and targets dev.
  2. ✅ The changes are commented in-code.
  3. ⚠️ Partially. Logic is unit-tested under stubs (see above), but nothing was run in a browser.
  4. ✅
  5. ✅
  6. ✅ Squashed into meaningful commits (plus CI's version bumps).
  7. ✅ No other PR covers this. Note #1018 touches the same submit handler; when it lands, its
    SubmitToEndedContestProblem fallback needs + GetCaptchaParameter() on its request body.
  8. ✅
  9. ✅
  10. ✅
  11. ❌ 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