二维码自动解析

鼠标悬停时自动在本地解析二维码

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

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

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

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

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

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

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

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

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

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

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

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

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

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

// ==UserScript==
// @name         二维码自动解析
// @description  鼠标悬停时自动在本地解析二维码
// @namespace    http://tampermonkey.net/
// @require      https://cdn.jsdelivr.net/npm/[email protected]/dist/jsQR.min.js
// @match        *://*/*
// @grant        GM_setClipboard
// @grant        GM_openInTab
// @grant        GM_addStyle
// @grant        GM_xmlhttpRequest
// @run-at       document-start
// @version      1.8
// @author       Gemini
// @license      GPLv3
// ==/UserScript==

(function() {
    'use strict';

    // === 配置 ===
    const DELAY_MS = 500;
    const TOLERANCE = 2;

    // === 全局变量 ===
    let hoverTimer = null;
    let tooltip = null;
    let currentTarget = null;

    // 坐标相关
    let lastMouseScreenX = 0;
    let lastMouseScreenY = 0;
    let lastMouseClientX = 0;
    let lastMouseClientY = 0;

    // 顶层窗口的视口偏移量 (Screen坐标 - Client坐标)
    // 用于消除浏览器地址栏、书签栏带来的坐标误差
    let topWinOffset = null;

    // 组合键状态控制
    let isRightClickHolding = false;
    let leftClickCount = 0;          // 记录右键按住期间左键点击的次数
    let interactionTarget = null;    // 记录组合键开始时的目标元素
    let suppressContextMenu = false; // 拦截右键菜单
    let suppressClick = false;       // 拦截左键点击

    // 会话缓存
    const qrCache = new Map(); // 用于图片 (Key: src string)
    const canvasCache = new WeakMap(); // 用于Canvas (Key: DOM Element)

    const isTop = window.self === window.top;

    // === 样式注入 ===
    GM_addStyle(`
        #qr-custom-tooltip {
            position: fixed;
            z-index: 2147483647;
            background: rgba(0, 0, 0, 0.9);
            color: #fff;
            padding: 8px 12px;
            font-size: 12px;
            max-width: 320px;
            word-break: break-all;
            pointer-events: none;
            display: none;
            border: 1px solid #555;
            border-radius: 0px !important;
            box-shadow: none !important;
            line-height: 1.5;
            text-align: left;
        }
        .qr-detected-style {
            cursor: pointer !important;
            outline: none !important;
        }
    `);

    // ==========================================
    //      通信模块 (跨域支持)
    // ==========================================

    function sendToTop(type, payload = {}) {
        if (isTop) {
            handleMessage({ data: { type, payload } });
        } else {
            window.top.postMessage({ type: 'QR_SCRIPT_MSG', action: type, payload }, '*');
        }
    }

    if (isTop) {
        window.addEventListener('message', (event) => {
            if (event.data && event.data.type === 'QR_SCRIPT_MSG') {
                handleMessage({ data: { type: event.data.action, payload: event.data.payload } });
            }
        });
    }

    function handleMessage(e) {
        const { type, payload } = e.data;
        switch (type) {
            case 'SHOW_TOOLTIP':
                renderTooltip(payload.text, payload.coords, payload.isLink);
                break;
            case 'HIDE_TOOLTIP':
                hideTooltipDOM();
                break;
            case 'SHOW_FEEDBACK':
                showFeedbackDOM();
                break;
        }
    }

    // ==========================================
    //      UI 渲染模块 (仅顶层窗口)
    // ==========================================

    function getTooltip() {
        if (!tooltip) {
            tooltip = document.createElement('div');
            tooltip.id = 'qr-custom-tooltip';
            document.body.appendChild(tooltip);
        }
        return tooltip;
    }

    function renderTooltip(text, coords, isLink) {
        const tip = getTooltip();
        const contentColor = isLink ? '#4dabf7' : '#ffffff';
        const actionColor = '#4CAF50';

        // 检查是否是加载状态或错误信息
        const isLoading = text.startsWith('⌛');
        const isError = text.startsWith('❌');

        let htmlContent = '';
        if (isLoading) {
            htmlContent = `<div style="color:#FFD700; font-weight:bold;">${escapeHtml(text)}</div>`;
        } else if (isError) {
            htmlContent = `<div style="color:#FF5252; font-weight:bold;">${escapeHtml(text)}</div>`;
        } else {
            htmlContent = `
                <div style="color:${contentColor}; margin-bottom:6px;">${escapeHtml(text)}</div>
                <div style="color:${actionColor}; font-weight:bold; border-top:1px solid #444; padding-top:4px;">
                    ${isLink ? '🔗 点击打开链接' : '📋 点击复制文本'}
                </div>
            `;
        }

        tip.innerHTML = htmlContent;
        tip.style.display = 'block';

        // --- 核心坐标修复逻辑 ---

        // 1. 获取顶层窗口的偏移量 (ScreenY - ClientY)
        // 如果有精确校准值(topWinOffset)则使用 否则使用估算值
        let offsetY, offsetX;

        if (topWinOffset) {
            // 精确模式:鼠标在顶层移动过 我们知道确切的 UI 高度
            offsetX = topWinOffset.x;
            offsetY = topWinOffset.y;
        } else {
            // 估算模式:鼠标只在 iframe 动过
            // 估算 UI 高度 = 窗口外高度 - 视口高度
            // 这通常能修正 95% 的误差 避免出现"几百像素"的偏差
            const winScreenX = window.screenX !== undefined ? window.screenX : window.screenLeft;
            const winScreenY = window.screenY !== undefined ? window.screenY : window.screenTop;

            // 假设左侧边框/滚动条宽度
            offsetX = winScreenX + (window.outerWidth - window.innerWidth);
            // 假设顶部 UI 高度 (地址栏等)
            offsetY = winScreenY + (window.outerHeight - window.innerHeight);
        }

        // 2. 计算 CSS 坐标
        // 元素屏幕坐标 - 视口起始屏幕坐标 = 元素视口内坐标
        let left = coords.absLeft - offsetX;
        let top = coords.absBottom - offsetY + 10; // 默认底部 + 10px

        // 3. 边界检测与翻转
        const tipRect = tip.getBoundingClientRect();
        const winHeight = window.innerHeight;
        const winWidth = window.innerWidth;

        // 垂直翻转:如果底部空间不足 移到上方
        if (top + tipRect.height > winHeight) {
            // 图片顶部屏幕坐标 - 视口起始Y - 提示框高度 - 间距
            top = (coords.absTop - offsetY) - tipRect.height - 10;
        }

        // 水平限制
        if (left + tipRect.width > winWidth) left = winWidth - tipRect.width - 10;
        if (left < 0) left = 10;

        tip.style.top = top + 'px';
        tip.style.left = left + 'px';
    }

    function hideTooltipDOM() {
        if (tooltip) tooltip.style.display = 'none';
    }

    function showFeedbackDOM() {
        const tip = getTooltip();
        if (tip.style.display === 'none') return;
        const originalHTML = tip.innerHTML;
        tip.innerHTML = `<div style="font-size:14px; text-align:center; color:#4dabf7; font-weight:bold;">✅ 已复制到剪贴板</div>`;
        setTimeout(() => {
            if (tip.style.display !== 'none') tip.innerHTML = originalHTML;
        }, 1000);
    }

    // ==========================================
    //      逻辑处理模块 (所有 Frame)
    // ==========================================

    function requestShowTooltip(text, element) {
        // 允许在加载状态下刷新 Tooltip 内容
        if (currentTarget !== element) currentTarget = element;

        const isLink = isUrl(text);
        const rect = element.getBoundingClientRect();

        // 计算当前 Frame 的偏移 (鼠标屏幕坐标 - 鼠标 Client 坐标)
        // 如果没有鼠标数据 降级为 0
        const frameOffsetX = (lastMouseScreenX && lastMouseClientX) ? (lastMouseScreenX - lastMouseClientX) : 0;
        const frameOffsetY = (lastMouseScreenY && lastMouseClientY) ? (lastMouseScreenY - lastMouseClientY) : 0;

        // 计算图片的绝对屏幕坐标
        const coords = {
            absLeft: rect.left + frameOffsetX,
            absTop: rect.top + frameOffsetY,
            absBottom: rect.bottom + frameOffsetY
        };

        sendToTop('SHOW_TOOLTIP', { text, coords, isLink });
    }

    function requestHideTooltip() {
        currentTarget = null;
        sendToTop('HIDE_TOOLTIP');
    }

    function requestFeedback() {
        sendToTop('SHOW_FEEDBACK');
    }

    // === 统一入口:扫描元素 ===
    function scanElement(target, force = false) {
        if (target.tagName === 'IMG') {
            scanImage(target, force);
        } else if (target.tagName === 'CANVAS') {
            scanCanvas(target, force);
        }
    }

    // === 外部解析 ===
    function scanExternal(target) {
        if (target.tagName !== 'IMG' || !target.src || !/^http/.test(target.src)) {
            requestShowTooltip("❌ 外部解析仅支持 http/https 图片链接", target);
            return;
        }

        const src = target.src;
        requestShowTooltip("⌛ 正在连接远程服务器解析...", target);

        GM_xmlhttpRequest({
            method: "GET",
            url: "https://zxing.org/w/decode?u=" + encodeURIComponent(src),
            onload: function(response) {
                if (response.status === 200) {
                    // 解析 HTML 提取结果
                    const parser = new DOMParser();
                    const doc = parser.parseFromString(response.responseText, "text/html");

                    // 查找结构: <td>Parsed Result</td><td><pre>RESULT</pre></td>
                    // 使用 XPath 或 QuerySelector 查找
                    const tds = doc.querySelectorAll('td');
                    let resultText = null;

                    for (let i = 0; i < tds.length; i++) {
                        if (tds[i].textContent.trim() === "Parsed Result") {
                            const nextTd = tds[i].nextElementSibling;
                            if (nextTd) {
                                const pre = nextTd.querySelector('pre');
                                if (pre) {
                                    resultText = pre.textContent;
                                    break;
                                }
                            }
                        }
                    }

                    if (resultText) {
                        qrCache.set(src, resultText);
                        applyQrSuccess(target, resultText);
                    } else {
                        requestShowTooltip("❌ 外部解析失败", target);
                    }
                } else {
                    requestShowTooltip("❌ 远程服务器响应错误: " + response.status, target);
                }
            },
            onerror: function() {
                requestShowTooltip("❌ 网络请求失败", target);
            }
        });
    }

    // === 分支 A: 扫描图片 ===
    function scanImage(img, force) {
        const src = img.src;
        if (!src) return;
        if (!force && qrCache.has(src)) return;

        // 1. 标准加载
        const canvas = document.createElement('canvas');
        const context = canvas.getContext('2d');
        const tempImg = new Image();
        tempImg.crossOrigin = "Anonymous";
        tempImg.src = src;

        tempImg.onload = () => processImage(tempImg, canvas, context, img, src, force, 'IMG');
        tempImg.onerror = () => scanImage_Fallback(img, src, force);
    }

    // === 方法 B: 强力加载 (GM_xmlhttpRequest) ===
    function scanImage_Fallback(originalImg, src, force) {
        GM_xmlhttpRequest({
            method: "GET",
            url: src,
            responseType: "blob",
            onload: function(response) {
                if (response.status === 200) {
                    const blob = response.response;
                    const blobUrl = URL.createObjectURL(blob);
                    const tempImg = new Image();
                    tempImg.onload = () => {
                        const canvas = document.createElement('canvas');
                        const context = canvas.getContext('2d');
                        processImage(tempImg, canvas, context, originalImg, src, force, 'IMG');
                        URL.revokeObjectURL(blobUrl);
                    };
                    tempImg.onerror = () => {
                        qrCache.set(src, null);
                        URL.revokeObjectURL(blobUrl);
                    };
                    tempImg.src = blobUrl;
                } else {
                    qrCache.set(src, null);
                }
            },
            onerror: () => qrCache.set(src, null)
        });
    }

    // === 分支 B: 扫描 Canvas ===
    function scanCanvas(canvasEl, force) {
        if (!force && canvasCache.has(canvasEl)) return;

        try {
            // 尝试直接获取 2D 上下文数据 (最高效)
            // 注意:如果 Canvas 是 WebGL 的 getContext('2d') 可能会返回 null
            let context = canvasEl.getContext('2d');

            if (context) {
                // 2D Canvas 路径
                try {
                    const imageData = context.getImageData(0, 0, canvasEl.width, canvasEl.height);
                    // 直接调用处理逻辑 复用反转代码
                    processImageData(imageData, canvasEl, force, 'CANVAS', canvasEl);
                } catch (e) {
                    // 如果 Canvas 被污染 (Tainted) getImageData 会报错
                    // 这种情况下通常无法读取 除非用特殊手段 但这里只能标记失败
                    canvasCache.set(canvasEl, null);
                }
            } else {
                // WebGL 或其他 Context 路径 -> 尝试转为 DataURL
                const dataUrl = canvasEl.toDataURL();
                const tempImg = new Image();
                tempImg.onload = () => {
                    const tempCanvas = document.createElement('canvas');
                    const tempCtx = tempCanvas.getContext('2d');
                    processImage(tempImg, tempCanvas, tempCtx, canvasEl, null, force, 'CANVAS');
                };
                tempImg.src = dataUrl;
            }
        } catch (e) {
            // toDataURL 也可能因为 Tainted 报错
            canvasCache.set(canvasEl, null);
        }
    }

    // === 公共处理逻辑 ===
    function processImage(imageObj, canvas, context, targetEl, cacheKey, force, type) {
        canvas.width = imageObj.width;
        canvas.height = imageObj.height;
        context.drawImage(imageObj, 0, 0, imageObj.width, imageObj.height);

        try {
            const imageData = context.getImageData(0, 0, canvas.width, canvas.height);
            processImageData(imageData, targetEl, force, type, cacheKey);
        } catch (e) {
            if (type === 'IMG') qrCache.set(cacheKey, null);
            else canvasCache.set(targetEl, null);

            if (force) {
                requestShowTooltip("❌ 解析出错 (可能是跨域限制)", targetEl);
            }
        }
    }

    // === 核心处理逻辑 (三级解析:原图 -> [强制模式下: 反转 -> 二值化]) ===
    function processImageData(imageData, targetEl, force, type, cacheKey) {
        // 1. 尝试正常解析 (所有模式都执行)
        let code = jsQR(imageData.data, imageData.width, imageData.height);

        // 修改点:仅在强制解析模式下 (force=true) 且正常解析失败时 才尝试高级策略
        if (!code && force) {
            // 2. 尝试颜色反转解析
            const invertedData = new Uint8ClampedArray(imageData.data);
            for (let i = 0; i < invertedData.length; i += 4) {
                invertedData[i] = 255 - invertedData[i];     // R
                invertedData[i + 1] = 255 - invertedData[i + 1]; // G
                invertedData[i + 2] = 255 - invertedData[i + 2]; // B
            }
            code = jsQR(invertedData, imageData.width, imageData.height);

            // 3. 如果还失败 尝试二值化 (Binarization)
            if (!code) {
                const binarizedData = new Uint8ClampedArray(imageData.data);
                const len = binarizedData.length;

                // 计算平均亮度作为阈值
                let totalLuminance = 0;
                for (let i = 0; i < len; i += 4) {
                    const lum = 0.299 * binarizedData[i] + 0.587 * binarizedData[i+1] + 0.114 * binarizedData[i+2];
                    totalLuminance += lum;
                }
                const avgLuminance = totalLuminance / (len / 4);

                // 应用二值化
                for (let i = 0; i < len; i += 4) {
                    const lum = 0.299 * binarizedData[i] + 0.587 * binarizedData[i+1] + 0.114 * binarizedData[i+2];
                    const val = lum > avgLuminance ? 255 : 0;
                    binarizedData[i] = val;
                    binarizedData[i+1] = val;
                    binarizedData[i+2] = val;
                }
                code = jsQR(binarizedData, imageData.width, imageData.height);
            }
        }

        handleScanResult(code, targetEl, force, type, cacheKey);
    }

    function handleScanResult(code, targetEl, force, type, imgCacheKey) {
        if (code) {
            if (type === 'IMG') qrCache.set(imgCacheKey, code.data);
            else canvasCache.set(targetEl, code.data);

            applyQrSuccess(targetEl, code.data);
        } else {
            if (type === 'IMG') qrCache.set(imgCacheKey, null);
            else canvasCache.set(targetEl, null);

            if (force) {
                requestShowTooltip("❌ 强制解析失败", targetEl);
            }
        }
    }

    function applyQrSuccess(el, text) {
        el.dataset.hasQr = "true";
        el.classList.add('qr-detected-style');
        requestShowTooltip(text, el);
    }

    function isUrl(text) { return /^https?:\/\//i.test(text); }
    function escapeHtml(text) {
        return text.replace(/&/g, "&amp;").replace(/</g, "&lt;").replace(/>/g, "&gt;").replace(/"/g, "&quot;").replace(/'/g, "&#039;");
    }

    // ==========================================
    //      事件监听
    // ==========================================

    // 鼠标移动监听:用于实时校准坐标
    document.addEventListener('mousemove', (e) => {
        // 记录当前 Frame 的鼠标数据
        lastMouseScreenX = e.screenX;
        lastMouseScreenY = e.screenY;
        lastMouseClientX = e.clientX;
        lastMouseClientY = e.clientY;

        // 如果是顶层窗口 记录精确的视口偏移量 (这是最准确的校准源)
        if (isTop) {
            topWinOffset = {
                x: e.screenX - e.clientX,
                y: e.screenY - e.clientY
            };
        }
    }, true);

    document.addEventListener('mouseover', (e) => {
        const target = e.target;
        // 支持 IMG 和 CANVAS
        const isImg = target.tagName === 'IMG';
        const isCanvas = target.tagName === 'CANVAS';

        if (!isImg && !isCanvas) return;
        if (isImg && (!target.complete || target.naturalWidth === 0)) return;

        // 检查缓存
        if (isImg && target.src && qrCache.has(target.src)) {
            const data = qrCache.get(target.src);
            if (data) {
                if (!target.dataset.hasQr) applyQrSuccess(target, data);
                else requestShowTooltip(data, target);
            }
            return;
        }
        if (isCanvas && canvasCache.has(target)) {
            const data = canvasCache.get(target);
            if (data) {
                if (!target.dataset.hasQr) applyQrSuccess(target, data);
                else requestShowTooltip(data, target);
            }
            return;
        }

        // 检查比例 (Canvas 使用 width/height 属性或 clientWidth/Height)
        let w, h;
        if (isImg) {
            w = target.naturalWidth;
            h = target.naturalHeight;
        } else {
            w = target.width || target.clientWidth;
            h = target.height || target.clientHeight;
        }

        if (Math.abs(w - h) > TOLERANCE || w < 30) {
            if (isImg && target.src) qrCache.set(target.src, null);
            else if (isCanvas) canvasCache.set(target, null);
            return;
        }

        hoverTimer = setTimeout(() => {
            // 再次检查缓存防止重复
            if (isImg && qrCache.has(target.src)) return;
            if (isCanvas && canvasCache.has(target)) return;
            scanElement(target);
        }, DELAY_MS);
    });

    document.addEventListener('mouseout', (e) => {
        const t = e.target;
        if (t.tagName === 'IMG' || t.tagName === 'CANVAS') {
            clearTimeout(hoverTimer);
            if (currentTarget === t) {
                requestHideTooltip();
            }
        }
    });

    // === 交互逻辑:右键+左键计数 ===

    // 1. 监听鼠标按下
    document.addEventListener('mousedown', (e) => {
        // 右键按下 (button 2)
        if (e.button === 2) {
            isRightClickHolding = true;
            leftClickCount = 0; // 重置计数
            interactionTarget = e.target; // 记录目标
            suppressContextMenu = false;
        }
        // 左键按下 (button 0)
        else if (e.button === 0) {
            // 如果右键按住中 -> 计数
            if (isRightClickHolding) {
                if (interactionTarget && (interactionTarget.tagName === 'IMG' || interactionTarget.tagName === 'CANVAS')) {
                    e.preventDefault();
                    e.stopPropagation();
                    e.stopImmediatePropagation();

                    leftClickCount++;
                    suppressContextMenu = true; // 屏蔽右键菜单
                    suppressClick = true;       // 屏蔽左键点击
                }
            }
        }
    }, true);

    // 2. 监听鼠标松开 (触发逻辑的核心)
    document.addEventListener('mouseup', (e) => {
        // 右键松开 (button 2)
        if (e.button === 2) {
            isRightClickHolding = false;

            // 如果在按住期间点击了左键
            if (leftClickCount > 0 && interactionTarget) {
                // 1次点击 -> 强制本地解析
                if (leftClickCount === 1) {
                    scanElement(interactionTarget, true);
                }
                // 2次及以上点击 -> 外部解析
                else if (leftClickCount >= 2) {
                    scanExternal(interactionTarget);
                }
            }

            // 重置目标
            interactionTarget = null;
            leftClickCount = 0;
        }
    }, true);

    // 3. 屏蔽右键菜单
    document.addEventListener('contextmenu', (e) => {
        if (suppressContextMenu) {
            e.preventDefault();
            e.stopPropagation();
            suppressContextMenu = false;
        }
    }, true);

    // 4. 屏蔽点击事件 (关键修复)
    // 必须使用捕获阶段 (true) 确保在任何其他点击逻辑之前拦截
    document.addEventListener('click', (e) => {
        // 如果处于拦截状态 直接销毁事件
        if (suppressClick) {
            e.preventDefault();
            e.stopPropagation();
            e.stopImmediatePropagation();
            suppressClick = false; // 重置状态
            return;
        }

        // 下面是正常的点击处理逻辑
        const target = e.target;
        if ((target.tagName === 'IMG' || target.tagName === 'CANVAS') && target.dataset.hasQr === "true") {
            // 获取数据
            let data = null;
            if (target.tagName === 'IMG') data = qrCache.get(target.src);
            else data = canvasCache.get(target);

            if (data) {
                e.preventDefault();
                e.stopPropagation();

                if (isUrl(data)) {
                    GM_openInTab(data, { active: true, insert: true });
                } else {
                    GM_setClipboard(data);
                    requestFeedback();
                }
            }
        }
    }, true);

})();