// ==UserScript== // @name 识别二维码 QR Reader // @name:en QR Reader - Decode QR Codes on Page // @namespace Runas_Ron/userscripts/decode-qr-code // @version 1.0.0 // @author Runas_Ron (forked from lovefqy) // @description 右键图片或内联 SVG 即可识别二维码;多通道预处理(原生彩色、中值去噪、Sauvola 局部阈值);分位数对比度拉伸对抗压缩发灰;支持剪贴板截图识别与多尺度自适应扫描。 // @description:en Right-click any image or inline SVG to decode QR codes. Multi-channel preprocessing (native color, median filter, Sauvola local threshold) with quantile contrast stretching. Supports clipboard decoding and multi-scale scanning. // @license MIT // @match http*://*/* // @connect * // @require https://unpkg.com/jsqr@1.4.0/dist/jsQR.js // @grant GM_registerMenuCommand // @grant GM_setClipboard // @grant GM_xmlhttpRequest // @run-at document-idle // @noframes // ==/UserScript== (function () { 'use strict'; const jsQR = window.jsQR; if (!jsQR) { console.error('[decode-qr] jsQR 加载失败'); toast('jsQR 加载失败,请检查网络连接后刷新页面重试。'); } const gmSetClipboard = (typeof GM_setClipboard !== 'undefined') ? GM_setClipboard : null; const gmRegister = (typeof GM_registerMenuCommand !== 'undefined') ? GM_registerMenuCommand : null; const gmXmlHttpRequest = (typeof GM_xmlhttpRequest !== 'undefined') ? GM_xmlhttpRequest : null; const MEDIAN_FILTER_MAX_PX = 2_000_000; const SAUVOLA_MAX_PX = 2_000_000; let selDomRef = null; function setSel(el) { selDomRef = el ? new WeakRef(el) : null; } /* ======================== 基础工具 ======================== */ function safeBtoa(str) { const bytes = new TextEncoder().encode(str); let bin = ''; for (let i = 0; i < bytes.length; i++) bin += String.fromCharCode(bytes[i]); return btoa(bin); } function blobToImg(blob) { return new Promise((resolve, reject) => { const url = URL.createObjectURL(blob); const img = new Image(); img.onload = () => { URL.revokeObjectURL(url); resolve(img); }; img.onerror = () => { URL.revokeObjectURL(url); reject(new Error('图片加载失败')); }; img.src = url; }); } function loadImageCors(src) { return new Promise((resolve, reject) => { const img = new Image(); img.crossOrigin = 'anonymous'; img.onload = () => resolve(img); img.onerror = reject; img.src = src; }); } function fetchImageBlob(url) { return new Promise((resolve, reject) => { if (gmXmlHttpRequest) { gmXmlHttpRequest({ method: 'GET', url, responseType: 'blob', timeout: 10000, anonymous: true, onload: r => r.status === 200 ? resolve(r.response) : reject(new Error('HTTP ' + r.status)), onerror: () => reject(new Error('network error')), ontimeout: () => reject(new Error('timeout')), }); } else { reject(new Error('GM_xmlhttpRequest unavailable')); } }); } /* ======================== 错误处理与 UI ======================== */ const ERR = { NO_TARGET: { title: '未找到有效图片', hint: '请先右键一张 图片或 (等其加载完成)再执行本命令;或截图后用「从剪贴板识别」。' }, DECODE_FAILED:{ title: '未检测到二维码', hint: '可能原因:旋转、模糊、过小(<60px)、艺术码识别率天然较低。建议放大后重试或截图识别。' }, CORS_TAINTED: { title: '图片被跨域拦截', hint: '若弹出授权提示请允许;仍失败请截图后用「从剪贴板识别」。' }, CLIP_NO_IMG: { title: '剪贴板中没有图片', hint: '请先截图(Win+Shift+S / Cmd+Shift+4)再试。' }, CLIP_FAIL: { title: '读取剪贴板失败', hint: '可能是权限被拒、浏览器不支持(Firefox/Safari)或页面失焦。建议改用右键图片识别。' }, SVG_FAIL: { title: 'SVG 转换失败', hint: '若引用了外部资源会渲染失败。建议单独打开该 SVG 或截图识别。' }, UNKNOWN: { title: '发生未知错误', hint: '详见控制台 [decode-qr] 日志。' }, }; function fail(code, detail) { const e = ERR[code] || ERR.UNKNOWN; const msg = detail ? `${e.title}\n\n${e.hint}\n\n细节:${detail}` : `${e.title}\n\n${e.hint}`; console.warn('[decode-qr] FAIL', { code, detail }); alert(msg); } function toast(msg, ms = 2000) { if (!document.body) return; const d = document.createElement('div'); Object.assign(d.style, { position: 'fixed', bottom: '24px', left: '50%', transform: 'translateX(-50%)', background: 'rgba(0,0,0,.78)', color: '#fff', padding: '10px 20px', borderRadius: '8px', fontSize: '14px', zIndex: '2147483647', pointerEvents: 'none', transition: 'opacity .3s' }); d.textContent = msg; document.body.appendChild(d); setTimeout(() => { d.style.opacity = '0'; setTimeout(() => d.remove(), 300); }, ms); } function showResultModal(text, label, engine) { if (!document.body) { fail('UNKNOWN'); return; } const wrap = document.createElement('div'); Object.assign(wrap.style, { position: 'fixed', inset: '0', background: 'rgba(0,0,0,.5)', zIndex: '2147483647', display: 'flex', flexDirection: 'column', alignItems: 'center', justifyContent: 'center', backdropFilter: 'blur(2px)' }); const box = document.createElement('div'); Object.assign(box.style, { background: '#fff', borderRadius: '12px', padding: '20px', width: 'min(600px, 90vw)', maxHeight: '80vh', display: 'flex', flexDirection: 'column', gap: '12px', boxShadow: '0 8px 32px rgba(0,0,0,.3)', boxSizing: 'border-box' }); const title = document.createElement('div'); title.textContent = `识别成功(${label} | 引擎: ${engine})`; Object.assign(title.style, { fontSize: '14px', color: '#333', fontWeight: '600' }); const ta = document.createElement('textarea'); ta.value = text; ta.readOnly = true; Object.assign(ta.style, { flex: '1', minHeight: '80px', padding: '10px', fontSize: '14px', borderRadius: '8px', border: '1px solid #ddd', resize: 'vertical', boxSizing: 'border-box', wordBreak: 'break-all', outline: 'none' }); const btnRow = document.createElement('div'); Object.assign(btnRow.style, { display: 'flex', gap: '10px', justifyContent: 'flex-end' }); const mkBtn = (txt, bg, onClick) => { const b = document.createElement('button'); b.textContent = txt; Object.assign(b.style, { padding: '8px 18px', borderRadius: '8px', border: 'none', background: bg, color: '#fff', fontSize: '14px', cursor: 'pointer', transition: 'opacity 0.2s' }); b.addEventListener('mouseover', () => b.style.opacity = '0.8'); b.addEventListener('mouseout', () => b.style.opacity = '1'); b.addEventListener('click', onClick); return b; }; const escHandler = (e) => { if (e.key === 'Escape') close(); }; const close = () => { wrap.remove(); document.removeEventListener('keydown', escHandler); }; // 移除静默覆盖剪贴板的逻辑,统一交由用户点击处理 btnRow.appendChild(mkBtn('复制', '#4285f4', async () => { try { if (gmSetClipboard) gmSetClipboard(text, 'text'); else await navigator.clipboard.writeText(text); ta.select(); toast('已复制到剪贴板 ✓'); close(); } catch (_) { ta.select(); toast('自动复制失败,请按 Ctrl+C 手动复制'); } })); btnRow.appendChild(mkBtn('关闭', '#888', close)); box.appendChild(title); box.appendChild(ta); box.appendChild(btnRow); wrap.appendChild(box); wrap.addEventListener('click', e => { if (e.target === wrap) close(); }); document.addEventListener('keydown', escHandler); document.body.appendChild(wrap); ta.focus(); ta.select(); } /* ================= 图像预处理算法 ================= */ function analyzeAndGrayscale(rgba, len) { const gray = new Uint8Array(len); let colorScoreSum = 0; const step = Math.max(1, Math.floor(len / 50000)); let sampleCount = 0; for (let i = 0; i < len; i += step) { const j = i * 4; const r = rgba[j], g = rgba[j + 1], b = rgba[j + 2]; colorScoreSum += Math.max(r, g, b) - Math.min(r, g, b); sampleCount++; } const isColorful = (colorScoreSum / sampleCount) > 12; for (let i = 0; i < len; i++) { const j = i * 4; gray[i] = (0.299 * rgba[j] + 0.587 * rgba[j + 1] + 0.114 * rgba[j + 2]) | 0; } const hist = new Uint32Array(256); for (let i = 0; i < len; i++) hist[gray[i]]++; const cut = Math.max(1, (len * 0.005) | 0); let acc = 0, lo = 0, hi = 255; for (let v = 0; v < 256; v++) { acc += hist[v]; if (acc >= cut) { lo = v; break; } } acc = 0; for (let v = 255; v >= 0; v--) { acc += hist[v]; if (acc >= cut) { hi = v; break; } } const range = hi - lo; if (range > 10 && range < 120) { const scale = 255 / range; for (let i = 0; i < len; i++) { let v = gray[i] - lo; v = v <= 0 ? 0 : (v * scale) | 0; gray[i] = v > 255 ? 255 : v; } } return { gray, isColorful }; } function sauvolaBinarize(gray, w, h, window = 25, k = 0.34) { const out = new Uint8Array(gray.length); const I = new Uint32Array((w + 1) * (h + 1)); const I2 = new Float64Array((w + 1) * (h + 1)); for (let y = 0; y < h; y++) { let rs = 0, rs2 = 0; for (let x = 0; x < w; x++) { const v = gray[y * w + x]; rs += v; rs2 += v * v; I[(y + 1) * (w + 1) + x + 1] = I[y * (w + 1) + x + 1] + rs; I2[(y + 1) * (w + 1) + x + 1] = I2[y * (w + 1) + x + 1] + rs2; } } const r = window >> 1; const maxStd = 64; const FLAT_EPS = 10; for (let y = 0; y < h; y++) { const y0 = Math.max(0, y - r), y1 = Math.min(h - 1, y + r); for (let x = 0; x < w; x++) { const x0 = Math.max(0, x - r), x1 = Math.min(w - 1, x + r); const area = (y1 - y0 + 1) * (x1 - x0 + 1); const s1 = I[(y1 + 1) * (w + 1) + x1 + 1] - I[y0 * (w + 1) + x1 + 1] - I[(y1 + 1) * (w + 1) + x0] + I[y0 * (w + 1) + x0]; const s2 = I2[(y1 + 1) * (w + 1) + x1 + 1] - I2[y0 * (w + 1) + x1 + 1] - I2[(y1 + 1) * (w + 1) + x0] + I2[y0 * (w + 1) + x0]; const m = s1 / area; const std = Math.sqrt(Math.max(0, s2 / area - m * m)); let bin; if (std < FLAT_EPS) { bin = m < 128 ? 0 : 255; } else { const t = m * (1 + k * (std / maxStd - 1)); bin = gray[y * w + x] < t ? 0 : 255; } out[y * w + x] = bin; } } return out; } function grayToImageData(gray, w, h) { const data = new Uint8ClampedArray(w * h * 4); for (let i = 0; i < gray.length; i++) { data[i * 4] = gray[i]; data[i * 4 + 1] = gray[i]; data[i * 4 + 2] = gray[i]; data[i * 4 + 3] = 255; } return { data, width: w, height: h }; } function medianFilter(gray, w, h) { const out = new Uint8Array(gray.length); const win = new Uint8Array(9); for (let y = 1; y < h - 1; y++) { for (let x = 1; x < w - 1; x++) { let n = 0; for (let ky = -1; ky <= 1; ky++) for (let kx = -1; kx <= 1; kx++) win[n++] = gray[(y + ky) * w + (x + kx)]; for (let i = 1; i < 9; i++) { const v = win[i]; let j = i - 1; while (j >= 0 && win[j] > v) { win[j + 1] = win[j]; j--; } win[j + 1] = v; } out[y * w + x] = win[4]; } } for (let x = 0; x < w; x++) { out[x] = gray[x]; out[(h - 1) * w + x] = gray[(h - 1) * w + x]; } for (let y = 0; y < h; y++) { out[y * w] = gray[y * w]; out[y * w + w - 1] = gray[y * w + w - 1]; } return out; } /* ================= 解码核心 ================= */ function safeJsQR(data, w, h) { try { return jsQR(data, w, h); } catch (e) { console.warn('[decode-qr] jsQR throw:', e.message || e); return null; } } function decodeJsQR(rawData, w, h, s) { if (!jsQR) throw new Error('jsQR not loaded'); const { gray, isColorful } = analyzeAndGrayscale(rawData, w * h); if (isColorful) { const code0 = safeJsQR(rawData, w, h); if (code0) return { data: code0.data, channel: '通道1-原生彩色直出' }; } const code1 = safeJsQR(grayToImageData(gray, w, h).data, w, h); if (code1) return { data: code1.data, channel: '通道1-灰度直出(块自适应)' }; let filtered = null; if (w * h <= MEDIAN_FILTER_MAX_PX && s > 0.25) { filtered = medianFilter(gray, w, h); const code2 = safeJsQR(grayToImageData(filtered, w, h).data, w, h); if (code2) return { data: code2.data, channel: '通道2-中值去噪(块自适应)' }; } if (w * h <= SAUVOLA_MAX_PX) { const win = (s <= 0.25) ? 15 : Math.max(15, Math.round(Math.min(w, h) / 40) | 1); const sauvolaInput = filtered || gray; const bin3 = sauvolaBinarize(sauvolaInput, w, h, win, 0.34); const code3 = safeJsQR(grayToImageData(bin3, w, h).data, w, h); if (code3) return { data: code3.data, channel: '通道3-Sauvola局部阈值' }; } return null; } async function decodeQrCodeImg(img) { let nw = img.naturalWidth || img.width || 0; let nh = img.naturalHeight || img.height || 0; if (nw === 0 || nh === 0) throw Object.assign(new Error('ZERO_SIZE'), { name: 'ZERO_SIZE' }); let sourceImg = img; let sourceSrc = (img instanceof HTMLImageElement) ? img.src : null; const MAX_SIZE = 2000; if ((nw > MAX_SIZE || nh > MAX_SIZE) && !(img instanceof HTMLCanvasElement)) { const ratio = Math.min(MAX_SIZE / nw, MAX_SIZE / nh); const preCanvas = document.createElement('canvas'); preCanvas.width = Math.round(nw * ratio); preCanvas.height = Math.round(nh * ratio); preCanvas.getContext('2d').drawImage(img, 0, 0, preCanvas.width, preCanvas.height); sourceImg = preCanvas; nw = preCanvas.width; nh = preCanvas.height; } const scales = [1, 0.5, 0.25, 2]; let triedCors = false; for (let scaleIdx = 0; scaleIdx < scales.length; scaleIdx++) { const s = scales[scaleIdx]; if (s > 1 && Math.max(nw, nh) >= 1500) continue; if (scaleIdx > 0) await new Promise(r => setTimeout(r, 0)); try { const sw = Math.max(1, Math.round(nw * s)); const sh = Math.max(1, Math.round(nh * s)); const canvas = document.createElement('canvas'); canvas.width = sw; canvas.height = sh; const ctx = canvas.getContext('2d', { willReadFrequently: true }); ctx.imageSmoothingEnabled = true; if (s < 1) ctx.imageSmoothingQuality = 'high'; ctx.drawImage(sourceImg, 0, 0, sw, sh); let imageData; try { imageData = ctx.getImageData(0, 0, sw, sh); } catch (e) { if (e.name === 'SecurityError' || String(e.message).includes('cross-origin')) { if (!triedCors && sourceSrc) { triedCors = true; try { let fetchedImg; if (gmXmlHttpRequest) { try { const blob = await fetchImageBlob(sourceSrc); fetchedImg = await blobToImg(blob); } catch (gmErr) { fetchedImg = await loadImageCors(sourceSrc); } } else { fetchedImg = await loadImageCors(sourceSrc); } sourceImg = fetchedImg; scaleIdx--; // 核心修复:CORS 成功后,回退索引重新以原图缩放 continue; } catch (_) { throw Object.assign(new Error('CORS'), { name: 'SecurityError' }); } } else { throw Object.assign(new Error('CORS'), { name: 'SecurityError' }); } } else { throw e; } } const result = decodeJsQR(imageData.data, sw, sh, s); if (result) return { data: result.data, engine: 'jsQR+' + result.channel, scale: s, w: sw, h: sh }; } catch (e) { if (e.name === 'SecurityError') throw e; } } throw Object.assign(new Error('DECODE_FAILED'), { name: 'DECODE_FAILED' }); } /* ================= 统一入口与菜单 ================= */ function svgToImg(el, callback, onError) { let svg = null; if (el instanceof SVGSVGElement) svg = el; else { let node = el && el.parentElement; for (let i = 0; i < 32 && node; i++) { if (node instanceof SVGSVGElement) { svg = node; break; } node = node.parentElement; } } if (!svg) { onError && onError({ code: 'SVG_FAIL', detail: '未找到 SVG 根节点' }); return; } try { const clone = svg.cloneNode(true); const wAttr = svg.getAttribute('width'); const hAttr = svg.getAttribute('height'); let w = parseFloat(wAttr), h = parseFloat(hAttr); if (!w || !h || isNaN(w) || isNaN(h) || (typeof wAttr === 'string' && wAttr.includes('%')) || (typeof hAttr === 'string' && hAttr.includes('%'))) { const rect = svg.getBoundingClientRect(); if (rect.width > 0 && rect.height > 0) { w = rect.width; h = rect.height; } else { const vb = svg.viewBox && svg.viewBox.baseVal; if (vb && vb.width > 0) { w = vb.width; h = vb.height; } else { w = 512; h = 512; } } } clone.setAttribute('width', String(w)); clone.setAttribute('height', String(h)); const img = new Image(); img.onload = () => callback(img); img.onerror = () => onError && onError({ code: 'SVG_FAIL', detail: `渲染失败 size=${w}x${h}` }); img.src = 'data:image/svg+xml;base64,' + safeBtoa(new XMLSerializer().serializeToString(clone)); } catch (e) { onError && onError({ code: 'SVG_FAIL', detail: '序列化异常: ' + String(e.message || e) }); } } async function handleImageSource(source, label) { try { let img; if (source instanceof Blob) img = await blobToImg(source); else if (source instanceof SVGElement) img = await new Promise((resolve, reject) => { svgToImg(source, resolve, reject); }); else if (source instanceof HTMLImageElement) img = source; else { fail('NO_TARGET', '目标非 img 或 svg 元素'); return; } const result = await decodeQrCodeImg(img); console.log('[decode-qr] OK', { engine: result.engine, scale: result.scale, size: result.w + 'x' + result.h, text: result.data.slice(0, 200) }); showResultModal(result.data, label, result.engine); } catch (e) { const msg = String(e.message || e); if (e.name === 'SecurityError') fail('CORS_TAINTED', msg); else if (msg === 'ZERO_SIZE') fail('NO_TARGET', '图片尺寸为 0(可能尚未加载完成)'); else if (msg === 'DECODE_FAILED') fail('DECODE_FAILED'); else if (e && e.code) fail(e.code, e.detail); else { console.error('[decode-qr]', e); fail('UNKNOWN', msg); } } } if (gmRegister) { gmRegister('Decode QR Code(右键图片)', async () => { if (!jsQR) { alert('jsQR 未加载,请检查网络后刷新页面重试。'); return; } const dom = selDomRef && selDomRef.deref ? selDomRef.deref() : null; if (!dom) { fail('NO_TARGET'); return; } await handleImageSource(dom, '右键图片'); selDomRef = null; }); gmRegister('从剪贴板识别', async () => { if (!jsQR) { alert('jsQR 未加载,请检查网络后刷新页面重试。'); return; } if (navigator.clipboard && navigator.clipboard.read) { try { const items = await navigator.clipboard.read(); let blob = null, type = ''; for (const item of items) { const t = item.types.find(t => /^image\/(png|jpe?g|webp|bmp|svg\+xml)$/i.test(t)); if (t) { blob = await item.getType(t); type = t; break; } } if (!blob) { fail('CLIP_NO_IMG'); return; } await handleImageSource(blob, '剪贴板(' + type + ')'); } catch (e) { fail('CLIP_FAIL', e.message || e.name || String(e)); } } else { fail('CLIP_FAIL', '浏览器未提供 navigator.clipboard.read API'); } }); } document.addEventListener('contextmenu', (event) => { const t = event.target; if (t instanceof HTMLImageElement) setSel(t); else if (t instanceof SVGElement) { if (t instanceof SVGSVGElement) { setSel(t); } else { let node = t.parentElement; for (let i = 0; i < 32 && node; i++) { if (node instanceof SVGSVGElement) { setSel(node); return; } // 修复:存储根节点 node = node.parentElement; } setSel(null); } } else setSel(null); }); })();