Greasy Fork is available in English.
一键复制。内置自定义 Markdown 转换逻辑,支持中文字符双倍视觉宽度防截断,精准识别 ASCII 架构图。优化依赖加载速度。
// ==UserScript== // @name Readeasy (一键复制内容) // @namespace http://tampermonkey.net/ // @version 2.4 // @description 一键复制。内置自定义 Markdown 转换逻辑,支持中文字符双倍视觉宽度防截断,精准识别 ASCII 架构图。优化依赖加载速度。 // @author _Sure.Lee // @match *://*/* // @grant GM_setClipboard // @grant GM_addStyle // @run-at document-end // @require https://fastly.jsdelivr.net/npm/@mozilla/[email protected]/Readability.min.js // ==/UserScript== (function() { 'use strict'; // 增加一层安全校验:某些特殊的纯文本或 XML 页面可能没有 body,防止报错阻塞执行 if (!document || !document.body) { console.warn('Readeasy: 找不到 document.body,脚本退出。'); return; } // 严谨的制表符边框正则,用于 SVG 内精确高亮 const strictBoxCharsRegex = /[┌┐└┘├┤┬┴┼│─═║╔╗╚╝╠╣╦╩╬]/g; // 精准检测:只有真正意义上的图(而不是普通日志或带箭头的列表)才转 SVG function isASCIIArt(text) { const lines = text.split('\n').filter(l => l.trim()); if (lines.length < 3) return false; let hasStrictBox = 0, hasAsciiBox = 0; for (const line of lines) { // 匹配真正的制表符边框 if (/[┌┐└┘├┤┬┴┼│─═║╔╗╚╝╠╣╦╩╬]/.test(line)) hasStrictBox++; // 匹配典型的组合图形特征:如 +---+ 或 | 内容 | if (/(\+[-=]{2,}\+)|(^|\s)(\|.*?\|)(\s|$)/.test(line)) hasAsciiBox++; } // 至少 2 行包含严格制表符,或者 3 行以上包含边框结构 return hasStrictBox >= 2 || (hasAsciiBox >= 3 && lines.length >= 4); } // 将 ASCII 艺术图转换为美化的、自适应的 SVG,修复中文宽度截断问题 function asciiArtToSVG(text) { const lines = text.split('\n'); while (lines.length > 0 && !lines[lines.length - 1].trim()) lines.pop(); // 核心修复:计算视觉宽度,中文字符算 2 倍,英文字符算 1 倍 function getVisualLength(str) { let len = 0; for (let i = 0; i < str.length; i++) { len += str.charCodeAt(i) > 255 ? 2 : 1.05; // 英文略加余量防止边缘太紧 } return len; } const charWidth = 8.5, charHeight = 18, padding = 20; const maxVisualLength = Math.max(...lines.map(l => getVisualLength(l))); // 动态计算尺寸,彻底杜绝右侧截断 const width = maxVisualLength * charWidth + padding * 2; const height = lines.length * charHeight + padding * 2; const escapeHTML = (str) => str.replace(/&/g, '&').replace(/</g, '<').replace(/>/g, '>'); const theme = { bg: '#1e1e1e', fg: '#d4d4d4', line: '#569cd6', radius: 6 }; let svg = `<svg xmlns="http://www.w3.org/2000/svg" width="${width}" height="${height}" viewBox="0 0 ${width} ${height}">\n`; // 增加了中文字体备选项,保证对齐和渲染效果 svg += `<style> .ascii-bg { fill: ${theme.bg}; rx: ${theme.radius}px; } .ascii-text { font-family: 'Consolas', 'Monaco', 'Courier New', 'PingFang SC', 'Microsoft YaHei', monospace; font-size: 14px; fill: ${theme.fg}; } .border-char { fill: ${theme.line}; font-weight: bold; } </style>\n`; svg += ` <rect class="ascii-bg" width="100%" height="100%"/>\n`; lines.forEach((line, index) => { if (line.trim() || strictBoxCharsRegex.test(line)) { let escaped = escapeHTML(line); escaped = escaped.replace(strictBoxCharsRegex, match => `<tspan class="border-char">${match}</tspan>`); const x = padding; const y = padding + index * charHeight + (charHeight * 0.8); svg += ` <text x="${x}" y="${y}" class="ascii-text" xml:space="preserve">${escaped}</text>\n`; } }); return svg + `</svg>`; } // --- Markdown 转换核心 --- function htmlToMarkdown(html) { const doc = new DOMParser().parseFromString(html, 'text/html'); let lastTag = null; function parseTable(table) { const rows = [], headers = []; let hasHeader = false; const thead = table.querySelector('thead'); if (thead) { thead.querySelectorAll('tr').forEach(tr => { const row = Array.from(tr.querySelectorAll('th, td')).map(cell => traverse(cell).trim().replace(/\|/g, '\\|').replace(/\n/g, ' ')); if (row.length > 0) { headers.push(row); hasHeader = true; } }); } const bodyRows = (table.querySelector('tbody') || table).querySelectorAll('tr'); bodyRows.forEach(tr => { if (thead && tr.closest('thead')) return; const tds = tr.querySelectorAll('td, th'); if (tds.length === 0) return; const row = Array.from(tds).map(cell => traverse(cell).trim().replace(/\|/g, '\\|').replace(/\n/g, ' ')); if (!hasHeader && rows.length === 0) { headers.push(row); hasHeader = true; } else { rows.push(row); } }); const colCount = Math.max(headers[0]?.length || 0, rows[0]?.length || 0); if (colCount === 0) return ''; let md = headers.map(h => '| ' + h.join(' | ') + ' |\n').join(''); md += '|' + Array(colCount).fill(' --- ').join('|') + '|\n'; md += rows.map(r => { while (r.length < colCount) r.push(''); return '| ' + r.join(' | ') + ' |\n'; }).join(''); return md.trim(); } return traverse(doc.body).replace(/\n{4,}/g, '\n\n\n').trim(); function traverse(node) { let md = ''; node.childNodes.forEach(child => { if (child.nodeType === Node.TEXT_NODE) { if (/\S/.test(child.nodeValue)) md += child.nodeValue.replace(/\s+/g, ' '); } else if (child.nodeType === Node.ELEMENT_NODE) { const tag = child.tagName.toLowerCase(); const content = traverse(child); const isQuoteLike = /quote/.test(child.getAttribute('class') || ''); const isFullQuote = /^“[^”]{2,}”$/.test(content); switch (tag) { case 'h1': case 'h2': case 'h3': case 'h4': case 'h5': case 'h6': md += `\n\n${'#'.repeat(parseInt(tag[1]))} ${content}\n\n`; break; case 'figure': md += '\n' + content + '\n\n'; lastTag = 'figure'; break; case 'figcaption': md += '\n> ' + content.replace(/\n/g, '\n> ') + '\n\n'; lastTag = 'figcaption'; break; case 'p': const tContent = content.trim(); if (lastTag === 'figcaption' && isFullQuote) { md += `\n\n${(child.parentElement?.tagName === 'FIGURE' ? '#' : '##')} ${tContent}\n\n`; } else if (isQuoteLike || isFullQuote) { md += '\n> ' + tContent.replace(/\n/g, '\n> ') + '\n\n'; } else { md += '\n\n' + tContent + '\n\n'; } lastTag = 'p'; break; case 'br': md += '\n'; break; case 'strong': case 'b': md += '**' + content + '**'; break; case 'em': case 'i': md += '*' + content + '*'; break; case 'code': if (child.parentElement?.tagName.toLowerCase() !== 'pre' || !isASCIIArt(child.innerText || child.textContent)) { md += '`' + content + '`'; } break; case 'pre': let preContent = (function extractText(n) { let text = ''; if (n.nodeType === Node.TEXT_NODE) text += n.textContent; else if (n.nodeType === Node.ELEMENT_NODE) { if (n.tagName.toLowerCase() === 'br') text += '\n'; else n.childNodes.forEach(c => text += extractText(c)); } return text; })(child).replace(/\r\n?/g, '\n'); if (isASCIIArt(preContent)) { try { const svg = asciiArtToSVG(preContent); const base64 = btoa(unescape(encodeURIComponent(svg))); md += `\n\n\n\n`; } catch (e) { console.error('Readeasy: SVG 生成失败', e); md += '\n```text\n' + preContent + '\n```\n\n'; } } else { // 现在普通带箭头的日志会平稳回退到纯代码块,不会误转 md += '\n```\n' + preContent.trim() + '\n```\n\n'; } break; case 'a': md += `[${content}](${child.getAttribute('href') || ''})`; break; case 'img': const src = child.getAttribute('data-src') || child.getAttribute('data-original') || (child.getAttribute('data-srcset')?.split(',')[0]?.split(' ')[0]) || child.getAttribute('src'); const w = parseInt(child.getAttribute('width') || '0'), h = parseInt(child.getAttribute('height') || '0'); if (src && (w > 1 || h > 1 || (!w && !h))) md += ``; break; case 'ul': md += '\n\n' + Array.from(child.children).map(li => '* ' + traverse(li).trim()).join('\n') + '\n\n'; break; case 'ol': md += '\n\n' + Array.from(child.children).map((li, i) => (i + 1) + '. ' + traverse(li).trim()).join('\n') + '\n\n'; break; case 'li': case 'tr': const c = traverse(child).trim(); if (c) md += c + '\n'; break; case 'blockquote': md += '\n\n> ' + content.trim().replace(/\n/g, '\n> ') + '\n\n'; break; case 'table': md += '\n\n' + parseTable(child) + '\n\n'; break; case 'thead': case 'tbody': md += traverse(child); break; case 'th': case 'td': md += '| ' + traverse(child).trim().replace(/\|/g, '\\|').replace(/\n/g, ' ') + ' '; break; default: md += isQuoteLike ? '\n> ' + content.replace(/\n/g, '\n> ') + '\n\n' : content; } } }); return md; } } // --- CSS --- GM_addStyle(` #copy-article-btn { position: fixed; top: 40%; right: 12px; z-index: 2147483647; width: 28px; height: 28px; border-radius: 50%; background: rgba(15, 23, 42, 0.4); border: 1px solid rgba(255, 255, 255, 0.1); backdrop-filter: blur(4px); box-shadow: 0 4px 12px rgba(0,0,0,0.1); display: flex; align-items: center; justify-content: center; cursor: pointer; color: rgba(255, 255, 255, 0.6); opacity: 0.3; transform: scale(0.9); transition: all 0.3s cubic-bezier(0.34, 1.56, 0.64, 1); user-select: none; overflow: hidden; font-family: sans-serif; -webkit-font-smoothing: antialiased; } #copy-article-btn:hover { width: 42px; height: 42px; opacity: 1; transform: scale(1.1); right: 24px; background: linear-gradient(135deg, rgba(99, 102, 241, 0.9), rgba(34, 211, 238, 0.9)); box-shadow: 0 8px 24px rgba(99, 102, 241, 0.4), 0 0 0 1px rgba(255,255,255,0.4) inset; color: #fff; border-color: transparent; } .copy-btn-icon { font-size: 14px; transition: transform 0.4s ease; } #copy-article-btn:hover .copy-btn-icon { font-size: 20px; transform: rotate(180deg); } #copy-format-menu { position: fixed; z-index: 2147483647; background: rgba(255, 255, 255, 0.9); backdrop-filter: blur(12px); box-shadow: 0 10px 30px rgba(0,0,0,0.15), 0 0 0 1px rgba(0,0,0,0.05); border-radius: 12px; padding: 5px; display: none; opacity: 0; transform: translateY(10px) scale(0.95); transition: all 0.2s ease; font-family: system-ui, -apple-system, sans-serif; min-width: 100px; } #copy-format-menu.show { opacity: 1; transform: translateY(0) scale(1); } #copy-format-menu button { display: block; width: 100%; padding: 8px 12px; background: transparent; border: none; border-radius: 8px; text-align: left; cursor: pointer; font-size: 13px; font-weight: 500; color: #333; transition: background 0.2s; margin-bottom: 2px; } #copy-format-menu button:hover { background: rgba(99, 102, 241, 0.1); color: #4f46e5; } @media (prefers-color-scheme: dark) { #copy-format-menu { background: rgba(30, 41, 59, 0.95); box-shadow: 0 10px 30px rgba(0,0,0,0.4), 0 0 0 1px rgba(255,255,255,0.1); } #copy-format-menu button { color: #cbd5e1; } #copy-format-menu button:hover { background: rgba(255,255,255,0.1); color: #fff; } } `); // --- UI Logic --- if (document.getElementById('copy-article-btn')) return; const btn = document.createElement('button'); btn.id = 'copy-article-btn'; btn.title = '复制文章内容'; btn.innerHTML = '<span class="copy-btn-icon">⚡</span>'; document.body.appendChild(btn); let isDragging = false, offsetX = 0, offsetY = 0, hasMoved = false; btn.addEventListener('mousedown', e => { isDragging = true; hasMoved = false; offsetX = e.clientX - btn.getBoundingClientRect().left; offsetY = e.clientY - btn.getBoundingClientRect().top; btn.style.transition = 'none'; }); document.addEventListener('mousemove', e => { if (isDragging) { hasMoved = true; btn.style.left = (e.clientX - offsetX) + 'px'; btn.style.top = (e.clientY - offsetY) + 'px'; btn.style.right = 'auto'; } }); document.addEventListener('mouseup', () => { if (isDragging) { isDragging = false; btn.style.transition = 'all 0.3s cubic-bezier(0.34, 1.56, 0.64, 1)'; } }); const menu = document.createElement('div'); menu.id = 'copy-format-menu'; menu.innerHTML = `<button data-format="markdown">Markdown</button><button data-format="text">Pure Text</button><button data-format="html">HTML Code</button>`; document.body.appendChild(menu); function hideMenu() { menu.classList.remove('show'); setTimeout(() => { if (!menu.classList.contains('show')) menu.style.display = 'none'; }, 200); } btn.addEventListener('click', e => { if (hasMoved) return; e.stopPropagation(); if (menu.style.display === 'block') hideMenu(); else { const rect = btn.getBoundingClientRect(); menu.style.display = 'block'; menu.style.left = Math.max(10, rect.left - 110) + 'px'; menu.style.top = (rect.bottom + 10) + 'px'; requestAnimationFrame(() => menu.classList.add('show')); } }); document.addEventListener('click', e => { if (!menu.contains(e.target) && e.target !== btn) hideMenu(); }); menu.addEventListener('click', e => { const format = e.target.getAttribute('data-format'); if (format) { e.stopPropagation(); hideMenu(); copyArticle(format); } }); // --- Core Action --- function cleanHtml(html) { const div = document.createElement('div'); div.innerHTML = html; div.querySelectorAll('script, style, link, meta, iframe, button, input, form, nav, footer, [role="complementary"]').forEach(el => el.remove()); div.querySelectorAll('div:empty, span:empty, p:empty').forEach(el => el.remove()); return div.innerHTML; } async function copyArticle(format) { const originalIcon = btn.innerHTML; btn.innerHTML = '<span class="copy-btn-icon">⏳</span>'; let title = document.title, htmlContent = '', textContent = '', markdownContent = ''; try { if (typeof Readability === 'undefined') throw new Error('Readability library not loaded'); const article = new Readability(document.cloneNode(true)).parse(); if (article) { title = article.title; htmlContent = cleanHtml(article.content); textContent = `${title}\n\n${article.textContent.replace(/\n{3,}/g, '\n\n')}`; } else { htmlContent = cleanHtml(document.body.innerHTML); textContent = `${title}\n\n${document.body.innerText.replace(/\n{3,}/g, '\n\n')}`; } markdownContent = `# ${title}\n\n` + htmlToMarkdown(htmlContent); const finalData = format === 'markdown' ? markdownContent : (format === 'html' ? `<h1>${title}</h1>${htmlContent}` : textContent); const mimeType = format === 'html' ? 'text/html' : 'text/plain'; try { if (navigator.clipboard && navigator.clipboard.writeText) await navigator.clipboard.writeText(finalData); else throw new Error('Clipboard API not available'); } catch (clipboardErr) { if (typeof GM_setClipboard !== 'undefined') GM_setClipboard(finalData, { type: mimeType }); else throw clipboardErr; } btn.innerHTML = '<span class="copy-btn-icon" style="color:#4ade80">✔</span>'; btn.style.transform = 'scale(1.2)'; setTimeout(() => { btn.innerHTML = originalIcon; btn.style.transform = ''; }, 1200); } catch (e) { console.error('Copy failed:', e); btn.innerHTML = '<span class="copy-btn-icon">❌</span>'; setTimeout(() => btn.innerHTML = originalIcon, 1200); alert('复制失败:' + e.message); } } })();