X-to-Markdown Copier

一键将X(Twitter)推文转换为Markdown并复制到剪贴板

Na nainštalovanie skriptu si budete musieť nainštalovať rozšírenie, ako napríklad Tampermonkey, Greasemonkey alebo Violentmonkey.

Na inštaláciu tohto skriptu je potrebné nainštalovať rozšírenie, ako napríklad Tampermonkey.

Na nainštalovanie skriptu si budete musieť nainštalovať rozšírenie, ako napríklad Tampermonkey, % alebo Violentmonkey.

Na nainštalovanie skriptu si budete musieť nainštalovať rozšírenie, ako napríklad Tampermonkey alebo Userscripts.

Na inštaláciu tohto skriptu je potrebné nainštalovať rozšírenie, ako napríklad Tampermonkey.

Na inštaláciu tohto skriptu je potrebné nainštalovať rozšírenie správcu používateľských skriptov.

(Už mám správcu používateľských skriptov, nechajte ma ho nainštalovať!)

Na inštaláciu tohto štýlu je potrebné nainštalovať rozšírenie, ako napríklad Stylus.

Na inštaláciu tohto štýlu je potrebné nainštalovať rozšírenie, ako napríklad Stylus.

Na inštaláciu tohto štýlu je potrebné nainštalovať rozšírenie, ako napríklad Stylus.

Na inštaláciu tohto štýlu je potrebné nainštalovať rozšírenie správcu používateľských štýlov.

Na inštaláciu tohto štýlu je potrebné nainštalovať rozšírenie správcu používateľských štýlov.

Na inštaláciu tohto štýlu je potrebné nainštalovať rozšírenie správcu používateľských štýlov.

(Už mám správcu používateľských štýlov, nechajte ma ho nainštalovať!)

// ==UserScript==
// @name         X-to-Markdown Copier
// @namespace    http://tampermonkey.net/
// @version      2.0.0
// @description  一键将X(Twitter)推文转换为Markdown并复制到剪贴板
// @author       OpenCode
// @match        https://x.com/*
// @match        https://twitter.com/*
// @grant        GM_xmlhttpRequest
// @grant        GM_setClipboard
// @grant        GM_addStyle
// @grant        GM_registerMenuCommand
// @connect      r.jina.ai
// @connect      r.jina.ai.v1-ext.tech
// @icon         https://x.com/favicon.ico
// ==/UserScript==

