// ==UserScript== // @name Email Copy Button for All Sites // @name:zh-CN 全站邮箱一键复制 // @namespace http://tampermonkey.net/ // @version 2.5 // @description Automatically detects email addresses on any webpage and adds a one-click copy button. Supports plain text emails, mailto links, input field values, aria attributes. Deduplication via DOM attribute markers (not WeakSet) to survive text-node replacement and SPA re-renders. // @description:zh-CN 在任意网页自动识别邮箱地址并添加一键复制按钮。通过 DOM 属性标记去重(而非 WeakSet),修复文本节点替换后新节点被重复扫描导致无限按钮的问题。 // @author Nosy Swab // @match *://*/* // @run-at document-end // @grant none // @license MIT // ==/UserScript== (function () { 'use strict'; 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; // Marks buttons we inject var BTN_ATTR = 'data-ecb-btn'; // Marks source elements already processed var DONE_ATTR = 'data-ecb-done'; // Scanning re-entrancy guard var _scanning = false; var SKIP_TAGS = new Set([ 'SCRIPT','STYLE','NOSCRIPT','CODE','PRE','SVG','CANVAS', 'HEAD','TEMPLATE','TEXTAREA' ]); function cleanEmail(raw) { return raw.replace(/[();:'">[\]]+$/, '').trim(); } // -- Copy helper: execCommand first, modern API as fallback ------------ function copyToClipboard(text) { var copied = false; try { var ta = document.createElement('textarea'); ta.value = text; ta.style.cssText = 'position:fixed;opacity:0;top:0;left:0;width:1px;height:1px'; document.body.appendChild(ta); ta.focus(); ta.select(); copied = document.execCommand('copy'); document.body.removeChild(ta); } catch (err) { copied = false; } if (!copied) { try { navigator.clipboard.writeText(text).catch(function () {}); copied = true; } catch (err) {} } return copied; } // -- Button factory --------------------------------------------------- function makeBtn(email) { var btn = document.createElement('button'); btn.textContent = '\uD83D\uDCCB'; btn.setAttribute(BTN_ATTR, '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(); copyToClipboard(email); 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); }); return btn; } // -- Text nodes ------------------------------------------------------- function injectIntoTextNode(node) { var text = node.nodeValue; if (!text || !EMAIL_RE.test(text)) return; var parent = node.parentNode; if (!parent || !parent.tagName) return; if (SKIP_TAGS.has(parent.tagName)) return; if (parent.getAttribute(BTN_ATTR)) return; if (parent.getAttribute(DONE_ATTR)) return; var matches = text.match(EMAIL_RE_G); if (!matches) return; parent.setAttribute(DONE_ATTR, '1'); var parts = text.split(EMAIL_RE_G); var frag = document.createDocumentFragment(); parts.forEach(function (part, i) { frag.appendChild(document.createTextNode(part)); if (i < matches.length) { var email = cleanEmail(matches[i]); if (email) frag.appendChild(makeBtn(email)); } }); parent.replaceChild(frag, node); } // -- mailto links ----------------------------------------------------- // FIX v2.5: If the already contains a native button/icon (like HK01), // skip injection to avoid duplicate buttons. We still mark DONE_ATTR on // the so child elements are not scanned again. function injectIntoMailto(a) { if (a.getAttribute(DONE_ATTR)) return; var href = a.getAttribute('href') || ''; var match = href.match(/^mailto:([^?]+)/); if (!match) return; var email = cleanEmail(match[1]); if (!email) return; // Always mark the as done first, so walkNode won't scan its children a.setAttribute(DONE_ATTR, '1'); // If the already has a child button/interactive element, the site // has its own copy UI -- just attach our click handler to the existing // button instead of inserting a new one next to the link. var existingBtn = a.querySelector('button, [role="button"]'); if (existingBtn && !existingBtn.getAttribute(BTN_ATTR)) { // Wrap existing button: hijack clicks to also copy to clipboard existingBtn.addEventListener('click', function (e) { e.stopPropagation(); copyToClipboard(email); }); return; } // No native button -- insert our own after the a.insertAdjacentElement('afterend', makeBtn(email)); } // -- Input / textarea fields ------------------------------------------ function injectIntoInput(el) { if (el.getAttribute(DONE_ATTR)) return; if (el.type === 'hidden') return; var val = el.value || ''; var m = val.match(EMAIL_RE); if (!m) return; var email = cleanEmail(m[0]); if (!email) return; el.setAttribute(DONE_ATTR, '1'); el.insertAdjacentElement('afterend', makeBtn(email)); } // -- Aria attributes -------------------------------------------------- function injectIntoAria(el) { if (el.getAttribute(DONE_ATTR)) return; // Skip elements that are inside a mailto already handled if (el.closest && el.closest('a[' + DONE_ATTR + ']')) return; var label = el.getAttribute('aria-label') || el.getAttribute('aria-description') || ''; var m = label.match(EMAIL_RE); if (!m) return; var email = cleanEmail(m[0]); if (!email) return; el.setAttribute(DONE_ATTR, '1'); el.insertAdjacentElement('afterend', makeBtn(email)); } // -- Walk the DOM ----------------------------------------------------- function walkNode(root) { var walker = document.createTreeWalker( root, NodeFilter.SHOW_TEXT | NodeFilter.SHOW_ELEMENT, { acceptNode: function (node) { if (node.nodeType === Node.ELEMENT_NODE) { if (SKIP_TAGS.has(node.tagName)) return NodeFilter.FILTER_REJECT; if (node.getAttribute(BTN_ATTR)) return NodeFilter.FILTER_REJECT; // FIX v2.5: skip children of already-processed mailto links if (node.tagName === 'A' && node.getAttribute('href') && node.getAttribute('href').indexOf('mailto:') === 0 && node.getAttribute(DONE_ATTR)) return NodeFilter.FILTER_REJECT; } return NodeFilter.FILTER_ACCEPT; } }, false ); var textNodes = []; var node; while ((node = walker.nextNode())) { if (node.nodeType === Node.TEXT_NODE) { textNodes.push(node); } else if (node.nodeType === Node.ELEMENT_NODE) { var tag = node.tagName; if (tag === 'A') injectIntoMailto(node); if (tag === 'INPUT' || tag === 'TEXTAREA') injectIntoInput(node); injectIntoAria(node); } } textNodes.forEach(injectIntoTextNode); } // -- Initial scan ----------------------------------------------------- walkNode(document.body); // -- MutationObserver for SPA/dynamic pages --------------------------- var observer = new MutationObserver(function (mutations) { if (_scanning) return; _scanning = true; try { var addedRoots = new Set(); mutations.forEach(function (m) { m.addedNodes.forEach(function (n) { if (n.nodeType === Node.ELEMENT_NODE || n.nodeType === Node.TEXT_NODE) { var root = n.nodeType === Node.ELEMENT_NODE ? n : n.parentElement; if (root && !root.getAttribute(BTN_ATTR)) addedRoots.add(root); } }); }); addedRoots.forEach(function (root) { if (document.body && document.body.contains(root)) walkNode(root); }); } finally { _scanning = false; } }); observer.observe(document.body, { childList: true, subtree: true }); })();