网页二维码识别器 - (支持 img/canvas/svg + 精准识别)

修复 SecurityError 和 ReferenceError,支持更多元素类型,精准识别 SVG

您需要先安裝使用者腳本管理器擴展,如 TampermonkeyGreasemonkeyViolentmonkey 之後才能安裝該腳本。

您需要先安裝使用者腳本管理器擴充功能,如 TampermonkeyViolentmonkey 後才能安裝該腳本。

您需要先安裝使用者腳本管理器擴充功能,如 TampermonkeyViolentmonkey 後才能安裝該腳本。

您需要先安裝使用者腳本管理器擴充功能,如 TampermonkeyUserscripts 後才能安裝該腳本。

你需要先安裝一款使用者腳本管理器擴展,比如 Tampermonkey,才能安裝此腳本

您需要先安裝使用者腳本管理器擴充功能後才能安裝該腳本。

(我已經安裝了使用者腳本管理器,讓我安裝!)

你需要先安裝一款使用者樣式管理器擴展,比如 Stylus,才能安裝此樣式

你需要先安裝一款使用者樣式管理器擴展,比如 Stylus,才能安裝此樣式

你需要先安裝一款使用者樣式管理器擴展,比如 Stylus,才能安裝此樣式

你需要先安裝一款使用者樣式管理器擴展後才能安裝此樣式

你需要先安裝一款使用者樣式管理器擴展後才能安裝此樣式

你需要先安裝一款使用者樣式管理器擴展後才能安裝此樣式

(我已經安裝了使用者樣式管理器,讓我安裝!)

// ==UserScript==
// @name         网页二维码识别器 - (支持 img/canvas/svg + 精准识别)
// @namespace    http://tampermonkey.net/
// @version      8.0
// @description  修复 SecurityError 和 ReferenceError,支持更多元素类型,精准识别 SVG
// @author       hucix
// @match        *://*/*
// @grant        none
// @require      https://cdn.jsdelivr.net/npm/[email protected]/dist/jsQR.js
// @run-at       document-end
// @license      MIT
// ==/UserScript==

(function () {
    'use strict';

    const HOTKEY = 'q';
    let isSelecting = false;
    let startX, startY;
    let selectionDiv = null;
    let overlay = null;
    let escCloseHandler = null;


    // ✅ 声明并初始化 currentResultUI
    let currentResultUI = null;

    function createOverlay() {
        if (overlay) {
            overlay.remove();
            selectionDiv?.remove();
        }
        overlay = document.createElement('div');
        overlay.style.position = 'fixed';
        overlay.style.top = '0';
        overlay.style.left = '0';
        overlay.style.width = '100%';
        overlay.style.height = '100%';
        overlay.style.background = 'rgba(0,0,0,0.1)';
        overlay.style.zIndex = '2147483646';
        overlay.style.cursor = 'crosshair';
        overlay.style.display = 'none';
        document.body.appendChild(overlay);

        selectionDiv = document.createElement('div');
        selectionDiv.style.position = 'absolute';
        selectionDiv.style.border = '2px dashed #007bff';
        selectionDiv.style.background = 'rgba(0,123,255,0.1)';
        selectionDiv.style.zIndex = '2147483647';
        selectionDiv.style.pointerEvents = 'none';
        document.body.appendChild(selectionDiv);
    }

    function resetState() {
        isSelecting = false;
        if (overlay) {
            overlay.style.display = 'none';
            document.body.style.userSelect = '';
        }
        document.removeEventListener('keydown', escHandler);
        overlay?.removeEventListener('mousedown', startSelect);
        overlay?.removeEventListener('mousemove', updateSelect);
        overlay?.removeEventListener('mouseup', endSelect);
        createOverlay();
    }

    function escHandler(e) {
        if (e.key === 'Escape') resetState();
    }

    function startSelect(e) {
        if (!isSelecting) return;
        startX = e.pageX;
        startY = e.pageY;
        selectionDiv.style.display = 'block';
        selectionDiv.style.left = startX + 'px';
        selectionDiv.style.top = startY + 'px';
        selectionDiv.style.width = '0';
        selectionDiv.style.height = '0';
    }

    function updateSelect(e) {
        if (!isSelecting || selectionDiv.style.display !== 'block') return;
        const x = Math.min(startX, e.pageX);
        const y = Math.min(startY, e.pageY);
        const w = Math.abs(e.pageX - startX);
        const h = Math.abs(e.pageY - startY);
        selectionDiv.style.left = x + 'px';
        selectionDiv.style.top = y + 'px';
        selectionDiv.style.width = w + 'px';
        selectionDiv.style.height = h + 'px';
    }

    // 检查两个矩形是否相交
    function rectsIntersect(r1, r2) {
        return !(r2.left > r1.right || r2.right < r1.left || r2.top > r1.bottom || r2.bottom < r1.top);
    }

    async function recognizeQR(x, y, width, height) {
        const selectionRect = { left: x, top: y, right: x + width, bottom: y + height };
        let found = false;

        // 1. 尝试识别 <img> 元素
        const images = Array.from(document.querySelectorAll('img'));
        for (const img of images) {
            if (!img.complete || img.naturalWidth === 0) continue;
            const imgRect = img.getBoundingClientRect();
            const imgPageRect = {
                left: imgRect.left + window.scrollX,
                top: imgRect.top + window.scrollY,
                right: imgRect.right + window.scrollX,
                bottom: imgRect.bottom + window.scrollY
            };
            if (rectsIntersect(selectionRect, imgPageRect)) {
                try {
                    const canvas = document.createElement('canvas');
                    const ctx = canvas.getContext('2d');
                    canvas.width = img.naturalWidth;
                    canvas.height = img.naturalHeight;
                    ctx.drawImage(img, 0, 0);
                    const imageData = ctx.getImageData(0, 0, canvas.width, canvas.height);
                    const code = jsQR(imageData.data, canvas.width, canvas.height);
                    if (code) {
                        showResult(code.data);
                        found = true;
                        break;
                    }
                } catch (e) {
                    console.warn('跳过受 CORS 保护的图片:', img.src);
                }
            }
        }

        // 2. 如果没有找到,尝试识别 <canvas> 元素
        if (!found) {
            const canvases = Array.from(document.querySelectorAll('canvas'));
            for (const canvas of canvases) {
                const canvasRect = canvas.getBoundingClientRect();
                const canvasPageRect = {
                    left: canvasRect.left + window.scrollX,
                    top: canvasRect.top + window.scrollY,
                    right: canvasRect.right + window.scrollX,
                    bottom: canvasRect.bottom + window.scrollY
                };
                if (rectsIntersect(selectionRect, canvasPageRect)) {
                    try {
                        const imageData = canvas.getContext('2d').getImageData(0, 0, canvas.width, canvas.height);
                        const code = jsQR(imageData.data, canvas.width, canvas.height);
                        if (code) {
                            showResult(code.data);
                            found = true;
                            break;
                        }
                    } catch (e) {
                        console.warn('跳过无法读取的 canvas:', canvas);
                    }
                }
            }
        }

        // 3. 如果还没有找到,尝试识别 <svg> 元素(只识别内部的图形)
        if (!found) {
            const svgs = Array.from(document.querySelectorAll('svg'));
            for (const svg of svgs) {
                const svgRect = svg.getBoundingClientRect();
                const svgPageRect = {
                    left: svgRect.left + window.scrollX,
                    top: svgRect.top + window.scrollY,
                    right: svgRect.right + window.scrollX,
                    bottom: svgRect.bottom + window.scrollY
                };
                if (rectsIntersect(selectionRect, svgPageRect)) {
                    try {
                        // 只提取 SVG 中的图形元素(如 path, rect, circle)
                        const graphics = Array.from(svg.querySelectorAll('path, rect, circle, polygon, polyline'));
                        if (graphics.length === 0) {
                            throw new Error('SVG 中没有图形元素');
                        }

                        // 创建一个临时 SVG,只包含图形元素
                        const tempSvg = document.createElementNS('http://www.w3.org/2000/svg', 'svg');
                        tempSvg.setAttribute('width', svg.getAttribute('width') || '100%');
                        tempSvg.setAttribute('height', svg.getAttribute('height') || '100%');
                        graphics.forEach(g => {
                            const clone = g.cloneNode(true);
                            tempSvg.appendChild(clone);
                        });

                        // 将临时 SVG 转换为 Data URL
                        const serializer = new XMLSerializer();
                        const source = serializer.serializeToString(tempSvg);
                        const image = new Image();
                        image.onload = () => {
                            const canvas = document.createElement('canvas');
                            canvas.width = image.width;
                            canvas.height = image.height;
                            const ctx = canvas.getContext('2d');
                            ctx.drawImage(image, 0, 0);
                            const imageData = ctx.getImageData(0, 0, canvas.width, canvas.height);
                            const code = jsQR(imageData.data, canvas.width, canvas.height);
                            if (code) {
                                showResult(code.data);
                                found = true;
                            } else {
                                showResult(null, '未识别到二维码。SVG 转换成功,但内容不包含二维码。建议:\n1. 确保框选的是二维码本身\n2. 截图后使用在线工具识别');
                            }
                        };
                        image.onerror = () => {
                            showResult(null, 'SVG 转换失败,无法识别。');
                        };
                        image.src = 'data:image/svg+xml;base64,' + btoa(unescape(encodeURIComponent(source)));
                    } catch (e) {
                        console.warn('跳过无法转换的 SVG:', svg);
                    }
                }
            }
        }

        // 4. 如果以上都失败,显示友好提示
        if (!found) {
            showResult(null, '未在选区中找到可识别的二维码。\n\n建议:\n1. 确保框选的是图片或画布元素\n2. 截图后使用在线工具识别');
        }
    }

    function showResult(text, errorMsg = null) {
    // 移除旧的 UI(如果存在)
    if (currentResultUI) {
        currentResultUI.remove();
    }

    currentResultUI = document.createElement('div');
    currentResultUI.id = 'qr-result-ui';
    currentResultUI.style.position = 'fixed';
    currentResultUI.style.top = '50%';
    currentResultUI.style.left = '50%';
    currentResultUI.style.transform = 'translate(-50%, -50%)';
    currentResultUI.style.zIndex = '2147483647';
    currentResultUI.style.background = '#fff';
    currentResultUI.style.padding = '20px';
    currentResultUI.style.borderRadius = '8px';
    currentResultUI.style.boxShadow = '0 4px 20px rgba(0,0,0,0.3)';
    currentResultUI.style.maxWidth = '90%';
    currentResultUI.style.wordBreak = 'break-all';
    currentResultUI.style.fontFamily = 'Arial, sans-serif';

    let content = '';

    if (errorMsg) {
        content = `<h3 style="color:#dc3545;">识别失败</h3><p>${errorMsg}</p>`;
    } else {
        let isUrl = false;
        try { new URL(text); isUrl = true; } catch {}

        content = `
            <h3>识别到内容:</h3>
            <pre style="background:#f8f9fa; padding:10px; border-radius:4px; overflow:auto; max-height:200px; font-family:monospace; white-space:pre-wrap;">${text}</pre>
        `;
        if (isUrl) {
            content += `<button id="qr-jump-btn" style="margin-top:10px; padding:6px 12px; background:#007bff; color:white; border:none; border-radius:4px;">跳转到链接</button>`;
        }
        content += `<button id="qr-copy-btn" style="margin-left:10px; padding:6px 12px; background:#28a745; color:white; border:none; border-radius:4px;">复制内容</button>`;
    }

    content += `<button id="qr-close-btn" style="margin-top:10px; padding:6px 12px; background:#6c757d; color:white; border:none; border-radius:4px; margin-left:10px;">关闭</button>`;

    currentResultUI.innerHTML = content;
    document.body.appendChild(currentResultUI);

    // 定义 ESC 关闭函数
    escCloseHandler = (e) => {
        if (e.key === 'Escape') {
            closeResultUI();
        }
    };

    // 绑定 ESC 监听
    document.addEventListener('keydown', escCloseHandler);

    // 安全绑定按钮事件
    const jumpBtn = document.getElementById('qr-jump-btn');
    const copyBtn = document.getElementById('qr-copy-btn');
    const closeBtn = document.getElementById('qr-close-btn');

    if (jumpBtn && text) {
        jumpBtn.onclick = () => { window.open(text, '_blank') };
    }
    if (copyBtn && text) {
        copyBtn.onclick = () => {
            navigator.clipboard.writeText(text).then(() => {
                alert('已复制到剪贴板!');
            }).catch(() => {
                prompt('复制失败,请手动复制:', text);
            });
        };
    }
    if (closeBtn) {
        closeBtn.onclick = closeResultUI;
    }

    resetState(); // 清理框选状态
}

// 关闭识别结果 UI 的统一函数
function closeResultUI() {
    if (currentResultUI) {
        currentResultUI.remove();
        currentResultUI = null;
    }
    // 移除 ESC 监听器
    if (escCloseHandler) {
        document.removeEventListener('keydown', escCloseHandler);
        escCloseHandler = null;
    }
}

    function endSelect() {
        if (!isSelecting) return;
        isSelecting = false;
        overlay.style.display = 'none';
        document.body.style.userSelect = '';
        document.removeEventListener('keydown', escHandler);

        const rect = selectionDiv.getBoundingClientRect();
        const x = rect.left + window.scrollX;
        const y = rect.top + window.scrollY;
        const w = rect.width;
        const h = rect.height;

        selectionDiv.style.display = 'none';

        if (w > 20 && h > 20) {
            recognizeQR(x, y, w, h);
        } else {
            showResult(null, '选区太小,请框选更大的二维码区域(至少 20x20 像素)。');
            resetState();
        }
    }

    function init() {
        createOverlay();
        document.addEventListener('keydown', (e) => {
            if (e.ctrlKey && e.key.toLowerCase() === HOTKEY && !isSelecting) {
                e.preventDefault();
                isSelecting = true;
                overlay.style.display = 'block';
                document.body.style.userSelect = 'none';
                document.addEventListener('keydown', escHandler);
                overlay.addEventListener('mousedown', startSelect);
                overlay.addEventListener('mousemove', updateSelect);
                overlay.addEventListener('mouseup', endSelect);
            }
        });
    }

    if (typeof jsQR !== 'undefined') {
        init();
    } else {
        const script = document.createElement('script');
        script.src = 'https://cdn.jsdelivr.net/npm/[email protected]/dist/jsQR.js';
        script.onload = init;
        document.head.appendChild(script);
    }
})();