X (Twitter) Feed to Markdown with Auto-Scroll

Extracts content from the X (Twitter) feed and converts it to Markdown format, with an added direct auto-scroll feature.

Ekde 2025/07/09. Vidu La ĝisdata versio.

You will need to install an extension such as Tampermonkey, Greasemonkey or Violentmonkey to install this script.

You will need to install an extension such as Tampermonkey or Violentmonkey to install this script.

You will need to install an extension such as Tampermonkey or Violentmonkey to install this script.

You will need to install an extension such as Tampermonkey or Userscripts to install this script.

You will need to install an extension such as Tampermonkey to install this script.

You will need to install a user script manager extension to install this script.

(I already have a user script manager, let me install it!)

You will need to install an extension such as Stylus to install this style.

You will need to install an extension such as Stylus to install this style.

You will need to install an extension such as Stylus to install this style.

You will need to install a user style manager extension to install this style.

You will need to install a user style manager extension to install this style.

You will need to install a user style manager extension to install this style.

(I already have a user style manager, let me install it!)

// ==UserScript==
// @name         X (Twitter) Feed to Markdown with Auto-Scroll
// @namespace    http://tampermonkey.net/
// @version      1.5
// @description  Extracts content from the X (Twitter) feed and converts it to Markdown format, with an added direct auto-scroll feature.
// @match        https://x.com/*
// @grant        none
// @license      MIT
// ==/UserScript==

