// ==UserScript== // @name 下载推特回复数据 // @namespace aplini.下载推特回复数据 // @version 0.2.0 // @description 打开推特任意账号的回复页面, 点击右上角 "开始抓取" 按钮, 等待自动结束即可 // @author ApliNi // @match https://x.com/* // @grant GM_getValue // ==/UserScript== /* ==UserConfig== config: alwaysDisplayButton: title: 始终显示按钮 (可在其他页面使用, 但没有进行测试) description: 启用 type: checkbox default: false disableAutoSave: title: 禁用自动保存 (开启后只能手动点击保存按钮) description: 启用 type: checkbox default: false disableAutoScroll: title: 禁用自动滚动 description: 启用 type: checkbox default: false saveImageBase64: title: 同时保存图片的 Base64 (这可能导致文件过大) description: 启用 type: checkbox default: false sleep: title: 延迟时间 (毫秒) type: number default: 500 scrollYOffset: title: 每次滚动的距离 (像素) type: number default: 700 Function: delay: title: 此页面功能可能被限制, 因此需要单独添加延迟时间 (毫秒) type: number default: 1200 tweet_main: title: 为每条橙色框中的推文 (可能被限制) type: select default: 无 values: [无, 点赞, 取消点赞, 转推, 取消转推, 添加书签, 移出书签] tweet_reply: title: 为每条蓝色框中的推文 (可能被限制) type: select default: 无 values: [无, 点赞, 取消点赞, 转推, 取消转推, 添加书签, 移出书签] ==/UserConfig== */ (async function() { 'use strict'; const sleep = (ms) => new Promise(resolve => setTimeout(resolve, ms)); let map = {}; let userId = '未命名'; let imgMap = {}; let stop = false; let pause = false; let lastY = 0; let repeatCount = 0; const on = async () => { const getTweetData = async (box, type) => { const url = box.querySelector('a > time')?.parentNode?.href; if(!url) return null; box.style.outline = `2px dashed #7d7d7d`; box.style.outlineOffset = '-6px'; const nameBox = box.querySelector('div[data-testid="User-Name"]'); const twTextBox = box.querySelector('div[data-testid="tweetText"]'); const twPhotoList = [...box.querySelectorAll('div[data-testid="tweetPhoto"]')]; if(twTextBox){ twTextBox.style.backgroundColor = '#355f8430'; twTextBox.style.outline = `3px solid #355f8430`; } const photos = []; for(const photo of twPhotoList){ const video = photo.querySelector('video[poster^="http"]'); const img = photo.querySelector('img[src^="http"]'); if(video){ const src = video.src || video.querySelector('source')?.src; if(!src){ console.log(`[错误] 未找到视频源`, photo); } photos.push({ ariaLabel: video.getAttribute('aria-label'), poster: video.poster, src: src, type: video.getAttribute('type') || video.querySelector('source')?.getAttribute('type'), }); }else if(img){ const imgData = { alt: img.alt, src: img.src, }; if(GM_getValue('config.saveImageBase64', false) === true){ if(!imgMap[img.src]){ const response = await fetch(img.src); const blob = await response.blob(); const base64 = await new Promise((resolve) => { const reader = new FileReader(); reader.onload = () => resolve(reader.result); reader.readAsDataURL(blob); }); imgMap[img.src] = base64; } imgData.base64 = imgMap[img.src]; } photos.push(imgData); }else{ console.log(`[错误] 未找到媒体元素`, photo); } } const data = { url: url, data: { time: box.querySelector('a > time').getAttribute('datetime'), id: nameBox.querySelector('div > a').href.split('/').pop(), name: nameBox.querySelector('div > a span').innerText, replyUser: `${box.querySelector('div > a > span')?.innerText || ''}`.split('@').pop() || null, for: {}, text: twTextBox?.innerText || null, photos: photos, indicator: box.querySelector('div[role="group"][aria-label]').getAttribute('aria-label'), }, }; if(type === 'main'){ box.style.outline = `2px dashed #F88C00`; }else if(type ==='reply'){ box.style.outline = `2px dashed #06b0ff`; } const func = GM_getValue(`Function.tweet_${type}`, '无'); if(true !== '无'){ await sleep(GM_getValue('Function.delay', 1200)); } switch(func){ case '点赞': box.querySelector('button[data-testid="like"]')?.click(); break; case '取消点赞': box.querySelector('button[data-testid="unlike"]')?.click(); break; case '转推': const btn = box.querySelector('button[data-testid="retweet"]'); if(btn){ btn.click(); await sleep(100); document.querySelector('div[data-testid="retweetConfirm"]')?.click(); } break; case '取消转推': const btn2 = box.querySelector('button[data-testid="unretweet"]'); if(btn2){ btn2.click(); await sleep(100); document.querySelector('div[data-testid="unretweetConfirm"]')?.click(); } break; case '添加书签': box.querySelector('button[data-testid="bookmark"]')?.click(); break; case '移出书签': box.querySelector('button[data-testid="removeBookmark"]')?.click(); break; default: break; } return data; }; const deepMergeObject = (target, source = {}) => { // 合并 target 的属性到 result 中 const result = { ...target }; // 合并 source 的属性到 result 中 for(const key in source){ if(source.hasOwnProperty(key)){ if(source[key] === null || source[key] === undefined){ result[key] = source[key]; continue; } switch(source[key].constructor){ case Object: // 递归合并对象 result[key] = deepMergeObject(result[key], source[key]); break; case Array: // 选择数组长度大的一方 if(source[key].length >= (result[key]?.length || 0)){ result[key] = source[key]; } break; default: result[key] = source[key]; break; } } } return result; }; if(pause === true){ await new Promise(async (resolve) => { while(true){ if(pause === false){ resolve(); break; } await sleep(100); } }); } // console.time(' - [耗时]'); // 通过分隔符查找作者自己发送的推文 const boxList = [...document.querySelectorAll('div > div[data-testid="cellInnerDiv"] > div[role="separator"]')].map(el => el.parentNode); for(const box of boxList){ const d1 = await getTweetData(box, 'main'); if(!d1) continue; map[d1.url] = deepMergeObject(map[d1.url], d1.data); userId = d1.data.id; // 保存推文的引用 let upBox = box; while(true){ upBox = upBox.previousElementSibling; if(upBox && !upBox.querySelector('& > div[role="separator"]')){ const d2 = await getTweetData(upBox, 'reply'); if(d2){ map[d1.url].for[d2.url] = deepMergeObject(map[d1.url].for[d2.url], d2.data); } }else{ break; } } } // console.timeEnd(' - [耗时]'); if(GM_getValue('config.disableAutoScroll', false) === false){ if(window.scrollY === lastY && GM_getValue('config.disableAutoSave', false) === false){ repeatCount++; if(repeatCount >= 20){ btn1.click(); } }else{ repeatCount = 0; } lastY = window.scrollY; window.scrollBy({ top: GM_getValue('config.scrollYOffset', 700), left: 0, behavior:'smooth', }); } await sleep(GM_getValue('config.sleep', 500)); if(!stop) queueMicrotask(on); }; await sleep(500); const shadow = document.body.appendChild(document.createElement('div')).attachShadow({ mode: 'open' }); const root = shadow.appendChild(document.createElement('div')); root.style.cssText = ` position: fixed; top: 51px; right: 15px; z-index: 9999; display: none; `; if(GM_getValue('config.alwaysDisplayButton', false) === false){ // 监听 url 变化, 只在特定页面显示按钮 setInterval(() => { const urlPath = window.location.pathname; if(/^\/([^\/]+)\/with_replies/.test(urlPath)){ root.style.display = 'flex'; }else{ root.style.display = 'none'; } }, 200); }else{ root.style.display = 'flex'; } const btnDefaultStyle = ` margin: 0px 7px 7px auto; padding: 4px 7px; background-color: #162838; color: #fff; border-radius: 3px; cursor: default; width: fit-content; `; const btn1 = document.createElement('div'); btn1.textContent = '开始抓取'; btn1.style.cssText = ` ${btnDefaultStyle} background-color: #06b0ff; `; root.appendChild(btn1); btn1.addEventListener('click', async () => { if(btn1.classList.contains('--open')){ stop = true; const str = JSON.stringify(map, null, '\t'); const url = URL.createObjectURL(new Blob([str], { type: 'text/plain' })); const a = document.createElement('a'); a.href = url; a.download = `${userId}.json`; a.click(); URL.revokeObjectURL(url); map = {}; imgMap = {}; btn1.classList.remove('--open'); btn1.textContent = '开始抓取'; btn1.style.backgroundColor = '#06b0ff'; }else{ btn1.classList.add('--open'); btn1.textContent = '下载文件'; btn1.style.backgroundColor = '#F88C00'; stop = false; on(); } }); const btn3 = document.createElement('div'); btn3.textContent = '暂停'; btn3.style.cssText = ` ${btnDefaultStyle} `; root.appendChild(btn3); btn3.addEventListener('click', async () => { if(btn3.classList.contains('--open')){ pause = false; btn3.classList.remove('--open'); btn3.textContent = '暂停'; }else{ pause = true; btn3.classList.add('--open'); btn3.textContent = '继续'; } }); const btn2 = document.createElement('div'); btn2.textContent = '导入文件'; btn2.style.cssText = ` ${btnDefaultStyle} `; root.appendChild(btn2); btn2.addEventListener('click', async () => { const input = document.createElement('input'); input.type = 'file'; input.accept = '.json'; input.addEventListener('change', async () => { const file = input.files[0]; if(!file) return; const reader = new FileReader(); reader.readAsText(file); reader.onload = async () => { const data = JSON.parse(reader.result); map = data; userId = file.name.replace(/.json$/, ''); btn2.textContent = `更新: ${file.name}`; console.log(map); }; }); input.click(); }); })();