// ==UserScript==
// @name 🔗 文本快链
// @description 自动识别文本中的网址和邮箱并转换为可点击链接
// @version 1.0.0
// @author aiccest
// @namespace Aiccest
// @license AGPL
// @include *
// @exclude *pan.baidu.com/*
// @exclude *renren.com/*
// @exclude *exhentai.org/*
// @exclude *music.google.com/*
// @exclude *play.google.com/music/*
// @exclude *mail.google.com/*
// @exclude *docs.google.com/*
// @exclude *www.google.*
// @exclude *acid3.acidtests.org/*
// @exclude *.163.com/*
// @exclude *.alipay.com/*
// @grant unsafeWindow
// @run-at document-end
// @require https://cdn.jsdelivr.net/npm/[email protected]/dist/purify.min.js
// ==/UserScript==
"use strict";
class TextLinkConverter {
static CONFIG = {
ALLOWED_PROTOCOLS: new Set([
'http:', 'https:', 'ftp:', 'mailto:',
'magnet:', 'ed2k:', 'thunder:',
'irc:', 'git:', 'ssh:', 'tel:', 'sms:'
]),
EXCLUDED_TAGS: new Set([
'a', 'svg', 'canvas', 'applet', 'input', 'button', 'area', 'pre',
'embed', 'frame', 'frameset', 'head', 'iframe', 'img', 'option',
'map', 'meta', 'noscript', 'object', 'script', 'style', 'textarea', 'code'
]),
URL_REGEX_PARTS: {
PROTOCOLS: '(?:https?|ftp|file|chrome|edge|magnet|irc|ssh|git|svn)',
DOMAIN: '(?:[\\w-]+\\.)+[\\w-]+',
PORT: '(?::\\d+)?',
PATH: '(?:/[\\x21-\\x7e]*[\\w/=#-])?',
EMAIL: '\\b[\\w.-]+@[\\w.-]+\\.(?:[a-z]{2,}|xn--[a-z0-9]+)\\b',
SPECIAL_LINKS: '(?:ed2k|thunder|flashget|qqdl):\\/\\/[\\x21-\\x7e]+'
},
BATCH_SIZE: 100,
IDLE_TIMEOUT: 1000
};
constructor() {
if (window !== window.top || document.title === "") return;
this.urlRegex = new RegExp([
`(?:(?:(?:${TextLinkConverter.CONFIG.URL_REGEX_PARTS.PROTOCOLS}:\\/\\/|www\\.)`,
`${TextLinkConverter.CONFIG.URL_REGEX_PARTS.DOMAIN}`,
`${TextLinkConverter.CONFIG.URL_REGEX_PARTS.PORT}`,
`${TextLinkConverter.CONFIG.URL_REGEX_PARTS.PATH})`,
`|${TextLinkConverter.CONFIG.URL_REGEX_PARTS.EMAIL}`,
`|${TextLinkConverter.CONFIG.URL_REGEX_PARTS.SPECIAL_LINKS})`
].join(''), 'gi');
this.init();
}
init() {
this.setupMutationObserver();
this.addEventListeners();
this.processDocument();
}
setupMutationObserver() {
this.observer = new MutationObserver(mutations => {
mutations.forEach(mutation => {
if (mutation.type === "childList") {
mutation.addedNodes.forEach(node => this.processNode(node));
}
});
});
this.observer.observe(document.body, {
childList: true,
subtree: true,
attributes: false,
characterData: false
});
}
addEventListeners() {
document.addEventListener("mouseover", this.clearLink.bind(this));
}
processDocument() {
requestIdleCallback(() => {
const nodes = [];
const walker = document.createTreeWalker(
document.body,
NodeFilter.SHOW_TEXT,
{ acceptNode: node => this.isExcluded(node.parentNode)
? NodeFilter.FILTER_REJECT
: NodeFilter.FILTER_ACCEPT },
false
);
while (walker.nextNode()) nodes.push(walker.currentNode);
this.processInBatches(nodes);
}, { timeout: TextLinkConverter.CONFIG.IDLE_TIMEOUT });
}
processNode(node) {
if (node.nodeType === Node.ELEMENT_NODE) {
const nodes = [];
const walker = document.createTreeWalker(
node,
NodeFilter.SHOW_TEXT,
{ acceptNode: node => this.isExcluded(node.parentNode)
? NodeFilter.FILTER_REJECT
: NodeFilter.FILTER_ACCEPT },
false
);
while (walker.nextNode()) nodes.push(walker.currentNode);
this.processInBatches(nodes);
}
else if (node.nodeType === Node.TEXT_NODE && !this.isExcluded(node.parentNode)) {
this.safeConvertTextNode(node);
}
}
processInBatches(nodes, batchSize = TextLinkConverter.CONFIG.BATCH_SIZE) {
let index = 0;
const processBatch = deadline => {
while (index < nodes.length && (deadline.timeRemaining() > 0 || deadline.didTimeout)) {
const node = nodes[index++];
if (node.nodeType === Node.TEXT_NODE && node.textContent.trim()) {
this.safeConvertTextNode(node);
}
if (index % batchSize === 0) break;
}
if (index < nodes.length) {
requestIdleCallback(processBatch, { timeout: TextLinkConverter.CONFIG.IDLE_TIMEOUT });
}
};
requestIdleCallback(processBatch, { timeout: TextLinkConverter.CONFIG.IDLE_TIMEOUT });
}
isExcluded(node) {
return TextLinkConverter.CONFIG.EXCLUDED_TAGS.has(node.localName.toLowerCase());
}
safeConvertTextNode(node) {
if (!node.textContent.trim() || this.isExcluded(node.parentNode)) return;
const text = node.textContent;
const html = text.replace(this.urlRegex, match => {
if (!this.isValidUrl(match)) return match;
const url = this.normalizeUrl(match);
return `<a href="${DOMPurify.sanitize(url)}" target="_blank" rel="noopener noreferrer" class="textToLink">${match}</a>`;
});
if (html !== text) this.safeInsertHTML(node, html);
}
isValidUrl(url) {
try {
if (url.includes('@')) return this.isValidEmail(url);
const normalizedUrl = url.includes('://') ? url : `http://${url}`;
const parsedUrl = new URL(normalizedUrl);
return TextLinkConverter.CONFIG.ALLOWED_PROTOCOLS.has(parsedUrl.protocol.toLowerCase()) &&
this.isValidDomain(parsedUrl.hostname) &&
this.hasValidPath(parsedUrl);
} catch {
return false;
}
}
isValidEmail(email) {
return /^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(email);
}
isValidDomain(hostname) {
return /^(?:[0-9]{1,3}\.){3}[0-9]{1,3}$/.test(hostname) ||
/^(?:[a-z0-9](?:[a-z0-9-]*[a-z0-9])?\.)+[a-z]{2,}$/i.test(hostname);
}
hasValidPath(url) {
return !/[<>"']/.test(url.pathname + url.search + url.hash);
}
normalizeUrl(url) {
if (url.includes('@')) return `mailto:${url}`;
if (url.startsWith('www.')) return `http://${url}`;
if (!url.includes('://')) return `http://${url}`;
return url;
}
safeInsertHTML(element, html) {
const template = document.createElement('template');
template.innerHTML = DOMPurify.sanitize(html, {
ALLOWED_TAGS: ['a'],
ALLOWED_ATTR: ['href', 'target', 'rel', 'class']
});
const parent = element.parentNode;
if (parent) parent.replaceChild(template.content, element);
}
clearLink(event) {
const link = event.target.closest('a.textToLink');
if (!link) return;
const url = link.getAttribute('href');
if (!url) return;
if (TextLinkConverter.CONFIG.ALLOWED_PROTOCOLS.has(url.split(':')[0] + ':')) return;
link.setAttribute('href', url.includes('@') ? `mailto:${url}` : `http://${url}`);
}
}
// 初始化
if (document.readyState === 'loading') {
document.addEventListener('DOMContentLoaded', () => new TextLinkConverter());
} else {
new TextLinkConverter();
}