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