// ==UserScript== // @name QR 直链化(二维码下方显示链接) // @namespace https://example.com/ // @version 2.1.0 // @description 自动识别页面中的二维码;点击二维码直接打开链接,并在二维码下方显示可点击链接,尽量保持原有布局。 // @author ChatGPT // @license AGPL-3.0-or-later // @match *://*/* // @run-at document-idle // @grant none // @require https://cdn.jsdelivr.net/npm/jsqr@1.4.0/dist/jsQR.js // @icon data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 64 64'%3E%3Crect width='64' height='64' rx='14' fill='%23111111'/%3E%3Cpath d='M20 44L44 20' stroke='%23ffffff' stroke-width='5' stroke-linecap='round'/%3E%3Cpath d='M29 20h15v15' fill='none' stroke='%23ffffff' stroke-width='5' stroke-linecap='round' stroke-linejoin='round'/%3E%3Cpath d='M18 18h14' stroke='%2300d084' stroke-width='5' stroke-linecap='round'/%3E%3Cpath d='M18 28h14' stroke='%2300d084' stroke-width='5' stroke-linecap='round'/%3E%3Cpath d='M18 38h14' stroke='%2300d084' stroke-width='5' stroke-linecap='round'/%3E%3C/svg%3E // @downloadURL https://update.greasyfork.icu/scripts/574043/QR%20%E7%9B%B4%E9%93%BE%E5%8C%96%EF%BC%88%E4%BA%8C%E7%BB%B4%E7%A0%81%E4%B8%8B%E6%96%B9%E6%98%BE%E7%A4%BA%E9%93%BE%E6%8E%A5%EF%BC%89.user.js // @updateURL https://update.greasyfork.icu/scripts/574043/QR%20%E7%9B%B4%E9%93%BE%E5%8C%96%EF%BC%88%E4%BA%8C%E7%BB%B4%E7%A0%81%E4%B8%8B%E6%96%B9%E6%98%BE%E7%A4%BA%E9%93%BE%E6%8E%A5%EF%BC%89.meta.js // ==/UserScript== /* * SPDX-License-Identifier: AGPL-3.0-or-later * * Copyright (C) 2026 * * This program is free software: you can redistribute it and/or modify * it under the terms of the GNU Affero General Public License as published by * the Free Software Foundation, either version 3 of the License, or * (at your option) any later version. */ (function () { 'use strict'; const SCAN_MIN_SIZE = 72; const MAX_IMAGE_PIXELS = 1600 * 1600; const RESCAN_INTERVAL_MS = 30000; const OBSERVER_DEBOUNCE_MS = 1000; const ATTR_WRAPPER = 'data-tm-qr-wrapper'; const ATTR_MEDIA = 'data-tm-qr-media'; const ATTR_SCANNED = 'data-tm-qr-scanned'; const ATTR_SCAN_KEY = 'data-tm-qr-scan-key'; const ATTR_RESULT = 'data-tm-qr-result'; const ATTR_URL = 'data-tm-qr-url'; const CLASS_WRAPPER = 'tm-qr-linkify-wrapper'; const CLASS_MEDIA = 'tm-qr-linkify-media'; const CLASS_LAYER = 'tm-qr-linkify-layer'; const CLASS_BADGE = 'tm-qr-linkify-badge'; const CLASS_LINKBOX = 'tm-qr-linkify-linkbox'; let rescanTimer = null; let periodicTimer = null; const BADGE_SVG = ` `; function injectStyle() { if (document.getElementById('tm-qr-linkify-style')) { return; } const style = document.createElement('style'); style.id = 'tm-qr-linkify-style'; style.textContent = ` .${CLASS_WRAPPER} { position: relative !important; display: inline-flex !important; flex-direction: column !important; align-items: flex-start !important; gap: 8px !important; margin: 0 !important; padding: 0 !important; border: 0 !important; max-width: 100% !important; vertical-align: top !important; line-height: normal !important; isolation: isolate !important; } .${CLASS_MEDIA} { position: relative !important; display: inline-block !important; line-height: 0 !important; margin: 0 !important; padding: 0 !important; border: 0 !important; max-width: 100% !important; } .${CLASS_MEDIA} > img, .${CLASS_MEDIA} > canvas { display: block !important; cursor: pointer !important; max-width: 100% !important; } .${CLASS_LAYER} { position: absolute !important; inset: 0 !important; z-index: 2147483645 !important; display: block !important; background: transparent !important; text-indent: -999999px !important; overflow: hidden !important; white-space: nowrap !important; cursor: pointer !important; border: 0 !important; outline: 0 !important; text-decoration: none !important; } .${CLASS_BADGE} { position: absolute !important; top: 6px !important; right: 6px !important; z-index: 2147483646 !important; width: 22px !important; height: 22px !important; display: flex !important; align-items: center !important; justify-content: center !important; border-radius: 999px !important; background: rgba(0, 0, 0, 0.72) !important; border: 1px solid rgba(255, 255, 255, 0.18) !important; box-shadow: 0 2px 8px rgba(0, 0, 0, 0.28) !important; pointer-events: none !important; backdrop-filter: blur(4px) !important; transition: transform 0.15s ease !important; } .${CLASS_BADGE} svg { width: 12px !important; height: 12px !important; fill: none !important; stroke: #ffffff !important; stroke-width: 2 !important; stroke-linecap: round !important; stroke-linejoin: round !important; } .${CLASS_MEDIA}:hover > .${CLASS_BADGE} { transform: scale(1.06) !important; } .${CLASS_MEDIA}:hover > img, .${CLASS_MEDIA}:hover > canvas { filter: brightness(1.02) !important; } .${CLASS_LINKBOX} { display: block !important; box-sizing: border-box !important; max-width: min(92vw, 560px) !important; min-width: 180px !important; padding: 7px 10px !important; border-radius: 10px !important; background: rgba(0, 0, 0, 0.66) !important; border: 1px solid rgba(255, 255, 255, 0.12) !important; color: #ffffff !important; font-size: 12px !important; line-height: 1.5 !important; text-decoration: underline !important; text-underline-offset: 2px !important; word-break: break-all !important; overflow-wrap: anywhere !important; white-space: normal !important; cursor: pointer !important; transition: opacity 0.15s ease !important; } .${CLASS_LINKBOX}:hover { opacity: 0.92 !important; } `; document.head.appendChild(style); } function isVisible(element) { if (!element || !element.isConnected) { return false; } const style = getComputedStyle(element); if (style.display === 'none' || style.visibility === 'hidden' || Number(style.opacity) === 0) { return false; } const rect = element.getBoundingClientRect(); return rect.width >= SCAN_MIN_SIZE && rect.height >= SCAN_MIN_SIZE; } function isProbablySquare(element) { const rect = element.getBoundingClientRect(); if (rect.width < SCAN_MIN_SIZE || rect.height < SCAN_MIN_SIZE) { return false; } const ratio = rect.width / rect.height; return ratio >= 0.65 && ratio <= 1.35; } function isEligibleElement(element) { if (!(element instanceof HTMLImageElement) && !(element instanceof HTMLCanvasElement)) { return false; } if (!isVisible(element)) { return false; } if (!isProbablySquare(element)) { return false; } return true; } function looksLikeUrl(text) { if (!text) { return false; } const value = text.trim(); if (/^https?:\/\//i.test(value)) { return true; } if (/^[a-z0-9.-]+\.[a-z]{2,}(\/.*)?$/i.test(value)) { return true; } return false; } function normalizeUrl(text) { const value = (text || '').trim(); if (!value) { return ''; } if (/^https?:\/\//i.test(value)) { return value; } if (/^[a-z0-9.-]+\.[a-z]{2,}(\/.*)?$/i.test(value)) { return `https://${value}`; } return ''; } function getScanKey(element) { const rect = element.getBoundingClientRect(); const visiblePart = `${Math.round(rect.width)}x${Math.round(rect.height)}`; if (element instanceof HTMLImageElement) { return `${element.currentSrc || element.src || ''}|${visiblePart}`; } if (element instanceof HTMLCanvasElement) { return `${element.width}x${element.height}|${visiblePart}`; } return visiblePart; } function drawElementToCanvas(element) { const canvas = document.createElement('canvas'); const ctx = canvas.getContext('2d', { willReadFrequently: true }); if (!ctx) { return null; } let sourceWidth = 0; let sourceHeight = 0; if (element instanceof HTMLImageElement) { if (!element.complete || !element.naturalWidth || !element.naturalHeight) { return null; } sourceWidth = element.naturalWidth; sourceHeight = element.naturalHeight; } else if (element instanceof HTMLCanvasElement) { if (!element.width || !element.height) { return null; } sourceWidth = element.width; sourceHeight = element.height; } else { return null; } let targetWidth = sourceWidth; let targetHeight = sourceHeight; if (targetWidth * targetHeight > MAX_IMAGE_PIXELS) { const scale = Math.sqrt(MAX_IMAGE_PIXELS / (targetWidth * targetHeight)); targetWidth = Math.max(1, Math.floor(targetWidth * scale)); targetHeight = Math.max(1, Math.floor(targetHeight * scale)); } canvas.width = targetWidth; canvas.height = targetHeight; ctx.imageSmoothingEnabled = false; try { ctx.drawImage(element, 0, 0, targetWidth, targetHeight); } catch (error) { console.debug('[TM-QR-Linkify] drawImage failed:', error); return null; } return canvas; } function decodeQrFromElement(element) { const canvas = drawElementToCanvas(element); if (!canvas) { return ''; } try { const ctx = canvas.getContext('2d', { willReadFrequently: true }); if (!ctx) { return ''; } const imageData = ctx.getImageData(0, 0, canvas.width, canvas.height); const decoded = jsQR(imageData.data, imageData.width, imageData.height, { inversionAttempts: 'attemptBoth' }); return decoded && decoded.data ? decoded.data.trim() : ''; } catch (error) { console.debug('[TM-QR-Linkify] decode failed:', error); return ''; } } function ensureStructure(element) { const parent = element.parentElement; if (!parent) { return null; } if (parent.getAttribute(ATTR_MEDIA) === '1') { return { wrapper: parent.parentElement, media: parent }; } if (parent.getAttribute(ATTR_WRAPPER) === '1') { let media = parent.querySelector(`:scope > .${CLASS_MEDIA}`); if (!media) { media = document.createElement('span'); media.className = CLASS_MEDIA; media.setAttribute(ATTR_MEDIA, '1'); parent.insertBefore(media, parent.firstChild || null); } if (element.parentElement !== media) { media.appendChild(element); } return { wrapper: parent, media }; } const wrapper = document.createElement('div'); wrapper.className = CLASS_WRAPPER; wrapper.setAttribute(ATTR_WRAPPER, '1'); const media = document.createElement('span'); media.className = CLASS_MEDIA; media.setAttribute(ATTR_MEDIA, '1'); parent.insertBefore(wrapper, element); wrapper.appendChild(media); media.appendChild(element); return { wrapper, media }; } function ensureLayer(media) { let layer = media.querySelector(`:scope > .${CLASS_LAYER}`); if (layer) { return layer; } layer = document.createElement('a'); layer.className = CLASS_LAYER; layer.textContent = 'Open QR Link'; media.appendChild(layer); return layer; } function ensureBadge(media) { let badge = media.querySelector(`:scope > .${CLASS_BADGE}`); if (badge) { return badge; } badge = document.createElement('span'); badge.className = CLASS_BADGE; badge.innerHTML = BADGE_SVG; media.appendChild(badge); return badge; } function ensureLinkBox(wrapper) { let linkBox = wrapper.querySelector(`:scope > .${CLASS_LINKBOX}`); if (linkBox) { return linkBox; } linkBox = document.createElement('a'); linkBox.className = CLASS_LINKBOX; linkBox.target = '_blank'; linkBox.rel = 'noopener noreferrer'; wrapper.appendChild(linkBox); return linkBox; } function linkifyElement(element, url, rawText) { const structure = ensureStructure(element); if (!structure || !structure.wrapper || !structure.media) { return; } const wrapper = structure.wrapper; const media = structure.media; const rect = element.getBoundingClientRect(); const boxWidth = Math.min(Math.max(Math.round(rect.width), 180), 560); const title = rawText && rawText !== url ? `${rawText}\n${url}` : url; const layer = ensureLayer(media); layer.href = url; layer.target = '_blank'; layer.rel = 'noopener noreferrer'; layer.title = title; layer.setAttribute('aria-label', title); ensureBadge(media); const linkBox = ensureLinkBox(wrapper); linkBox.href = url; linkBox.title = title; linkBox.textContent = url; linkBox.style.width = `${boxWidth}px`; element.style.cursor = 'pointer'; element.title = title; element.setAttribute(ATTR_RESULT, rawText); element.setAttribute(ATTR_URL, url); } function processElement(element) { if (!isEligibleElement(element)) { return; } const isCanvas = element instanceof HTMLCanvasElement; const currentKey = getScanKey(element); const prevKey = element.getAttribute(ATTR_SCAN_KEY); const savedUrl = element.getAttribute(ATTR_URL); const savedText = element.getAttribute(ATTR_RESULT); if (!isCanvas && prevKey === currentKey && element.getAttribute(ATTR_SCANNED) === '1') { if (savedUrl) { linkifyElement(element, savedUrl, savedText || savedUrl); } return; } const decodedText = decodeQrFromElement(element); element.setAttribute(ATTR_SCAN_KEY, currentKey); element.setAttribute(ATTR_SCANNED, '1'); if (!decodedText) { return; } if (!looksLikeUrl(decodedText)) { return; } const url = normalizeUrl(decodedText); if (!url) { return; } linkifyElement(element, url, decodedText); } function collectCandidates(root = document) { const result = []; if (root instanceof HTMLImageElement || root instanceof HTMLCanvasElement) { result.push(root); return result; } if (root !== document && !(root instanceof Element)) { return result; } const scope = root === document ? document : root; result.push(...scope.querySelectorAll('img, canvas')); return result; } function scanPage(root = document) { const candidates = collectCandidates(root); for (const element of candidates) { processElement(element); } } function scheduleRescan(root = document) { clearTimeout(rescanTimer); rescanTimer = setTimeout(() => { scanPage(root); }, OBSERVER_DEBOUNCE_MS); } function observeDomChanges() { const observer = new MutationObserver((mutations) => { for (const mutation of mutations) { if (mutation.type === 'childList') { for (const node of mutation.addedNodes) { if (node instanceof Element) { scheduleRescan(node); } } } else if (mutation.type === 'attributes') { const target = mutation.target; if (target instanceof HTMLImageElement || target instanceof HTMLCanvasElement) { target.removeAttribute(ATTR_SCANNED); target.removeAttribute(ATTR_SCAN_KEY); scheduleRescan(target); } } } }); observer.observe(document.documentElement, { childList: true, subtree: true, attributes: true, attributeFilter: [ 'src', 'srcset', 'style', 'class', 'width', 'height' ] }); } function startPeriodicScan() { clearInterval(periodicTimer); periodicTimer = setInterval(() => { scanPage(document); }, RESCAN_INTERVAL_MS); } function registerHotkey() { window.addEventListener('keydown', (event) => { if (event.altKey && event.key.toLowerCase() === 'q') { scanPage(document); } }); } function init() { injectStyle(); scanPage(document); observeDomChanges(); startPeriodicScan(); registerHotkey(); console.log('[TM-QR-Linkify] 已启动,Alt + Q 可手动重扫。'); } if (document.readyState === 'loading') { document.addEventListener('DOMContentLoaded', init, { once: true }); } else { init(); } })();