-
-
Save warm200/78032eddea29cb9200ea69d7fa2c93de to your computer and use it in GitHub Desktop.
X Article to Markdown
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| // ==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