// ==UserScript== // @name 验证码自动识别填充(通用 CRNN) // @namespace https://github.com/Conastin/VerificationCode // @version 0.3.2 // @description 通用验证码识别:自动发现验证码与输入框,图片走本地 CRNN 推理、纯文字 DOM 直读(无需服务器),低置信自动刷新重试。 // @author Conastin // @match *://*/* // @run-at document-idle // @homepage https://github.com/Conastin/VerificationCode // @homepageURL https://github.com/Conastin/VerificationCode // @supportURL https://github.com/Conastin/VerificationCode/issues // @tag 验证码 // @tag OCR // @tag 自动填充 // @license MIT // @connect cdn.jsdelivr.net // @connect raw.githubusercontent.com // @connect github.com // @connect objects.githubusercontent.com // @connect fastly.jsdelivr.net // @require https://cdn.jsdelivr.net/npm/onnxruntime-web@1.19.2/dist/ort.min.js // @resource model https://fastly.jsdelivr.net/gh/Conastin/VerificationCode@v3.1.0/userscript/model.onnx // @grant GM_xmlhttpRequest // @grant GM_getResourceURL // @grant GM_setValue // @grant GM_getValue // @grant GM_deleteValue // @grant GM_registerMenuCommand // @grant GM_info // @noframes // ==/UserScript== /* eslint-disable no-undef */ "use strict"; (() => { // ------------------------------------------------------------ 常量 // 模型与脚本同版本分发: 发新版时同步更新此 tag 与 @version const MODEL_TAG = "v3.1.0"; const MODEL_VERSION = "v11-fp32-" + MODEL_TAG; const MODEL_URLS = [ "https://fastly.jsdelivr.net/gh/Conastin/VerificationCode@" + MODEL_TAG + "/userscript/model.onnx", "https://cdn.jsdelivr.net/gh/Conastin/VerificationCode@" + MODEL_TAG + "/userscript/model.onnx", "https://raw.githubusercontent.com/Conastin/VerificationCode/" + MODEL_TAG + "/userscript/model.onnx", ]; const ORT_WASM_PATHS = "https://cdn.jsdelivr.net/npm/onnxruntime-web@1.19.2/dist/"; const MODEL_SIZE_MB = 10.5; const CHARSET = "0123456789ABCDEFGHIJKLMNOPQRSTUVWXYZ"; const BLANK = 36; const IMG_H = 48; const CONF_THRESHOLD = 0.9; const MAX_ATTEMPTS = 5; const DEFAULT_TEXT_PATTERN = /[A-Za-z0-9]{3,8}/; const state = { session: null, status: "idle", // idle | loading | ready | error config: null, // {mode:"image"|"text", imgSelector?, textSelector?, inputSelector, textPattern?, refreshSelector?} chip: null, chipTimer: null, running: false, // 识别循环进行中(含低置信刷新重试), 屏蔽刷新监听的递归触发 srcObserver: null, textObserver: null, watching: false, lastSrc: null, }; // ------------------------------------------------------------ 工具 const sleep = (ms) => new Promise((r) => setTimeout(r, ms)); function shortSelector(el) { if (el.id) return "#" + CSS.escape(el.id); if (el.name) { const byName = document.querySelector( `${el.tagName.toLowerCase()}[name="${CSS.escape(el.name)}"]`); if (byName === el) return `${el.tagName.toLowerCase()}[name="${CSS.escape(el.name)}"]`; } const path = []; let node = el; while (node && node.nodeType === 1 && path.length < 5) { const parent = node.parentElement; if (!parent) { path.unshift(node.tagName.toLowerCase()); break; } const sibs = Array.from(parent.children).filter((c) => c.tagName === node.tagName); const idx = sibs.indexOf(node); path.unshift(node.tagName.toLowerCase() + (sibs.length > 1 ? `:nth-of-type(${idx + 1})` : "")); node = parent; } return path.join(">"); } // 角标: 按需显示 —— ok/info 4s、err 8s 自动隐藏; load/busy 常驻直到下一条或隐藏 function setChip(text, kind) { if (!state.chip) { state.chip = document.createElement("div"); state.chip.className = "cap-chip"; state.chip.style.display = "none"; document.body.appendChild(state.chip); } clearTimeout(state.chipTimer); state.chip.textContent = text; state.chip.dataset.kind = kind || "info"; state.chip.style.display = ""; const ttl = kind === "err" ? 8000 : (kind === "ok" || kind === "info") ? 4000 : 0; if (ttl) state.chipTimer = setTimeout(() => { state.chip.style.display = "none"; }, ttl); } function hideChip() { clearTimeout(state.chipTimer); if (state.chip) state.chip.style.display = "none"; } // ------------------------------------------------------------ 模型加载(三级容灾) async function fetchWithGM(url, onProgress) { return new Promise((resolve, reject) => { GM_xmlhttpRequest({ method: "GET", url, responseType: "arraybuffer", timeout: 120000, headers: { "Cache-Control": "no-cache" }, onprogress: (e) => { if (onProgress && e.lengthComputable) onProgress(e.loaded, e.total); }, onload: (res) => (res.status === 200 ? resolve(res.response) : reject(new Error(url + " -> " + res.status))), onerror: () => reject(new Error("network: " + url)), ontimeout: () => reject(new Error("timeout: " + url)), }); }); } async function loadModelBytes() { // 1) @resource —— 管理器级缓存, 安装脚本时下载一次、所有网站共享 try { const url = GM_getResourceURL("model", true); if (url && (url.startsWith("blob:") || url.startsWith("data:"))) { const resp = await fetch(url); if (resp.ok) { const bytes = await resp.arrayBuffer(); if (bytes.byteLength > 1e6) return { bytes, cached: true }; } } } catch (e) { /* 管理器差异, 走下一级 */ } // 2) Cache API 本地缓存(按域名隔离: 命中说明本站曾直拉过) if ("caches" in window && window.isSecureContext) { try { const cache = await caches.open("captcha-model"); const hit = await cache.match(MODEL_VERSION); if (hit) return { bytes: await hit.arrayBuffer(), cached: true }; } catch (e) { /* 页面 CSP 等原因, 走下一级 */ } } // 3) 多 CDN 直拉(模型大小已知, 进度不依赖 lengthComputable) let lastErr = null; for (const url of MODEL_URLS) { try { setChip(`模型下载 0.0 / ${MODEL_SIZE_MB}MB(管理器级共享缓存建立后其他网站秒加载)`, "load"); const bytes = await fetchWithGM(url, (loaded, total) => { const mb = Math.min(loaded / 1e6, MODEL_SIZE_MB); setChip(`模型下载 ${mb.toFixed(1)} / ${MODEL_SIZE_MB}MB`, "load"); }); if ("caches" in window && window.isSecureContext) { try { const cache = await caches.open("captcha-model"); await cache.put(MODEL_VERSION, new Response(bytes)); } catch (e) { /* 缓存失败不影响使用 */ } } return { bytes, cached: false }; } catch (e) { lastErr = e; } } throw lastErr || new Error("all model sources failed"); } async function initModel() { if (state.session) return; state.status = "loading"; setChip("模型加载中…", "load"); try { const { bytes } = await loadModelBytes(); ort.env.wasm.wasmPaths = ORT_WASM_PATHS; ort.env.wasm.numThreads = 1; state.session = await ort.InferenceSession.create(bytes, { executionProviders: ["wasm"], graphOptimizationLevel: "all", }); state.status = "ready"; hideChip(); } catch (e) { state.status = "error"; setChip("模型加载失败: " + e.message, "err"); throw e; } } // ------------------------------------------------------------ 识别(CRNN + CTC) function imageToTensor(image) { const natural = image.naturalWidth || image.width || 80; const naturalH = image.naturalHeight || image.height || 34; const w = Math.max(8, Math.round((natural * IMG_H) / naturalH)); const canvas = document.createElement("canvas"); canvas.width = w; canvas.height = IMG_H; const ctx = canvas.getContext("2d", { willReadFrequently: true }); ctx.drawImage(image, 0, 0, w, IMG_H); const rgba = ctx.getImageData(0, 0, w, IMG_H).data; const plane = w * IMG_H; const data = new Float32Array(3 * plane); for (let p = 0; p < plane; p++) { data[p] = rgba[p * 4] / 255; data[plane + p] = rgba[p * 4 + 1] / 255; data[2 * plane + p] = rgba[p * 4 + 2] / 255; } return new ort.Tensor("float32", data, [1, 3, IMG_H, w]); } async function recognize(image) { const results = await state.session.run({ image: imageToTensor(image) }); const logits = results.logits.data; const T = results.logits.dims[1]; const label = []; const confs = []; let prev = -1; for (let t = 0; t < T; t++) { const base = t * (BLANK + 1); let max = -Infinity, best = 0; for (let c = 0; c <= BLANK; c++) { const v = logits[base + c]; if (v > max) { max = v; best = c; } } let sum = 0; for (let c = 0; c <= BLANK; c++) sum += Math.exp(logits[base + c] - max); if (best !== prev && best !== BLANK) { label.push(CHARSET[best]); confs.push(1 / sum); } prev = best; } const conf = confs.length ? confs.reduce((a, b) => a + b, 0) / confs.length : 0; return { label: label.join(""), conf, perSlot: confs }; } // ------------------------------------------------------------ 自动发现(图片验证码) const IMG_KEYWORDS = /(captcha|verify|rand|seccode|vcode|kaptcha|checkcode|imgcode|validcode|randcode|getrandcode)/i; const INPUT_KEYWORDS = /(验证码|校验码|captcha|verification|randcode|vcode|seccode)/i; function plausibleImage(img) { const w = img.naturalWidth || img.width; const h = img.naturalHeight || img.height; if (!w || !h || w < 40 || w > 260 || h < 14 || h > 100) return false; const ratio = w / h; if (ratio < 1.2 || ratio > 8) return false; if (/logo|icon|avatar|banner|qrcode|barcode/i.test(img.src + " " + (img.id || "") + " " + (img.className || ""))) return false; if (img.src && /\.svg(\?|$)/i.test(img.src)) return false; const rect = img.getBoundingClientRect(); if (rect.width === 0 || rect.height === 0) return false; return true; } function discover() { const imgs = Array.from(document.querySelectorAll("img, canvas")) .filter(plausibleImage); let best = null; for (const img of imgs) { const sig = `${img.src || ""} ${img.id || ""} ${img.className || ""} ${img.getAttribute("onclick") || ""}`; const kw = IMG_KEYWORDS.test(sig); // 输入框: 从最近公共容器往外找 let scope = img.closest("form, div, section"); for (let depth = 0; depth < 4 && scope; depth++) { const inputs = Array.from(scope.querySelectorAll('input[type="text"], input:not([type])')) .filter((inp) => { if (inp.disabled || inp.readOnly || inp.offsetParent === null) return false; const s = `${inp.id || ""} ${inp.name || ""} ${inp.placeholder || ""} ${inp.getAttribute("aria-label") || ""} ${inp.maxLength || 8}`; if (/\b(email|tel|phone|user|password|search)\b/i.test(s)) return false; return INPUT_KEYWORDS.test(s) || (inp.maxLength >= 4 && inp.maxLength <= 8); }); if (inputs.length) { const input = inputs[0]; const score = (kw ? 10 : 0) + (depth === 0 ? 5 : 3 - depth); if (!best || score > best.score) { best = { img, input, score, keyword: kw }; } break; } scope = scope.parentElement; } } return best; } // ------------------------------------------------------------ UI(按需角标 + 发现横幅) function injectStyle() { const style = document.createElement("style"); style.textContent = ` .cap-chip{position:fixed;right:14px;bottom:14px;z-index:2147483000;background:#24292f;color:#fff; padding:6px 12px;border-radius:8px;font:12px/1.6 Consolas,"Microsoft YaHei",monospace; box-shadow:0 2px 10px rgba(0,0,0,.35);opacity:.92} .cap-chip[data-kind="ok"]{background:#1a7f37}.cap-chip[data-kind="err"]{background:#b62324} .cap-chip[data-kind="load"]{background:#9a6700}.cap-chip[data-kind="busy"]{background:#0969da} .cap-banner{position:fixed;top:0;left:50%;transform:translateX(-50%);z-index:2147483000; background:#fff;border:1px solid #d0d7de;border-top:none;border-radius:0 0 10px 10px; padding:10px 14px;display:flex;align-items:center;gap:12px; font:13px/1.5 "Microsoft YaHei",sans-serif;color:#1f2328;box-shadow:0 2px 12px rgba(0,0,0,.18)} .cap-banner img{width:96px;height:40px;object-fit:contain;background:#f6f8fa;border:1px solid #d0d7de;border-radius:4px} .cap-btn{cursor:pointer;border:1px solid #d0d7de;background:#f6f8fa;border-radius:6px;padding:4px 12px;font-size:13px} .cap-btn.primary{background:#1f883d;color:#fff;border-color:#1f883d} .cap-btn:hover{filter:brightness(.97)} .cap-hl{outline:3px solid #0969da !important;outline-offset:2px} .cap-note{color:#656d76;font-size:12px} `; document.head.appendChild(style); } function showDiscoveryBanner(cand) { if (document.querySelector(".cap-banner")) return; const banner = document.createElement("div"); banner.className = "cap-banner"; const img = document.createElement("img"); img.src = cand.img.src || ""; if (cand.img.tagName === "CANVAS") img.src = cand.img.toDataURL(); const text = document.createElement("div"); text.innerHTML = `检测到验证码
${shortSelector(cand.input)}` +
`${cand.keyword ? "" : "(低置信猜测,请确认)"}