// ==UserScript==
// @name 网页URL二维码生成
// @namespace http://yeyu1024.xyz
// @version 2.0
// @description 生成当前网页的地址(url)的二维码,方便手机扫描.支持二维码图片解析、Canvas二维码扫描(Alt+Q)
// @description:en Generate the QR code of the address of the current webpage (URL), which is convenient for mobile phone scanning
// @author 夜雨
// @match *://*/*
// @icon https://www.google.com/s2/favicons?sz=64&domain=https://www.the-qrcode-generator.com
// @grant GM_registerMenuCommand
// @homepageURL https://greasyfork.org/zh-CN/scripts/480612
// @supportURL https://greasyfork.org/zh-CN/scripts/480612
// @license MIT
// ==/UserScript==
(function () {
'use strict';
// ==================== 动态加载外部库 ====================
function injectScript(id, src) {
return new Promise((resolve, reject) => {
if (document.getElementById(id)) {
resolve();
return;
}
const script = document.createElement('script');
script.id = id;
script.src = src;
script.onload = resolve;
script.onerror = reject;
document.head.appendChild(script);
});
}
// 预加载 QRCode 和 jsQR
const libsReady = Promise.all([
injectScript('qrcode-script', 'https://cdn.bootcdn.net/ajax/libs/qrcodejs/1.0.0/qrcode.min.js'),
injectScript('jsQR-script', 'https://cdn.jsdelivr.net/npm/jsqr/dist/jsQR.js')
]).catch(() => {
console.warn('[二维码脚本] 部分库加载失败,尝试备用 CDN');
// 备用 CDN
return Promise.all([
typeof QRCode === 'undefined'
? injectScript('qrcode-script-alt', 'https://cdnjs.cloudflare.com/ajax/libs/qrcodejs/1.0.0/qrcode.min.js')
: Promise.resolve(),
typeof jsQR === 'undefined'
? injectScript('jsQR-script-alt', 'https://unpkg.com/jsqr/dist/jsQR.js')
: Promise.resolve()
]);
});
// ==================== 样式 ====================
const STYLE = `
.qr-overlay {
position: fixed;
top: 0; left: 0; right: 0; bottom: 0;
z-index: 9999998;
}
.qr-modal {
z-index: 9999999;
border-radius: 8px;
padding: 16px;
position: fixed;
top: 20%;
left: 50%;
transform: translateX(-50%);
background: #fff;
box-shadow: 0 4px 20px rgba(0, 0, 0, 0.2);
min-width: 300px;
max-width: 90vw;
font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, sans-serif;
}
.qr-modal-header {
display: flex;
justify-content: space-between;
align-items: center;
padding-bottom: 4px;
}
.qr-modal-title {
font-size: 15px;
font-weight: 600;
color: #333;
}
.qr-close {
font-size: 20px;
cursor: pointer;
color: #999;
line-height: 1;
transition: color 0.2s;
}
.qr-close:hover {
color: #333;
}
.qr-content {
display: flex;
flex-direction: column;
align-items: center;
padding: 16px 0;
}
.qr-btn {
cursor: pointer;
color: white;
border: none;
outline: none;
background: #4caf50;
padding: 10px 0;
border-radius: 4px;
font-size: 14px;
width: 200px;
margin-top: 16px;
transition: background 0.2s;
}
.qr-btn:hover {
background: #43a047;
}
.qr-btn-secondary {
background: #1a73e8;
}
.qr-btn-secondary:hover {
background: #1565c0;
}
.qr-textarea {
width: 240px;
margin: 16px;
padding: 8px;
border: 1px solid #ddd;
border-radius: 4px;
resize: vertical;
font-size: 14px;
}
.qr-link {
font-size: 12px;
color: #1a73e8;
text-decoration: none;
margin-top: 8px;
}
.qr-link:hover {
text-decoration: underline;
}
.qr-hint {
position: fixed;
top: 10px;
left: 50%;
transform: translateX(-50%);
background: rgba(0,0,0,0.75);
color: #fff;
padding: 10px 24px;
border-radius: 4px;
z-index: 9999999;
font-size: 14px;
pointer-events: none;
}
.qr-highlight {
outline: 3px solid #4caf50 !important;
outline-offset: 2px;
cursor: crosshair !important;
}
canvas.qr-highlight {
outline: 3px solid #ff9800 !important;
outline-offset: 2px;
cursor: crosshair !important;
position: relative;
}
.qr-shortcut-tip {
position: fixed;
bottom: 12px;
right: 12px;
background: rgba(0,0,0,0.6);
color: #fff;
padding: 6px 12px;
border-radius: 4px;
font-size: 12px;
z-index: 9999997;
cursor: pointer;
transition: opacity 0.3s;
opacity: 0.5;
font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, sans-serif;
}
.qr-shortcut-tip:hover {
opacity: 1;
background: rgba(0,0,0,0.8);
}
.qr-shortcut-tip kbd {
background: rgba(255,255,255,0.2);
border: 1px solid rgba(255,255,255,0.3);
border-radius: 3px;
padding: 1px 5px;
font-size: 11px;
font-family: inherit;
}
.qr-drop-zone {
width: 260px;
min-height: 120px;
border: 2px dashed #ccc;
border-radius: 8px;
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
color: #999;
font-size: 13px;
transition: all 0.2s;
cursor: pointer;
padding: 16px;
text-align: center;
gap: 8px;
}
.qr-drop-zone:hover {
border-color: #4caf50;
color: #4caf50;
}
.qr-drop-zone.qr-drag-over {
border-color: #4caf50;
background: #f1f8e9;
color: #4caf50;
}
.qr-drop-zone-icon {
font-size: 32px;
line-height: 1;
}
.qr-tab-bar {
display: flex;
gap: 0;
border-bottom: 1px solid #eee;
margin-bottom: 12px;
width: 100%;
}
.qr-tab {
flex: 1;
text-align: center;
padding: 8px 4px;
font-size: 13px;
color: #666;
cursor: pointer;
border-bottom: 2px solid transparent;
transition: all 0.2s;
}
.qr-tab:hover {
color: #333;
}
.qr-tab.qr-tab-active {
color: #4caf50;
border-bottom-color: #4caf50;
font-weight: 600;
}
.qr-tab-panel {
display: none;
width: 100%;
align-items: center;
flex-direction: column;
}
.qr-tab-panel.qr-tab-panel-active {
display: flex;
}
.qr-url-input {
width: 240px;
padding: 8px;
border: 1px solid #ddd;
border-radius: 4px;
font-size: 13px;
outline: none;
transition: border-color 0.2s;
}
.qr-url-input:focus {
border-color: #4caf50;
}
.qr-img-preview {
max-width: 200px;
max-height: 200px;
border-radius: 4px;
margin-top: 12px;
border: 1px solid #eee;
}
.qr-btn-row {
display: flex;
gap: 8px;
margin-top: 16px;
}
`;
// 注入样式
function injectStyles() {
if (document.getElementById('qr-userscript-style')) return;
const style = document.createElement('style');
style.id = 'qr-userscript-style';
style.textContent = STYLE;
document.head.appendChild(style);
}
// ==================== 工具函数 ====================
function isURL(str) {
const pattern = /^(https?:\/\/)?((([a-z\d]([a-z\d-]*[a-z\d])*)\.)+[a-z]{2,}|((\d{1,3}\.){3}\d{1,3}))(:\d+)?(\/[-a-z\d%_.~+]*)*(\?[;&a-z\d%_.~+=-]*)?(#[-a-z\d_]*)?$/i;
return pattern.test(str);
}
// 移除已存在的弹窗
function removeExistingModal() {
const existing = document.getElementById('qr-modal');
if (existing) existing.remove();
const overlay = document.getElementById('qr-overlay');
if (overlay) overlay.remove();
}
// 创建弹窗 DOM
function createModal({ title, contentHTML, onClose, buttons }) {
removeExistingModal();
const overlay = document.createElement('div');
overlay.id = 'qr-overlay';
overlay.className = 'qr-overlay';
overlay.addEventListener('click', () => {
overlay.remove();
modal.remove();
if (onClose) onClose();
});
const modal = document.createElement('div');
modal.id = 'qr-modal';
modal.className = 'qr-modal';
modal.innerHTML = `
${contentHTML}
`;
document.body.appendChild(overlay);
document.body.appendChild(modal);
// 关闭按钮
modal.querySelector('#qr-close-btn').addEventListener('click', () => {
overlay.remove();
modal.remove();
if (onClose) onClose();
});
// 阻止点击弹窗本身关闭
modal.addEventListener('click', (e) => e.stopPropagation());
// 绑定按钮事件
if (buttons) {
buttons.forEach(({ id, handler }) => {
const el = modal.querySelector(`#${id}`);
if (el) el.addEventListener('click', handler);
});
}
return { overlay, modal };
}
// ==================== 生成二维码 ====================
async function generateQRCode() {
await libsReady;
if (typeof QRCode === 'undefined') {
showToast('QRCode 库加载失败,请刷新页面重试');
return;
}
const contentHTML = `
`;
const { modal } = createModal({
title: '网址二维码',
contentHTML,
buttons: [
{
id: 'qr-regenerate-btn',
handler() {
qrcode.clear();
qrcode.makeCode(location.href);
}
}
]
});
const container = modal.querySelector('#qr-code-container');
const qrcode = new QRCode(container, {
text: location.href,
width: 256,
height: 256,
colorDark: '#000000',
colorLight: '#ffffff',
correctLevel: QRCode.CorrectLevel.H
});
}
// ==================== 解析二维码 ====================
// 从 canvas 解析二维码
async function decodeCanvas(canvas) {
await libsReady;
if (typeof jsQR === 'undefined') {
throw new Error('jsQR 库加载失败');
}
const ctx = canvas.getContext('2d');
const imageData = ctx.getImageData(0, 0, canvas.width, canvas.height);
return jsQR(imageData.data, imageData.width, imageData.height);
}
// 从 Image 元素解析二维码
async function decodeFromImage(img) {
const canvas = document.createElement('canvas');
try {
canvas.width = img.naturalWidth || img.width;
canvas.height = img.naturalHeight || img.height;
const ctx = canvas.getContext('2d');
ctx.drawImage(img, 0, 0, canvas.width, canvas.height);
return await decodeCanvas(canvas);
} finally {
canvas.width = 0;
canvas.height = 0;
}
}
// 从 File/Blob 加载为 Image
function loadImageFromBlob(blob) {
return new Promise((resolve, reject) => {
const url = URL.createObjectURL(blob);
const img = new Image();
img.onload = () => {
URL.revokeObjectURL(url);
resolve(img);
};
img.onerror = () => {
URL.revokeObjectURL(url);
reject(new Error('图片加载失败'));
};
img.src = url;
});
}
// 显示解析结果
function showDecodeResult(resultText) {
const isUrl = isURL(resultText);
const contentHTML = `
${isUrl ? `🔗 打开网址` : ''}
`;
createModal({
title: '解析结果',
contentHTML,
buttons: [
{
id: 'qr-copy-btn',
async handler() {
const textarea = document.getElementById('qr-decode-result');
try {
await navigator.clipboard.writeText(textarea.value);
showToast('已复制到剪贴板');
} catch {
textarea.select();
document.execCommand('copy');
showToast('已复制到剪贴板');
}
}
}
]
});
}
// 解析入口:处理图片并显示结果
async function processAndDecode(img) {
try {
const code = await decodeFromImage(img);
if (code) {
showDecodeResult(code.data);
} else {
showToast('未找到二维码,请确认图片包含清晰的二维码');
}
} catch (e) {
console.error('二维码解析失败:', e);
showToast('解析失败: ' + e.message);
}
}
// 直接从 canvas 元素解析(无需转图片,避免跨域问题)
async function processCanvasElement(canvas) {
try {
const code = await decodeCanvas(canvas);
if (code) {
showDecodeResult(code.data);
} else {
showToast('该 Canvas 上未找到二维码');
}
} catch (e) {
console.error('Canvas 解析失败:', e);
// tainted canvas 无法读取像素,降级为 toDataURL 方式
try {
const dataURL = canvas.toDataURL('image/png');
const img = new Image();
img.onload = () => processAndDecode(img);
img.onerror = () => showToast('Canvas 数据读取失败(跨域限制)');
img.src = dataURL;
} catch (e2) {
showToast('Canvas 受跨域限制,无法读取');
}
}
}
// 一键扫描页面上所有 Canvas
async function scanAllCanvases() {
await libsReady;
if (typeof jsQR === 'undefined') {
showToast('jsQR 库加载失败,请刷新页面重试');
return;
}
const canvases = document.querySelectorAll('canvas');
if (canvases.length === 0) {
showToast('页面上没有找到 Canvas 元素');
return;
}
showToast(`正在扫描 ${canvases.length} 个 Canvas...`, 1200);
let found = false;
for (const canvas of canvases) {
if (canvas.width === 0 || canvas.height === 0) continue;
try {
const ctx = canvas.getContext('2d');
const imageData = ctx.getImageData(0, 0, canvas.width, canvas.height);
const code = jsQR(imageData.data, imageData.width, imageData.height);
if (code) {
found = true;
showDecodeResult(code.data);
break;
}
} catch {
// tainted canvas,跳过
continue;
}
}
if (!found) {
showToast('扫描完成,未在 Canvas 上找到二维码');
}
}
// 解析 File 对象
async function decodeFile(file) {
if (!file || !file.type.startsWith('image/')) {
showToast('请选择图片文件');
return;
}
try {
const img = await loadImageFromBlob(file);
await processAndDecode(img);
} catch (e) {
showToast(e.message);
}
}
// 解析图片 URL(通过 fetch + blob 绕过跨域)
async function decodeImageURL(url) {
if (!url) {
showToast('请输入图片地址');
return;
}
showToast('正在加载图片...');
try {
const resp = await fetch(url, { mode: 'cors' });
if (!resp.ok) throw new Error('加载失败');
const blob = await resp.blob();
const img = await loadImageFromBlob(blob);
await processAndDecode(img);
} catch (e) {
console.error('URL加载失败:', e);
showToast('加载失败,可能是跨域限制');
}
}
// ==================== 解析弹窗(多 Tab) ====================
function showDecodeModal() {
const contentHTML = `
📁 本地图片
📋 粘贴图片
🔗 图片地址
🎯 页面元素
📋
点击此处后按 Ctrl+V 粘贴
或直接粘贴截图
点击「选取元素」后,再点击页面上的图片或Canvas
适用于无法下载的图片、Canvas绘制的二维码
或
快捷键: Alt+Q
`;
const { modal } = createModal({
title: '解析二维码',
contentHTML,
onClose() {
cleanupPagePick();
}
});
// ---- Tab 切换 ----
const tabs = modal.querySelectorAll('.qr-tab');
const panels = modal.querySelectorAll('.qr-tab-panel');
tabs.forEach(tab => {
tab.addEventListener('click', () => {
tabs.forEach(t => t.classList.remove('qr-tab-active'));
panels.forEach(p => p.classList.remove('qr-tab-panel-active'));
tab.classList.add('qr-tab-active');
modal.querySelector(`[data-panel="${tab.dataset.tab}"]`).classList.add('qr-tab-panel-active');
});
});
// ---- Tab 1: 本地上传 ----
const dropZone = modal.querySelector('#qr-drop-zone');
const fileInput = modal.querySelector('#qr-file-input');
dropZone.addEventListener('click', () => fileInput.click());
fileInput.addEventListener('change', () => {
if (fileInput.files[0]) decodeFile(fileInput.files[0]);
});
dropZone.addEventListener('dragover', (e) => {
e.preventDefault();
dropZone.classList.add('qr-drag-over');
});
dropZone.addEventListener('dragleave', () => {
dropZone.classList.remove('qr-drag-over');
});
dropZone.addEventListener('drop', (e) => {
e.preventDefault();
dropZone.classList.remove('qr-drag-over');
const file = e.dataTransfer.files[0];
if (file) decodeFile(file);
});
// ---- Tab 2: 粘贴 ----
const pasteZone = modal.querySelector('#qr-paste-zone');
pasteZone.setAttribute('tabindex', '0');
pasteZone.addEventListener('click', () => pasteZone.focus());
// 监听全局粘贴(弹窗打开时)
function onPaste(e) {
const items = e.clipboardData?.items;
if (!items) return;
for (const item of items) {
if (item.type.startsWith('image/')) {
e.preventDefault();
const file = item.getAsFile();
if (file) decodeFile(file);
return;
}
}
showToast('剪贴板中没有图片');
}
document.addEventListener('paste', onPaste);
// 弹窗关闭时移除粘贴监听
const observer = new MutationObserver(() => {
if (!document.getElementById('qr-modal')) {
document.removeEventListener('paste', onPaste);
observer.disconnect();
}
});
observer.observe(document.body, { childList: true });
// ---- Tab 3: URL ----
const urlInput = modal.querySelector('#qr-url-input');
const urlDecodeBtn = modal.querySelector('#qr-url-decode-btn');
urlDecodeBtn.addEventListener('click', () => decodeImageURL(urlInput.value.trim()));
urlInput.addEventListener('keydown', (e) => {
if (e.key === 'Enter') decodeImageURL(urlInput.value.trim());
});
// ---- Tab 4: 页面元素选取 ----
const pagePickBtn = modal.querySelector('#qr-page-pick-btn');
const scanCanvasBtn = modal.querySelector('#qr-scan-canvas-btn');
const canvasCountEl = modal.querySelector('#qr-canvas-count');
let pagePickActive = false;
// 显示页面上 canvas 数量
const canvasCount = document.querySelectorAll('canvas').length;
canvasCountEl.textContent = canvasCount > 0
? `页面上发现 ${canvasCount} 个 Canvas 元素`
: '页面上未发现 Canvas 元素';
// 一键扫描
scanCanvasBtn.addEventListener('click', () => {
removeExistingModal();
scanAllCanvases();
});
// 选取元素
function cleanupPagePick() {
if (!pagePickActive) return;
pagePickActive = false;
const elements = document.querySelectorAll('img, canvas');
elements.forEach(el => {
el.classList.remove('qr-highlight');
el.removeEventListener('click', onElementClick);
el.removeEventListener('mouseenter', onHighlight);
el.removeEventListener('mouseleave', onUnhighlight);
});
document.removeEventListener('keydown', onEscExit);
}
function onHighlight(e) { e.target.classList.add('qr-highlight'); }
function onUnhighlight(e) { e.target.classList.remove('qr-highlight'); }
function onEscExit(e) {
if (e.key === 'Escape') {
cleanupPagePick();
showToast('已退出选取模式');
}
}
function onElementClick(e) {
e.preventDefault();
e.stopPropagation();
const el = e.target;
cleanupPagePick();
removeExistingModal();
if (el.tagName === 'CANVAS') {
processCanvasElement(el);
} else {
processAndDecode(el);
}
}
pagePickBtn.addEventListener('click', () => {
pagePickActive = true;
removeExistingModal();
showToast('请点击要解析的图片或 Canvas(按 Esc 退出)', 3000);
// 同时支持 img 和 canvas
const elements = document.querySelectorAll('img, canvas');
elements.forEach(el => {
el.addEventListener('click', onElementClick, { once: true });
el.addEventListener('mouseenter', onHighlight);
el.addEventListener('mouseleave', onUnhighlight);
});
document.addEventListener('keydown', onEscExit);
setTimeout(cleanupPagePick, 30000);
});
}
// HTML 转义
function escapeHTML(str) {
return str.replace(/&/g, '&').replace(//g, '>').replace(/"/g, '"');
}
function escapeAttr(str) {
return str.replace(/&/g, '&').replace(/"/g, '"').replace(/'/g, ''');
}
// 轻提示
function showToast(msg, duration = 2000) {
const existing = document.querySelector('.qr-hint');
if (existing) existing.remove();
const hint = document.createElement('div');
hint.className = 'qr-hint';
hint.textContent = msg;
document.body.appendChild(hint);
setTimeout(() => hint.remove(), duration);
}
// ==================== 菜单注册 ====================
injectStyles();
GM_registerMenuCommand('生成二维码', generateQRCode, 'qrcodeGenerate');
GM_registerMenuCommand('解析二维码', showDecodeModal, 'decodeQRImg');
// ==================== 快捷键 & 页面提示 ====================
const HOTKEY = { ctrl: false, shift: false, alt: true, key: 'KeyQ' }; // Alt+Q
const HOTKEY_LABEL = 'Alt+Q';
// 快捷键监听:一键扫描 Canvas
document.addEventListener('keydown', (e) => {
if (e.altKey && !e.ctrlKey && !e.shiftKey && e.code === HOTKEY.key) {
e.preventDefault();
scanAllCanvases();
}
});
// 页面右下角常驻提示
function showShortcutTip() {
if (document.getElementById('qr-shortcut-tip')) return;
const tip = document.createElement('div');
tip.id = 'qr-shortcut-tip';
tip.className = 'qr-shortcut-tip';
tip.innerHTML = `📷 扫描Canvas: ${HOTKEY_LABEL}`;
tip.title = '点击也可扫描页面上的Canvas二维码';
tip.addEventListener('click', () => scanAllCanvases());
document.body.appendChild(tip);
}
showShortcutTip();
})();