Greasy Fork

Greasy Fork is available in English.

二维码自动解析

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

当前为 2025-12-01 提交的版本,查看 最新版本

您需要先安装一个扩展,例如 篡改猴Greasemonkey暴力猴,之后才能安装此脚本。

You will need to install an extension such as Tampermonkey to install this script.

您需要先安装一个扩展,例如 篡改猴暴力猴,之后才能安装此脚本。

您需要先安装一个扩展,例如 篡改猴Userscripts ,之后才能安装此脚本。

您需要先安装一款用户脚本管理器扩展,例如 Tampermonkey,才能安装此脚本。

您需要先安装用户脚本管理器扩展后才能安装此脚本。

(我已经安装了用户脚本管理器,让我安装!)

您需要先安装一款用户样式管理器扩展,比如 Stylus,才能安装此样式。

您需要先安装一款用户样式管理器扩展,比如 Stylus,才能安装此样式。

您需要先安装一款用户样式管理器扩展,比如 Stylus,才能安装此样式。

您需要先安装一款用户样式管理器扩展后才能安装此样式。

您需要先安装一款用户样式管理器扩展后才能安装此样式。

您需要先安装一款用户样式管理器扩展后才能安装此样式。

(我已经安装了用户样式管理器,让我安装!)

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

(function() {
    'use strict';

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

    // === ZXing 初始化 ===
    let zxingReader = null;
    function getZXingReader() {
        if (!window.ZXing) return null;
        if (!zxingReader) {
            const hints = new Map();
            const formats = [ZXing.BarcodeFormat.QR_CODE, ZXing.BarcodeFormat.DATA_MATRIX];
            hints.set(ZXing.DecodeHintType.POSSIBLE_FORMATS, formats);
            hints.set(ZXing.DecodeHintType.TRY_HARDER, true); // 开启深度扫描
            zxingReader = new ZXing.BrowserMultiFormatReader(hints);
        }
        return zxingReader;
    }

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

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

    // 组合键状态控制
    let isRightClickHolding = false;
    let leftClickCount = 0;
    let interactionTarget = null;
    let suppressContextMenu = false;
    let suppressClick = false;

    // 会话缓存 (存储对象 { text: string, method: string })
    const qrCache = new Map(); // Key: src string
    const canvasCache = new WeakMap(); // 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, payload.method);
                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, method) {
        const tip = getTooltip();
        const contentColor = isLink ? '#4dabf7' : '#ffffff';
        const actionColor = '#4CAF50';
        const bracketColor = '#F6B64E';
        const parenColor = '#B28BF7';

        const isLoading = text.startsWith('⌛');
        const isError = text.startsWith('❌');

        // 构建标题 HTML
        let titleHtml = '';
        if (method === '远程解析') {
            titleHtml = `<div style="margin-bottom:4px;">
                <span style="color:${bracketColor}; font-weight:bold;">[远程解析]</span>
            </div>`;
        } else {
            // 本地解析
            titleHtml = `<div style="margin-bottom:4px;">
                <span style="color:${bracketColor}; font-weight:bold;">[本地解析]</span>
                <span style="color:${parenColor}; font-weight:bold;"> (${escapeHtml(method || '未知')})</span>
            </div>`;
        }

        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 = `
                ${titleHtml}
                <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';

        // --- 坐标计算 ---
        let offsetY, offsetX;
        if (topWinOffset) {
            offsetX = topWinOffset.x;
            offsetY = topWinOffset.y;
        } else {
            const winScreenX = window.screenX !== undefined ? window.screenX : window.screenLeft;
            const winScreenY = window.screenY !== undefined ? window.screenY : window.screenTop;
            offsetX = winScreenX + (window.outerWidth - window.innerWidth);
            offsetY = winScreenY + (window.outerHeight - window.innerHeight);
        }

        let left = coords.absLeft - offsetX;
        let top = coords.absBottom - offsetY + 10;

        const tipRect = tip.getBoundingClientRect();
        const winHeight = window.innerHeight;
        const winWidth = window.innerWidth;

        if (top + tipRect.height > winHeight) {
            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, method = "JSQR") {
        if (currentTarget !== element) currentTarget = element;

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

        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, method });
    }

    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) {
                    const parser = new DOMParser();
                    const doc = parser.parseFromString(response.responseText, "text/html");
                    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, { text: resultText, method: "远程解析" });
                        applyQrSuccess(target, resultText, "远程解析");
                    } else {
                        requestShowTooltip("❌ 远程解析失败", target);
                    }
                } else {
                    requestShowTooltip("❌ 远程服务器响应错误: " + response.status, target);
                }
            },
            onerror: function() {
                requestShowTooltip("❌ 网络请求失败", target);
            }
        });
    }

    // ==========================================
    //      图像获取与预处理
    // ==========================================

    function scanImage(img, force) {
        const src = img.src;
        if (!src) return;
        if (!force && qrCache.has(src)) return;

        let displayWidth = img.width || img.clientWidth || 0;
        let displayHeight = img.height || img.clientHeight || 0;

        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', displayWidth, displayHeight);
        tempImg.onerror = () => scanImage_Fallback(img, src, force, displayWidth, displayHeight);
    }

    function scanImage_Fallback(originalImg, src, force, w, h) {
        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', w, h);
                        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)
        });
    }

    function scanCanvas(canvasEl, force) {
        if (!force && canvasCache.has(canvasEl)) return;

        try {
            let context = canvasEl.getContext('2d');
            if (context) {
                try {
                    const imageData = context.getImageData(0, 0, canvasEl.width, canvasEl.height);
                    const tempCanvas = document.createElement('canvas');
                    const tempCtx = tempCanvas.getContext('2d');
                    const padding = 50;
                    tempCanvas.width = canvasEl.width + (padding * 2);
                    tempCanvas.height = canvasEl.height + (padding * 2);
                    tempCtx.fillStyle = '#FFFFFF';
                    tempCtx.fillRect(0, 0, tempCanvas.width, tempCanvas.height);
                    tempCtx.putImageData(imageData, padding, padding);

                    runScanPipeline(tempCanvas, tempCtx, canvasEl, force, 'CANVAS', canvasEl);
                } catch (e) {
                    canvasCache.set(canvasEl, null);
                }
            } else {
                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', canvasEl.width, canvasEl.height);
                };
                tempImg.src = dataUrl;
            }
        } catch (e) {
            canvasCache.set(canvasEl, null);
        }
    }

    function processImage(imageObj, canvas, context, targetEl, cacheKey, force, type, forcedWidth, forcedHeight) {
        let finalWidth = imageObj.naturalWidth;
        let finalHeight = imageObj.naturalHeight;
        if (!finalWidth || finalWidth < 10) {
            finalWidth = forcedWidth || 300;
            finalHeight = forcedHeight || 300;
        }

        const padding = 50;
        canvas.width = finalWidth + (padding * 2);
        canvas.height = finalHeight + (padding * 2);

        context.fillStyle = '#FFFFFF';
        context.fillRect(0, 0, canvas.width, canvas.height);
        context.imageSmoothingEnabled = false;

        context.drawImage(imageObj, padding, padding, finalWidth, finalHeight);

        runScanPipeline(canvas, context, targetEl, force, type, cacheKey);
    }

    // ==========================================
    //      核心扫描管道 (JSQR + ZXing)
    // ==========================================

    async function runScanPipeline(canvas, context, targetEl, force, type, cacheKey) {
        if (force) requestShowTooltip("⌛ 正在进行强制解析...", targetEl);

        let result = null;
        const imageData = context.getImageData(0, 0, canvas.width, canvas.height);

        // --- 阶段 1: 标准解析 ---

        // 1.1 JSQR 标准
        result = jsQR(imageData.data, imageData.width, imageData.height);
        if (result) {
            handleSuccess(result.data, "JSQR", type, cacheKey, targetEl);
            return;
        }

        // 1.2 ZXing 标准
        result = await tryZXing(canvas);
        if (result) {
            handleSuccess(result, "ZXing", type, cacheKey, targetEl);
            return;
        }

        if (!force) {
            handleFail(type, cacheKey, targetEl, false);
            return;
        }

        // --- 阶段 2: 增强解析 (仅强制模式) ---

        // 反色数据准备
        const invertedData = new Uint8ClampedArray(imageData.data);
        for (let i = 0; i < invertedData.length; i += 4) {
            invertedData[i] = 255 - invertedData[i];
            invertedData[i + 1] = 255 - invertedData[i + 1];
            invertedData[i + 2] = 255 - invertedData[i + 2];
            invertedData[i + 3] = 255;
        }

        // 2.1 JSQR 反色
        result = jsQR(invertedData, imageData.width, imageData.height);
        if (result) {
            handleSuccess(result.data, "JSQR 反色", type, cacheKey, targetEl);
            return;
        }

        // 2.2 ZXing 反色
        const invertedImageData = new ImageData(invertedData, canvas.width, canvas.height);
        context.putImageData(invertedImageData, 0, 0);
        result = await tryZXing(canvas);
        if (result) {
            handleSuccess(result, "ZXing 反色", type, cacheKey, targetEl);
            return;
        }

        // 二值化数据准备
        const binarizedData = new Uint8ClampedArray(imageData.data);
        const len = binarizedData.length;
        let totalLum = 0;
        for (let i = 0; i < len; i += 4) {
            totalLum += 0.299 * binarizedData[i] + 0.587 * binarizedData[i+1] + 0.114 * binarizedData[i+2];
        }
        const avgLum = totalLum / (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 > avgLum ? 255 : 0;
            binarizedData[i] = val;
            binarizedData[i+1] = val;
            binarizedData[i+2] = val;
            binarizedData[i+3] = 255;
        }

        // 2.3 JSQR 二值化
        result = jsQR(binarizedData, imageData.width, imageData.height);
        if (result) {
            handleSuccess(result.data, "JSQR 二值化", type, cacheKey, targetEl);
            return;
        }

        // 2.4 ZXing 二值化
        const binarizedImageData = new ImageData(binarizedData, canvas.width, canvas.height);
        context.putImageData(binarizedImageData, 0, 0);
        result = await tryZXing(canvas);
        if (result) {
            handleSuccess(result, "ZXing 二值化", type, cacheKey, targetEl);
            return;
        }

        handleFail(type, cacheKey, targetEl, true);
    }

    function tryZXing(canvas) {
        return new Promise((resolve) => {
            if (typeof ZXing === 'undefined') { resolve(null); return; }

            const dataUrl = canvas.toDataURL('image/png');
            const img = new Image();
            img.onload = () => {
                const reader = getZXingReader();
                if (!reader) { resolve(null); return; }
                reader.decodeFromImageElement(img)
                    .then(res => resolve(res.text))
                    .catch(() => resolve(null));
            };
            img.onerror = () => resolve(null);
            img.src = dataUrl;
        });
    }

    function handleSuccess(text, method, type, cacheKey, targetEl) {
        const cacheObj = { text: text, method: method };
        if (type === 'IMG') qrCache.set(cacheKey, cacheObj);
        else canvasCache.set(targetEl, cacheObj);
        applyQrSuccess(targetEl, text, method);
    }

    function handleFail(type, cacheKey, targetEl, isForce) {
        if (type === 'IMG') qrCache.set(cacheKey, null);
        else canvasCache.set(targetEl, null);

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

    // ==========================================
    //      公共辅助函数
    // ==========================================

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

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

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

    document.addEventListener('mousemove', (e) => {
        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;
        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 cacheData = qrCache.get(target.src);
            if (cacheData) {
                if (!target.dataset.hasQr) applyQrSuccess(target, cacheData.text, cacheData.method);
                else requestShowTooltip(cacheData.text, target, cacheData.method);
            }
            return;
        }
        if (isCanvas && canvasCache.has(target)) {
            const cacheData = canvasCache.get(target);
            if (cacheData) {
                if (!target.dataset.hasQr) applyQrSuccess(target, cacheData.text, cacheData.method);
                else requestShowTooltip(cacheData.text, target, cacheData.method);
            }
            return;
        }

        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, false); // 自动模式
        }, DELAY_MS);
    });

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

    // === 交互逻辑 ===

    document.addEventListener('mousedown', (e) => {
        if (e.button === 2) {
            isRightClickHolding = true;
            leftClickCount = 0;
            interactionTarget = e.target;
            suppressContextMenu = false;
        }
        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);

    document.addEventListener('mouseup', (e) => {
        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);

    document.addEventListener('contextmenu', (e) => {
        if (suppressContextMenu) {
            e.preventDefault();
            e.stopPropagation();
            suppressContextMenu = false;
        }
    }, 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') {
                const c = qrCache.get(target.src);
                if (c) data = c.text;
            } else {
                const c = canvasCache.get(target);
                if (c) data = c.text;
            }

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

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

})();