X-to-Markdown Copier

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

이 스크립트를 설치하려면 Tampermonkey, Greasemonkey 또는 Violentmonkey와 같은 확장 프로그램이 필요합니다.

이 스크립트를 설치하려면 Tampermonkey와 같은 확장 프로그램을 설치해야 합니다.

이 스크립트를 설치하려면 Tampermonkey 또는 Violentmonkey와 같은 확장 프로그램이 필요합니다.

이 스크립트를 설치하려면 Tampermonkey 또는 Userscripts와 같은 확장 프로그램이 필요합니다.

이 스크립트를 설치하려면 Tampermonkey와 같은 확장 프로그램이 필요합니다.

이 스크립트를 설치하려면 유저 스크립트 관리자 확장 프로그램이 필요합니다.

(이미 유저 스크립트 관리자가 설치되어 있습니다. 설치를 진행합니다!)

이 스타일을 설치하려면 Stylus와 같은 확장 프로그램이 필요합니다.

이 스타일을 설치하려면 Stylus와 같은 확장 프로그램이 필요합니다.

이 스타일을 설치하려면 Stylus와 같은 확장 프로그램이 필요합니다.

이 스타일을 설치하려면 유저 스타일 관리자 확장 프로그램이 필요합니다.

이 스타일을 설치하려면 유저 스타일 관리자 확장 프로그램이 필요합니다.

이 스타일을 설치하려면 유저 스타일 관리자 확장 프로그램이 필요합니다.

(이미 유저 스타일 관리자가 설치되어 있습니다. 설치를 진행합니다!)

// ==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();
})();