X Post Exporter with Image Downloader

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})();
X Post Exporter with Image Downloader | Robomonkey