// ==UserScript== // @name XJGK 成绩查询辅助 小助手 // @namespace https://cjcx.xjzk.gov.cn/ // @version 0.3.0 // @description 解锁成绩查询表单、保存非验证码信息,并轮询 isQuery 开放后自动点击查询 // @match https://cjcx.xjzk.gov.cn:8443/* // @match https://cjcx.xjzk.gov.cn/* // @run-at document-start // @grant none // ==/UserScript== (() => { "use strict"; const STORE_KEY = "gkcx.saved.form.v1"; const POLL_STATE_KEY = "gkcx.polling.enabled.v1"; const CAPTCHA_AUTO_REFRESH_KEY = "gkcx.captcha.auto-refresh.v1"; const CAPTCHA_TS_KEY = "gkcx.captcha.loaded-at.v1"; const CLICK_LOCK_KEY = "gkcx.query.click.lock.v1"; const CLICK_LOCK_TTL = 30_000; const POLL_INTERVAL = 2_000; const OPEN_API_HOST = "https://cjcxapi.xjzk.gov.cn:19000"; const state = { pollTimer: 0, panelReady: false, prefetchedUrls: new Set(), }; window.__gkcxQueryClickLock = false; const nativeFetch = window.fetch.bind(window); window.__gkcxNativeFetch = nativeFetch; function getUrl(input) { if (typeof input === "string") return input; if (input instanceof URL) return input.href; if (input && typeof input.url === "string") return input.url; return String(input || ""); } function isIsQueryUrl(url) { return /\/gseea-resource\/score\/isQuery(?:\?|$)/.test(url); } function isScoreQueryUrl(url) { return /\/gseea-resource\/score\/query(?:\?|$)/.test(url); } function isCaptchaCodeUrl(url) { return /\/gseea-resource\/score\/code(?:\?|$)/.test(url); } function looksLikeCaptchaError(json) { const text = [json?.msg, json?.message, json?.error, json?.data] .map((value) => (typeof value === "string" ? value : "")) .join(" "); return ( /验证码|校验码|图形码|code/i.test(text) && /错|误|失效|过期| expired|invalid/i.test(text) ); } function hexToBytes(hex) { const clean = String(hex || "").trim(); const bytes = new Uint8Array(clean.length / 2); for (let i = 0; i < bytes.length; i += 1) { bytes[i] = parseInt(clean.slice(i * 2, i * 2 + 2), 16); } return bytes; } async function decryptScorePayload(hexCiphertext) { const enc = new TextEncoder(); const key = await crypto.subtle.importKey( "raw", enc.encode("gssjyksy23456789"), { name: "AES-CBC" }, false, ["decrypt"], ); const plain = await crypto.subtle.decrypt( { name: "AES-CBC", iv: enc.encode("23456789gssjyksy") }, key, hexToBytes(hexCiphertext), ); return new TextDecoder().decode(plain); } function valueOrDash(value) { return value === 0 || value ? String(value) : "-"; } function formatScoreSummary(info) { const rank = Array.isArray(info?.wc) && info.wc.length ? info.wc.join(" / ") : valueOrDash(info?.wc); return `姓名:${valueOrDash(info?.xm)} 总分 ${valueOrDash(info?.zf)} 位次 ${rank}`; } async function alertScoreSummaryBeforeRoute(json) { if (json?.code !== 200 || !json?.data || JSON.stringify(json.data) === "{}") return; try { const decrypted = await decryptScorePayload(json.data); const info = JSON.parse(decrypted); if (!info || JSON.stringify(info) === "{}") return; console.log("[GK Helper] score/query decrypted payload", info); alert(formatScoreSummary(info)); clearClickLock(); } catch (err) { console.error("[GK Helper] failed to decrypt score payload", err); } } function shouldAutoRefreshCaptcha() { return localStorage.getItem(CAPTCHA_AUTO_REFRESH_KEY) === "1"; } function setAutoRefreshCaptcha(enabled) { localStorage.setItem(CAPTCHA_AUTO_REFRESH_KEY, enabled ? "1" : "0"); } function prefetchUrl(url, asType) { if (!url) return; const absUrl = new URL(url, location.href).href; if (state.prefetchedUrls.has(absUrl)) return; state.prefetchedUrls.add(absUrl); const link = document.createElement("link"); link.rel = "prefetch"; link.href = absUrl; link.as = asType; link.crossOrigin = "anonymous"; link.dataset.gkcxPrefetch = absUrl; document.head.appendChild(link); } function prefetchDetailResources() { const detailScript = [...document.querySelectorAll('script[type="module"]')] .map((script) => script.src) .find((src) => /\/assets\/index-[\w-]+\.js(?:\?|$)/.test(src)); if (!detailScript) { toast("未找到 detail chunk,稍后会再尝试预加载。"); return false; } prefetchUrl(detailScript, "script"); nativeFetch(detailScript, { cache: "force-cache" }) .then((resp) => resp.text()) .then((code) => { const urls = new Set(); const re = /\.\/([^"']+\.(?:js|css|png|jpg|jpeg|webp|svg|woff2?))(?:["'])/g; let match; while ((match = re.exec(code))) urls.add(new URL(match[1], detailScript).href); urls.forEach((url) => { const asType = url.endsWith(".js") ? "script" : url.endsWith(".css") ? "style" : undefined; prefetchUrl(url, asType); }); toast(`已预加载 detail 资源 ${urls.size + 1} 个。`); }) .catch((err) => console.warn("[GK Helper] prefetch detail failed", err)); return true; } function installValidatorBypass() { const nativeStartsWith = String.prototype.startsWith; String.prototype.startsWith = function gkcxStartsWithHack( searchString, position, ) { const value = String(this); if (searchString === "25" && /^26\d{12}$/.test(value)) return true; return nativeStartsWith.call(this, searchString, position); }; } installValidatorBypass(); function jsonResponse(body, init = {}) { return new Response(JSON.stringify(body), { status: init.status || 200, statusText: init.statusText || "OK", headers: { "content-type": "application/json;charset=utf-8", ...(init.headers || {}), }, }); } // 只劫持页面自身的 isQuery 门禁,让表单可以提前填写;轮询使用 nativeFetch,不受这里影响。 window.fetch = async function gkcxFetchHack(input, init) { const url = getUrl(input); if (isIsQueryUrl(url)) { return jsonResponse({ code: 200, success: true, data: true, time: new Date().toISOString().replace("T", " ").slice(0, 19), msg: "Tampermonkey unlocked local form only", }); } const resp = await nativeFetch(input, init); if (isCaptchaCodeUrl(url)) { markCaptchaLoaded(); } if (isScoreQueryUrl(url)) { resp .clone() .json() .then((json) => { console.log("[GK Helper] score/query response", json); alertScoreSummaryBeforeRoute(json); if (looksLikeCaptchaError(json)) { clearClickLock(); toast(`查询返回:${json.msg || "验证码错误"}`); if (shouldAutoRefreshCaptcha()) { window.setTimeout(() => refreshCaptchaAfterError(), 150); } } }) .catch(() => {}); } return resp; }; function getCxlx() { return new URLSearchParams(location.search).get("cxlx") || ""; } function savedKeyForCurrentType() { return `${STORE_KEY}.${getCxlx() || "default"}`; } function loadSaved() { try { const current = JSON.parse(localStorage.getItem(savedKeyForCurrentType()) || "{}"); if (current && Object.keys(current).length) return current; // 兼容旧版本保存的数据;只在查询类型一致时使用,避免串用不同入口的信息。 const legacy = JSON.parse(localStorage.getItem(STORE_KEY) || "{}"); if (!legacy || Object.keys(legacy).length === 0) return {}; if (legacy.cxlx && legacy.cxlx !== getCxlx()) return {}; return legacy; } catch { return {}; } } function saveSaved(data) { localStorage.setItem(savedKeyForCurrentType(), JSON.stringify(data)); } function clearSaved() { localStorage.removeItem(savedKeyForCurrentType()); localStorage.removeItem(STORE_KEY); } function visible(el) { if (!el) return false; const rect = el.getBoundingClientRect(); return rect.width > 0 && rect.height > 0; } function allInputs() { return [...document.querySelectorAll("input")].filter(visible); } function inputByPlaceholder(part) { return allInputs().find((input) => (input.getAttribute("placeholder") || "").includes(part), ); } function inputByFormLabel(labelText) { const items = [ ...document.querySelectorAll('.n-form-item, [class*="form-item"]'), ]; const item = items.find((el) => (el.textContent || "").includes(labelText)); return item ? item.querySelector("input") : null; } function fieldElements() { return { ksh: inputByPlaceholder("准考证号") || inputByPlaceholder("考生编号") || inputByPlaceholder("就读省考生号") || inputByFormLabel("准考证号") || inputByFormLabel("考生编号") || inputByFormLabel("就读省考生号"), sfzh: inputByPlaceholder("身份证号") || inputByFormLabel("身份证号"), pwd: inputByPlaceholder("密码") || inputByFormLabel("密码") || allInputs().find((input) => input.type === "password"), code: inputByPlaceholder("验证码") || inputByFormLabel("验证码"), }; } function setInputValue(input, value) { if (!input || value == null) return; const old = input.value; if (old === String(value)) return; const proto = Object.getPrototypeOf(input); const desc = Object.getOwnPropertyDescriptor(proto, "value"); if (desc && desc.set) desc.set.call(input, String(value)); else input.value = String(value); input.dispatchEvent(new Event("input", { bubbles: true })); input.dispatchEvent(new Event("change", { bubbles: true })); } function readForm() { const fields = fieldElements(); return { cxlx: getCxlx(), ksh: fields.ksh?.value || "", sfzh: fields.sfzh?.value || "", pwd: fields.pwd?.value || "", // 故意不保存验证码和验证码 key。 }; } function shouldUseSaved(saved) { return !saved?.cxlx || saved.cxlx === getCxlx(); } function setInputValueIfEmpty(input, value) { if (!input || input.value || value == null || value === "") return; setInputValue(input, value); } function fillForm(saved = loadSaved()) { if (!shouldUseSaved(saved)) return false; const fields = fieldElements(); setInputValueIfEmpty(fields.ksh, saved.ksh || ""); setInputValueIfEmpty(fields.sfzh, saved.sfzh || ""); setInputValueIfEmpty(fields.pwd, saved.pwd || ""); return true; } function saveForm() { const data = readForm(); saveSaved(data); toast("已保存:准考证号/身份证号/密码;未保存验证码。"); } function clearForm() { clearSaved(); const fields = fieldElements(); setInputValue(fields.ksh, ""); setInputValue(fields.sfzh, ""); setInputValue(fields.pwd, ""); toast("已清除保存的信息。"); } function toast(message) { console.log(`[GK Helper] ${message}`); const tip = document.getElementById("gkcx-helper-tip"); if (!tip) return; tip.textContent = message; clearTimeout(toast._timer); toast._timer = setTimeout(() => { tip.textContent = ""; }, 3500); } function markCaptchaLoaded() { localStorage.setItem(CAPTCHA_TS_KEY, String(Date.now())); updateCaptchaAge(); } function formatDuration(ms) { if (!Number.isFinite(ms) || ms < 0) return "未知"; const totalSeconds = Math.floor(ms / 1000); const minutes = Math.floor(totalSeconds / 60); const seconds = totalSeconds % 60; if (minutes <= 0) return `${seconds}秒`; return `${minutes}分${String(seconds).padStart(2, "0")}秒`; } function updateCaptchaAge() { const el = document.getElementById("gkcx-helper-captcha-age"); if (!el) return; const ts = Number(localStorage.getItem(CAPTCHA_TS_KEY)); el.textContent = Number.isFinite(ts) && ts > 0 ? `验证码:${formatDuration(Date.now() - ts)}前获取` : "验证码:尚未记录"; } function getRealIsQueryUrl() { const type = encodeURIComponent(getCxlx()); return `${OPEN_API_HOST}/gseea-resource/score/isQuery?type=${type}`; } async function checkIsQueryOpen() { const resp = await nativeFetch(getRealIsQueryUrl(), { method: "GET", cache: "no-store", credentials: "omit", }); const json = await resp.json(); return json && json.code === 200 && json.data === true; } function lockActive() { if (window.__gkcxQueryClickLock) return true; const raw = localStorage.getItem(CLICK_LOCK_KEY); if (!raw) return false; const ts = Number(raw); if (!Number.isFinite(ts)) return false; if (Date.now() - ts > CLICK_LOCK_TTL) { localStorage.removeItem(CLICK_LOCK_KEY); return false; } return true; } function acquireClickLock() { if (lockActive()) return false; window.__gkcxQueryClickLock = true; localStorage.setItem(CLICK_LOCK_KEY, String(Date.now())); return true; } function clearClickLock() { window.__gkcxQueryClickLock = false; localStorage.removeItem(CLICK_LOCK_KEY); } function clearClickLockManually() { clearClickLock(); toast("已解除自动点击锁;如轮询仍在运行,开放时可再次自动点击。"); } function findQueryButton() { const candidates = [ ...document.querySelectorAll("button, .n-button"), ].filter(visible); return candidates.find( (btn) => /查询/.test(btn.textContent || "") && !/成绩查询辅助/.test(btn.textContent || ""), ); } function findCaptchaImage() { const fields = fieldElements(); const holders = [ fields.code?.closest('.n-form-item, [class*="form-item"]'), fields.code?.closest("form"), fields.code?.parentElement?.parentElement, document, ].filter(Boolean); for (const holder of holders) { const imgs = [...holder.querySelectorAll("img")].filter(visible); const matched = imgs.find((img) => (img.src || "").startsWith("data:image")) || imgs.find((img) => /code|captcha|yzm|验证码/i.test(`${img.src || ""} ${img.alt || ""}`)) || imgs[0]; if (matched) return matched; } return null; } function refreshCaptchaAfterError() { const img = findCaptchaImage(); if (!img) { toast("验证码错误,但未找到验证码图片,请手动刷新。"); return; } img.click(); markCaptchaLoaded(); const fields = fieldElements(); setInputValue(fields.code, ""); toast("验证码验证失败,已刷新验证码,请重新输入后查询。"); } function requiredFieldMissing(fields) { if (!fields.ksh?.value?.trim() && !fields.sfzh?.value?.trim()) return "准考证号/身份证号"; if (fields.pwd && !fields.pwd.value.trim()) return "密码"; if (!fields.code?.value?.trim()) return "验证码"; return ""; } function warnMissingFieldsBeforePolling() { fillForm(); const missing = requiredFieldMissing(fieldElements()); if (!missing) return false; toast(`轮询中:${missing}为空;开放后不会自动点击,请提前补全。`); return true; } async function clickQueryOnce() { if (!acquireClickLock()) { toast("已触发过自动查询,跳过重复点击。"); return; } fillForm(); const fields = fieldElements(); const missing = requiredFieldMissing(fields); if (missing) { clearClickLock(); toast(`已开放,但${missing}为空,未自动点击。请补全后手动查询。`); return; } const btn = findQueryButton(); if (!btn) { clearClickLock(); // 不要弹窗浪费时间。 // alert("成绩查询已开放,但没有找到“查询”按钮,请手动点击。"); return; } // 不要弹窗浪费时间。 // alert( // "成绩查询已开放,即将自动点击查询。请确认验证码已填写且当前页面信息正确。", // ); btn.click(); } async function pollOnce() { try { const hasMissingFields = warnMissingFieldsBeforePolling(); if (!hasMissingFields) toast("正在检查是否开放查询……"); const open = await checkIsQueryOpen(); if (open) { stopPolling(false); toast("已开放,准备自动查询。"); clickQueryOnce(); } else { toast(`暂未开放,下次 ${POLL_INTERVAL / 1000}s 后检查。`); } } catch (err) { console.error("[GK Helper] polling failed", err); toast("检查失败,稍后重试。"); } } function startPolling() { if (!getCxlx()) { alert("当前 URL 缺少 cxlx 参数,无法轮询查询类型。"); return; } clearClickLock(); localStorage.setItem(POLL_STATE_KEY, "1"); if (state.pollTimer) clearInterval(state.pollTimer); pollOnce(); state.pollTimer = window.setInterval(pollOnce, POLL_INTERVAL); setPanelStatus(true); toast("已启动轮询;本次启动已清除自动点击锁。"); } function stopPolling(showToast = true) { if (state.pollTimer) clearInterval(state.pollTimer); state.pollTimer = 0; localStorage.removeItem(POLL_STATE_KEY); setPanelStatus(false); if (showToast) toast("已停止轮询。"); } function setPanelStatus(running) { const status = document.getElementById("gkcx-helper-status"); if (status) { status.textContent = running ? "轮询中" : "未轮询"; status.style.color = running ? "#18a058" : "#666"; } } function makeButton(text, onClick, primary = false) { const btn = document.createElement("button"); btn.type = "button"; btn.textContent = text; btn.style.cssText = ` border: 0; border-radius: 6px; padding: 6px 10px; cursor: pointer; font-size: 13px; color: ${primary ? "#fff" : "#333"}; background: ${primary ? "#3277c5" : "#f0f0f0"}; `; btn.addEventListener("click", onClick); return btn; } function makeCheckbox(labelText, checked, onChange) { const label = document.createElement("label"); label.style.cssText = "display: flex; align-items: center; gap: 6px; margin-bottom: 8px; user-select: none; cursor: pointer;"; const input = document.createElement("input"); input.type = "checkbox"; input.checked = checked; input.addEventListener("change", () => onChange(input.checked)); const span = document.createElement("span"); span.textContent = labelText; label.append(input, span); return label; } function closePanel() { document.getElementById("gkcx-helper-panel")?.remove(); state.panelReady = false; } function injectPanel() { if (state.panelReady || document.getElementById("gkcx-helper-panel")) return; state.panelReady = true; const panel = document.createElement("div"); panel.id = "gkcx-helper-panel"; panel.style.cssText = ` position: fixed; right: 16px; bottom: 16px; z-index: 2147483647; width: 260px; box-sizing: border-box; padding: 12px; padding-top: 28px; border-radius: 10px; background: rgba(255, 255, 255, 0.96); border: 1px solid rgba(0, 0, 0, 0.12); box-shadow: 0 8px 28px rgba(0, 0, 0, 0.18); font-family: system-ui, -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif; font-size: 13px; color: #222; `; const closeBtn = document.createElement("button"); closeBtn.type = "button"; closeBtn.textContent = "×"; closeBtn.title = "关闭小助手"; closeBtn.style.cssText = ` position: absolute; top: 6px; right: 8px; border: 0; background: transparent; color: #888; font-size: 18px; line-height: 1; cursor: pointer; padding: 2px 4px; `; closeBtn.addEventListener("click", closePanel); const title = document.createElement("div"); title.textContent = "成绩查询辅助"; title.style.cssText = "font-weight: 700; margin-bottom: 8px;"; const status = document.createElement("div"); status.innerHTML = `状态:未轮询`; status.style.cssText = "margin-bottom: 8px;"; const row1 = document.createElement("div"); row1.style.cssText = "display: flex; gap: 8px; margin-bottom: 8px; flex-wrap: wrap;"; row1.append( makeButton("保存信息", saveForm, true), makeButton("清除信息", clearForm), ); const row2 = document.createElement("div"); row2.style.cssText = "display: flex; gap: 8px; margin-bottom: 8px; flex-wrap: wrap;"; row2.append( makeButton("启动轮询", startPolling, true), makeButton("停止轮询", () => stopPolling(true)), makeButton("预加载详情页", prefetchDetailResources), ); const captchaOption = makeCheckbox( "验证码错误时自动刷新", shouldAutoRefreshCaptcha(), (checked) => { setAutoRefreshCaptcha(checked); toast( checked ? "已开启验证码错误后自动刷新。" : "已关闭验证码自动刷新。", ); }, ); const row3 = document.createElement("div"); row3.style.cssText = "display: flex; gap: 8px; margin-bottom: 4px; flex-wrap: wrap;"; row3.append(makeButton("解除点击锁", clearClickLockManually)); const lockHelp = document.createElement("div"); lockHelp.textContent = "点击锁用于防止开放瞬间重复自动提交;异常时可手动解除。"; lockHelp.style.cssText = "margin-bottom: 8px; color: #888; font-size: 12px; line-height: 1.35;"; const captchaAge = document.createElement("div"); captchaAge.id = "gkcx-helper-captcha-age"; captchaAge.style.cssText = "margin-bottom: 8px; color: #666; font-size: 12px; line-height: 1.35;"; captchaAge.textContent = "验证码:尚未记录"; const tip = document.createElement("div"); tip.id = "gkcx-helper-tip"; tip.style.cssText = "min-height: 18px; color: #666; line-height: 1.4;"; tip.textContent = "验证码不会保存。"; panel.append(closeBtn, title, status, row1, row2, captchaOption, row3, lockHelp, captchaAge, tip); document.documentElement.appendChild(panel); setPanelStatus(Boolean(state.pollTimer)); updateCaptchaAge(); } function initWhenReady() { injectPanel(); window.setInterval(updateCaptchaAge, 1_000); window.setTimeout(prefetchDetailResources, 1_000); window.setTimeout(prefetchDetailResources, 4_000); // Vue/NaiveUI 可能在脚本执行时还没把输入框挂载出来,多尝试几次。 let attempts = 0; const timer = window.setInterval(() => { attempts += 1; fillForm(); if (attempts >= 20 || fieldElements().ksh || fieldElements().sfzh) { clearInterval(timer); } }, 300); if (localStorage.getItem(POLL_STATE_KEY) === "1") { startPolling(); } } if (document.readyState === "loading") { document.addEventListener("DOMContentLoaded", initWhenReady, { once: true, }); } else { initWhenReady(); } })();