// ==UserScript== // @name X Pro // @namespace http://tampermonkey.net/ // @version 0.3 // @description Twitter AD Filter, Bookmark Remover, and Media Downloader. Hides ads, auto-removes bookmarks, and downloads media. // @description:zh-CN 隐藏推特广告、自动移除书签并一键下载媒体。 // @author OxqNbloF // @match https://x.com/* // @match https://mobile.x.com/* // @match https://*.x.com/* // @icon https://www.x.com/favicon.ico // @grant GM_registerMenuCommand // @grant GM_setValue // @grant GM_getValue // @grant GM_download // @license MIT // @downloadURL https://update.greasyfork.icu/scripts/529379/X%20Pro.user.js // @updateURL https://update.greasyfork.icu/scripts/529379/X%20Pro.meta.js // ==/UserScript== (function () { 'use strict'; // --- AD Filter --- const adFilter = (function () { function hideAd(node) { if (!node || node.nodeName !== "DIV") return; const testId = node.getAttribute("data-testid"); const placement = node.querySelector("div[data-testid='placementTracking'] > article"); if (testId === "cellInnerDiv" && placement) { node.style.display = "none"; } } function init() { const observer = new IntersectionObserver((entries, obs) => { entries.forEach(entry => { if (entry.isIntersecting) { hideAd(entry.target); obs.unobserve(entry.target); } }); }, { rootMargin: "100px", threshold: 0.1 }); const initialNodes = document.querySelectorAll("div[data-testid='cellInnerDiv']"); initialNodes.forEach(node => observer.observe(node)); const pageObserver = new MutationObserver(mutations => { mutations.forEach(mutation => { mutation.addedNodes.forEach(node => { if (node.nodeType === Node.ELEMENT_NODE && node.getAttribute("data-testid") === "cellInnerDiv") { observer.observe(node); } }); }); }); const mainContent = document.querySelector('main') || document.body; pageObserver.observe(mainContent, { childList: true, subtree: true, attributes: false }); const sidebarAd = document.querySelector( "[data-testid='sidebarColumn'] aside, " + "main > div > div > div > div > div > div > div > div > aside" ); if (sidebarAd) sidebarAd.style.display = "none"; } return { init }; })(); // --- Bookmark Remover --- const bookmarkRemover = (function () { function isBookmarkPage() { return window.location.pathname === '/i/bookmarks'; } function getSafeBottomOffset(tweetBottom) { const potentialObstacles = [ 'nav[role="navigation"]', 'div[role="navigation"]', 'footer', 'div[style*="position: fixed"][style*="bottom"]' ].map(selector => document.querySelector(selector)).filter(Boolean); let minOffset = 20; const viewportHeight = window.innerHeight; for (const obstacle of potentialObstacles) { const rect = obstacle.getBoundingClientRect(); if (rect.bottom > viewportHeight * 0.8) { minOffset = Math.max(minOffset, rect.height + 10); } } return Math.max(minOffset, viewportHeight - tweetBottom + 20); } function updateButtonPosition(button) { if (!isBookmarkPage()) { button.style.display = 'none'; return; } const tweets = document.querySelectorAll('article[data-testid="tweet"]'); if (!tweets.length) { button.style.display = 'flex'; button.style.bottom = '20px'; button.style.left = '50%'; button.style.transform = 'translateX(-50%)'; return; } button.style.display = 'flex'; const lastTweet = tweets[tweets.length - 1]; const lastTweetRect = lastTweet.getBoundingClientRect(); const tweetBottom = lastTweetRect.bottom + window.scrollY; button.style.bottom = `${getSafeBottomOffset(tweetBottom)}px`; button.style.left = '50%'; button.style.transform = 'translateX(-50%)'; } function delay(ms) { return new Promise(resolve => setTimeout(resolve, ms)); } async function removeSingleBookmark(item) { const bookmarkButton = item.querySelector('[data-testid="bookmark"]'); const removeButton = item.querySelector('[data-testid="removeBookmark"]'); if (removeButton) { removeButton.click(); return true; } else if (bookmarkButton) { bookmarkButton.click(); await delay(200); const newRemoveButton = document.querySelector('[data-testid="removeBookmark"]'); if (newRemoveButton) { newRemoveButton.click(); return true; } } return false; } function init() { const button = document.createElement('button'); button.innerHTML = '🗑️'; button.style.cssText = ` position: fixed; z-index: 9999; width: 60px; height: 60px; background-color: #ffffff; color: #000000; border: 2px solid #000000; border-radius: 50%; cursor: pointer; font-size: 28px; display: flex; align-items: center; justify-content: center; padding: 0; transition: transform 0.2s ease, background-color 0.3s ease; `; const styleSheet = document.createElement('style'); styleSheet.textContent = ` @keyframes pulse { 0% { transform: scale(1); } 50% { transform: scale(1.1); } 100% { transform: scale(1); } } @keyframes spin { 0% { transform: rotate(0deg); } 100% { transform: rotate(360deg); } } @keyframes bounce { 0%, 100% { transform: translateY(0); } 50% { transform: translateY(-10px); } } .idle { animation: pulse 2s infinite ease-in-out; } .removing { animation: spin 1s linear infinite; } .completed { animation: bounce 0.5s ease; } `; document.head.appendChild(styleSheet); let isRemoving = false; if (isBookmarkPage()) { document.body.appendChild(button); button.classList.add('idle'); updateButtonPosition(button); } button.addEventListener('click', async () => { if (!isBookmarkPage() || isRemoving) return; isRemoving = true; button.disabled = true; button.classList.remove('idle', 'completed'); button.classList.add('removing'); button.innerHTML = '⌛'; const removeAllBookmarks = async () => { window.scrollTo({ top: 0, behavior: 'smooth' }); await delay(500); let bookmarkItems = Array.from(document.querySelectorAll('article[data-testid="tweet"]')); if (!bookmarkItems.length || !isBookmarkPage()) return 0; let removedCount = 0; for (const item of bookmarkItems) { item.scrollIntoView({ behavior: 'smooth', block: 'center' }); await delay(300); if (await removeSingleBookmark(item)) { removedCount++; await delay(300); } } return removedCount; }; let removed = await removeAllBookmarks(); while (isBookmarkPage() && removed > 0) { await delay(500); removed = await removeAllBookmarks(); } if (isBookmarkPage()) { button.classList.remove('removing'); button.classList.add('completed'); button.innerHTML = '✔️'; button.disabled = false; isRemoving = false; setTimeout(() => { if (isBookmarkPage()) { button.classList.remove('completed'); button.classList.add('idle'); button.innerHTML = '🗑️'; } }, 3000); } else { button.classList.remove('removing', 'completed'); button.innerHTML = '🗑️'; button.disabled = false; isRemoving = false; } updateButtonPosition(button); }); button.addEventListener('mouseover', () => { if (!isRemoving) button.style.backgroundColor = '#f0f0f0'; }); button.addEventListener('mouseout', () => { if (!isRemoving) button.style.backgroundColor = '#ffffff'; }); const observer = new MutationObserver(() => { if (isBookmarkPage() && !document.body.contains(button)) { document.body.appendChild(button); button.classList.add('idle'); updateButtonPosition(button); } else if (!isBookmarkPage() && document.body.contains(button)) { button.remove(); } else if (isBookmarkPage()) { updateButtonPosition(button); } }); observer.observe(document.documentElement, { childList: true, subtree: true, attributes: true, attributeFilter: ['data-testid'] }); window.addEventListener('resize', () => { if (isBookmarkPage() && document.body.contains(button)) updateButtonPosition(button); }); window.addEventListener('scroll', () => { if (isBookmarkPage() && document.body.contains(button)) updateButtonPosition(button); }); window.addEventListener('popstate', () => { if (!isBookmarkPage() && isRemoving) { isRemoving = false; button.classList.remove('removing', 'completed'); button.disabled = false; button.innerHTML = '🗑️'; } if (isBookmarkPage()) updateButtonPosition(button); }); } return { init }; })(); // ---Media Downloader --- const mediaDownloader = (function () { const filename = 'twitter_{user-name}(@{user-id})_{date-time}_{status-id}_{file-type}'; const TMD = (function () { let lang, host, history, show_sensitive, is_tweetdeck; return { init: async function () { if (typeof GM_registerMenuCommand !== 'undefined') { GM_registerMenuCommand((this.language[navigator.language] || this.language.en).settings, this.settings); } lang = this.language[document.querySelector('html').lang] || this.language.en; host = location.hostname; is_tweetdeck = host.indexOf('tweetdeck') >= 0; history = this.storage_obsolete(); if (history.length) { this.storage(history); this.storage_obsolete(true); } else history = await this.storage(); show_sensitive = typeof GM_getValue !== 'undefined' ? GM_getValue('show_sensitive', false) : false; document.head.insertAdjacentHTML('beforeend', ''); let observer = new MutationObserver(ms => ms.forEach(m => m.addedNodes.forEach(node => this.detect(node)))); observer.observe(document.body, { childList: true, subtree: true }); }, detect: function (node) { let article = node.tagName == 'ARTICLE' && node || node.tagName == 'DIV' && (node.querySelector('article') || node.closest('article')); if (article) this.addButtonTo(article); let listitems = node.tagName == 'LI' && node.getAttribute('role') == 'listitem' && [node] || node.querySelectorAll('li[role="listitem"]'); if (listitems) this.addButtonToMedia(listitems); }, addButtonTo: function (article) { if (article.dataset.detected) return; article.dataset.detected = 'true'; let media_selector = [ 'a[href*="/photo"]', 'div[role="progressbar"]', 'div[data-testid="videoPlayer"], div[data-testid="playButton"]', 'a[href*="/settings/content"]', 'div[data-testid="tweetPhoto"]', 'img[src*="media"]' ]; let media = article.querySelector(media_selector.join(',')); if (media) { let status_id = (article.querySelector('a[href*="/status/"]')?.href.split('/status/').pop().split('/').shift()) || ''; if (!status_id) { console.warn('TMD: 无法提取status_id', article); return; } let btn_group = article.querySelector('div[role="group"]:last-of-type, ul.tweet-actions, ul.tweet-detail-actions, div[data-testid="tweetActions"]'); if (!btn_group) { console.warn('TMD: 未找到按钮组', article); return; } let btn_share = Array.from(btn_group.querySelectorAll(':scope>div>div, li.tweet-action-item>a, li.tweet-detail-action-item>a')).pop()?.parentNode; if (!btn_share) { console.warn('TMD: 未找到分享按钮', article); return; } let btn_down = btn_share.cloneNode(true); if (is_tweetdeck) { btn_down.firstElementChild.innerHTML = '' + this.svg + ''; btn_down.firstElementChild.removeAttribute('rel'); btn_down.classList.replace("pull-left", "pull-right"); } else { let svg = btn_down.querySelector('svg'); if (svg) svg.innerHTML = this.svg; } let is_exist = history.indexOf(status_id) >= 0; this.status(btn_down, 'tmd-down'); this.status(btn_down, is_exist ? 'completed' : 'download', is_exist ? lang.completed : lang.download); btn_group.insertBefore(btn_down, btn_share.nextSibling); btn_down.onclick = () => this.click(btn_down, status_id, is_exist); if (show_sensitive) { let btn_show = article.querySelector('div[aria-labelledby] div[role="button"][tabindex="0"], div[data-testid="sensitiveContentWarning"] span, button[aria-label*="sensitive"]'); if (btn_show) { btn_show.click(); console.log('TMD: 已点击敏感内容按钮'); } else { console.warn('TMD: 未找到敏感内容按钮'); } } } let imgs = article.querySelectorAll('img[src^="https://pbs.twimg.com/media/"]'); if (imgs.length > 1) { let status_id = (article.querySelector('a[href*="/status/"]')?.href.split('/status/').pop().split('/').shift()) || ''; let btn_group = article.querySelector('div[role="group"]:last-of-type, div[data-testid="tweetActions"]'); if (!btn_group) return; let btn_share = Array.from(btn_group.querySelectorAll(':scope>div>div')).pop()?.parentNode; imgs.forEach((img, i) => { if (!img.parentElement?.dataset.testid === 'tweetPhoto') return; let index = i + 1; let is_exist = history.indexOf(status_id) >= 0; let btn_down = document.createElement('div'); btn_down.innerHTML = '
' + this.svg + '
'; btn_down.classList.add('tmd-down', 'tmd-img'); this.status(btn_down, 'download'); img.parentNode.appendChild(btn_down); btn_down.onclick = e => { e.preventDefault(); this.click(btn_down, status_id, is_exist, index); }; }); } }, addButtonToMedia: function (listitems) { listitems.forEach(li => { if (li.dataset.detected) return; li.dataset.detected = 'true'; let status_id = (li.querySelector('a[href*="/status/"]')?.href.split('/status/').pop().split('/').shift()) || ''; let is_exist = history.indexOf(status_id) >= 0; let btn_down = document.createElement('div'); btn_down.innerHTML = '
' + this.svg + '
'; btn_down.classList.add('tmd-down', 'tmd-media'); this.status(btn_down, is_exist ? 'completed' : 'download', is_exist ? lang.completed : lang.download); li.appendChild(btn_down); btn_down.onclick = () => this.click(btn_down, status_id, is_exist); }); }, click: async function (btn, status_id, is_exist, index) { if (btn.classList.contains('loading')) return; this.status(btn, 'loading'); let out = (await (typeof GM_getValue !== 'undefined' ? GM_getValue('filename', filename) : filename)).split('\n').join(''); let save_history = await (typeof GM_getValue !== 'undefined' ? GM_getValue('save_history', true) : true); // Fetch user info from DOM let article = btn.closest('article') || btn.closest('li[role="listitem"]'); if (!article) { this.status(btn, 'failed', 'ARTICLE_NOT_FOUND'); alert('无法找到推文容器'); console.error('TMD: 未找到推文容器', btn); return; } let userLink = article.querySelector('a[href*="/status/"]')?.href.match(/\/([^\/]+)\/status\//)?.[1] || 'unknown'; let userName = article.querySelector('div[dir="ltr"] span')?.textContent.replace(/@.*$/, '').trim() || 'unknown'; let createdAt = article.querySelector('time')?.getAttribute('datetime') || new Date().toISOString(); let fullText = article.querySelector('div[lang]')?.textContent.trim().replace(/\s*https:\/\/t\.co\/\w+/g, '') || ''; let invalid_chars = {'\\': '\', '\/': '/', '\|': '|', '<': '<', '>': '>', ':': ':', '*': '*', '?': '?', '"': '"', '\u200b': '', '\u200c': '', '\u200d': '', '\u2060': '', '\ufeff': '', '🔞': ''}; let datetime = out.match(/{date-time(-local)?:[^{}]+}/) ? out.match(/{date-time(?:-local)?:([^{}]+)}/)[1].replace(/[\\/|<>*?:"]/g, v => invalid_chars[v]) : 'YYYYMMDD-hhmmss'; let info = { 'status-id': status_id, 'user-name': userName.replace(/([\\/|*?:"]|[\u200b-\u200d\u2060\ufeff]|🔞)/g, v => invalid_chars[v]), 'user-id': userLink.replace(/([\\/|*?:"]|[\u200b-\u200d\u2060\ufeff]|🔞)/g, v => invalid_chars[v]), 'date-time': this.formatDate(createdAt, datetime), 'date-time-local': this.formatDate(createdAt, datetime, true), 'full-text': fullText.replace(/[\\/|<>*?:"]|[\u200b-\u200d\u2060\ufeff]/g, v => invalid_chars[v]) }; let imageURLs = []; let gifURLs = []; let videoURLs = []; imageURLs = Array.from(article.querySelectorAll('img[src^="https://pbs.twimg.com/media/"]')) .filter(img => img.parentElement?.dataset.testid === 'tweetPhoto' && !img.closest('div[tabindex="0"][role="link"]') && !img.closest('div[data-testid="previewInterstitial"]') && !img.src.includes('tweet_video')) .map(img => img.src.includes('name=') ? img.src.replace(/name=.*/ig, 'name=4096x4096') : img.src); gifURLs = Array.from(article.querySelectorAll('img[src*="tweet_video"]')) .filter(img => img.parentElement?.dataset.testid === 'tweetPhoto') .map(img => img.src.replace('tweet_video_thumb', 'tweet_video').replace(/\.jpg$|\.png$/i, '.mp4')); videoURLs = Array.from(article.querySelectorAll('div[data-testid="videoPlayer"] video[src]')) .map(video => video.src) .filter(url => url && url.includes('video')); if (!videoURLs.length) { videoURLs = Array.from(article.querySelectorAll('div[data-testid="videoPlayer"] video[poster]')) .map(video => { let poster = video.getAttribute('poster'); if (poster && poster.includes('amplify')) { return poster.replace('amplify', 'vid').replace(/\.jpg$|\.png$/i, '') + '/mp4'; } return null; }) .filter(url => url); } console.log('TMD: Extracted URLs', { imageURLs, gifURLs, videoURLs }); let allMediaURLs = [...imageURLs, ...gifURLs, ...videoURLs]; if (allMediaURLs.length === 0) { this.status(btn, 'failed', 'MEDIA_NOT_FOUND'); alert('此推文没有图片、GIF或视频'); console.error('TMD: 未找到媒体', article); return; } if (index) { allMediaURLs = [allMediaURLs[index - 1]].filter(url => url); } let tasks_result = []; let tasks = allMediaURLs.length; allMediaURLs.forEach((mediaURL, i) => { let cleanURL = mediaURL.split(/[?#]/)[0]; let extMatch = cleanURL.match(/\.(\w+)$/); let ext = extMatch ? extMatch[1].toLowerCase() : (mediaURL.includes('tweet_video') || mediaURL.includes('video') ? 'mp4' : 'jpg'); let type = mediaURL.includes('tweet_video') ? 'gif' : (mediaURL.includes('video') || mediaURL.includes('vid') ? 'video' : 'photo'); info['file-ext'] = ext; info['file-type'] = type; let filenameBase = out.replace(/{([^{}:]+)(:[^{}]+)?}/g, (match, name) => info[name] || ''); let finalFilename = filenameBase.replace(/\.?{file-ext}/, '') + (allMediaURLs.length > 1 && !index ? `-${i}` : '') + `.${ext}`; console.log('TMD: Generated filename', { mediaURL, type, ext, finalFilename }); this.downloader.add({ url: mediaURL, name: finalFilename, onload: () => { tasks -= 1; tasks_result.push((allMediaURLs.length > 1 || index ? (index || i + 1) + ': ' : '') + lang.completed); this.status(btn, null, tasks_result.sort().join('\n')); if (tasks === 0) { this.status(btn, 'completed', lang.completed); if (save_history && !is_exist) { history.push(status_id); this.storage(status_id); } } }, onerror: result => { tasks = -1; tasks_result.push((allMediaURLs.length > 1 || index ? (index || i + 1) + ': ' : '') + (result.details?.current || '下载失败')); this.status(btn, 'failed', tasks_result.sort().join('\n')); console.error('TMD: 下载失败', { mediaURL, finalFilename, error: result.details?.current }); } }); }); }, status: function (btn, css, title, style) { if (css) { btn.classList.remove('download', 'completed', 'loading', 'failed'); btn.classList.add(css); } if (title) btn.title = title; if (style) btn.style.cssText = style; }, settings: async function () { const $element = (parent, tag, style, content, css) => { let el = document.createElement(tag); if (style) el.style.cssText = style; if (typeof content !== 'undefined') { if (tag == 'input') { if (content == 'checkbox') el.type = content; else el.value = content; } else el.innerHTML = content; } if (css) css.split(' ').forEach(c => el.classList.add(c)); parent.appendChild(el); return el; }; let wapper = $element(document.body, 'div', 'position: fixed; left: 0px; top: 0px; width: 100%; height: 100%; background-color: #0009; z-index: 10;'); let wapper_close; wapper.onmousedown = e => { wapper_close = e.target == wapper; }; wapper.onmouseup = e => { if (wapper_close && e.target == wapper) wapper.remove(); }; let dialog = $element(wapper, 'div', 'position: absolute; left: 50%; top: 50%; transform: translateX(-50%) translateY(-50%); width: fit-content; width: -moz-fit-content; background-color: #f3f3f3; border: 1px solid #ccc; border-radius: 10px; color: black;'); let title = $element(dialog, 'h3', 'margin: 10px 20px;', lang.dialog.title); let options = $element(dialog, 'div', 'margin: 10px; border: 1px solid #ccc; border-radius: 5px;'); let save_history_label = $element(options, 'label', 'display: block; margin: 10px;', lang.dialog.save_history); let save_history_input = $element(save_history_label, 'input', 'float: left;', 'checkbox'); save_history_input.checked = await (typeof GM_getValue !== 'undefined' ? GM_getValue('save_history', true) : true); save_history_input.onchange = () => { if (typeof GM_setValue !== 'undefined') GM_setValue('save_history', save_history_input.checked); }; let clear_history = $element(save_history_label, 'label', 'display: inline-block; margin: 0 10px; color: blue;', lang.dialog.clear_history); clear_history.onclick = () => { if (confirm(lang.dialog.clear_confirm)) { history = []; if (typeof GM_setValue !== 'undefined') GM_setValue('download_history', []); } }; let show_sensitive_label = $element(options, 'label', 'display: block; margin: 10px;', lang.dialog.show_sensitive); let show_sensitive_input = $element(show_sensitive_label, 'input', 'float: left;', 'checkbox'); show_sensitive_input.checked = await (typeof GM_getValue !== 'undefined' ? GM_getValue('show_sensitive', false) : false); show_sensitive_input.onchange = () => { show_sensitive = show_sensitive_input.checked; if (typeof GM_setValue !== 'undefined') GM_setValue('show_sensitive', show_sensitive); }; let filename_div = $element(dialog, 'div', 'margin: 10px; border: 1px solid #ccc; border-radius: 5px;'); let filename_label = $element(filename_div, 'label', 'display: block; margin: 10px 15px;', lang.dialog.pattern); let filename_input = $element(filename_label, 'textarea', 'display: block; min-width: 500px; max-width: 500px; min-height: 100px; font-size: inherit; background: white; color: black;', await (typeof GM_getValue !== 'undefined' ? GM_getValue('filename', filename) : filename)); let filename_tags = $element(filename_div, 'label', 'display: table; margin: 10px;', ` {user-name} {user-id} {status-id} {date-time}
{full-text} {file-type} `); filename_input.selectionStart = filename_input.value.length; filename_tags.querySelectorAll('.tmd-tag').forEach(tag => { tag.onclick = () => { let ss = filename_input.selectionStart; let se = filename_input.selectionEnd; filename_input.value = filename_input.value.substring(0, ss) + tag.innerText + filename_input.value.substring(se); filename_input.selectionStart = ss + tag.innerText.length; filename_input.selectionEnd = ss + tag.innerText.length; filename_input.focus(); }; }); let btn_save = $element(title, 'label', 'float: right;', lang.dialog.save, 'tmd-btn'); btn_save.onclick = async () => { if (typeof GM_setValue !== 'undefined') await GM_setValue('filename', filename_input.value); wapper.remove(); }; }, getCookie: function (name) { let cookies = {}; document.cookie.split(';').filter(n => n.indexOf('=') > 0).forEach(n => { n.replace(/^([^=]+)=(.+)$/, (match, name, value) => { cookies[name.trim()] = value.trim(); }); }); return name ? cookies[name] : cookies; }, storage: async function (value) { let data = await (typeof GM_getValue !== 'undefined' ? GM_getValue('download_history', []) : []); let data_length = data.length; if (value) { if (Array.isArray(value)) data = data.concat(value); else if (data.indexOf(value) < 0) data.push(value); } else return data; if (data.length > data_length && typeof GM_setValue !== 'undefined') GM_setValue('download_history', data); }, storage_obsolete: function (is_remove) { let data = JSON.parse(localStorage.getItem('history') || '[]'); if (is_remove) localStorage.removeItem('history'); else return data; }, formatDate: function (i, o, tz) { let d = new Date(i); if (tz) d.setMinutes(d.getMinutes() - d.getTimezoneOffset()); let m = ['JAN', 'FEB', 'MAR', 'APR', 'MAY', 'JUN', 'JUL', 'AUG', 'SEP', 'OCT', 'NOV', 'DEC']; let v = { YYYY: d.getUTCFullYear().toString(), YY: d.getUTCFullYear().toString().slice(-2), MM: ('0' + (d.getUTCMonth() + 1)).slice(-2), MMM: m[d.getUTCMonth()], DD: ('0' + d.getUTCDate()).slice(-2), hh: ('0' + d.getUTCHours()).slice(-2), mm: ('0' + d.getUTCMinutes()).slice(-2), ss: ('0' + d.getUTCSeconds()).slice(-2), h2: ('0' + (d.getUTCHours() % 12 || 12)).slice(-2), ap: d.getUTCHours() < 12 ? 'AM' : 'PM' }; return o.replace(/(YY(YY)?|MMM?|DD|hh|mm|ss|h2|ap)/g, n => v[n]); }, downloader: (function () { let tasks = [], thread = 0, max_thread = 2, retry = 0, max_retry = 2, failed = 0, notifier, has_failed = false; return { add: function (task) { tasks.push(task); if (thread < max_thread) { thread += 1; this.next(); } else this.update(); }, next: async function () { let task = tasks.shift(); await this.start(task); if (tasks.length > 0 && thread <= max_thread) this.next(); else thread -= 1; this.update(); }, start: function (task) { this.update(); return new Promise(resolve => { if (typeof GM_download !== 'undefined') { GM_download({ url: task.url, name: task.name, onload: result => { task.onload(); resolve(); }, onerror: result => { this.downloadBlob({ url: task.url, name: task.name }).then(() => { task.onload(); resolve(); }).catch(err => { this.retry(task, { details: { current: err.message || 'BLOB下载失败' } }); resolve(); }); }, ontimeout: result => { this.retry(task, result); resolve(); } }); } else { this.downloadBlob({ url: task.url, name: task.name }).then(() => { task.onload(); resolve(); }).catch(err => { task.onerror({ details: { current: err.message || 'BLOB下载失败' } }); resolve(); }); } }); }, fetchStream: async function (url) { try { const response = await fetch(url, { headers: { 'User-Agent': 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/134.0.0.0 Safari/537.36', 'Referer': 'https://x.com/' }, credentials: 'omit' }); if (!response.ok) throw new Error(`HTTP ${response.status}: ${response.statusText}`); return await response.blob(); } catch (error) { console.error('TMD: 流下载失败', error); return null; } }, downloadBlob: async function (task) { let blob = await this.fetchStream(task.url); if (!blob) throw new Error('无法获取Blob'); let url = URL.createObjectURL(blob); let a = document.createElement('a'); a.href = url; a.download = task.name; document.body.appendChild(a); a.click(); document.body.removeChild(a); URL.revokeObjectURL(url); }, retry: function (task, result) { retry += 1; if (retry == 3) max_thread = 1; if (task.retry && task.retry >= max_retry || result.details?.current == 'USER_CANCELED') { task.onerror(result); failed += 1; } else { if (max_thread == 1) task.retry = (task.retry || 0) + 1; this.add(task); } }, update: function () { if (!notifier) { notifier = document.createElement('div'); notifier.title = 'Twitter Media Downloader'; notifier.classList.add('tmd-notifier'); notifier.innerHTML = '|'; document.body.appendChild(notifier); } if (failed > 0 && !has_failed) { has_failed = true; notifier.innerHTML += '|'; let clear = document.createElement('label'); notifier.appendChild(clear); clear.onclick = () => { notifier.innerHTML = '|'; failed = 0; has_failed = false; this.update(); }; } notifier.firstChild.innerText = thread; notifier.firstChild.nextElementSibling.innerText = tasks.length; if (failed > 0) notifier.lastChild.innerText = failed; if (thread > 0 || tasks.length > 0 || failed > 0) notifier.classList.add('running'); else notifier.classList.remove('running'); } }; })(), language: { en: { download: 'Download', completed: 'Download Completed', settings: 'Settings', dialog: { title: 'Download Settings', save: 'Save', save_history: 'Remember download history', clear_history: '(Clear)', clear_confirm: 'Clear download history?', show_sensitive: 'Always show sensitive content', pattern: 'File Name Pattern' } }, ja: { download: 'ダウンロード', completed: 'ダウンロード完了', settings: '設定', dialog: { title: 'ダウンロード設定', save: '保存', save_history: 'ダウンロード履歴を保存する', clear_history: '(クリア)', clear_confirm: 'ダウンロード履歴を削除する?', show_sensitive: 'センシティブな内容を常に表示する', pattern: 'ファイル名パターン' } }, zh: { download: '下载', completed: '下载完成', settings: '设置', dialog: { title: '下载设置', save: '保存', save_history: '保存下载记录', clear_history: '(清除)', clear_confirm: '确认要清除下载记录?', show_sensitive: '自动显示敏感的内容', pattern: '文件名格式' } }, 'zh-Hant': { download: '下載', completed: '下載完成', settings: '設置', dialog: { title: '下載設置', save: '保存', save_history: '保存下載記錄', clear_history: '(清除)', clear_confirm: '確認要清除下載記錄?', show_sensitive: '自動顯示敏感的内容', pattern: '文件名規則' } } }, css: ` .tmd-down {margin-left: 12px; order: 99;} .tmd-down:hover > div > div > div > div {color: rgba(29, 161, 242, 1.0);} .tmd-down:hover > div > div > div > div > div {background-color: rgba(29, 161, 242, 0.1);} .tmd-down:active > div > div > div > div > div {background-color: rgba(29, 161, 242, 0.2);} .tmd-down:hover svg {color: rgba(29, 161, 242, 1.0);} .tmd-down:hover div:first-child:not(:last-child) {background-color: rgba(29, 161, 242, 0.1);} .tmd-down:active div:first-child:not(:last-child) {background-color: rgba(29, 161, 242, 0.2);} .tmd-down.tmd-media {position: absolute; right: 0;} .tmd-down.tmd-media > div {display: flex; border-radius: 99px; margin: 2px;} .tmd-down.tmd-media > div > div {display: flex; margin: 6px; color: #fff;} .tmd-down.tmd-media:hover > div {background-color: rgba(255,255,255, 0.6);} .tmd-down.tmd-media:hover > div > div {color: rgba(29, 161, 242, 1.0);} .tmd-down.tmd-media:not(:hover) > div > div {filter: drop-shadow(0 0 1px #000);} .tmd-down g {display: none;} .tmd-down.download g.download, .tmd-down.completed g.completed, .tmd-down.loading g.loading,.tmd-down.failed g.failed {display: unset;} .tmd-down.loading svg {animation: spin 1s linear infinite;} @keyframes spin {0% {transform: rotate(0deg);} 100% {transform: rotate(360deg);}} .tmd-btn {display: inline-block; background-color: #1DA1F2; color: #FFFFFF; padding: 0 20px; border-radius: 99px;} .tmd-tag {display: inline-block; background-color: #FFFFFF; color: #1DA1F2; padding: 0 10px; border-radius: 10px; border: 1px solid #1DA1F2; font-weight: bold; margin: 5px;} .tmd-btn:hover {background-color: rgba(29, 161, 242, 0.9);} .tmd-tag:hover {background-color: rgba(29, 161, 242, 0.1);} .tmd-notifier {display: none; position: fixed; left: 16px; bottom: 16px; color: #000; background: #fff; border: 1px solid #ccc; border-radius: 8px; padding: 4px;} .tmd-notifier.running {display: flex; align-items: center;} .tmd-notifier label {display: inline-flex; align-items: center; margin: 0 8px;} .tmd-notifier label:before {content: " "; width: 32px; height: 16px; background-position: center; background-repeat: no-repeat;} .tmd-notifier label:nth-child(1):before {background-image:url("data:image/svg+xml;charset=utf8,");} .tmd-notifier label:nth-child(2):before {background-image:url("data:image/svg+xml;charset=utf8,");} .tmd-notifier label:nth-child(3):before {background-image:url("data:image/svg+xml;charset=utf8,");} .tmd-down.tmd-img {position: absolute; right: 0; bottom: 0; display: none !important;} .tmd-down.tmd-img > div {display: flex; border-radius: 99px; margin: 2px; background-color: rgba(255,255,255, 0.6);} .tmd-down.tmd-img > div > div {display: flex; margin: 6px; color: #fff !important;} .tmd-down.tmd-img:not(:hover) > div > div {filter: drop-shadow(0 0 1px #000);} .tmd-down.tmd-img:hover > div > div {color: rgba(29, 161, 242, 1.0);} :hover > .tmd-down.tmd-img, .tmd-img.loading, .tmd-img.completed, .tmd-img.failed {display: block !important;} .tweet-detail-action-item {width: 20% !important;} `, css_ss: ` li[role="listitem"]>div>div>div>div:not(:last-child) {filter: none;} li[role="listitem"]>div>div>div>div+div:last-child {display: none;} `, svg: ` ` }; })(); return TMD; })(); adFilter.init(); bookmarkRemover.init(); mediaDownloader.init(); })();