// ==UserScript== // @name Email Copy Button for All Sites // @name:zh-CN 全站邮箱一键复制 // @namespace http://tampermonkey.net/ // @version 1.9 // @description Automatically detects email addresses on any webpage and adds a one-click copy button. Supports plain text emails, mailto links, input field values (e.g. read-only forms, admin dashboards), table cells, drawer/modal content, aria attributes, and title attributes. Includes deduplication, trailing punctuation trim, and rescan on DOM updates for SPA/dynamic pages. // @description:zh-CN 在任意网页自动识别邮箱地址并添加一键复制按钮,支持纯文本邮箱、mailto 链接及表单输入框内的邮箱值(兼容组件库只读表单、表格详情、抽屉弹窗等后台系统),兼容 React / Next.js 动态渲染页面,修复重复按钮、正则误匹配、滚动懒加载等问题。 // @author Nosy Swab // @match *://*/* // @exclude https://apps.sfc.hk/* // @run-at document-end // @grant none // @license MIT // @icon data:image/svg+xml;base64,PHN2ZyB4bWxucz0iaHR0cDovL3d3dy5zvcmcvMjAwMC9zdmciIHZpZXdCb3g9IjAgMCA2NkNCIgd2lkdGg9IjY0IiBoZWlnaHQ9IjY0Ij48cmVjdCB3aWR0aD0iNjQiIGhlaWdodD0iNjQiIHJ4PSI4IiBmaWxsPSIjMTg0OGZmIi8+PHBhdGggZD0iTTMyIDIwYzYuNjMgMCAxMiA1LjM3IDEyIDEycy01LjM3IDEyLTEyIDEyLTEyLTUuMzctMTItMTIgNS4zNy0xMiAxMi0xMnptMCA0Yy00LjQxIDAtOCAzLjU5LTggOHMzLjU5IDggOCA4IDgtMy41OSA4LTgtMy41OS04LTgtOHoiIGZpbGw9IndoaXRlIi8+PHBhdGggZD0iTTE2IDQ0YzAtOC44NCA3LjE2LTE2IDE2LTE2czE2IDcuMTYgMTYgMTZ2NGgtMzJ2LTR6IiBmaWxsPSJ3aGl0ZSIvPjwvc3ZnPg== // ==/UserScript== (function () { 'use strict'; if (location.hostname === 'apps.sfc.hk') return; var EMAIL_RE = /[a-zA-Z0-9._%+\-]+@[a-zA-Z0-9.\-]+\.[a-zA-Z]{2,}/; var EMAIL_RE_G = /[a-zA-Z0-9._%+\-]+@[a-zA-Z0-9.\-]+\.[a-zA-Z]{2,}/g; // Clean trailing punctuation/brackets from extracted email function cleanEmail(raw) { return raw.replace(/[),;:.'"\]>]+$/, '').trim(); } // Marker to avoid inserting duplicate buttons var MARKER = 'data-email-btn-injected'; function makeBtn(email) { var btn = document.createElement('button'); btn.textContent = '\uD83D\uDCCB'; btn.title = 'Copy: ' + email; btn.setAttribute(MARKER, '1'); btn.style.cssText = [ 'display:inline-flex', 'align-items:center', 'justify-content:center', 'margin-left:4px', 'padding:1px 5px', 'font-size:12px', 'line-height:1', 'border:1px solid #d9d9d9', 'border-radius:4px', 'background:#fff', 'cursor:pointer', 'vertical-align:middle', 'transition:background 0.15s', 'flex-shrink:0' ].join(';'); btn.addEventListener('mouseenter', function () { btn.style.background = '#f0f0f0'; }); btn.addEventListener('mouseleave', function () { btn.style.background = '#fff'; }); btn.addEventListener('click', function (e) { e.preventDefault(); e.stopPropagation(); navigator.clipboard.writeText(email).then(function () { var orig = btn.textContent; btn.textContent = '\u2713'; btn.style.background = '#52c41a'; btn.style.color = '#fff'; btn.style.borderColor = '#52c41a'; setTimeout(function () { btn.textContent = orig; btn.style.background = '#fff'; btn.style.color = ''; btn.style.borderColor = '#d9d9d9'; }, 1500); }).catch(function () { var ta = document.createElement('textarea'); ta.value = email; ta.style.cssText = 'position:fixed;opacity:0;top:0;left:0'; document.body.appendChild(ta); ta.select(); document.execCommand('copy'); document.body.removeChild(ta); }); }); return btn; } // ── Inject into plain text nodes ────────────────────────────────────── var SKIP_TAGS = new Set([ 'SCRIPT','STYLE','NOSCRIPT','CODE','PRE','SVG','CANVAS', 'HEAD','TEMPLATE','TEXTAREA' ]); function injectIntoTextNode(node) { var text = node.nodeValue; if (!text || !EMAIL_RE.test(text)) return; var parent = node.parentNode; if (!parent) return; if (SKIP_TAGS.has(parent.tagName)) return; if (parent.getAttribute && parent.getAttribute(MARKER)) return; // Avoid double-processing if (parent.hasAttribute && parent.hasAttribute(MARKER)) return; var parts = text.split(EMAIL_RE_G); var matches = text.match(EMAIL_RE_G); if (!matches) return; var frag = document.createDocumentFragment(); parts.forEach(function (part, i) { frag.appendChild(document.createTextNode(part)); if (matches[i]) { var email = cleanEmail(matches[i]); frag.appendChild(document.createTextNode(email)); frag.appendChild(makeBtn(email)); } }); parent.setAttribute(MARKER, '1'); parent.replaceChild(frag, node); } // ── Inject after mailto links ────────────────────────────────────────── function injectMailtoLinks(root) { var links = (root.querySelectorAll ? root : document) .querySelectorAll('a[href^="mailto:"]'); links.forEach(function (a) { if (a.getAttribute(MARKER)) return; var email = cleanEmail(a.href.replace('mailto:', '').split('?')[0]); if (!email) return; a.setAttribute(MARKER, '1'); a.insertAdjacentElement('afterend', makeBtn(email)); }); } // ── Inject into input/textarea values ───────────────────────────────── function injectInputValues(root) { var fields = (root.querySelectorAll ? root : document) .querySelectorAll('input[value], input[readonly], input[disabled], textarea[readonly], textarea[disabled]'); fields.forEach(function (el) { if (el.getAttribute(MARKER)) return; var val = el.value || el.getAttribute('value') || ''; var m = val.match(EMAIL_RE); if (!m) return; var email = cleanEmail(m[0]); el.setAttribute(MARKER, '1'); // insert button after the field if parent allows var parent = el.parentNode; if (parent && !SKIP_TAGS.has(parent.tagName)) { var btn = makeBtn(email); el.insertAdjacentElement('afterend', btn); } }); } // ── Inject into title / aria-label / data-* attributes ──────────────── function injectAttrHints(root) { var attrs = ['title', 'aria-label', 'placeholder', 'data-email', 'data-value']; attrs.forEach(function (attr) { var els = (root.querySelectorAll ? root : document) .querySelectorAll('[' + attr + ']'); els.forEach(function (el) { var key = MARKER + '-attr-' + attr; if (el.getAttribute(key)) return; var val = el.getAttribute(attr) || ''; var m = val.match(EMAIL_RE); if (!m) return; var email = cleanEmail(m[0]); el.setAttribute(key, '1'); el.insertAdjacentElement('afterend', makeBtn(email)); }); }); } // ── Walk text nodes ──────────────────────────────────────────────────── function walkTextNodes(root) { var walker = document.createTreeWalker( root, NodeFilter.SHOW_TEXT, { acceptNode: function (node) { if (SKIP_TAGS.has(node.parentElement && node.parentElement.tagName)) { return NodeFilter.FILTER_REJECT; } return NodeFilter.FILTER_ACCEPT; } }, false ); var nodes = []; var n; while ((n = walker.nextNode())) nodes.push(n); nodes.forEach(injectIntoTextNode); } // ── Full scan on a root element ──────────────────────────────────────── function scanRoot(root) { try { walkTextNodes(root); injectMailtoLinks(root); injectInputValues(root); injectAttrHints(root); } catch (e) { // silently ignore per-node errors } } // ── Initial scan ────────────────────────────────────────────────────── scanRoot(document.body); // Delayed second pass for late-mounting components setTimeout(function () { scanRoot(document.body); }, 1200); setTimeout(function () { scanRoot(document.body); }, 3000); // ── MutationObserver for SPA / dynamic content ──────────────────────── var pending = false; var observer = new MutationObserver(function (mutations) { if (pending) return; pending = true; requestAnimationFrame(function () { pending = false; mutations.forEach(function (mut) { mut.addedNodes.forEach(function (node) { if (node.nodeType === 1) { // Element node — scan it and its subtree if (!SKIP_TAGS.has(node.tagName)) { scanRoot(node); } } else if (node.nodeType === 3) { // Text node injectIntoTextNode(node); } }); }); }); }); observer.observe(document.body, { childList: true, subtree: true }); // ── Rescan visible area on scroll (throttled) — catches virtual scroll ─ var scrollTimer = null; window.addEventListener('scroll', function () { clearTimeout(scrollTimer); scrollTimer = setTimeout(function () { // Only re-scan input/textarea values on scroll as text nodes are handled by MO injectInputValues(document.body); injectAttrHints(document.body); }, 400); }, { passive: true }); })();