// ==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 });
})();