// ==UserScript==
// @name 🔗 文本快链
// @namespace https://greasyfork.org/zh-CN/users/1454800
// @version 1.0.2
// @description 智能识别网页中纯文本链接并转为可点击链接
// @author Aiccest
// @match *://*/*
// @grant none
// @license MIT
// ==/UserScript==
(function() {
'use strict';
const linkPrefixes = [
'http://', 'https://', 'ftp://', 'thunder://', 'ed2k://',
'magnet:', 'mailto:', 'tel:', 'sms:'
];
const fileExtensions = [
'.zip', '.rar', '.7z', '.exe', '.pdf', '.doc', '.docx', '.xls', '.xlsx',
'.ppt', '.pptx', '.mp4', '.mp3', '.jpg', '.png', '.gif', '.txt', '.js', '.css'
];
// 只保留中文标点,不加英文符号
const punctuations = ',。!?、;:”“‘’()【】《》…';
const linkRegex = new RegExp(
`(${linkPrefixes.map(p => p.replace(/[:\\/]/g, '\\$&')).join('|')})[^\\s<>"'${punctuations}]*`,
'gi'
);
const markdownRegex = /.*?(https?:\/\/[^\s)]+)/gi;
const ignoredTags = new Set(['A', 'SCRIPT', 'STYLE', 'TEXTAREA', 'INPUT', 'BUTTON']);
function findExtensionEnd(url) {
const lowerUrl = url.toLowerCase();
for (const ext of fileExtensions) {
const idx = lowerUrl.indexOf(ext);
if (idx !== -1) {
return idx + ext.length;
}
}
return -1;
}
function createLinkElement(url) {
const a = document.createElement('a');
a.href = url;
a.textContent = url;
a.style.textDecoration = 'none';
a.target = '_blank';
a.rel = 'noopener noreferrer';
return a;
}
function cleanUrlEnd(url) {
// 去除末尾孤立的英文标点
return url.replace(/[.,!?]+$/, '');
}
function processTextNode(textNode) {
let text = textNode.nodeValue;
// 先处理markdown格式,转成普通链接
text = text.replace(markdownRegex, (full, url) => url);
const frag = document.createDocumentFragment();
let lastIndex = 0;
let match;
linkRegex.lastIndex = 0;
while ((match = linkRegex.exec(text)) !== null) {
const matchStart = match.index;
const rawUrl = match[0];
let realUrl = rawUrl;
let overflowText = '';
const extEnd = findExtensionEnd(rawUrl);
if (extEnd !== -1 && extEnd < rawUrl.length) {
realUrl = rawUrl.slice(0, extEnd);
overflowText = rawUrl.slice(extEnd);
} else {
realUrl = cleanUrlEnd(rawUrl); // 重点:剥掉末尾标点
overflowText = rawUrl.slice(realUrl.length);
}
if (matchStart > lastIndex) {
frag.appendChild(document.createTextNode(text.slice(lastIndex, matchStart)));
}
frag.appendChild(createLinkElement(realUrl));
if (overflowText) {
frag.appendChild(document.createTextNode(overflowText));
}
lastIndex = matchStart + rawUrl.length;
}
if (lastIndex < text.length) {
frag.appendChild(document.createTextNode(text.slice(lastIndex)));
}
if (frag.childNodes.length > 0) {
textNode.parentNode.replaceChild(frag, textNode);
}
}
function walkAndProcess(root) {
const walker = document.createTreeWalker(
root,
NodeFilter.SHOW_TEXT,
{
acceptNode: function(node) {
if (!node.parentNode) return NodeFilter.FILTER_REJECT;
const parentTag = node.parentNode.tagName;
if (ignoredTags.has(parentTag)) return NodeFilter.FILTER_REJECT;
const text = node.nodeValue;
if (!text || (!linkRegex.test(text) && !markdownRegex.test(text))) {
return NodeFilter.FILTER_REJECT;
}
return NodeFilter.FILTER_ACCEPT;
}
},
false
);
const nodes = [];
let node;
while ((node = walker.nextNode())) {
nodes.push(node);
}
for (const n of nodes) {
processTextNode(n);
}
}
function debounce(fn, delay) {
let timer = null;
return function() {
clearTimeout(timer);
timer = setTimeout(fn, delay);
};
}
const observer = new MutationObserver(debounce(() => {
walkAndProcess(document.body);
}, 300)); // 300ms 响应速度
observer.observe(document.body, { childList: true, subtree: true });
walkAndProcess(document.body);
})();