(function() {
    'use strict';

    // ==================== 配置区 ====================
    const CONFIG = {
        // API 配置
        primaryApi: 'https://r.jina.ai/',
        fallbackApis: [
            'https://r.jina.ai.v1-ext.tech/',
        ],
        
        // 请求配置
        timeout: 15000,
        maxRetries: 2,
        
        // 缓存配置
        cacheTTL: 5000,
        
        // 快捷键
        shortcutKey: 'M',
        shortcutModifiers: ['ctrlKey', 'metaKey'],
        
        // 复制格式: 'markdown' | 'text' | 'pure-markdown'
        defaultFormat: 'markdown',
        
        // 调试模式
        debug: false
    };

    // ==================== DOM 选择器配置 ====================
    const SELECTORS = {
        buttonGroup: [
            '[data-testid="toolBar"]',
            'div[role="group"]',
            'article [role="group"]'
        ],
        bookmarkBtn: [
            '[data-testid="bookmark"]',
            '[data-testid="save"]'
        ],
        tweetText: [
            '[data-testid="tweetText"]',
            '[data-testid="postText"]'
        ],
        article: [
            'article[data-testid="tweet"]',
            'article[role="article"]'
        ],
        images: [
            'article img[src*="pbs.twimg.com"]',
            'article picture img'
        ],
        video: [
            'article video',
            'article [data-testid="videoPlayer"]'
        ]
    };

    // ==================== 常量 ====================
    const BUTTON_ID = 'x-to-markdown-btn';
    
    // ==================== 状态 ====================
    let currentTweetUrl = '';
    let isRequestInProgress = false;
    let domCache = {
        buttonGroup: null,
        bookmarkBtn: null,
        timestamp: 0
    };
    let currentFormat = CONFIG.defaultFormat;

    // ==================== 样式 ====================
    const styles = `
        .xmd-btn {
            display: inline-flex;
            align-items: center;
            justify-content: center;
            background-color: transparent;
            border: none;
            cursor: pointer;
            transition: background-color 0.2s;
        }
        .xmd-btn:hover {
            background-color: rgba(29, 155, 240, 0.1);
        }
        .xmd-btn:disabled {
            opacity: 0.5;
            cursor: not-allowed;
        }
        .xmd-btn svg {
            width: 20px;
            height: 20px;
            fill: #1d9bf0;
            transition: fill 0.2s;
        }
        .xmd-btn:hover svg {
            fill: #1a8cd8;
        }
        .xmd-btn.success svg {
            fill: #00ba7c;
        }
        .xmd-btn.error svg {
            fill: #f4212e;
        }
        @media (prefers-color-scheme: dark) {
            .xmd-btn svg {
                fill: #1d9bf0;
            }
            .xmd-btn:hover svg {
                fill: #1a8cd8;
            }
        }
        .xmd-toast {
            position: fixed;
            top: 20px;
            left: 50%;
            transform: translateX(-50%);
            background-color: #333;
            color: white;
            padding: 12px 20px;
            border-radius: 8px;
            font-size: 14px;
            z-index: 99999;
            animation: xmd-fadein 0.3s ease;
        }
        .xmd-toast.success {
            background-color: #00ba7c;
        }
        .xmd-toast.error {
            background-color: #f4212e;
        }
        @keyframes xmd-fadein {
            from { opacity: 0; transform: translateX(-50%) translateY(-10px); }
            to { opacity: 1; transform: translateX(-50%) translateY(0); }
        }
        .xmd-menu {
            position: absolute;
            background: #fff;
            border: 1px solid #cfd9de;
            border-radius: 8px;
            box-shadow: 0 4px 12px rgba(0,0,0,0.15);
            z-index: 99999;
            overflow: hidden;
        }
        .xmd-menu-item {
            padding: 10px 16px;
            cursor: pointer;
            font-size: 14px;
            color: #0f1419;
        }
        .xmd-menu-item:hover {
            background: #f7f9f9;
        }
        .xmd-menu-item.active {
            background: #e8f5fd;
            color: #1d9bf0;
        }
        @media (prefers-color-scheme: dark) {
            .xmd-menu {
                background: #15202b;
                border-color: #38444d;
            }
            .xmd-menu-item {
                color: #fff;
            }
            .xmd-menu-item:hover {
                background: #273340;
            }
            .xmd-menu-item.active {
                background: #1d9bf0;
                color: #fff;
            }
        }
    `;

    // ==================== 工具函数 ====================
    function log(...args) {
        if (CONFIG.debug) {
            console.log('[X-to-MD]', ...args);
        }
    }

    function querySelector(selectorList) {
        for (const selector of selectorList) {
            const el = document.querySelector(selector);
            if (el) return el;
        }
        return null;
    }

    function querySelectorAll(selectorList) {
        for (const selector of selectorList) {
            const els = document.querySelectorAll(selector);
            if (els.length > 0) return Array.from(els);
        }
        return [];
    }

    function isTweetDetailPage() {
        const path = window.location.pathname;
        return /^\/[^\/]+\/status\/\d+$/.test(path);
    }

    function getTweetUrl() {
        return window.location.href;
    }

    function showToast(message, type = 'default') {
        const existing = document.querySelector('.xmd-toast');
        if (existing) existing.remove();

        const toast = document.createElement('div');
        toast.className = `xmd-toast ${type}`;
        toast.textContent = message;
        document.body.appendChild(toast);

        setTimeout(() => {
            toast.remove();
        }, 2500);
    }

    function showFormatMenu() {
        const existing = document.querySelector('.xmd-menu');
        if (existing) existing.remove();

        const btn = document.getElementById(BUTTON_ID);
        if (!btn) return;

        const rect = btn.getBoundingClientRect();
        const menu = document.createElement('div');
        menu.className = 'xmd-menu';
        menu.style.top = `${rect.bottom + 8}px`;
        menu.style.left = `${rect.left}px`;

        const formats = [
            { key: 'markdown', label: 'Markdown (含链接)' },
            { key: 'pure-markdown', label: '纯 Markdown' },
            { key: 'text', label: '纯文本' }
        ];

        formats.forEach(fmt => {
            const item = document.createElement('div');
            item.className = `xmd-menu-item ${currentFormat === fmt.key ? 'active' : ''}`;
            item.textContent = fmt.label;
            item.addEventListener('click', (e) => {
                e.stopPropagation();
                currentFormat = fmt.key;
                menu.remove();
                handleCopy(btn);
            });
            menu.appendChild(item);
        });

        document.body.appendChild(menu);

        const closeMenu = (e) => {
            if (!menu.contains(e.target)) {
                menu.remove();
                document.removeEventListener('click', closeMenu);
            }
        };
        setTimeout(() => document.addEventListener('click', closeMenu), 0);
    }

    function copyToClipboard(text) {
        let cleanedText = text;
        
        if (currentFormat === 'text') {
            cleanedText = text.replace(/\[([^\]]+)\]\([^)]+\)/g, '$1').trim();
        } else if (currentFormat === 'pure-markdown') {
            cleanedText = text.replace(/\nSources:[\s\S]*$/i, '').trim();
        } else {
            cleanedText = text.replace(/\nLinks:[\s\S]*$/i, '').replace(/\nSources:[\s\S]*$/i, '').trim();
        }
        
        GM_setClipboard(cleanedText, 'text');
        return cleanedText;
    }

    function extractMediaFromDOM() {
        const images = querySelectorAll(SELECTORS.images);
        const videos = querySelectorAll(SELECTORS.video);
        
        let mediaMarkdown = '';
        
        if (images.length > 0) {
            images.forEach((img, i) => {
                const src = img.src || img.dataset.src;
                if (src) {
                    mediaMarkdown += `\n![图片${i + 1}](${src})`;
                }
            });
        }
        
        if (videos.length > 0) {
            videos.forEach((video, i) => {
                const src = video.src || video.querySelector('source')?.src;
                if (src) {
                    mediaMarkdown += `\n[视频${i + 1}](${src})`;
                }
            });
        }
        
        return mediaMarkdown;
    }

    function buildEnhancedFallback() {
        const text = getTweetTextFromDOM();
        if (!text) return null;
        
        let content = text;
        const mediaMarkdown = extractMediaFromDOM();
        
        if (mediaMarkdown) {
            content += '\n\n---\n' + mediaMarkdown;
        }
        
        const url = getTweetUrl();
        content += `\n\n原文链接: ${url}`;
        
        return content;
    }

    function fetchViaJina(url, onSuccess, onError, retryCount = 0, apiIndex = 0) {
        const apis = [CONFIG.primaryApi, ...CONFIG.fallbackApis];
        
        if (apiIndex >= apis.length) {
            onError('所有 API 均不可用');
            return;
        }
        
        const apiUrl = apis[apiIndex] + url;
        
        log(`请求 API ${apiIndex + 1}: ${apiUrl}`);

        GM_xmlhttpRequest({
            method: 'GET',
            url: apiUrl,
            timeout: CONFIG.timeout,
            onload: function(response) {
                if (response.status === 200 && response.responseText) {
                    onSuccess(response.responseText);
                } else if (retryCount < CONFIG.maxRetries) {
                    setTimeout(() => {
                        fetchViaJina(url, onSuccess, onError, retryCount + 1, apiIndex);
                    }, 1000 * (retryCount + 1));
                } else if (apiIndex < apis.length - 1) {
                    log(`API ${apiIndex + 1} 失败,尝试备用 API`);
                    fetchViaJina(url, onSuccess, onError, 0, apiIndex + 1);
                } else {
                    onError(`请求失败: ${response.status}`);
                }
            },
            onerror: function(error) {
                log(`API ${apiIndex + 1} 网络错误:`, error);
                if (retryCount < CONFIG.maxRetries) {
                    setTimeout(() => {
                        fetchViaJina(url, onSuccess, onError, retryCount + 1, apiIndex);
                    }, 1000 * (retryCount + 1));
                } else if (apiIndex < apis.length - 1) {
                    fetchViaJina(url, onSuccess, onError, 0, apiIndex + 1);
                } else {
                    onError('网络错误,请检查连接');
                }
            },
            ontimeout: function() {
                log(`API ${apiIndex + 1} 请求超时`);
                if (apiIndex < apis.length - 1) {
                    fetchViaJina(url, onSuccess, onError, 0, apiIndex + 1);
                } else if (retryCount < CONFIG.maxRetries) {
                    setTimeout(() => {
                        fetchViaJina(url, onSuccess, onError, retryCount + 1, apiIndex);
                    }, 1000 * (retryCount + 1));
                } else {
                    onError('请求超时');
                }
            }
        });
    }

    function getTweetTextFromDOM() {
        const article = querySelector(SELECTORS.article);
        if (!article) return null;

        const tweetText = querySelector(SELECTORS.tweetText);
        if (!tweetText) return null;

        return tweetText.innerText || tweetText.textContent;
    }

    function handleCopy(btn) {
        if (isRequestInProgress) return;
        isRequestInProgress = true;

        const svg = btn.querySelector('svg');
        const originalFill = svg ? svg.getAttribute('fill') : '#1d9bf0';
        
        btn.disabled = true;
        if (svg) svg.setAttribute('fill', '#1d9bf0');

        const tweetUrl = getTweetUrl();

        fetchViaJina(
            tweetUrl,
            (markdownText) => {
                const copiedText = copyToClipboard(markdownText);
                btn.classList.add('success');
                if (svg) svg.setAttribute('fill', '#00ba7c');
                showToast('Markdown 已复制到剪贴板', 'success');

                setTimeout(() => {
                    btn.classList.remove('success');
                    if (svg) svg.setAttribute('fill', originalFill);
                    btn.disabled = false;
                    isRequestInProgress = false;
                }, 2000);
            },
            (errorMsg) => {
                const fallbackText = buildEnhancedFallback();
                if (fallbackText) {
                    const copiedText = copyToClipboard(fallbackText);
                    btn.classList.add('success');
                    if (svg) svg.setAttribute('fill', '#00ba7c');
                    showToast('已复制(增强降级模式)', 'success');

                    setTimeout(() => {
                        btn.classList.remove('success');
                        if (svg) svg.setAttribute('fill', originalFill);
                        btn.disabled = false;
                        isRequestInProgress = false;
                    }, 2000);
                } else {
                    btn.classList.add('error');
                    if (svg) svg.setAttribute('fill', '#f4212e');
                    showToast(`转换失败: ${errorMsg}`, 'error');

                    setTimeout(() => {
                        btn.classList.remove('error');
                        if (svg) svg.setAttribute('fill', originalFill);
                        btn.disabled = false;
                        isRequestInProgress = false;
                    }, 2000);
                }
            }
        );
    }

    const MARKDOWN_ICON = `<svg viewBox="0 0 24 24"><path d="M19 3H5c-1.1 0-2 .9-2 2v14c0 1.1.9 2 2 2h14c1.1 0 2-.9 2-2V5c0-1.1-.9-2-2-2zm-7 14H6v-2h6v2zm4-4H6v-2h10v2zm0-4H6V7h10v2z"/></svg>`;

    function createButton() {
        if (document.getElementById(BUTTON_ID)) return null;

        const btn = document.createElement('button');
        btn.id = BUTTON_ID;
        btn.className = 'xmd-btn';
        btn.title = '复制为 Markdown (Ctrl+M)';
        btn.innerHTML = `
            <div dir="ltr" class="css-146c3p1 r-bcqeeo r-1ttztb7 r-qvutc0 r-37j5jr r-a023e6 r-rjixqe r-16dba41 r-1awozwy r-6koalj r-1h0z5md r-o7ynqc r-clp7b1 r-3s2u2q">
                <div class="css-175oi2r r-xoduu5">
                    <div class="css-175oi2r r-xoduu5 r-1p0dtai r-1d2f490 r-u8s1d r-zchlnj r-ipm5af r-1niwhzg r-sdzlij r-xf4iuw r-o7ynqc r-6416eg r-1ny4l3l"></div>
                    ${MARKDOWN_ICON}
                </div>
            </div>
        `;
        btn.addEventListener('click', (e) => {
            e.stopPropagation();
            if (e.shiftKey || e.altKey) {
                showFormatMenu();
            } else {
                handleCopy(btn);
            }
        });
        
        btn.addEventListener('contextmenu', (e) => {
            e.preventDefault();
            showFormatMenu();
        });
        
        return btn;
    }

    function removeExistingButton() {
        const existingBtn = document.getElementById(BUTTON_ID);
        if (existingBtn) {
            existingBtn.parentElement.remove();
        }
        
        const existingMenu = document.querySelector('.xmd-menu');
        if (existingMenu) existingMenu.remove();
    }

    function getCachedDOM() {
        const now = Date.now();
        if (domCache.buttonGroup && (now - domCache.timestamp) < CONFIG.cacheTTL) {
            return domCache;
        }

        domCache = {
            buttonGroup: querySelector(SELECTORS.buttonGroup),
            bookmarkBtn: null,
            timestamp: now
        };

        if (domCache.buttonGroup) {
            domCache.bookmarkBtn = querySelector(SELECTORS.bookmarkBtn);
        }

        return domCache;
    }

    function injectButton() {
        if (!isTweetDetailPage()) {
            removeExistingButton();
            return;
        }

        if (getTweetUrl() === currentTweetUrl && document.getElementById(BUTTON_ID)) {
            return;
        }

        removeExistingButton();

        const btn = createButton();
        if (!btn) return;

        const { buttonGroup, bookmarkBtn } = getCachedDOM();

        if (buttonGroup) {
            if (bookmarkBtn) {
                const bookmarkWrapper = bookmarkBtn.closest('.css-175oi2r.r-18u37iz') || bookmarkBtn.parentElement;
                if (bookmarkWrapper && bookmarkWrapper.parentNode) {
                    const wrapper = document.createElement('div');
                    wrapper.className = 'css-175oi2r r-18u37iz r-1h0z5md r-1wron08';
                    wrapper.appendChild(btn);
                    try {
                        bookmarkWrapper.parentNode.insertBefore(wrapper, bookmarkWrapper);
                        currentTweetUrl = getTweetUrl();
                        return;
                    } catch (e) {
                        log('插入失败,使用兜底方案:', e);
                    }
                }
            }
            
            // 兜底:直接添加到 buttonGroup
            buttonGroup.appendChild(btn);
            currentTweetUrl = getTweetUrl();
            return;
        }

        // 最终兜底:查找 article
        const article = querySelector(SELECTORS.article);
        if (article) {
            const toolBar = article.querySelector('[data-testid="toolBar"]') || 
                           article.querySelector('[data-testid="tweetButtonInline"]');
            if (toolBar) {
                try {
                    toolBar.parentNode.insertBefore(btn, toolBar);
                    currentTweetUrl = getTweetUrl();
                } catch (e) {
                    log('最终兜底插入失败:', e);
                }
            }
        }
    }

    function handleKeyboardShortcut(e) {
        if ((e.ctrlKey || e.metaKey) && e.key.toLowerCase() === CONFIG.shortcutKey.toLowerCase()) {
            e.preventDefault();
            const btn = document.getElementById(BUTTON_ID);
            if (btn && !btn.disabled) {
                handleCopy(btn);
            }
        }
    }

    function setupHistoryInterceptor() {
        const originalPushState = history.pushState;
        const originalReplaceState = history.replaceState;

        history.pushState = function(...args) {
            originalPushState.apply(this, args);
            setTimeout(injectButton, 800);
        };

        history.replaceState = function(...args) {
            originalReplaceState.apply(this, args);
            setTimeout(injectButton, 800);
        };
    }

    function setupObserver() {
        GM_addStyle(styles);

        const observer = new MutationObserver((mutations) => {
            let shouldInject = false;
            
            for (const mutation of mutations) {
                if (mutation.type === 'childList' && mutation.addedNodes.length > 0) {
                    for (const node of mutation.addedNodes) {
                        if (node.nodeType === Node.ELEMENT_NODE) {
                            const matchSelector = (sel) => {
                                try {
                                    return node.matches && node.matches(sel);
                                } catch (e) {
                                    return false;
                                }
                            };
                            
                            if (matchSelector('[data-testid="tweet"]') ||
                                matchSelector('[role="group"]') ||
                                matchSelector('[data-testid="bookmark"]') ||
                                (node.querySelector && node.querySelector('[role="group"]'))) {
                                shouldInject = true;
                                break;
                            }
                        }
                    }
                }
                if (shouldInject) break;
            }

            if (shouldInject) {
                injectButton();
            }
        });

        observer.observe(document.body, {
            childList: true,
            subtree: true
        });

        // URL 轮询检测
        let lastUrl = location.href;
        setInterval(() => {
            if (lastUrl !== location.href) {
                lastUrl = location.href;
                setTimeout(injectButton, 800);
            }
        }, 1000);

        window.addEventListener('popstate', () => {
            setTimeout(injectButton, 800);
        });
        
        // 键盘快捷键
        document.addEventListener('keydown', handleKeyboardShortcut);
    }

    function init() {
        if (document.readyState === 'loading') {
            document.addEventListener('DOMContentLoaded', () => {
                setupHistoryInterceptor();
                setupObserver();
                setTimeout(injectButton, 1500);
            });
        } else {
            setupHistoryInterceptor();
            setupObserver();
            setTimeout(injectButton, 1500);
        }
    }

    init();
})();