Export X posts with text, images, and metadata. Download images individually or as a batch.
Size
13.3 KB
Version
1.0.1
Created
Dec 13, 2025
Updated
3 days ago
1// ==UserScript==
2// @name X Post Exporter with Image Downloader
3// @description Export X posts with text, images, and metadata. Download images individually or as a batch.
4// @version 1.0.1
5// @match https://*.x.com/*
6// @icon https://abs.twimg.com/favicons/twitter-pip.3.ico
7// @grant GM.xmlhttpRequest
8// @grant GM.download
9// ==/UserScript==
10(function() {
11 'use strict';
12
13 console.log('X Post Exporter initialized');
14
15 // Utility function to debounce
16 function debounce(func, wait) {
17 let timeout;
18 return function executedFunction(...args) {
19 const later = () => {
20 clearTimeout(timeout);
21 func(...args);
22 };
23 clearTimeout(timeout);
24 timeout = setTimeout(later, wait);
25 };
26 }
27
28 // Extract post data from a tweet article element
29 function extractPostData(article) {
30 try {
31 const postData = {
32 text: '',
33 images: [],
34 author: '',
35 timestamp: '',
36 likes: '',
37 retweets: '',
38 replies: '',
39 url: ''
40 };
41
42 // Extract text content
43 const tweetTextElement = article.querySelector('[data-testid="tweetText"]');
44 if (tweetTextElement) {
45 postData.text = tweetTextElement.innerText || tweetTextElement.textContent;
46 }
47
48 // Extract author information
49 const authorLink = article.querySelector('a[role="link"][href^="/"]');
50 if (authorLink) {
51 const href = authorLink.getAttribute('href');
52 postData.author = href ? href.replace('/', '') : '';
53 }
54
55 // Extract timestamp and post URL
56 const timeElement = article.querySelector('time');
57 if (timeElement) {
58 postData.timestamp = timeElement.getAttribute('datetime') || timeElement.textContent;
59 const tweetLink = timeElement.closest('a');
60 if (tweetLink) {
61 postData.url = 'https://x.com' + tweetLink.getAttribute('href');
62 }
63 }
64
65 // Extract images
66 const imageElements = article.querySelectorAll('img[src*="media"]');
67 imageElements.forEach(img => {
68 let imgSrc = img.src;
69 // Get the highest quality version
70 if (imgSrc.includes('?')) {
71 imgSrc = imgSrc.split('?')[0] + '?format=jpg&name=large';
72 }
73 if (imgSrc && !postData.images.includes(imgSrc)) {
74 postData.images.push(imgSrc);
75 }
76 });
77
78 // Extract engagement metrics
79 const replyButton = article.querySelector('[data-testid="reply"]');
80 if (replyButton) {
81 const replyText = replyButton.getAttribute('aria-label');
82 postData.replies = replyText ? replyText.match(/\d+/)?.[0] || '0' : '0';
83 }
84
85 const retweetButton = article.querySelector('[data-testid="retweet"]');
86 if (retweetButton) {
87 const retweetText = retweetButton.getAttribute('aria-label');
88 postData.retweets = retweetText ? retweetText.match(/\d+/)?.[0] || '0' : '0';
89 }
90
91 const likeButton = article.querySelector('[data-testid="like"]');
92 if (likeButton) {
93 const likeText = likeButton.getAttribute('aria-label');
94 postData.likes = likeText ? likeText.match(/\d+/)?.[0] || '0' : '0';
95 }
96
97 console.log('Extracted post data:', postData);
98 return postData;
99 } catch (error) {
100 console.error('Error extracting post data:', error);
101 return null;
102 }
103 }
104
105 // Download a single image
106 async function downloadImage(imageUrl, filename) {
107 try {
108 console.log('Downloading image:', imageUrl);
109 const response = await GM.xmlhttpRequest({
110 method: 'GET',
111 url: imageUrl,
112 responseType: 'blob'
113 });
114
115 const blob = response.response;
116 const url = URL.createObjectURL(blob);
117 const a = document.createElement('a');
118 a.href = url;
119 a.download = filename;
120 document.body.appendChild(a);
121 a.click();
122 document.body.removeChild(a);
123 URL.revokeObjectURL(url);
124
125 console.log('Image downloaded successfully:', filename);
126 } catch (error) {
127 console.error('Error downloading image:', error);
128 }
129 }
130
131 // Download post data as JSON
132 function downloadJSON(data, filename) {
133 const jsonStr = JSON.stringify(data, null, 2);
134 const blob = new Blob([jsonStr], { type: 'application/json' });
135 const url = URL.createObjectURL(blob);
136 const a = document.createElement('a');
137 a.href = url;
138 a.download = filename;
139 document.body.appendChild(a);
140 a.click();
141 document.body.removeChild(a);
142 URL.revokeObjectURL(url);
143 console.log('JSON downloaded successfully:', filename);
144 }
145
146 // Create export button for a post
147 function createExportButton(article) {
148 // Check if button already exists
149 if (article.querySelector('.x-export-button')) {
150 return;
151 }
152
153 const actionBar = article.querySelector('[role="group"]');
154 if (!actionBar) {
155 return;
156 }
157
158 // Create export button container
159 const exportContainer = document.createElement('div');
160 exportContainer.className = 'css-175oi2r r-18u37iz r-1h0z5md r-13awgt0 x-export-button';
161
162 // Create export button
163 const exportButton = document.createElement('button');
164 exportButton.setAttribute('aria-label', 'Export post');
165 exportButton.setAttribute('role', 'button');
166 exportButton.setAttribute('type', 'button');
167 exportButton.className = 'css-175oi2r r-1777fci r-bt1l66 r-bztko3 r-lrvibr r-1loqt21 r-1ny4l3l';
168 exportButton.style.cssText = 'background-color: transparent; border: none; cursor: pointer;';
169
170 exportButton.innerHTML = `
171 <div class="css-146c3p1 r-bcqeeo r-1ttztb7 r-qvutc0 r-37j5jr r-a023e6 r-rjixqe r-b88u0q r-1awozwy r-6koalj r-1h0z5md r-o7ynqc r-clp7b1 r-3s2u2q">
172 <div class="css-175oi2r r-xoduu5">
173 <svg viewBox="0 0 24 24" aria-hidden="true" class="r-4qtqp9 r-yyyyoo r-dnmrzs r-bnwqim r-1plcrui r-lrvibr r-1xvli5t r-1hdv0qi" style="width: 18.75px; height: 18.75px;">
174 <g><path d="M12 2C6.48 2 2 6.48 2 12s4.48 10 10 10 10-4.48 10-10S17.52 2 12 2zm-1 17.93c-3.95-.49-7-3.85-7-7.93 0-.62.08-1.21.21-1.79L9 15v1c0 1.1.9 2 2 2v1.93zm6.9-2.54c-.26-.81-1-1.39-1.9-1.39h-1v-3c0-.55-.45-1-1-1H8v-2h2c.55 0 1-.45 1-1V7h2c1.1 0 2-.9 2-2v-.41c2.93 1.19 5 4.06 5 7.41 0 2.08-.8 3.97-2.1 5.39z"></path></g>
175 </svg>
176 </div>
177 <div class="css-175oi2r r-xoduu5 r-1udh08x">
178 <span class="css-1jxf684 r-1ttztb7 r-qvutc0 r-poiln3 r-n6v787 r-1cwl3u0 r-1k6nrdp r-n7gxbd">
179 <span class="css-1jxf684 r-bcqeeo r-1ttztb7 r-qvutc0 r-poiln3">Export</span>
180 </span>
181 </div>
182 </div>
183 `;
184
185 // Add click handler
186 exportButton.addEventListener('click', async (e) => {
187 e.preventDefault();
188 e.stopPropagation();
189
190 const postData = extractPostData(article);
191 if (!postData) {
192 alert('Failed to extract post data');
193 return;
194 }
195
196 // Create export menu
197 showExportMenu(e.target.closest('button'), postData);
198 });
199
200 exportContainer.appendChild(exportButton);
201 actionBar.appendChild(exportContainer);
202 }
203
204 // Show export menu with options
205 function showExportMenu(button, postData) {
206 // Remove existing menu if any
207 const existingMenu = document.querySelector('.x-export-menu');
208 if (existingMenu) {
209 existingMenu.remove();
210 }
211
212 const menu = document.createElement('div');
213 menu.className = 'x-export-menu';
214 menu.style.cssText = `
215 position: fixed;
216 background: rgb(0, 0, 0);
217 border: 1px solid rgb(47, 51, 54);
218 border-radius: 16px;
219 padding: 12px 0;
220 z-index: 10000;
221 box-shadow: rgba(255, 255, 255, 0.2) 0px 0px 15px, rgba(255, 255, 255, 0.15) 0px 0px 3px 1px;
222 min-width: 200px;
223 `;
224
225 const rect = button.getBoundingClientRect();
226 menu.style.left = rect.left + 'px';
227 menu.style.top = (rect.bottom + 5) + 'px';
228
229 const menuItems = [
230 { label: 'Export as JSON', action: () => exportAsJSON(postData) },
231 { label: 'Export Text Only', action: () => exportTextOnly(postData) },
232 { label: 'Download All Images', action: () => downloadAllImages(postData) },
233 { label: 'Export Everything', action: () => exportEverything(postData) }
234 ];
235
236 menuItems.forEach(item => {
237 const menuItem = document.createElement('div');
238 menuItem.style.cssText = `
239 padding: 12px 16px;
240 cursor: pointer;
241 color: rgb(231, 233, 234);
242 font-size: 15px;
243 font-weight: 400;
244 transition: background-color 0.2s;
245 `;
246 menuItem.textContent = item.label;
247
248 menuItem.addEventListener('mouseenter', () => {
249 menuItem.style.backgroundColor = 'rgb(22, 24, 28)';
250 });
251
252 menuItem.addEventListener('mouseleave', () => {
253 menuItem.style.backgroundColor = 'transparent';
254 });
255
256 menuItem.addEventListener('click', () => {
257 item.action();
258 menu.remove();
259 });
260
261 menu.appendChild(menuItem);
262 });
263
264 document.body.appendChild(menu);
265
266 // Close menu when clicking outside
267 setTimeout(() => {
268 document.addEventListener('click', function closeMenu(e) {
269 if (!menu.contains(e.target)) {
270 menu.remove();
271 document.removeEventListener('click', closeMenu);
272 }
273 });
274 }, 100);
275 }
276
277 // Export functions
278 function exportAsJSON(postData) {
279 const timestamp = new Date().getTime();
280 const filename = `x-post-${postData.author}-${timestamp}.json`;
281 downloadJSON(postData, filename);
282 }
283
284 function exportTextOnly(postData) {
285 const textContent = `
286Author: ${postData.author}
287Timestamp: ${postData.timestamp}
288URL: ${postData.url}
289Likes: ${postData.likes}
290Retweets: ${postData.retweets}
291Replies: ${postData.replies}
292
293Text:
294${postData.text}
295 `.trim();
296
297 const blob = new Blob([textContent], { type: 'text/plain' });
298 const url = URL.createObjectURL(blob);
299 const a = document.createElement('a');
300 a.href = url;
301 a.download = `x-post-${postData.author}-${new Date().getTime()}.txt`;
302 document.body.appendChild(a);
303 a.click();
304 document.body.removeChild(a);
305 URL.revokeObjectURL(url);
306 }
307
308 async function downloadAllImages(postData) {
309 if (postData.images.length === 0) {
310 alert('No images found in this post');
311 return;
312 }
313
314 for (let i = 0; i < postData.images.length; i++) {
315 const imageUrl = postData.images[i];
316 const filename = `x-image-${postData.author}-${i + 1}-${new Date().getTime()}.jpg`;
317 await downloadImage(imageUrl, filename);
318 // Add delay between downloads
319 await new Promise(resolve => setTimeout(resolve, 500));
320 }
321
322 alert(`Downloaded ${postData.images.length} image(s)`);
323 }
324
325 async function exportEverything(postData) {
326 // Export JSON
327 exportAsJSON(postData);
328
329 // Export text
330 await new Promise(resolve => setTimeout(resolve, 300));
331 exportTextOnly(postData);
332
333 // Download images
334 if (postData.images.length > 0) {
335 await new Promise(resolve => setTimeout(resolve, 300));
336 await downloadAllImages(postData);
337 }
338 }
339
340 // Add export buttons to all posts
341 function addExportButtons() {
342 const articles = document.querySelectorAll('article[data-testid="tweet"]');
343 articles.forEach(article => {
344 createExportButton(article);
345 });
346 }
347
348 // Initialize
349 function init() {
350 console.log('Adding export buttons to posts...');
351
352 // Add buttons to existing posts
353 addExportButtons();
354
355 // Watch for new posts
356 const observer = new MutationObserver(debounce(() => {
357 addExportButtons();
358 }, 500));
359
360 observer.observe(document.body, {
361 childList: true,
362 subtree: true
363 });
364
365 console.log('X Post Exporter ready!');
366 }
367
368 // Wait for page to load
369 if (document.readyState === 'loading') {
370 document.addEventListener('DOMContentLoaded', init);
371 } else {
372 init();
373 }
374})();