Greasy Fork is available in English.
在任意网页自动识别邮箱地址并添加一键复制按钮,支持纯文本邮箱和 mailto 链接,兼容 React/Next.js 动态渲染页面及 AI 聊天界面(如 Gumloop)。
// ==UserScript==
// @name Email Copy Button for All Sites
// @name:zh-CN 全站邮箱一键复制
// @namespace http://tampermonkey.net/
// @version 1.7
// @description Automatically detects email addresses on any webpage and adds a one-click copy button. Supports plain text emails and mailto links, including React/Next.js dynamically rendered pages and AI chat interfaces (e.g. Gumloop).
// @description:zh-CN 在任意网页自动识别邮箱地址并添加一键复制按钮,支持纯文本邮箱和 mailto 链接,兼容 React/Next.js 动态渲染页面及 AI 聊天界面(如 Gumloop)。
// @author Nosy Swab
// @match *://*/*
// @exclude https://apps.sfc.hk/*
// @run-at document-end
// @grant none
// @license MIT
// @icon data:image/svg+xml;base64,PHN2ZyB4bWxucz0iaHR0cDovL3d3dy53My5vcmcvMjAwMC9zdmciIHZpZXdCb3g9IjAgMCA2NCA2NCIgd2lkdGg9IjY0IiBoZWlnaHQ9IjY0Ij48cmVjdCB3aWR0aD0iNjQiIGhlaWdodD0iNjQiIHJ4PSIxMiIgZmlsbD0iIzI1NjNFQiIvPjxyZWN0IHg9IjEwIiB5PSIxOCIgd2lkdGg9IjM0IiBoZWlnaHQ9IjI0IiByeD0iMyIgZmlsbD0ibm9uZSIgc3Ryb2tlPSJ3aGl0ZSIgc3Ryb2tlLXdpZHRoPSIyLjUiLz48cG9seWxpbmUgcG9pbnRzPSIxMCwxOCAyNywzMiA0NCwxOCIgZmlsbD0ibm9uZSIgc3Ryb2tlPSJ3aGl0ZSIgc3Ryb2tlLXdpZHRoPSIyLjUiLz48cmVjdCB4PSIzNiIgeT0iMzYiIHdpZHRoPSIxNiIgaGVpZ2h0PSIxOCIgcng9IjMiIGZpbGw9IiMxRDREQjgiIHN0cm9rZT0id2hpdGUiIHN0cm9rZS13aWR0aD0iMiIvPjxyZWN0IHg9IjMxIiB5PSIzMSIgd2lkdGg9IjE2IiBoZWlnaHQ9IjE4IiByeD0iMyIgZmlsbD0iIzM3ODRGOCIgc3Ryb2tlPSJ3aGl0ZSIgc3Ryb2tlLXdpZHRoPSIyIi8+PC9zdmc+
// ==/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;
// v1.7: Removed CODE and PRE from skip list to support AI chat output (e.g. Gumloop)
var SKIP_TAGS = new Set([
'SCRIPT', 'STYLE', 'NOSCRIPT', 'TEXTAREA', 'INPUT',
'HEAD', 'IFRAME', 'BUTTON'
]);
var pending = [];
var scheduled = false;
var MAX_WALK_PER_CALL = 2000;
var MAX_PROCESS_PER_BATCH = 50;
function makeBtn(email) {
var btn = document.createElement('button');
btn.textContent = '\uD83D\uDCCB';
btn.title = email;
btn.setAttribute('data-copy-btn', '1');
btn.style.cssText =
'display:inline-block;margin-left:5px;padding:0 5px;' +
'background:#00695c;color:#fff;border:none;border-radius:3px;' +
'cursor:pointer;font-size:11px;height:17px;line-height:17px;' +
'vertical-align:middle;font-family:sans-serif';
btn.addEventListener('click', function (e) {
e.stopPropagation();
e.preventDefault();
function showSuccess() {
btn.textContent = '\u2705';
setTimeout(function () { btn.textContent = '\uD83D\uDCCB'; }, 2000);
}
if (!navigator.clipboard || !navigator.clipboard.writeText) {
var tmp = document.createElement('textarea');
tmp.value = email;
tmp.style.cssText = 'position:fixed;top:0;left:-9999px;opacity:0';
document.body.appendChild(tmp);
tmp.select();
try { document.execCommand('copy'); } catch (err) {}
document.body.removeChild(tmp);
showSuccess();
return;
}
navigator.clipboard.writeText(email).then(showSuccess).catch(function () {
var tmp = document.createElement('textarea');
tmp.value = email;
tmp.style.cssText = 'position:fixed;top:0;left:-9999px;opacity:0';
document.body.appendChild(tmp);
tmp.select();
try { document.execCommand('copy'); } catch (err) {}
document.body.removeChild(tmp);
showSuccess();
});
});
return btn;
}
function isInDocument(node) {
try {
return document.contains ? document.contains(node) : document.body.contains(node);
} catch (e) {
return false;
}
}
function processMailtoLink(a) {
if (!a || !a.parentNode) return;
if (!isInDocument(a)) return;
if (a.hasAttribute('data-email-tagged')) return;
var href = a.getAttribute('href') || '';
if (href.toLowerCase().indexOf('mailto:') !== 0) return;
var email = href.replace(/^mailto:/i, '').split('?')[0].trim();
if (!email) email = (a.textContent || '').trim();
if (!email || !EMAIL_RE.test(email)) return;
var next = a.nextSibling;
while (next && next.nodeType === Node.TEXT_NODE && next.textContent.trim() === '') {
next = next.nextSibling;
}
if (next && next.nodeType === Node.ELEMENT_NODE &&
next.getAttribute && next.getAttribute('data-copy-btn')) return;
a.setAttribute('data-email-tagged', '1');
try {
a.parentNode.insertBefore(makeBtn(email), a.nextSibling);
} catch (e) {}
}
function processTextNode(node) {
if (!node || !node.parentNode) return;
if (!isInDocument(node)) return;
var text = node.textContent;
if (!text || text.indexOf('@') === -1) return;
var parent = node.parentNode;
if (SKIP_TAGS.has(parent.nodeName)) return;
if (parent.nodeName === 'A') return;
if (parent.hasAttribute &&
(parent.hasAttribute('data-email-tagged') ||
parent.hasAttribute('data-copy-btn'))) return;
EMAIL_RE_G.lastIndex = 0;
var matches = [];
var m;
while ((m = EMAIL_RE_G.exec(text)) !== null) {
matches.push({ email: m[0], index: m.index, length: m[0].length });
}
if (!matches.length) return;
var frag = document.createDocumentFragment();
var last = 0;
for (var i = 0; i < matches.length; i++) {
var match = matches[i];
if (match.index > last) {
frag.appendChild(document.createTextNode(text.slice(last, match.index)));
}
var span = document.createElement('span');
span.textContent = match.email;
span.setAttribute('data-email-tagged', '1');
frag.appendChild(span);
frag.appendChild(makeBtn(match.email));
last = match.index + match.length;
}
if (last < text.length) {
frag.appendChild(document.createTextNode(text.slice(last)));
}
try {
parent.replaceChild(frag, node);
} catch (e) {}
}
function queueFromRoot(root) {
if (!root) return;
if (root.nodeType === Node.ELEMENT_NODE) {
try {
var style = window.getComputedStyle(root);
if (style && (style.display === 'none' || style.visibility === 'hidden')) return;
} catch (e) {
return;
}
try {
var allLinks = (root.nodeName === 'A')
? [root]
: root.querySelectorAll('a[href]');
for (var i = 0; i < allLinks.length; i++) {
var h = allLinks[i].getAttribute('href') || '';
if (h.toLowerCase().indexOf('mailto:') === 0) {
pending.push({ type: 'mailto', el: allLinks[i] });
}
}
} catch (e) {}
}
try {
var walker = document.createTreeWalker(
root,
NodeFilter.SHOW_TEXT,
{
acceptNode: function (node) {
var p = node.parentNode;
if (!p) return NodeFilter.FILTER_REJECT;
if (SKIP_TAGS.has(p.nodeName)) return NodeFilter.FILTER_REJECT;
if (p.nodeName === 'A') return NodeFilter.FILTER_REJECT;
if (p.hasAttribute &&
(p.hasAttribute('data-email-tagged') ||
p.hasAttribute('data-copy-btn'))) return NodeFilter.FILTER_REJECT;
if (!node.textContent || node.textContent.indexOf('@') === -1)
return NodeFilter.FILTER_REJECT;
return NodeFilter.FILTER_ACCEPT;
}
}
);
var n;
var limit = MAX_WALK_PER_CALL;
while ((n = walker.nextNode()) && limit-- > 0) {
pending.push({ type: 'text', el: n });
}
} catch (e) {}
if (pending.length) schedule();
}
function flush(deadline) {
scheduled = false;
var count = 0;
while (pending.length > 0 && count < MAX_PROCESS_PER_BATCH) {
if (deadline && typeof deadline.timeRemaining === 'function' && deadline.timeRemaining() < 2) {
schedule();
return;
}
var item = pending.shift();
if (item.type === 'mailto') {
processMailtoLink(item.el);
} else {
processTextNode(item.el);
}
count++;
}
if (pending.length) schedule();
}
function schedule() {
if (scheduled) return;
scheduled = true;
if ('requestIdleCallback' in window) {
window.requestIdleCallback(flush, { timeout: 1500 });
} else {
setTimeout(function () { flush({}); }, 100);
}
}
queueFromRoot(document.body);
// v1.7: Extended retry delays to cover AI streaming output (up to 15s)
[500, 1000, 2000, 3000, 5000, 8000, 12000, 15000].forEach(function (delay) {
setTimeout(function () { queueFromRoot(document.body); }, delay);
});
var obs = new MutationObserver(function (mutations) {
for (var i = 0; i < mutations.length; i++) {
var m = mutations[i];
// v1.7: Also handle characterData mutations for streaming text output
if (m.type === 'characterData') {
var tn = m.target;
if (tn && tn.textContent && tn.textContent.indexOf('@') !== -1) {
pending.push({ type: 'text', el: tn });
}
}
for (var j = 0; j < m.addedNodes.length; j++) {
var n = m.addedNodes[j];
if (n.nodeType === Node.TEXT_NODE) {
if (n.textContent && n.textContent.indexOf('@') !== -1) {
pending.push({ type: 'text', el: n });
}
} else if (n.nodeType === Node.ELEMENT_NODE) {
queueFromRoot(n);
}
}
}
if (pending.length) schedule();
});
try {
// v1.7: Added characterData:true to capture streaming text mutations
obs.observe(document.body, { childList: true, subtree: true, characterData: true });
} catch (e) {}
})();