Greasy Fork

Greasy Fork is available in English.

Readeasy (一键复制内容)

一键复制。内置自定义 Markdown 转换逻辑,支持中文字符双倍视觉宽度防截断,精准识别 ASCII 架构图。优化依赖加载速度。

您需要先安装一款用户脚本管理器扩展,例如 Tampermonkey 篡改猴Greasemonkey 油猴子Violentmonkey 暴力猴,才能安装此脚本。

您需要先安装一款用户脚本管理器扩展,例如 Tampermonkey 篡改猴,才能安装此脚本。

您需要先安装一款用户脚本管理器扩展,例如 Tampermonkey 篡改猴Violentmonkey 暴力猴,才能安装此脚本。

您需要先安装一款用户脚本管理器扩展,例如 Tampermonkey 篡改猴Userscripts ,才能安装此脚本。

您需要先安装一款用户脚本管理器扩展,例如 Tampermonkey 篡改猴,才能安装此脚本。

您需要先安装一款用户脚本管理器扩展后才能安装此脚本。

(我已经安装了用户脚本管理器,让我安装!)

您需要先安装一款用户样式管理器扩展,比如 Stylus,才能安装此样式。

您需要先安装一款用户样式管理器扩展,比如 Stylus,才能安装此样式。

您需要先安装一款用户样式管理器扩展,比如 Stylus,才能安装此样式。

您需要先安装一款用户样式管理器扩展后才能安装此样式。

您需要先安装一款用户样式管理器扩展后才能安装此样式。

您需要先安装一款用户样式管理器扩展后才能安装此样式。

(我已经安装了用户样式管理器,让我安装!)

// ==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, '&amp;').replace(/</g, '&lt;').replace(/>/g, '&gt;');

        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![架构图](data:image/svg+xml;base64,${base64})\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 += `![${child.getAttribute('alt') || ''}](${src})`;
                            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);
        }
    }
})();