// ==UserScript==
// @name 二维码自动解析
// @description 鼠标悬停时自动在本地解析二维码
// @namespace http://tampermonkey.net/
// @require https://cdn.jsdelivr.net/npm/jsqr@1.4.0/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
// @downloadURL none
// ==/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 = `
${escapeHtml(text)}
`;
} else if (isError) {
htmlContent = `${escapeHtml(text)}
`;
} else {
htmlContent = `
${escapeHtml(text)}
${isLink ? '🔗 点击打开链接' : '📋 点击复制文本'}
`;
}
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 = `✅ 已复制到剪贴板
`;
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");
// 查找结构: Parsed Result | RESULT |
// 使用 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, "&").replace(//g, ">").replace(/"/g, """).replace(/'/g, "'");
}
// ==========================================
// 事件监听
// ==========================================
// 鼠标移动监听:用于实时校准坐标
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);
})();