Skip to content

Instantly share code, notes, and snippets.

@warm200
Forked from geekjourneyx/x-article-to-md.user.js
Created February 9, 2026 14:17
Show Gist options
  • Select an option

  • Save warm200/78032eddea29cb9200ea69d7fa2c93de to your computer and use it in GitHub Desktop.

Select an option

Save warm200/78032eddea29cb9200ea69d7fa2c93de to your computer and use it in GitHub Desktop.
X Article to Markdown
// ==UserScript==
// @name X Article to Markdown
// @namespace http://tampermonkey.net/
// @version 1.0.0
// @description Copy X (Twitter) long articles as Markdown format
// @author geekjourney
// @match https://x.com/*/status/*
// @match https://x.com/*/article/*
// @icon https://x.com/favicon.ico
// @grant none
// ==/UserScript==
(function() {
'use strict';
// ========================================================================
// 配置
// ========================================================================
const CONFIG = {
buttonId: 'x2md-button',
toastId: 'x2md-toast',
checkInterval: 500,
maxRetries: 20,
};
// ========================================================================
// 页面检测
// ========================================================================
/**
* 检测当前页面是否是 X 长文章页面
*/
function isXArticlePage() {
return /^https:\/\/x\.com\/[^/]+\/(status|article)\//.test(window.location.href);
}
/**
* 检测页面是否包含长文章内容
*/
function hasLongformContent() {
return !!document.querySelector('[data-testid="twitter-article-title"]');
}
// ========================================================================
// 内容提取
// ========================================================================
/**
* 提取文章元信息
*/
function extractMetadata() {
const metadata = {
title: '',
author: '',
url: window.location.href,
date: '',
};
// 提取标题
const titleEl = document.querySelector('[data-testid="twitter-article-title"]');
if (titleEl) {
metadata.title = titleEl.textContent.trim();
}
// 提取作者
const authorLinkEl = document.querySelector('[data-testid="User-Name"] a[href*="/"]');
if (authorLinkEl) {
const href = authorLinkEl.getAttribute('href');
metadata.author = href ? href.replace(/^\//, '') : '';
// 如果没有找到作者名,尝试从用户名元素获取
if (!metadata.author) {
const usernameEl = document.querySelector('[data-testid="User-Name"] span');
if (usernameEl) {
const text = usernameEl.textContent.trim();
if (text.startsWith('@')) {
metadata.author = text.substring(1);
}
}
}
}
// 提取日期
const timeEl = document.querySelector('time');
if (timeEl) {
metadata.date = timeEl.getAttribute('datetime') || '';
}
return metadata;
}
/**
* 提取文章内容
*/
function extractArticleContent() {
const tweetEl = document.querySelector('[data-testid="tweet"]');
if (!tweetEl) {
console.error('[X2MD] Tweet container not found');
return [];
}
// 找所有包含 longform 类的元素
const longformElements = tweetEl.querySelectorAll('[class*="longform-"]');
const seenOffsets = new Set();
const content = [];
longformElements.forEach((el) => {
const offsetKey = el.getAttribute('data-offset-key');
if (!offsetKey || seenOffsets.has(offsetKey)) {
return;
}
seenOffsets.add(offsetKey);
// 获取元素类型
const classList = Array.from(el.classList);
const type = classList.find((c) => c.startsWith('longform-')) || 'longform-unstyled';
// 提取文本 - 查找 span[data-text="true"]
const textSpans = el.querySelectorAll('span[data-text="true"]');
let text = Array.from(textSpans).map((s) => s.textContent).join('').trim();
if (!text) {
return;
}
// 检查是否有粗体或斜体样式
const boldSpan = el.querySelector('span[style*="font-weight: bold"]');
const italicSpan = el.querySelector('span[style*="font-style: italic"]');
content.push({
type,
text,
isBold: !!boldSpan,
isItalic: !!italicSpan,
});
});
return content;
}
// ========================================================================
// Markdown 转换
// ========================================================================
/**
* 将提取的内容转换为 Markdown 格式
*/
function convertToMarkdown(metadata, content) {
let md = '';
// 标题
if (metadata.title) {
md += `# ${metadata.title}\n\n`;
}
// 元信息
if (metadata.author) {
md += `**作者**: [@${metadata.author}](https://x.com/${metadata.author})\n`;
}
if (metadata.date) {
const dateOnly = metadata.date.split('T')[0];
md += `**日期**: ${dateOnly}\n`;
}
md += `**链接**: ${metadata.url}\n\n`;
md += `---\n\n`;
// 内容
let lastWasList = false;
content.forEach((item, index) => {
const { type, text, isBold, isItalic } = item;
// 处理文本格式
let formattedText = text;
if (isBold) {
formattedText = `**${formattedText}**`;
}
if (isItalic && !isBold) {
formattedText = `*${formattedText}*`;
}
// 处理列表中断
const isList = type.includes('unordered-list-item') || type.includes('ordered-list-item');
if (!isList && lastWasList) {
md += '\n';
}
lastWasList = isList;
switch (type) {
case 'longform-header-one':
md += `# ${formattedText}\n\n`;
break;
case 'longform-header-two':
md += `## ${formattedText}\n\n`;
break;
case 'longform-blockquote':
md += `> ${formattedText}\n\n`;
break;
case 'longform-unordered-list-item':
md += `- ${formattedText}\n`;
break;
case 'longform-ordered-list-item':
md += `1. ${formattedText}\n`;
break;
default:
// longform-unstyled
md += `${formattedText}\n\n`;
}
});
return md;
}
// ========================================================================
// 剪贴板操作
// ========================================================================
/**
* 复制文本到剪贴板
*/
async function copyToClipboard(text) {
try {
await navigator.clipboard.writeText(text);
return true;
} catch (err) {
console.warn('[X2MD] Clipboard API failed, trying fallback:', err);
return fallbackCopy(text);
}
}
/**
* 降级复制方案
*/
function fallbackCopy(text) {
const textarea = document.createElement('textarea');
textarea.value = text;
textarea.style.position = 'fixed';
textarea.style.top = '-9999px';
textarea.style.left = '-9999px';
document.body.appendChild(textarea);
textarea.focus();
textarea.select();
try {
const successful = document.execCommand('copy');
document.body.removeChild(textarea);
return successful;
} catch (err) {
console.error('[X2MD] Fallback copy failed:', err);
document.body.removeChild(textarea);
return false;
}
}
// ========================================================================
// UI 组件
// ========================================================================
/**
* 创建浮动按钮
*/
function createButton() {
const btn = document.createElement('button');
btn.id = CONFIG.buttonId;
btn.innerHTML = `
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" style="vertical-align:middle;margin-right:4px;">
<path d="M16 4h2a2 2 0 0 1 2 2v14a2 2 0 0 1-2 2H6a2 2 0 0 1-2-2V6a2 2 0 0 1 2-2h2"></path>
<rect x="8" y="2" width="8" height="4" rx="1" ry="1"></rect>
</svg>
Copy MD
`;
btn.style.cssText = `
position: fixed;
top: 80px;
right: 20px;
background: #1d9bf0;
color: white;
border: none;
padding: 10px 16px;
border-radius: 9999px;
cursor: pointer;
font-size: 14px;
font-weight: 600;
font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, Helvetica, Arial, sans-serif;
z-index: 999998;
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.15);
display: flex;
align-items: center;
transition: all 0.2s ease;
`;
// Hover 效果
btn.addEventListener('mouseenter', () => {
btn.style.background = '#1a8cd8';
btn.style.transform = 'translateY(-1px)';
});
btn.addEventListener('mouseleave', () => {
btn.style.background = '#1d9bf0';
btn.style.transform = 'translateY(0)';
});
// 点击事件
btn.addEventListener('click', handleCopyClick);
return btn;
}
/**
* 显示提示消息
*/
function showToast(message, isError = false) {
const existingToast = document.getElementById(CONFIG.toastId);
if (existingToast) {
existingToast.remove();
}
const toast = document.createElement('div');
toast.id = CONFIG.toastId;
toast.textContent = message;
toast.style.cssText = `
position: fixed;
top: 20px;
right: 20px;
background: ${isError ? '#f4212e' : '#00ba7c'};
color: white;
padding: 12px 20px;
border-radius: 4px;
font-size: 14px;
font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, Helvetica, Arial, sans-serif;
z-index: 999999;
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.15);
animation: slideIn 0.3s ease;
`;
// 添加动画样式
if (!document.getElementById('x2md-toast-style')) {
const style = document.createElement('style');
style.id = 'x2md-toast-style';
style.textContent = `
@keyframes slideIn {
from { transform: translateX(100%); opacity: 0; }
to { transform: translateX(0); opacity: 1; }
}
@keyframes fadeOut {
from { opacity: 1; }
to { opacity: 0; }
}
`;
document.head.appendChild(style);
}
document.body.appendChild(toast);
setTimeout(() => {
toast.style.animation = 'fadeOut 0.3s ease';
setTimeout(() => toast.remove(), 300);
}, 2000);
}
// ========================================================================
// 主处理逻辑
// ========================================================================
/**
* 处理复制按钮点击
*/
async function handleCopyClick() {
const btn = document.getElementById(CONFIG.buttonId);
if (btn) {
btn.disabled = true;
btn.style.opacity = '0.6';
}
try {
// 检查是否是文章页面
if (!hasLongformContent()) {
showToast('❌ 页面没有检测到长文章内容', true);
return;
}
// 提取内容
const metadata = extractMetadata();
const content = extractArticleContent();
if (!content || content.length === 0) {
showToast('❌ 无法提取文章内容', true);
return;
}
console.log('[X2MD] Extracted:', { metadata, contentCount: content.length });
// 转换为 Markdown
const markdown = convertToMarkdown(metadata, content);
console.log('[X2MD] Markdown length:', markdown.length);
// 复制到剪贴板
const success = await copyToClipboard(markdown);
if (success) {
showToast('✅ 已复制到剪贴板!');
console.log('[X2MD] Successfully copied to clipboard');
} else {
showToast('❌ 复制失败,请重试', true);
}
} catch (error) {
console.error('[X2MD] Error:', error);
showToast('❌ 发生错误: ' + error.message, true);
} finally {
if (btn) {
btn.disabled = false;
btn.style.opacity = '1';
}
}
}
/**
* 注入按钮到页面
*/
function injectButton() {
// 避免重复注入
if (document.getElementById(CONFIG.buttonId)) {
return;
}
const btn = createButton();
document.body.appendChild(btn);
console.log('[X2MD] Button injected');
}
/**
* 移除按钮
*/
function removeButton() {
const btn = document.getElementById(CONFIG.buttonId);
if (btn) {
btn.remove();
console.log('[X2MD] Button removed');
}
}
// ========================================================================
// 初始化
// ========================================================================
/**
* 初始化脚本
*/
function init() {
console.log('[X2MD] Initializing...');
if (!isXArticlePage()) {
console.log('[X2MD] Not an X article page, skipping');
return;
}
// 等待页面加载完成后再注入按钮
const tryInject = () => {
if (hasLongformContent()) {
injectButton();
return true;
}
return false;
};
// 立即尝试一次
if (tryInject()) {
return;
}
// 使用 MutationObserver 监听 DOM 变化
const observer = new MutationObserver((mutations) => {
if (isXArticlePage() && hasLongformContent()) {
if (!document.getElementById(CONFIG.buttonId)) {
injectButton();
}
} else if (!isXArticlePage()) {
removeButton();
}
});
observer.observe(document.body, {
childList: true,
subtree: true,
});
// 备用: 定时检查
let retries = 0;
const intervalId = setInterval(() => {
if (tryInject() || retries >= CONFIG.maxRetries) {
clearInterval(intervalId);
}
retries++;
}, CONFIG.checkInterval);
console.log('[X2MD] Initialized');
}
// ========================================================================
// 启动
// ========================================================================
// 页面加载时初始化
if (document.readyState === 'loading') {
document.addEventListener('DOMContentLoaded', init);
} else {
init();
}
// 监听 URL 变化 (SPA 导航)
let lastUrl = location.href;
new MutationObserver(() => {
const url = location.href;
if (url !== lastUrl) {
lastUrl = url;
console.log('[X2MD] URL changed:', url);
removeButton();
setTimeout(init, 100);
}
}).observe(document, { subtree: true, childList: true });
console.log('[X2MD] Script loaded');
})();
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment