// ==UserScript== // @name Email Copy Button for All Sites // @name:zh-CN 全站邮箱一键复制 // @namespace http://tampermonkey.net/ // @version 2.7.1 // @description Fixed: restored BTN_ATTR guard (was silently dropped in 2.7's stylesheet refactor, causing runaway button injection via aria-label re-match). Added BUTTON to SKIP_TAGS as a second guard. // @description:zh-CN 修复:补回 BTN_ATTR 守卫属性(2.7 版单样式表重构时被误删,导致按钮通过 aria-label 被重新匹配从而无限追加)。SKIP_TAGS 增加 BUTTON 作为双重保险。 // @author Nosy Swab (optimized, fixed) // @match *://*/* // @exclude https://apps.sfc.hk/* // @noframes // @run-at document-end // @grant none // @license MIT // ==/UserScript== (function () { 'use strict'; // Subdomain-safe guard (covers apps.sfc.hk and *.apps.sfc.hk) if (location.hostname === 'apps.sfc.hk' || location.hostname.endsWith('.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; var BTN_ATTR = 'data-ecb-btn'; var DONE_ATTR = 'data-ecb-done'; // FIX: added 'BUTTON' as a second guard against walking into our own // injected buttons (their aria-label contains the email and would // otherwise re-match and spawn another button — see BTN_ATTR fix below). var SKIP_TAGS = new Set([ 'SCRIPT','STYLE','NOSCRIPT','CODE','PRE','SVG','CANVAS', 'HEAD','TEMPLATE','TEXTAREA','BUTTON' ]); // v2.7: strip leading <( and trailing .,;:'">[] so copies are clean function cleanEmail(raw) { return raw.replace(/^[<\s(]+/, '').replace(/[)\]}>;:'",.\s]+$/, '').trim(); } function isInEditableRegion(node) { var el = node.nodeType === Node.ELEMENT_NODE ? node : node.parentElement; while (el) { if (el.isContentEditable) return true; el = el.parentElement; } return false; } // v2.7: modern async API first, legacy execCommand fallback; never false-success function legacyCopy(text) { 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(); var ok = document.execCommand('copy'); document.body.removeChild(ta); return ok; } catch (e) { return false; } } function copyToClipboard(text) { if (navigator.clipboard && window.isSecureContext) { return navigator.clipboard.writeText(text) .then(function () { return true; }) .catch(function () { return legacyCopy(text); }); } return Promise.resolve(legacyCopy(text)); } // v2.7: single shared stylesheet (injected once) instead of per-button inline styles (function injectStyle() { var s = document.createElement('style'); s.textContent = '.ecb-btn{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;color:inherit;cursor:pointer;vertical-align:middle;transition:background .15s;flex-shrink:0}' + '.ecb-btn:hover{background:#f0f0f0}' + '.ecb-btn.ok{background:#52c41a;border-color:#52c41a;color:#fff}'; (document.head || document.documentElement).appendChild(s); })(); function makeBtn(email) { var btn = document.createElement('button'); btn.type = 'button'; btn.className = 'ecb-btn'; // FIX (critical): this attribute was dropped in 2.7's refactor to // className-based styling. It is NOT a style artifact — it's the guard // that (a) makes TreeWalker's acceptNode FILTER_REJECT this button, and // (b) makes injectIntoMailto/injectIntoTextNode recognize "already ours". // Without it, the button's own aria-label (which contains the email) // gets re-matched by injectIntoAria on the next MutationObserver pass, // spawning a new button every ~200ms — runaway injection. btn.setAttribute(BTN_ATTR, '1'); btn.textContent = '\uD83D\uDCCB'; btn.title = 'Copy ' + email; btn.setAttribute('aria-label', 'Copy ' + email); btn.addEventListener('click', function (e) { e.preventDefault(); e.stopPropagation(); copyToClipboard(email).then(function (ok) { btn.classList.add('ok'); btn.textContent = ok ? '\u2713' : '\u2716'; setTimeout(function () { btn.classList.remove('ok'); btn.textContent = '\uD83D\uDCCB'; }, 1500); }); }); return btn; } // v2.7: process ALL direct text children of the parent at once, so a parent // with emails split across multiple text nodes is fully handled (no skips) 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; if (isInEditableRegion(parent)) return; var targets = []; for (var c = parent.firstChild; c; c = c.nextSibling) { if (c.nodeType === Node.TEXT_NODE && EMAIL_RE.test(c.nodeValue)) targets.push(c); } if (!targets.length) return; parent.setAttribute(DONE_ATTR, '1'); targets.forEach(function (tn) { var parts = tn.nodeValue.split(EMAIL_RE_G); var matches = tn.nodeValue.match(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, tn); }); } function injectIntoMailto(a) { if (a.getAttribute(DONE_ATTR)) return; if (isInEditableRegion(a)) return; var href = a.getAttribute('href') || ''; var match = href.match(/^mailto:([^?]+)/); if (!match) return; var email = cleanEmail(match[1]); if (!email) return; a.setAttribute(DONE_ATTR, '1'); var existingBtn = a.querySelector('button, [role="button"]'); if (existingBtn && !existingBtn.getAttribute(BTN_ATTR)) { existingBtn.addEventListener('click', function (e) { e.stopPropagation(); copyToClipboard(email); }); return; } a.insertAdjacentElement('afterend', makeBtn(email)); } function injectIntoInput(el) { if (el.getAttribute(DONE_ATTR)) return; if (el.type === 'hidden' || el.type === 'password') return; if (isInEditableRegion(el)) 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)); } function injectIntoAria(el) { if (el.getAttribute(DONE_ATTR)) return; if (isInEditableRegion(el)) return; 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)); } function walkNode(root) { if (isInEditableRegion(root)) return; var fields = []; if (root.nodeType === Node.ELEMENT_NODE) { if (root.tagName === 'INPUT' || root.tagName === 'TEXTAREA') fields.push(root); if (root.querySelectorAll) { fields = fields.concat(Array.prototype.slice.call(root.querySelectorAll('input, textarea'))); } } fields.forEach(injectIntoInput); 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; if (node.isContentEditable) return NodeFilter.FILTER_REJECT; 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) { if (node.tagName === 'A') injectIntoMailto(node); injectIntoAria(node); } } textNodes.forEach(injectIntoTextNode); } walkNode(document.body); // v2.7 FIX: trailing debounce + accumulated roots. The earlier build did // `if (_timer) return`, which dropped every mutation batch arriving inside // the 200ms window (only the first batch's addedRoots were ever walked, so // sibling nodes added in later batches could stay unscanned). Here we push // every batch's roots into a persistent Set and reset the timer on each // batch, so we drain ALL roots once, 200ms after the last activity. var _scanning = false, _timer = null; var pendingRoots = new Set(); function drain() { if (_scanning) return; _scanning = true; try { var roots = pendingRoots; pendingRoots = new Set(); roots.forEach(function (root) { if (document.body && document.body.contains(root)) walkNode(root); }); } finally { _scanning = false; } } var observer = new MutationObserver(function (mutations) { 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) && !root.getAttribute(DONE_ATTR)) { pendingRoots.add(root); } } }); }); // reset timer on every batch -> trailing debounce clearTimeout(_timer); _timer = setTimeout(drain, 200); }); observer.observe(document.body, { childList: true, subtree: true }); })();