Extracts content from the X (Twitter) feed and converts it to Markdown format, with an added direct auto-scroll feature.
当前为
// ==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, "<").replace(/>/g, ">") + '</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
};
}
})();