Last active
February 26, 2026 01:21
-
-
Save shuidong/702765b65590647baf1177053fe9d4b4 to your computer and use it in GitHub Desktop.
autoTweetFetch
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 Twitter Timeline Downloader (修复展开按钮 v11 - 简约输出版) | |
| // @namespace http://tampermonkey.net/ | |
| // @version 11.0 | |
| // @description 修复日语展开按钮识别 + 精确选择器 + 简约机器友好输出格式 | |
| // @author You | |
| // @match https://twitter.com/home* | |
| // @match https://x.com/home* | |
| // @grant GM_setValue | |
| // @grant GM_getValue | |
| // ==/UserScript== | |
| (function() { | |
| 'use strict'; | |
| // ================= 配置区域 ================= | |
| const CONFIG = { | |
| scrollDelay: 2000, | |
| scrollStep: 800, | |
| minTweetLength: 10, | |
| maxAttempts: 200, | |
| expandDelay: 2000, | |
| expandRetries: 2, | |
| showLog: true | |
| }; | |
| const STORAGE_KEY = 'twitter_last_tweet_id'; | |
| // =========================================== | |
| let downloadBtn = null; | |
| let stopBtn = null; | |
| let clearBtn = null; | |
| let infoLabel = null; | |
| let shouldStop = false; | |
| let isRunning = false; | |
| let capturedTweets = []; | |
| // 1. 创建控制按钮组 | |
| function createButtons() { | |
| const container = document.createElement('div'); | |
| container.style.position = 'fixed'; | |
| container.style.bottom = '20px'; | |
| container.style.right = '20px'; | |
| container.style.zIndex = '9999'; | |
| container.style.display = 'flex'; | |
| container.style.gap = '10px'; | |
| container.style.flexDirection = 'column'; | |
| container.style.alignItems = 'flex-end'; | |
| document.body.appendChild(container); | |
| infoLabel = document.createElement('div'); | |
| infoLabel.style.background = 'rgba(0,0,0,0.8)'; | |
| infoLabel.style.color = '#fff'; | |
| infoLabel.style.padding = '6px 12px'; | |
| infoLabel.style.borderRadius = '8px'; | |
| infoLabel.style.fontSize = '11px'; | |
| infoLabel.style.fontFamily = 'monospace'; | |
| infoLabel.style.marginBottom = '5px'; | |
| updateInfoLabel(); | |
| container.appendChild(infoLabel); | |
| const btnRow = document.createElement('div'); | |
| btnRow.style.display = 'flex'; | |
| btnRow.style.gap = '10px'; | |
| container.appendChild(btnRow); | |
| downloadBtn = createButton({ | |
| text: '📥 开始抓取', | |
| bg: '#1d9bf0', | |
| hoverBg: '#1a8cd8', | |
| onclick: startExport | |
| }); | |
| btnRow.appendChild(downloadBtn); | |
| stopBtn = createButton({ | |
| text: '⏹️ 停止并下载', | |
| bg: '#e0245e', | |
| hoverBg: '#c41c50', | |
| onclick: stopAndDownload, | |
| display: 'none' | |
| }); | |
| btnRow.appendChild(stopBtn); | |
| clearBtn = createButton({ | |
| text: '🗑️ 清除断点', | |
| bg: '#536471', | |
| hoverBg: '#3f4b58', | |
| onclick: clearCheckpoint | |
| }); | |
| btnRow.appendChild(clearBtn); | |
| } | |
| function createButton({ text, bg, hoverBg, onclick, display = 'block' }) { | |
| const btn = document.createElement('button'); | |
| btn.innerText = text; | |
| btn.style.padding = '10px 16px'; | |
| btn.style.background = bg; | |
| btn.style.color = 'white'; | |
| btn.style.border = 'none'; | |
| btn.style.borderRadius = '20px'; | |
| btn.style.fontSize = '13px'; | |
| btn.style.fontWeight = 'bold'; | |
| btn.style.cursor = 'pointer'; | |
| btn.style.boxShadow = '0 2px 8px rgba(0,0,0,0.3)'; | |
| btn.style.display = display; | |
| btn.onmouseover = () => { if (!isRunning || btn === clearBtn) btn.style.background = hoverBg; }; | |
| btn.onmouseout = () => { if (!isRunning || btn === clearBtn) btn.style.background = bg; }; | |
| btn.onclick = onclick; | |
| return btn; | |
| } | |
| function updateInfoLabel() { | |
| const lastId = getLastTweetId(); | |
| if (lastId) { | |
| infoLabel.innerText = `📍 断点:${lastId.substring(0, 12)}...`; | |
| infoLabel.style.color = '#1d9bf0'; | |
| } else { | |
| infoLabel.innerText = '⚪ 无断点记录'; | |
| infoLabel.style.color = '#888'; | |
| } | |
| } | |
| // 2. 存储管理 | |
| function getLastTweetId() { | |
| return GM_getValue(STORAGE_KEY, ''); | |
| } | |
| function saveLastTweetId(tweetId) { | |
| GM_setValue(STORAGE_KEY, tweetId); | |
| log(`💾 已保存断点:${tweetId}`); | |
| updateInfoLabel(); | |
| } | |
| // 3. 清除断点 | |
| function clearCheckpoint() { | |
| if (isRunning) { | |
| alert('⚠️ 抓取进行中,请先停止'); | |
| return; | |
| } | |
| const lastId = getLastTweetId(); | |
| if (!lastId) { | |
| updateInfoLabel(); | |
| return; | |
| } | |
| if (confirm(`🗑️ 确定要清除断点吗?\n\n上次抓取 ID: ${lastId}\n\n清除后下次将从头开始抓取。`)) { | |
| GM_setValue(STORAGE_KEY, ''); | |
| updateInfoLabel(); | |
| log('✅ 断点已清除'); | |
| } | |
| } | |
| // 4. 开始抓取 | |
| async function startExport() { | |
| if (isRunning) return; | |
| isRunning = true; | |
| shouldStop = false; | |
| capturedTweets = []; | |
| downloadBtn.style.display = 'none'; | |
| stopBtn.style.display = 'block'; | |
| clearBtn.style.display = 'none'; | |
| try { | |
| window.scrollTo(0, 0); | |
| await new Promise(r => setTimeout(r, 1000)); | |
| const lastId = getLastTweetId(); | |
| log(`🎯 开始抓取,上次断点:${lastId || '无'}`); | |
| log(`ℹ️ 说明:转发的旧推文也会被抓取(按时间线出现顺序,非发布时间)`); | |
| await scrollUntilCheckpoint(lastId); | |
| if (capturedTweets.length === 0) { | |
| alert('⚠️ 没抓到新推文'); | |
| resetButtons(); | |
| return; | |
| } | |
| downloadToFile(capturedTweets, lastId); | |
| if (capturedTweets.length > 0) { | |
| saveLastTweetId(capturedTweets[capturedTweets.length - 1].tweetId); | |
| } | |
| showSuccessStatus(capturedTweets.length); | |
| resetButtons(); | |
| } catch (error) { | |
| console.error('导出失败:', error); | |
| alert('❌ 导出失败,请查看控制台错误'); | |
| resetButtons(); | |
| } | |
| } | |
| function showSuccessStatus(count) { | |
| downloadBtn.innerText = `✅ 已导出 ${count} 条`; | |
| downloadBtn.style.background = '#00ba7c'; | |
| setTimeout(() => { | |
| downloadBtn.innerText = '📥 开始抓取'; | |
| downloadBtn.style.background = '#1d9bf0'; | |
| }, 3000); | |
| } | |
| // 5. 手动停止并下载 | |
| function stopAndDownload() { | |
| shouldStop = true; | |
| log('🛑 用户请求停止...'); | |
| } | |
| // 6. 重置按钮 | |
| function resetButtons() { | |
| isRunning = false; | |
| downloadBtn.style.display = 'block'; | |
| stopBtn.style.display = 'none'; | |
| clearBtn.style.display = 'block'; | |
| stopBtn.innerText = '⏹️ 停止并下载'; | |
| } | |
| // 7. 滚动直到遇到断点或手动停止 | |
| async function scrollUntilCheckpoint(lastId) { | |
| let consecutiveNoNew = 0; | |
| let attempts = 0; | |
| let foundCheckpoint = false; | |
| const globalTweetIds = new Set(); | |
| while (!shouldStop && attempts < CONFIG.maxAttempts) { | |
| attempts++; | |
| stopBtn.innerText = `⏹️ 停止 (${capturedTweets.length})`; | |
| window.scrollBy(0, CONFIG.scrollStep); | |
| await new Promise(r => setTimeout(r, CONFIG.scrollDelay)); | |
| const expandedCount = await expandAllLongTweets(); | |
| if (expandedCount > 0) { | |
| log(`🔓 展开了 ${expandedCount} 条长推文`); | |
| } | |
| const currentTweets = captureTweets(); | |
| let newCount = 0; | |
| let duplicateInBatch = 0; | |
| let oldTweetCount = 0; | |
| for (const tweet of currentTweets) { | |
| if (lastId && tweet.tweetId === lastId) { | |
| log(`✅ 遇到断点 ID: ${tweet.tweetId}`); | |
| foundCheckpoint = true; | |
| break; | |
| } | |
| if (globalTweetIds.has(tweet.tweetId)) { | |
| duplicateInBatch++; | |
| continue; | |
| } | |
| if (lastId && tweet.tweetId < lastId) { | |
| oldTweetCount++; | |
| } | |
| globalTweetIds.add(tweet.tweetId); | |
| capturedTweets.push(tweet); | |
| newCount++; | |
| } | |
| if (foundCheckpoint) break; | |
| if (newCount > 0) { | |
| consecutiveNoNew = 0; | |
| log(`📜 第${attempts}次 | 新增${newCount}条 (含${oldTweetCount}条旧推文转发) | 重复${duplicateInBatch}条 | 总计${capturedTweets.length}条`); | |
| } else { | |
| consecutiveNoNew++; | |
| log(`⚠️ 第${attempts}次 | 无新增 | 连续${consecutiveNoNew}次`); | |
| } | |
| if (consecutiveNoNew >= 15) { | |
| log('⚠️ 连续 15 次无新内容,停止'); | |
| break; | |
| } | |
| } | |
| if (shouldStop) { | |
| log('🛑 用户手动停止'); | |
| } | |
| log(`✅ 抓取完成,共 ${capturedTweets.length} 条新推文`); | |
| return capturedTweets; | |
| } | |
| // 8. 修复:展开所有长推文(v11 精确选择器版) | |
| async function expandAllLongTweets() { | |
| let expandedCount = 0; | |
| const initialUrl = window.location.href; | |
| const allExpandButtons = document.querySelectorAll('[data-testid="tweet-text-show-more-link"]'); | |
| log(`🔍 找到 ${allExpandButtons.length} 个展开按钮`); | |
| if (allExpandButtons.length === 0) { | |
| log(`ℹ️ 当前页面没有需要展开的长推文`); | |
| return 0; | |
| } | |
| const validButtons = []; | |
| for (const btn of allExpandButtons) { | |
| try { | |
| const article = btn.closest('article[data-testid="tweet"]'); | |
| if (!article) continue; | |
| let hasNestedArticle = false; | |
| let current = btn.parentElement; | |
| while (current && current !== article) { | |
| if (current.tagName === 'ARTICLE') { | |
| hasNestedArticle = true; | |
| break; | |
| } | |
| current = current.parentElement; | |
| } | |
| if (hasNestedArticle) continue; | |
| const quotedTweet = btn.closest('[data-testid="quoted-tweet"]'); | |
| if (quotedTweet) continue; | |
| const conversationThread = btn.closest('[data-testid="conversationThread"]'); | |
| if (conversationThread && conversationThread !== article) continue; | |
| const textElement = article.querySelector('[data-testid="tweetText"]'); | |
| if (!textElement) continue; | |
| const btnParent = btn.parentElement; | |
| const textParent = textElement.parentElement; | |
| let sameContainer = false; | |
| let checkParent = btnParent; | |
| let depth = 0; | |
| while (checkParent && checkParent !== article && depth < 5) { | |
| if (checkParent === textParent || checkParent.contains(textElement)) { | |
| sameContainer = true; | |
| break; | |
| } | |
| checkParent = checkParent.parentElement; | |
| depth++; | |
| } | |
| if (!sameContainer) continue; | |
| validButtons.push({ btn, article, textElement }); | |
| } catch (e) { | |
| log(`⚠️ 验证失败:${e.message}`); | |
| } | |
| } | |
| log(`📋 过滤后剩余 ${validButtons.length}/${allExpandButtons.length} 个有效按钮`); | |
| if (validButtons.length === 0) { | |
| log(`ℹ️ 没有需要展开的主推文`); | |
| return 0; | |
| } | |
| for (let i = 0; i < validButtons.length; i++) { | |
| const { btn, article, textElement } = validButtons[i]; | |
| try { | |
| const text = btn.innerText || btn.textContent || ''; | |
| if (text.includes('Show less') || | |
| text.includes('显示较少') || | |
| text.includes('表示を減らす')) { | |
| continue; | |
| } | |
| const beforeLength = textElement.innerText.length; | |
| const beforeUrl = window.location.href; | |
| btn.scrollIntoView({ block: 'center', behavior: 'smooth' }); | |
| await new Promise(r => setTimeout(r, 800)); | |
| btn.click(); | |
| await new Promise(r => setTimeout(r, 3000)); | |
| if (window.location.href !== beforeUrl) { | |
| window.history.back(); | |
| await new Promise(r => setTimeout(r, 2000)); | |
| continue; | |
| } | |
| const afterLength = textElement.innerText.length; | |
| const newText = btn.innerText || btn.textContent || ''; | |
| if (afterLength > beforeLength) { | |
| expandedCount++; | |
| } else if (newText.includes('Show less') || newText.includes('显示较少') || newText.includes('表示を減らす')) { | |
| expandedCount++; | |
| } | |
| await new Promise(r => setTimeout(r, 500)); | |
| } catch (e) { | |
| log(`❌ 按钮${i+1}: 错误 ${e.message}`); | |
| } | |
| } | |
| log(`🔓 共展开了 ${expandedCount}/${validButtons.length} 条长推文`); | |
| await new Promise(r => setTimeout(r, 500)); | |
| return expandedCount; | |
| } | |
| // 9. 抓取推文 | |
| function captureTweets() { | |
| const articles = document.querySelectorAll('article[data-testid="tweet"], article[role="article"]'); | |
| const tweets = []; | |
| const seenIds = new Set(); | |
| articles.forEach(article => { | |
| try { | |
| const textElement = article.querySelector('[data-testid="tweetText"]'); | |
| const content = textElement ? textElement.innerText.trim() : ''; | |
| const userContainer = article.querySelector('[data-testid="User-Name"]'); | |
| let authorName = 'Unknown'; | |
| let authorHandle = 'Unknown'; | |
| if (userContainer) { | |
| const spans = userContainer.querySelectorAll('span span'); | |
| if (spans.length >= 1) authorName = spans[0].innerText.trim(); | |
| if (spans.length >= 2) authorHandle = spans[1].innerText.trim().replace('@', ''); | |
| } | |
| const timeElement = article.querySelector('time'); | |
| const time = timeElement ? timeElement.dateTime : ''; | |
| const linkElement = article.querySelector('a[href*="/status/"]'); | |
| let tweetId = ''; | |
| let tweetUrl = ''; | |
| if (linkElement && linkElement.href) { | |
| tweetUrl = linkElement.href; | |
| const match = linkElement.href.match(/\/status\/(\d+)/); | |
| if (match) tweetId = match[1]; | |
| } | |
| if (!tweetId) { | |
| tweetId = `${authorHandle}_${time}_${content.substring(0, 30)}`; | |
| } | |
| if (seenIds.has(tweetId)) return; | |
| seenIds.add(tweetId); | |
| if (content.length >= CONFIG.minTweetLength) { | |
| tweets.push({ | |
| tweetId, | |
| authorName, | |
| authorHandle, | |
| time, | |
| content, | |
| tweetUrl | |
| }); | |
| } | |
| } catch (e) { | |
| // 跳过 | |
| } | |
| }); | |
| return tweets; | |
| } | |
| // 10. 下载文件 ✅ 简约机器友好格式 | |
| function downloadToFile(tweets, lastId) { | |
| const now = new Date(); | |
| const timestamp = now.toISOString().replace(/[:.]/g, '-').slice(0, -5); | |
| const filename = `twitter_new_${tweets.length}条_${timestamp}.txt`; | |
| // ✅ 简约格式:字段换行分隔 + 横线分割每组推文 | |
| const content = tweets.map((t, i) => { | |
| return `推文 #${String(i + 1).padStart(3, '0')} | |
| 作者:${t.authorName} (@${t.authorHandle}) | |
| 时间:${t.time} | |
| ID: ${t.tweetId} | |
| 链接:${t.tweetUrl || 'N/A'} | |
| 内容: | |
| ${t.content} | |
| ----------------------------------------`; | |
| }).join('\n'); | |
| // ✅ 简化header,保留关键元数据(机器可解析) | |
| const header = `EXPORT_META | |
| export_time: ${now.toISOString()} | |
| tweet_count: ${tweets.length} | |
| last_checkpoint_id: ${lastId || 'none'} | |
| first_tweet_id: ${tweets[0]?.tweetId || 'none'} | |
| last_tweet_id: ${tweets[tweets.length - 1]?.tweetId || 'none'} | |
| --- | |
| `; | |
| const blob = new Blob([header + content], { type: 'text/plain;charset=utf-8' }); | |
| const url = URL.createObjectURL(blob); | |
| const a = document.createElement('a'); | |
| a.href = url; | |
| a.download = filename; | |
| document.body.appendChild(a); | |
| a.click(); | |
| document.body.removeChild(a); | |
| URL.revokeObjectURL(url); | |
| } | |
| function formatTime(timeStr) { | |
| if (!timeStr) return 'N/A'; | |
| try { | |
| const date = new Date(timeStr); | |
| return date.toLocaleString('zh-CN'); | |
| } catch { | |
| return timeStr; | |
| } | |
| } | |
| function log(...args) { | |
| if (CONFIG.showLog) { | |
| console.log('[Twitter Downloader]', ...args); | |
| } | |
| } | |
| // 初始化 | |
| createButtons(); | |
| log('🚀 Twitter Downloader v11.0 已加载'); | |
| })(); |
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment