Greasy Fork is available in English.
一键将X(Twitter)推文转换为Markdown并复制到剪贴板
// ==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`;
}
});
}
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();
})();