// ==UserScript==
// @name Twitter/X Media Downloader
// @description Download videos/pictures with one click | Automatically package them into a ZIP file for batch download
// @author KanashiiWolf
// @namespace https://wulf.nekoweb.org
// @homepage https://wulf.nekoweb.org
// @supportURL https://greasyfork.org/en/scripts/560318-twitter-x-media-downloader/feedback
// @license MIT
// @icon data:image/svg+xml;base64,PHN2ZyB4bWxucz0iaHR0cDovL3d3dy53My5vcmcvMjAwMC9zdmciIHdpZHRoPSI2NCIgaGVpZ2h0PSI2NCIgdmlld0JveD0iMCAwIDI0IDI0IiBmaWxsPSJub25lIiBzdHJva2U9IiMwMGYzZmYiIHN0cm9rZS13aWR0aD0iMiIgc3Ryb2tlLWxpbmVjYXA9InJvdW5kIiBzdHJva2UtbGluZWpvaW49InJvdW5kIiBzdHlsZT0iYmFja2dyb3VuZDojMDAwIj48cGF0aCBkPSJNMTggNiA2IDE4Ii8+PHBhdGggZD0ibTYgNiAxMiAxMiIvPjwvc3ZnPg==
// @grant GM_registerMenuCommand
// @grant GM_setValue
// @grant GM_getValue
// @grant GM_download
// @grant GM_xmlhttpRequest
// @match https://x.com/*
// @match https://twitter.com/*
// @version 1.0.3
// @require https://cdnjs.cloudflare.com/ajax/libs/jszip/3.7.1/jszip.min.js
// @downloadURL https://update.greasyfork.icu/scripts/560318/TwitterX%20Media%20Downloader.user.js
// @updateURL https://update.greasyfork.icu/scripts/560318/TwitterX%20Media%20Downloader.meta.js
// ==/UserScript==
/**
* Code started from:
* File: twitter-media-downloader.user.js
* Project: UserScripts
* Author: goemon2017,天音,Tiande,molanp,人民的勤务员@ChinaGodMan (china.qinwuyuan@gmail.com)
* URL: https://github.com/ChinaGodMan/UserScripts
* License: MIT License
*/
/* jshint esversion: 8 */
// ==========================================
// Constants & Configuration
// ==========================================
const FILENAME_PATTERN = '@{user-id}-{status-id}';
const INVALID_CHARS = { '\\': '\', '/': '/', '|': '|', '<': '<', '>': '>', ':': ':', '*': '*', '?': '?', '"': '"', '\u200b': '', '\u200c': '', '\u200d': '', '\u2060': '', '\ufeff': '', '🔞': '' };
// Selectors for media elements in the DOM
const MEDIA_SELECTOR = [
'a[href*="/photo/1"]', 'div[role="progressbar"]', 'button[data-testid="playButton"]',
'a[href="/settings/content_you_see"]', 'div.media-image-container', 'div.media-preview-container',
'div[aria-labelledby]>div:first-child>div[role="button"][tabindex="0"]'
].join(',');
const TMD = (function () {
// ==========================================
// State Variables
// ==========================================
let lang, host, is_tweetdeck;
let history_cache = new Set(); // Using Set for O(1) lookups and auto-deduplication
// ==========================================
// Helper Functions (Private)
// ==========================================
// Safely extract tweet ID from URL, handling params like ?newtwitter=true
const getId = url => url.split('/status/').pop().split(/[/?#]/)[0];
// Sanitize string for markdown/filenames
const cleanText = str => str.replace(/([\\/|*?:"\u200b-\u200d\u2060\ufeff]|🔞)/g, c => INVALID_CHARS[c] || '');
// Check if website is in dark mode
const updateTheme = () => {
let rgb = getComputedStyle(document.body).backgroundColor.match(/\d+/g);
if (rgb && (0.299 * rgb[0] + 0.587 * rgb[1] + 0.114 * rgb[2]) < 128) {
document.body.classList.add('tmd-dark');
} else {
document.body.classList.remove('tmd-dark');
}
};
// ==========================================
// Core Logic
// ==========================================
return {
/**
* Initialize the script, register menu commands, and start the observer.
*/
init: async function () {
// Wait 100ms to allow Old Twitter DOM injection to occur (if present)
await new Promise(resolve => setTimeout(resolve, 100));
// Check for #oldtwitter-version to prevent running on incompatible layouts
if (document.querySelector('#oldtwitter-version')) {
console.log('TMD: Old Twitter Layout detected. Aborting.');
return;
}
// Fix: Bind 'this' to settings so it functions correctly when called from menu
GM_registerMenuCommand((this.language[navigator.language] || this.language.en).settings, () => this.settings());
GM_registerMenuCommand('Export History (Markdown)', async () => this.exportHistory());
lang = this.language[document.querySelector('html').lang] || this.language.en;
host = location.hostname;
is_tweetdeck = host.indexOf('tweetdeck') >= 0;
// Load history from storage into memory cache
await this.history_load();
document.head.insertAdjacentHTML('beforeend', '');
// Initial theme check
updateTheme();
// Observer to detect new tweets and theme changes
new MutationObserver(ms => {
ms.forEach(m => {
if (m.target === document.body && m.attributeName === 'style') updateTheme();
m.addedNodes.forEach(node => this.detect(node));
});
}).observe(document.body, { childList: true, subtree: true, attributes: true });
// MANUAL SWEEP: Detect content that loaded during the 100ms delay
document.querySelectorAll('article').forEach(article => this.addButtonTo(article));
const existingMediaTabs = document.querySelectorAll('li[role="listitem"]');
if (existingMediaTabs.length > 0) {
this.addButtonToMedia(existingMediaTabs);
}
},
// ==========================================
// History Management (Refactored)
// ==========================================
history_load: async function() {
// 1. Load standard GM storage
let gm_data = await GM_getValue('download_history', []);
// 2. Load legacy localStorage (migration path)
let ls_data = JSON.parse(localStorage.getItem('history') || '[]');
// 3. Merge into Set (Handles deduplication automatically)
history_cache = new Set([...gm_data, ...ls_data]);
// 4. Clean up legacy storage if found
if (ls_data.length > 0) {
localStorage.removeItem('history');
await this.history_save();
}
},
history_add: async function(val) {
let prevSize = history_cache.size;
// Handle both single string and array of strings
if (Array.isArray(val)) {
val.forEach(v => history_cache.add(v));
} else {
history_cache.add(val);
}
// Only write to disk if something actually changed
if (history_cache.size > prevSize) {
await this.history_save();
}
},
history_save: async function() {
// Convert Set back to Array for storage
await GM_setValue('download_history', Array.from(history_cache));
},
history_clear: async function() {
// 1. Clear memory cache
history_cache.clear();
// 2. Clear persistent storage
await GM_setValue('download_history', []);
// 3. Visual Feedback: Reset all "Completed" buttons on the page to "Download"
document.querySelectorAll('.tmd-down.completed').forEach(btn => {
this.status(btn, 'download', lang.download);
});
},
// ==========================================
// Export
// ==========================================
exportHistory: async function () {
try {
// Use cache for export
const historyList = Array.from(history_cache);
if (!historyList.length) return;
const markdownContent = '# Twitter/X Media Downloader history\n\n' +
(await Promise.all(historyList.map(id => this.generateMarkdown(id)))).join('\n');
const blob = new Blob([markdownContent], { type: 'text/markdown;charset=utf-8' });
const link = document.createElement('a');
link.href = URL.createObjectURL(blob);
link.download = `twitter_download_history_(${historyList.length}).md`;
document.body.appendChild(link);
link.click();
document.body.removeChild(link);
URL.revokeObjectURL(link.href);
} catch (error) {
console.error('Error exporting history:', error);
alert('An error occurred while exporting Markdown history.');
}
},
generateMarkdown: async function (tweet_id, fetch = true) {
// Skip individual image history entries (e.g., 12345_1) when generating generic tweet history
if (tweet_id.includes('_')) return '';
if (!fetch) return `[Tweet] - ${tweet_id} (https://x.com/i/web/status/${tweet_id})`;
try {
let json = await this.fetchJson(tweet_id);
let tweet = json.quoted_status_result?.result?.legacy?.media || json.quoted_status_result?.result?.legacy || json.legacy;
let user = json.core.user_results.result.legacy;
// Clean inputs
let user_name = cleanText(user.name);
let full_text = cleanText(tweet.full_text.split('\n').join(' ').replace(/\s*https:\/\/t\.co\/\w+/g, ''));
return `[${user_name} (@${user.screen_name})](https://x.com/i/web/status/${tweet_id})\n> ${full_text}\n`;
} catch (e) {
return `[Error] - Could not fetch ${tweet_id}`;
}
},
// ==========================================
// DOM Detection & UI Injection
// ==========================================
detect: function (node) {
// Standard: Check if node is ARTICLE or contains one
let article = (node.tagName == 'ARTICLE' && node) || (node.tagName == 'DIV' && (node.querySelector('article') || node.closest('article')));
if (article) this.addButtonTo(article);
// Standard: Check if node is listitem (media tab)
let listitems = (node.tagName == 'LI' && node.getAttribute('role') == 'listitem' && [node]) || (node.tagName == 'DIV' && node.querySelectorAll('li[role="listitem"]'));
if (listitems) this.addButtonToMedia(listitems);
},
addButtonTo: function (article) {
// Check for valid tweet link
let statusLink = article.querySelector('a[href*="/status/"]');
if (!statusLink) return;
let status_id = getId(statusLink.href);
// =================================================================
// Main Button Logic (For Videos, GIFs, or Main Container)
// =================================================================
let media = article.querySelector(MEDIA_SELECTOR);
if (media) {
let btn_group = article.querySelector('div[role="group"]:last-of-type, ul.tweet-actions, ul.tweet-detail-actions');
if (btn_group) {
let existingBtn = btn_group.querySelector('.tmd-down:not(.tmd-img)');
// Cleanup Stale Button (Recycled DOM Node)
if (existingBtn && existingBtn.dataset.statusId !== status_id) {
existingBtn.remove();
existingBtn = null;
}
// Check history using Set
let is_exist = history_cache.has(status_id);
if (!existingBtn) {
// Create Button
let btn_share = Array.from(btn_group.querySelectorAll(':scope>div>div, li.tweet-action-item>a, li.tweet-detail-action-item>a')).pop().parentNode;
let btn_down = btn_share.cloneNode(true);
btn_down.querySelector('button').removeAttribute('disabled');
if (is_tweetdeck) {
btn_down.firstElementChild.innerHTML = ``;
btn_down.firstElementChild.removeAttribute('rel');
btn_down.classList.replace('pull-left', 'pull-right');
} else {
btn_down.querySelector('svg').innerHTML = this.svg;
}
// Store ID on button for future staleness checks
btn_down.dataset.statusId = status_id;
this.status(btn_down, 'tmd-down');
// Insert
btn_group.insertBefore(btn_down, btn_share.nextSibling);
// Setup logic
this.status(btn_down, is_exist ? 'completed' : 'download', is_exist ? lang.completed : lang.download);
btn_down.onclick = () => this.click(btn_down, status_id, is_exist);
} else {
// Button exists & ID matches: Update status
this.status(existingBtn, is_exist ? 'completed' : 'download', is_exist ? lang.completed : lang.download);
// Ensure click handler has latest state
existingBtn.onclick = () => this.click(existingBtn, status_id, is_exist);
}
}
}
// =================================================================
// Multiple Images Logic
// =================================================================
let imgs = article.querySelectorAll('a[href*="/photo/"]');
if (imgs.length > 0) {
let main_status_id = status_id;
let all_images_downloaded = true;
imgs.forEach(img => {
let urlParts = img.href.split('/status/');
if (urlParts.length < 2) return;
let specific_id = urlParts[1].split('/')[0];
let index = urlParts[1].split('/').pop().split(/[/?#]/)[0];
let img_uid = `${specific_id}_${index}`;
// Check history using Set
let is_exist = history_cache.has(img_uid) || history_cache.has(specific_id);
if (!is_exist) all_images_downloaded = false;
let existingImgBtn = img.parentNode.querySelector('.tmd-down.tmd-img');
// Cleanup Stale Button (Recycled DOM Node)
if (existingImgBtn && existingImgBtn.dataset.imgUid !== img_uid) {
existingImgBtn.remove();
existingImgBtn = null;
}
if (!existingImgBtn) {
let btn_down = document.createElement('div');
// Use larger dimensions for image buttons (24px vs 18px)
btn_down.innerHTML = `
`;
btn_down.classList.add('tmd-down', 'tmd-img');
btn_down.dataset.imgUid = img_uid;
btn_down.dataset.statusId = specific_id;
this.status(btn_down, is_exist ? 'completed' : 'download');
img.parentNode.classList.add('tmd-img-parent');
img.parentNode.appendChild(btn_down);
btn_down.onclick = e => {
e.preventDefault();
e.stopPropagation();
// Re-check state on click
let current_exist = history_cache.has(img_uid) || history_cache.has(specific_id);
this.click(btn_down, specific_id, current_exist, index);
};
} else {
// Update status
this.status(existingImgBtn, is_exist ? 'completed' : 'download');
}
});
// Update Main Button if all images are done
if (all_images_downloaded && media) {
let mainBtn = article.querySelector('.tmd-down:not(.tmd-img)');
if (mainBtn && !history_cache.has(main_status_id)) {
this.status(mainBtn, 'completed', lang.completed);
}
}
}
},
addButtonToMedia: function (listitems) {
listitems.forEach(li => {
let statusLink = li.querySelector('a[href*="/status/"]');
if (!statusLink) return;
let status_id = getId(statusLink.href);
let is_exist = history_cache.has(status_id);
let existingBtn = li.querySelector('.tmd-down.tmd-media');
// Cleanup Stale Button
if (existingBtn && existingBtn.dataset.statusId !== status_id) {
existingBtn.remove();
existingBtn = null;
}
if (!existingBtn) {
let btn_down = document.createElement('div');
btn_down.innerHTML = ``;
btn_down.classList.add('tmd-down', 'tmd-media');
btn_down.dataset.statusId = status_id;
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);
} else {
// Update status
this.status(existingBtn, is_exist ? 'completed' : 'download', is_exist ? lang.completed : lang.download);
existingBtn.onclick = () => this.click(existingBtn, status_id, is_exist);
}
});
},
// ==========================================
// User Interactions
// ==========================================
selectTweetDialog: function (originalUser, quotedUser) {
return new Promise(resolve => {
const overlay = document.createElement('div');
overlay.className = 'tmd-overlay';
const dialog = document.createElement('div');
dialog.className = 'tmd-modal';
const title = document.createElement('h3');
title.innerText = `${lang.choose}`;
const container = document.createElement('div');
container.style.cssText = 'display:flex;flex-direction:column;gap:12px;';
const createBtn = (text, type) => {
const btn = document.createElement('button');
btn.textContent = text;
btn.className = `tmd-btn ${type || ''}`;
return btn;
};
const originalBtn = createBtn(`${lang.original} (by ${originalUser})`);
originalBtn.onclick = () => { resolve('original'); overlay.remove(); };
const quotedBtn = createBtn(`${lang.quote} (by ${quotedUser})`, 'secondary');
quotedBtn.onclick = () => { resolve('quoted'); overlay.remove(); };
const cancelBtn = createBtn(`${lang.cancel}`, 'text');
cancelBtn.onclick = () => { resolve(null); overlay.remove(); };
container.append(originalBtn, quotedBtn, cancelBtn);
dialog.append(title, container);
overlay.appendChild(dialog);
document.body.appendChild(overlay);
overlay.onclick = e => { if (e.target === overlay) { resolve(null); overlay.remove(); }};
});
},
click: async function (btn, status_id, is_exist, index) {
if (btn.classList.contains('loading')) return;
this.status(btn, 'loading');
let out = (await GM_getValue('filename', FILENAME_PATTERN)).split('\n').join('');
let save_history = await GM_getValue('save_history', true);
let json;
try {
json = await this.fetchJson(status_id);
} catch (e) {
return this.status(btn, 'failed', 'API Error');
}
// Check media availability
let hasOriginal = json.legacy?.extended_entities?.media || json.legacy?.media;
let quotedResult = json.quoted_status_result?.result;
let hasQuoted = quotedResult?.legacy?.extended_entities?.media || quotedResult?.legacy?.media;
let tweet, user;
let download_id = status_id; // Default to the requested ID
// If we have an index (specific photo download), we don't need the dialog.
if (index) {
if (hasOriginal) {
tweet = json.legacy;
user = json.core.user_results.result.legacy;
} else if (hasQuoted) {
tweet = quotedResult.legacy;
user = quotedResult.core.user_results.result.legacy;
// In individual mode, status_id is usually correct, but let's be safe
download_id = tweet.id_str;
} else {
return this.status(btn, 'failed', 'MEDIA_NOT_FOUND');
}
} else if (hasOriginal && hasQuoted) {
let originalUser = `${json.core?.user_results?.result?.legacy?.name} @${json.core?.user_results?.result?.legacy?.screen_name}`;
let quotedUser = `${quotedResult?.core?.user_results?.result?.legacy?.name} @${quotedResult?.core?.user_results?.result?.legacy?.screen_name}`;
let choice = await this.selectTweetDialog(originalUser, quotedUser);
if (!choice) return this.status(btn, 'download', lang.download);
let target = choice === 'quoted' ? quotedResult : json;
tweet = target.legacy;
user = target.core.user_results.result.legacy;
// Update ID if we switched targets
download_id = tweet.id_str;
} else if (hasQuoted) {
tweet = quotedResult.legacy;
user = quotedResult.core.user_results.result.legacy;
download_id = tweet.id_str;
} else {
tweet = json.legacy;
user = json.core.user_results.result.legacy;
}
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': download_id, // Use actual ID
'user-name': cleanText(user.name),
'user-id': user.screen_name,
'date-time': this.formatDate(tweet.created_at, datetime),
'date-time-local': this.formatDate(tweet.created_at, datetime, true),
'full-text': cleanText(tweet.full_text.split('\n').join(' ').replace(/\s*https:\/\/t\.co\/\w+/g, ''))
};
let medias = tweet.extended_entities?.media || tweet.media;
if (json?.card) return this.status(btn, 'failed', 'Links not supported');
if (!Array.isArray(medias)) return this.status(btn, 'failed', 'MEDIA_NOT_FOUND');
let mediaToDownload = medias;
let isIndividual = false;
if (index) {
let mediaIndex = parseInt(index) - 1;
if (medias[mediaIndex]) {
mediaToDownload = [medias[mediaIndex]];
isIndividual = true;
} else {
return this.status(btn, 'failed', 'Invalid Media Index');
}
}
if (mediaToDownload.length > 0) {
// Generate ZIP name
let zipName = out.replace(/\.?\{file-ext\}/, '').replace(/\{([^{}:]+)(:[^{}]+)?\}/g, (match, name) => info[name] || match);
let tasks = mediaToDownload.map((media, i) => {
let url = media.type == 'photo' ? media.media_url_https + ':orig' : media.video_info.variants.filter(n => n.content_type == 'video/mp4').sort((a, b) => b.bitrate - a.bitrate)[0].url;
let ext = url.split('/').pop().split(/[:?]/).shift().split('.').pop();
// If individual, use index from arg, else loop index
let idx = isIndividual ? (parseInt(index) - 1) : i;
let filename = (out.replace(/\.?\{file-ext\}/, '') + ((medias.length > 1 || index) && !out.match('{file-name}') ? '-' + idx : '') + '.' + ext).replace(/\{([^{}:]+)(:[^{}]+)?\}/g, (match, name) => info[name] || match);
return { url: url, name: filename };
});
// Completion Callback for History Sync
const onDownloadComplete = async () => {
if (!save_history) return;
if (isIndividual) {
// 1. Save specific image history (e.g. 12345_1)
let imgUid = `${download_id}_${index}`;
await this.history_add(imgUid);
// 2. Check if all sibling images are now downloaded
let article = btn.closest('article');
if (article) {
let allImgs = Array.from(article.querySelectorAll('.tmd-down.tmd-img'));
// Filter to only buttons belonging to THIS tweet (Using download_id)
let siblingImgs = allImgs.filter(b => b.dataset.statusId === download_id);
// Check if all siblings are done (either just done, or in history)
let allDone = siblingImgs.every(b => {
let uid = b.dataset.imgUid;
return history_cache.has(uid) || history_cache.has(download_id);
});
if (allDone) {
// Mark main button for this specific ID as completed
let mainBtns = Array.from(article.querySelectorAll('.tmd-down:not(.tmd-img)'));
let targetMainBtn = mainBtns.find(b => b.dataset.statusId === download_id);
if (targetMainBtn) {
this.status(targetMainBtn, 'completed', lang.completed);
this.history_add(download_id);
}
}
}
} else {
// Batch download
// 1. Save main ID (of the actual content downloaded)
this.history_add(download_id);
// 2. Save all individual IDs for this tweet
let individualIds = medias.map((_, i) => `${download_id}_${i+1}`);
this.history_add(individualIds);
// 3. Visual Sync: Update all individual buttons matching the downloaded ID
let article = btn.closest('article');
if (article) {
let allImgs = Array.from(article.querySelectorAll('.tmd-down.tmd-img'));
// Filter to match download_id so we don't color original images if we downloaded quoted (or vice versa)
allImgs.filter(b => b.dataset.statusId === download_id)
.forEach(b => this.status(b, 'completed', lang.completed));
// Also update any matching main buttons (e.g. if quoted tweet has its own container)
let allMainBtns = Array.from(article.querySelectorAll('.tmd-down:not(.tmd-img)'));
allMainBtns.filter(b => b.dataset.statusId === download_id)
.forEach(b => this.status(b, 'completed', lang.completed));
}
}
};
this.downloader.add(tasks, btn, GM_getValue('enable_packaging', true) && !isIndividual, zipName, onDownloadComplete);
} else {
this.status(btn, 'failed', 'MEDIA_NOT_FOUND');
}
},
// ==========================================
// Download Module
// ==========================================
downloader: (function () {
let tasks = [], thread = 0, failed = 0, notifier, has_failed = false;
return {
add: function (taskList, btn, packaging, zipName, onComplete) {
tasks.push(...taskList);
this.update();
const handleComplete = () => {
this.status(btn, 'completed', lang.completed);
if (onComplete) onComplete();
};
// Packaging mode (Only if multiple files and enabled)
if (packaging && taskList.length > 1) {
let zip = new JSZip();
let promises = taskList.map(task => {
thread++; this.update();
return new Promise((resolve, reject) => {
GM_xmlhttpRequest({
method: "GET",
url: task.url,
responseType: "arraybuffer",
onload: (response) => {
if (response.status >= 200 && response.status < 300) {
zip.file(task.name, response.response);
resolve();
} else {
reject(new Error(`HTTP ${response.status}`));
}
},
onerror: (e) => reject(new Error("Network Error")),
onabort: () => reject(new Error("Aborted"))
});
})
.then(() => {
tasks = tasks.filter(t => t.url !== task.url);
})
.catch(e => {
failed++;
tasks = tasks.filter(t => t.url !== task.url);
console.error(`Download failed for ${task.name}:`, e);
})
.finally(() => {
thread--;
this.update();
});
});
Promise.allSettled(promises).then(() => {
if (Object.keys(zip.files).length > 0) {
zip.generateAsync({ type: 'blob' }).then(content => {
let a = document.createElement('a');
a.href = URL.createObjectURL(content);
a.download = `${zipName}.zip`;
a.click();
handleComplete();
setTimeout(() => URL.revokeObjectURL(a.href), 60000);
}).catch(err => {
console.error("ZIP Generation Error:", err);
this.status(btn, 'failed', 'ZIP Error');
});
} else {
this.status(btn, 'failed', 'All downloads failed');
}
});
} else {
// Standard mode
taskList.forEach(task => {
thread++; this.update();
GM_download({
url: task.url, name: task.name,
onload: () => {
thread--;
tasks = tasks.filter(t => t.url !== task.url);
handleComplete();
this.update();
},
onerror: e => {
thread--; failed++;
tasks = tasks.filter(t => t.url !== task.url);
this.status(btn, 'failed', e.details.current);
this.update();
}
});
});
}
},
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;
},
update: function () {
if (!notifier) {
notifier = document.createElement('div'); notifier.className = 'tmd-notifier'; notifier.title = 'Twitter Media Downloader';
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 - thread - failed;
if (failed > 0) notifier.lastChild.innerText = failed;
notifier.classList.toggle('running', thread > 0 || tasks.length > 0 || failed > 0);
}
};
})(),
// ==========================================
// Helper Functions
// ==========================================
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 $el = (p, t, c, k) => {
let e = document.createElement(t);
if (c !== undefined) {
if (t === 'input') {
if (c === 'checkbox') e.type = 'checkbox';
else e.value = c;
} else if (t === 'textarea') {
e.value = c;
} else {
e.innerHTML = c;
}
}
if (k) e.className = k;
p.appendChild(e); return e;
};
let w = $el(document.body, 'div', '', 'tmd-overlay');
let wc; w.onmousedown = e => wc = e.target == w; w.onmouseup = e => { if (wc && e.target == w) w.remove(); };
let d = $el(w, 'div', '', 'tmd-modal');
let t = $el(d, 'h3', lang.dialog.title);
let o = $el(d, 'div', '', 'tmd-option-group');
// Save History
let shl = $el(o, 'label', lang.dialog.save_history, 'tmd-label');
let shi = $el(shl, 'input', 'checkbox');
shi.checked = await GM_getValue('save_history', true);
shi.onchange = () => GM_setValue('save_history', shi.checked);
let clr = $el(shl, 'label', lang.dialog.clear_history, 'tmd-link');
clr.onclick = (e) => {
e.preventDefault();
if (confirm(lang.dialog.clear_confirm)) {
this.history_clear(); // UPDATED
}
};
// Packaging
let sep = $el(o, 'label', lang.enable_packaging, 'tmd-label');
let sepi = $el(sep, 'input', 'checkbox');
sepi.checked = await GM_getValue('enable_packaging', true);
sepi.onchange = () => GM_setValue('enable_packaging', sepi.checked);
// Filename
let fd = $el(d, 'div', '', 'tmd-option-group');
$el(fd, 'label', lang.dialog.pattern, 'tmd-label');
let fi = $el(fd, 'textarea', await GM_getValue('filename', FILENAME_PATTERN), 'tmd-textarea');
fi.addEventListener('mousedown', e => e.stopPropagation()); // Prevent drag/selection issues if parent has listeners
let ft = $el(fd, 'div', `
{user-name}
{user-id}
{status-id}
{date-time}
{full-text}
{file-type}
{file-name}`, 'tmd-tags');
ft.querySelectorAll('.tmd-tag').forEach(tag => {
tag.onclick = () => {
let s = fi.selectionStart, e = fi.selectionEnd;
// Use textContent to get the text exactly as it is in the HTML, avoiding CSS transforms (uppercase)
let text = tag.textContent;
fi.value = fi.value.substring(0, s) + text + fi.value.substring(e);
fi.selectionStart = fi.selectionEnd = s + text.length; fi.focus();
};
});
// Footer
let footer = $el(d, 'div', '', 'tmd-footer');
let saveBtn = $el(footer, 'button', lang.dialog.save, 'tmd-btn');
saveBtn.onclick = async () => { await GM_setValue('filename', fi.value); w.remove(); };
},
fetchJson: async function (status_id) {
let base = `https://${host}/i/api/graphql/2ICDjqPd81tulZcYrtpTuQ/TweetResultByRestId`;
let vars = { tweetId: status_id, with_rux_injections: false, includePromotedContent: true, withCommunity: true, withQuickPromoteEligibilityTweetFields: true, withBirdwatchNotes: true, withVoice: true, withV2Timeline: true };
let feats = { articles_preview_enabled: true, c9s_tweet_anatomy_moderator_badge_enabled: true, communities_web_enable_tweet_community_results_fetch: false, creator_subscriptions_quote_tweet_preview_enabled: false, creator_subscriptions_tweet_preview_api_enabled: false, freedom_of_speech_not_reach_fetch_enabled: true, graphql_is_translatable_rweb_tweet_is_translatable_enabled: true, longform_notetweets_consumption_enabled: false, longform_notetweets_inline_media_enabled: true, longform_notetweets_rich_text_read_enabled: false, premium_content_api_read_enabled: false, profile_label_improvements_pcf_label_in_post_enabled: true, responsive_web_edit_tweet_api_enabled: false, responsive_web_enhance_cards_enabled: false, responsive_web_graphql_exclude_directive_enabled: false, responsive_web_graphql_skip_user_profile_image_extensions_enabled: false, responsive_web_graphql_timeline_navigation_enabled: false, responsive_web_grok_analysis_button_from_backend: false, responsive_web_grok_analyze_button_fetch_trends_enabled: false, responsive_web_grok_analyze_post_followups_enabled: false, responsive_web_grok_image_annotation_enabled: false, responsive_web_grok_share_attachment_enabled: false, responsive_web_grok_show_grok_translated_post: false, responsive_web_jetfuel_frame: false, responsive_web_media_download_video_enabled: false, responsive_web_twitter_article_tweet_consumption_enabled: true, rweb_tipjar_consumption_enabled: true, rweb_video_screen_enabled: false, standardized_nudges_misinfo: true, tweet_awards_web_tipping_enabled: false, tweet_with_visibility_results_prefer_gql_limited_actions_policy_enabled: true, tweetypie_unmention_optimization_enabled: false, verified_phone_label_enabled: false, view_counts_everywhere_api_enabled: true };
let c = this.getCookie();
let h = { authorization: 'Bearer AAAAAAAAAAAAAAAAAAAAANRILgAAAAAAnNwIzUejRCOuH5E6I8xnZz4puTs%3D1Zv7ttfk8LF81IUq16cHjhLTvJu4FA33AGWWjCpTnA', 'x-twitter-active-user': 'yes', 'x-twitter-client-language': c.lang, 'x-csrf-token': c.ct0 };
if (c.ct0?.length == 32) h['x-guest-token'] = c.gt;
try {
let url = encodeURI(`${base}?variables=${JSON.stringify(vars)}&features=${JSON.stringify(feats)}`);
let r = await fetch(url, { headers: h });
if (!r.ok) throw new Error('HTTP ' + r.status);
let text = await r.text();
if (!text) throw new Error('Empty response');
let res = JSON.parse(text);
return res.data.tweetResult.result.tweet || res.data.tweetResult.result;
} catch (e) {
console.error('API Error:', e);
throw e;
}
},
getCookie: function (name) {
let c = {}; document.cookie.split(';').forEach(n => { let [k, v] = n.split('='); if (k && v) c[k.trim()] = v.trim(); });
return name ? c[name] : c;
},
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(), YY: d.getUTCFullYear(), MM: d.getUTCMonth() + 1, MMM: m[d.getUTCMonth()],
DD: d.getUTCDate(), hh: d.getUTCHours(), mm: d.getUTCMinutes(), ss: d.getUTCSeconds(),
h2: d.getUTCHours() % 12, ap: d.getUTCHours() < 12 ? 'AM' : 'PM'
};
return o.replace(/(YY(YY)?|MMM?|DD|hh|mm|ss|h2|ap)/g, n => ('0' + v[n]).toString().substr(-n.length));
},
// ==========================================
// Assets & Locales
// ==========================================
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?', pattern: 'File Name Pattern' },
enable_packaging: 'Package multiple files into a ZIP', original: 'Original Tweet', quote: 'Quoted Tweet', cancel: 'Cancel', choose: 'Select media to download'
}
},
css: `
:root {
--tmd-bg: #ffffff;
--tmd-bg-modal: rgba(255, 255, 255, 0.98);
--tmd-text: #000000;
--tmd-border: #000000;
--tmd-hover: #ff003c;
--tmd-accent: #ff003c; /* Cyber Red */
--tmd-accent-hover: #c4002f;
--tmd-text-btn: #ffffff;
--tmd-shadow: 5px 5px 0px rgba(0,0,0,1);
--tmd-radius: 0px;
--tmd-font: "Courier New", monospace;
/* State Colors (Light Mode) */
--tmd-state-down: #00f3ff; /* Cyan */
--tmd-state-done: #39ff14; /* Green */
--tmd-state-load: #fdf500; /* Yellow */
--tmd-state-fail: #ff003c; /* Red */
--tmd-icon-color: #000000; /* Black icons */
--tmd-shadow-color: #000000;
}
body.tmd-dark {
--tmd-bg: #000000;
--tmd-bg-modal: rgba(10, 10, 10, 0.95);
--tmd-text: #00f3ff; /* Cyber Cyan */
--tmd-border: #00f3ff;
--tmd-hover: rgba(0, 243, 255, 0.2);
--tmd-accent: #ffee00; /* Cyber Yellow */
--tmd-accent-hover: #d4c600;
--tmd-text-btn: #000000;
--tmd-shadow: 0 0 15px rgba(0, 243, 255, 0.4);
--tmd-radius: 2px;
--tmd-font: "Consolas", "Monaco", monospace;
/* State Colors (Dark Mode) */
--tmd-state-down: #00f3ff;
--tmd-state-done: #39ff14;
--tmd-state-load: #fdf500;
--tmd-state-fail: #ff003c;
--tmd-icon-color: #000000;
--tmd-shadow-color: #ffffff;
}
.tmd-down {margin-left: 12px; order: 99;}
/* Define local scoped variable for the specific state */
.tmd-down { --btn-color: var(--tmd-state-down); }
.tmd-down.completed { --btn-color: var(--tmd-state-done); }
.tmd-down.loading { --btn-color: var(--tmd-state-load); }
.tmd-down.failed { --btn-color: var(--tmd-state-fail); }
/* Specific Selector to Target Button Background */
.tmd-down button > div {
background-color: var(--btn-color) !important;
border-radius: 0px !important;
border: 2px solid var(--tmd-text) !important;
box-shadow: 4px 4px 0px var(--tmd-shadow-color) !important;
color: var(--tmd-icon-color) !important;
opacity: 1 !important;
transition: all 0.1s ease !important;
}
/* Specific Selector to Target Button Background Hover */
.tmd-down button:hover > div {
transform: translate(2px, 2px) !important;
box-shadow: 2px 2px 0px var(--tmd-shadow-color) !important;
}
/* Specific Selector to Target SVG Icon Color */
.tmd-down svg {
color: var(--tmd-icon-color) !important;
fill: var(--tmd-icon-color) !important;
filter: none !important;
}
/* Ensure paths inherit the forced color */
.tmd-down svg path, .tmd-down svg rect, .tmd-down svg circle {
fill: currentColor !important;
stroke: none !important;
}
/* Loading Spinner needs Stroke, not Fill */
.tmd-down svg g.loading path, .tmd-down svg g.loading circle {
fill: none !important;
stroke: currentColor !important;
}
/* Media overlay buttons */
.tmd-down.tmd-img {
position: absolute; left: 10px; top: 10px;
z-index: 999;
display: none; /* HIDDEN BY DEFAULT */
}
.tmd-img-parent:hover .tmd-down.tmd-img {
display: block; /* SHOW ON HOVER */
}
.tmd-down.tmd-img > div {
display: flex; border-radius: 0px; margin: 0;
background-color: var(--btn-color);
border: 2px solid var(--tmd-text);
box-shadow: 3px 3px 0px var(--tmd-shadow-color);
cursor: pointer;
transition: all 0.1s ease;
}
.tmd-down.tmd-img:hover > div {
transform: translate(1px, 1px);
box-shadow: 2px 2px 0px var(--tmd-shadow-color);
background-color: var(--tmd-accent-hover);
}
.tmd-down.tmd-img svg {
width: 24px; height: 24px;
color: var(--tmd-icon-color) !important;
fill: var(--tmd-icon-color) !important;
margin: 4px;
}
.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);}}
/* Modern Modal & Overlay */
.tmd-overlay {
position: fixed; left: 0; top: 0; width: 100%; height: 100%;
background-color: rgba(0,0,0,0.4); z-index: 2147483647;
backdrop-filter: blur(4px);
display: flex; justify-content: center; align-items: center;
}
.tmd-modal {
background: var(--tmd-bg-modal);
color: var(--tmd-text);
border: 2px solid var(--tmd-border);
border-radius: var(--tmd-radius);
padding: 24px;
width: 400px;
max-width: 90vw;
box-shadow: var(--tmd-shadow);
font-family: var(--tmd-font);
backdrop-filter: blur(12px);
display: flex; flex-direction: column;
text-transform: uppercase;
letter-spacing: 1px;
z-index: 2147483647;
}
.tmd-modal h3 {
margin: 0 0 20px 0; text-align: center;
font-size: 20px; font-weight: 700;
text-shadow: 0 0 5px var(--tmd-text);
}
/* Buttons */
.tmd-btn {
background: var(--tmd-accent); color: var(--tmd-text-btn);
border: 1px solid var(--tmd-accent); border-radius: var(--tmd-radius);
padding: 12px 24px; font-size: 15px; font-weight: 700;
cursor: pointer; transition: 0.2s;
width: 100%; text-align: center;
margin-bottom: 8px;
font-family: var(--tmd-font);
text-transform: uppercase;
letter-spacing: 1px;
box-shadow: 0 0 10px var(--tmd-accent);
}
.tmd-btn:hover {
background-color: var(--tmd-accent-hover);
box-shadow: 0 0 20px var(--tmd-accent);
}
.tmd-btn.secondary {
background: transparent; color: var(--tmd-accent);
border: 1px solid var(--tmd-accent);
box-shadow: none;
}
.tmd-btn.secondary:hover {
background-color: var(--tmd-hover);
box-shadow: 0 0 10px var(--tmd-accent);
}
.tmd-btn.text {
background: transparent; color: var(--tmd-text);
font-weight: 400; padding: 8px;
border: none; box-shadow: none;
opacity: 0.7;
}
.tmd-btn.text:hover {
opacity: 1; text-decoration: underline;
background: transparent; box-shadow: none;
}
/* Settings Styles */
.tmd-option-group {
border: 1px solid var(--tmd-border);
border-radius: var(--tmd-radius);
padding: 12px; margin-bottom: 12px;
background: rgba(0,0,0,0.2);
}
.tmd-label {
display: flex; justify-content: space-between; align-items: center;
margin: 12px 0; font-size: 14px; font-weight: 600; cursor: pointer; color: var(--tmd-text);
}
.tmd-label input {
margin: 0; width: 16px; height: 16px;
accent-color: var(--tmd-accent);
cursor: pointer;
}
.tmd-link {
color: var(--tmd-accent); font-size: 12px; margin-left: 10px;
cursor: pointer; text-decoration: none; border-bottom: 1px dotted var(--tmd-accent);
}
.tmd-link:hover { color: var(--tmd-text); border-bottom-style: solid; }
.tmd-textarea {
width: 100%; min-height: 80px;
background: rgba(0,0,0,0.3); color: var(--tmd-text);
border: 1px solid var(--tmd-border);
border-radius: var(--tmd-radius); padding: 8px;
font-family: var(--tmd-font); font-size: 12px;
margin-top: 8px; box-sizing: border-box;
user-select: text !important;
-webkit-user-select: text !important;
cursor: text !important;
}
.tmd-textarea:focus { outline: none; border-color: var(--tmd-accent); box-shadow: 0 0 5px var(--tmd-accent); }
.tmd-tags { display: flex; flex-wrap: wrap; gap: 6px; margin-top: 12px; }
.tmd-tag {
background-color: transparent;
color: var(--tmd-accent);
border: 1px solid var(--tmd-accent);
padding: 4px 8px; border-radius: var(--tmd-radius);
font-size: 11px; font-weight: 700; cursor: pointer;
transition: 0.2s; font-family: var(--tmd-font);
}
.tmd-tag:hover { background-color: var(--tmd-accent); color: var(--tmd-text-btn); box-shadow: 0 0 8px var(--tmd-accent); }
.tmd-footer { margin-top: 12px; }
/* Notifier */
.tmd-notifier {
display: none; position: fixed; left: 20px; bottom: 20px;
color: var(--tmd-text); background: var(--tmd-bg-modal);
border: 2px solid var(--tmd-border); border-radius: var(--tmd-radius);
padding: 10px 16px; font-size: 14px; font-weight: 600;
box-shadow: var(--tmd-shadow);
backdrop-filter: blur(10px); z-index: 9999;
font-family: var(--tmd-font);
text-transform: uppercase;
}
.tmd-notifier.running {display: flex; align-items: center; gap: 12px;}
.tmd-notifier label {display: flex; align-items: center; gap: 6px;}
.tmd-notifier label:before {content: ""; width: 16px; height: 16px; background-size: contain; background-repeat: no-repeat; opacity: 0.8;}
.tmd-notifier label:nth-child(1):before {background-image:url("data:image/svg+xml;charset=utf8,"); animation: spin 2s linear infinite;}
.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,");}
`,
css_ss: ``,
svg: `
`
};
})();
TMD.init();