(function() {
    'use strict';

    // --- Markdown转换功能的状态变量 ---
    let isMonitoring = false;
    let collectedTweets = new Map();
    let observer;

    // --- 自动滚动功能的状态变量 ---
    let isAutoScrolling = false;
    let scrollIntervalId = null;

    // --- 创建Markdown转换按钮 ---
    const markdownButton = document.createElement('button');
    markdownButton.textContent = '开始转换Markdown';
    Object.assign(markdownButton.style, {
        position: 'fixed',
        top: '10px',
        right: '10px',
        zIndex: '9999',
        padding: '8px 16px',
        backgroundColor: '#1DA1F2',
        color: 'white',
        border: 'none',
        borderRadius: '5px',
        cursor: 'pointer',
        fontSize: '14px'
    });
    document.body.appendChild(markdownButton);
    markdownButton.addEventListener('click', toggleMonitoring);

    // --- 创建自动滚动按钮 ---
    const scrollButton = document.createElement('button');
    scrollButton.textContent = '开始自动滚动';
    Object.assign(scrollButton.style, {
        position: 'fixed',
        top: '55px', // 放在第一个按钮的下方
        right: '10px',
        zIndex: '9999',
        padding: '8px 16px',
        backgroundColor: '#28a745', // 绿色
        color: 'white',
        border: 'none',
        borderRadius: '5px',
        cursor: 'pointer',
        fontSize: '14px'
    });
    document.body.appendChild(scrollButton);
    scrollButton.addEventListener('click', toggleAutoScroll);


    // --- 自动滚动功能 ---

    /**
     * 【已修改】直接执行浏览器滚动,而不是模拟按键
     */
    function performScroll() {
        // window.scrollBy(x, y) 让窗口从当前位置滚动指定的像素值
        // x为0表示水平不滚动,y为400表示向下滚动400像素
        // 你可以调整 400 这个数值来改变滚动的速度/距离
        window.scrollBy(0, 400);
        console.log('Auto-scroll: Scrolled down by 400px.');
    }

    /**
     * 切换自动滚动状态
     */
    function toggleAutoScroll() {
        if (isAutoScrolling) {
            // 停止滚动
            clearInterval(scrollIntervalId);
            scrollIntervalId = null;
            isAutoScrolling = false;
            scrollButton.textContent = '开始自动滚动';
            scrollButton.style.backgroundColor = '#28a745'; // 恢复绿色
            console.log('自动滚动已停止。');
        } else {
            // 开始滚动
            isAutoScrolling = true;
            // 【已修改】调用新的滚动函数
            scrollIntervalId = setInterval(performScroll, 500); // 每500ms滚动一次
            scrollButton.textContent = '停止自动滚动';
            scrollButton.style.backgroundColor = '#dc3545'; // 变为红色
            console.log('自动滚动已开始...');
        }
    }


    // --- Markdown转换功能 (原脚本逻辑) ---
    // (以下代码保持不变)

    function toggleMonitoring() {
        if (isMonitoring) {
            stopMonitoring();
            displayCollectedTweets();
        } else {
            startMonitoring();
        }
    }

    function startMonitoring() {
        isMonitoring = true;
        markdownButton.textContent = '停止并导出Markdown';
        markdownButton.style.backgroundColor = '#FF4136';
        collectedTweets.clear();
        console.log("开始监控推文...");

        document.querySelectorAll('article[data-testid="tweet"]').forEach(processTweet);

        const config = { childList: true, subtree: true };
        observer = new MutationObserver(mutations => {
            for (const mutation of mutations) {
                if (mutation.addedNodes.length) {
                    mutation.addedNodes.forEach(node => {
                        if (node.nodeType === Node.ELEMENT_NODE) {
                            if (node.matches('article[data-testid="tweet"]')) {
                                processTweet(node);
                            }
                            node.querySelectorAll('article[data-testid="tweet"]').forEach(processTweet);
                        }
                    });
                }
            }
        });

        observer.observe(document.body, config);
    }

    function stopMonitoring() {
        isMonitoring = false;
        markdownButton.textContent = '开始转换Markdown';
        markdownButton.style.backgroundColor = '#1DA1F2';
        if (observer) {
            observer.disconnect();
        }
        console.log("停止监控。");
    }

    function processTweet(tweet) {
        if (tweet.querySelector('[data-testid="promotedTweet"]')) return;
        const timeElement = tweet.querySelector('time[datetime]');
        if (timeElement && timeElement.closest('div[data-testid="User-Name"]')?.nextElementSibling?.textContent?.includes('Ad')) {
             return;
        }

        const tweetData = formatTweet(tweet);
        if (tweetData && tweetData.url && !collectedTweets.has(tweetData.url)) {
            collectedTweets.set(tweetData.url, tweetData.markdown);
        }
    }

    function displayCollectedTweets() {
        if (collectedTweets.size === 0) {
            alert('没有收集到任何推文。');
            return;
        }

        const sortedTweets = Array.from(collectedTweets.values()).sort((a, b) => {
             const timeMatchA = a.match(/\*\*发布时间\*\*: (.*)/);
             const timeMatchB = b.match(/\*\*发布时间\*\*: (.*)/);
             if (!timeMatchA || !timeMatchB) return 0;
             const timeA = new Date(timeMatchA[1]);
             const timeB = new Date(timeMatchB[1]);
             return timeB - timeA;
        });

        const markdownOutput = sortedTweets.join('\n\n---\n\n');
        const newWindow = window.open('', '_blank');
        newWindow.document.write('<pre style="white-space: pre-wrap; word-wrap: break-word; padding: 10px;">' + markdownOutput.replace(/</g, "&lt;").replace(/>/g, "&gt;") + '</pre>');
        newWindow.document.title = 'Twitter Feed as Markdown';
    }

    function extractTextContent(element) {
        if (!element) return '';
        let text = '';
        element.childNodes.forEach(node => {
            if (node.nodeType === Node.ELEMENT_NODE) {
                if (node.tagName === 'IMG') {
                    text += node.alt;
                } else if (node.tagName === 'A') {
                    const url = node.href;
                    if (!url.includes('/photo/') && !url.includes('/video/')) {
                        text += `[${node.textContent}](${url})`;
                    }
                } else {
                    text += node.textContent;
                }
            } else {
                text += node.textContent;
            }
        });
        return text.trim();
    }

    function formatTweet(tweet) {
        const timeElement = tweet.querySelector('time');
        if (!timeElement) return null;

        const linkElement = timeElement.closest('a');
        if (!linkElement) return null;

        const tweetUrl = 'https://x.com' + linkElement.getAttribute('href');
        const authorHandle = `@${tweetUrl.split('/')[3]}`;
        const postTime = timeElement.getAttribute('datetime');

        const mainContentElement = tweet.querySelector('div[data-testid="tweetText"]');
        const mainContent = extractTextContent(mainContentElement);

        let quoteContent = '';
        const quoteHeader = Array.from(tweet.querySelectorAll('span')).find(s => s.textContent === 'Quote');
        if (quoteHeader) {
            const quoteContainer = quoteHeader.parentElement.nextElementSibling;
            if (quoteContainer && quoteContainer.getAttribute('role') === 'link') {
                const quoteAuthorEl = quoteContainer.querySelector('[data-testid="User-Name"]');
                const quoteAuthor = quoteAuthorEl ? quoteAuthorEl.textContent.replace(/\n/g, ' ').replace(/\s+/g, ' ').trim() : '未知作者';
                const quoteTextEl = quoteContainer.querySelector('div[lang]');
                const quoteText = extractTextContent(quoteTextEl);
                const quoteLines = `**${quoteAuthor}**: ${quoteText}`.split('\n');
                quoteContent = `\n\n${quoteLines.map(line => `> ${line}`).join('\n> ')}`;
            }
        }

        let sharedLink = '';
        const cardWrapper = tweet.querySelector('[data-testid="card.wrapper"]');
        if (cardWrapper) {
            const cardLinkEl = cardWrapper.querySelector('a');
            if(cardLinkEl) {
                const cardUrl = cardLinkEl.href;
                const detailContainer = cardWrapper.querySelector('[data-testid$="detail"]');
                let cardTitle = '';
                if (detailContainer) {
                    const spans = detailContainer.querySelectorAll('span');
                    cardTitle = spans.length > 1 ? spans[1].textContent : '链接';
                } else {
                    const largeMediaTitleEl = cardWrapper.querySelector('div[class*="r-fdjqy7"] span');
                    cardTitle = largeMediaTitleEl ? largeMediaTitleEl.textContent : '链接';
                }
                sharedLink = `\n- **分享链接**: [${cardTitle.trim()}](${cardUrl})`;
            }
        }

        const socialContext = tweet.querySelector('[data-testid="socialContext"]');
        let repostedBy = '';
        if (socialContext && socialContext.textContent.toLowerCase().includes('reposted')) {
            repostedBy = `> *由 ${socialContext.textContent.replace(/reposted/i, '').trim()} 转推*\n\n`;
        }

        let threadIndicator = '';
        const hasThreadLink = Array.from(tweet.querySelectorAll('a[role="link"] span')).some(span => span.textContent === 'Show this thread');
        if (hasThreadLink) {
            threadIndicator = `- **串推**: 是\n`;
        }

        let markdown = `${repostedBy}- **原文链接**: ${tweetUrl}\n`;
        markdown += `- **作者**: ${authorHandle}\n`;
        markdown += `- **发布时间**: ${postTime}\n`;
        markdown += threadIndicator;
        markdown += `- **推文内容**:\n${mainContent}${quoteContent}`;
        markdown += sharedLink;

        return {
            url: tweetUrl,
            markdown: markdown
        };
    }
})();