Size
15.0 KB
Version
1.0.1
Created
Oct 21, 2025
Updated
2 days ago
1// ==UserScript==
2// @name X Post Scraper & Saver
3// @description Save X (Twitter) posts content for later viewing
4// @version 1.0.1
5// @match https://*.x.com/*
6// @icon https://abs.twimg.com/favicons/twitter.3.ico
7// ==/UserScript==
8(function() {
9 'use strict';
10
11 console.log('X Post Scraper & Saver initialized');
12
13 // Debounce function to prevent excessive calls
14 function debounce(func, wait) {
15 let timeout;
16 return function executedFunction(...args) {
17 const later = () => {
18 clearTimeout(timeout);
19 func(...args);
20 };
21 clearTimeout(timeout);
22 timeout = setTimeout(later, wait);
23 };
24 }
25
26 // Extract post data from a tweet article element
27 function extractPostData(article) {
28 try {
29 const postData = {
30 id: article.getAttribute('aria-labelledby') || Date.now().toString(),
31 timestamp: new Date().toISOString(),
32 author: '',
33 username: '',
34 content: '',
35 images: [],
36 links: [],
37 stats: {
38 replies: 0,
39 reposts: 0,
40 likes: 0,
41 views: 0
42 }
43 };
44
45 // Extract author info
46 const authorLink = article.querySelector('a[role="link"][href^="/"]');
47 if (authorLink) {
48 const authorName = article.querySelector('div[data-testid="User-Name"] span');
49 if (authorName) {
50 postData.author = authorName.textContent.trim();
51 }
52 postData.username = authorLink.getAttribute('href');
53 }
54
55 // Extract tweet text content
56 const tweetText = article.querySelector('div[data-testid="tweetText"]');
57 if (tweetText) {
58 postData.content = tweetText.textContent.trim();
59 }
60
61 // Extract images
62 const images = article.querySelectorAll('div[data-testid="tweetPhoto"] img');
63 images.forEach(img => {
64 if (img.src) {
65 postData.images.push(img.src);
66 }
67 });
68
69 // Extract links
70 const links = article.querySelectorAll('div[data-testid="tweetText"] a[href]');
71 links.forEach(link => {
72 postData.links.push({
73 text: link.textContent,
74 url: link.href
75 });
76 });
77
78 // Extract engagement stats
79 const statsGroup = article.querySelector('div[role="group"]');
80 if (statsGroup) {
81 const ariaLabel = statsGroup.getAttribute('aria-label');
82 if (ariaLabel) {
83 const repliesMatch = ariaLabel.match(/(\d+)\s+repl/i);
84 const repostsMatch = ariaLabel.match(/(\d+)\s+repost/i);
85 const likesMatch = ariaLabel.match(/(\d+)\s+like/i);
86 const viewsMatch = ariaLabel.match(/(\d+)\s+view/i);
87
88 if (repliesMatch) postData.stats.replies = parseInt(repliesMatch[1]);
89 if (repostsMatch) postData.stats.reposts = parseInt(repostsMatch[1]);
90 if (likesMatch) postData.stats.likes = parseInt(likesMatch[1]);
91 if (viewsMatch) postData.stats.views = parseInt(viewsMatch[1]);
92 }
93 }
94
95 return postData;
96 } catch (error) {
97 console.error('Error extracting post data:', error);
98 return null;
99 }
100 }
101
102 // Save post to storage
103 async function savePost(postData) {
104 try {
105 const savedPosts = await GM.getValue('saved_posts', []);
106
107 // Check if post already exists
108 const existingIndex = savedPosts.findIndex(p => p.id === postData.id);
109 if (existingIndex !== -1) {
110 console.log('Post already saved, updating...');
111 savedPosts[existingIndex] = postData;
112 } else {
113 savedPosts.unshift(postData); // Add to beginning
114 }
115
116 await GM.setValue('saved_posts', savedPosts);
117 console.log('Post saved successfully:', postData);
118 return true;
119 } catch (error) {
120 console.error('Error saving post:', error);
121 return false;
122 }
123 }
124
125 // Create save button for a tweet
126 function createSaveButton(article) {
127 // Check if button already exists
128 if (article.querySelector('.rm-save-post-btn')) {
129 return;
130 }
131
132 const saveButton = document.createElement('button');
133 saveButton.className = 'rm-save-post-btn';
134 saveButton.innerHTML = '💾 Save';
135 saveButton.style.cssText = `
136 position: absolute;
137 top: 8px;
138 right: 8px;
139 background: #1d9bf0;
140 color: white;
141 border: none;
142 border-radius: 20px;
143 padding: 6px 16px;
144 font-size: 13px;
145 font-weight: 600;
146 cursor: pointer;
147 z-index: 10;
148 transition: background 0.2s;
149 font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, Helvetica, Arial, sans-serif;
150 `;
151
152 saveButton.addEventListener('mouseenter', () => {
153 saveButton.style.background = '#1a8cd8';
154 });
155
156 saveButton.addEventListener('mouseleave', () => {
157 saveButton.style.background = '#1d9bf0';
158 });
159
160 saveButton.addEventListener('click', async (e) => {
161 e.preventDefault();
162 e.stopPropagation();
163
164 saveButton.disabled = true;
165 saveButton.innerHTML = '⏳ Saving...';
166
167 const postData = extractPostData(article);
168 if (postData) {
169 const success = await savePost(postData);
170 if (success) {
171 saveButton.innerHTML = '✅ Saved!';
172 saveButton.style.background = '#00ba7c';
173 setTimeout(() => {
174 saveButton.innerHTML = '💾 Save';
175 saveButton.style.background = '#1d9bf0';
176 saveButton.disabled = false;
177 }, 2000);
178 } else {
179 saveButton.innerHTML = '❌ Error';
180 saveButton.style.background = '#f4212e';
181 setTimeout(() => {
182 saveButton.innerHTML = '💾 Save';
183 saveButton.style.background = '#1d9bf0';
184 saveButton.disabled = false;
185 }, 2000);
186 }
187 }
188 });
189
190 // Make article position relative for absolute positioning
191 article.style.position = 'relative';
192 article.appendChild(saveButton);
193 }
194
195 // Create view saved posts button
196 function createViewSavedButton() {
197 if (document.querySelector('.rm-view-saved-btn')) {
198 return;
199 }
200
201 const viewButton = document.createElement('button');
202 viewButton.className = 'rm-view-saved-btn';
203 viewButton.innerHTML = '📚 View Saved Posts';
204 viewButton.style.cssText = `
205 position: fixed;
206 bottom: 20px;
207 right: 20px;
208 background: #1d9bf0;
209 color: white;
210 border: none;
211 border-radius: 30px;
212 padding: 12px 24px;
213 font-size: 15px;
214 font-weight: 700;
215 cursor: pointer;
216 z-index: 9999;
217 box-shadow: 0 4px 12px rgba(0, 0, 0, 0.3);
218 transition: all 0.2s;
219 font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, Helvetica, Arial, sans-serif;
220 `;
221
222 viewButton.addEventListener('mouseenter', () => {
223 viewButton.style.transform = 'scale(1.05)';
224 viewButton.style.boxShadow = '0 6px 16px rgba(0, 0, 0, 0.4)';
225 });
226
227 viewButton.addEventListener('mouseleave', () => {
228 viewButton.style.transform = 'scale(1)';
229 viewButton.style.boxShadow = '0 4px 12px rgba(0, 0, 0, 0.3)';
230 });
231
232 viewButton.addEventListener('click', () => {
233 showSavedPostsModal();
234 });
235
236 document.body.appendChild(viewButton);
237 }
238
239 // Show saved posts in a modal
240 async function showSavedPostsModal() {
241 const savedPosts = await GM.getValue('saved_posts', []);
242
243 const modal = document.createElement('div');
244 modal.className = 'rm-saved-posts-modal';
245 modal.style.cssText = `
246 position: fixed;
247 top: 0;
248 left: 0;
249 width: 100%;
250 height: 100%;
251 background: rgba(0, 0, 0, 0.7);
252 z-index: 10000;
253 display: flex;
254 align-items: center;
255 justify-content: center;
256 padding: 20px;
257 `;
258
259 const modalContent = document.createElement('div');
260 modalContent.style.cssText = `
261 background: #15202b;
262 color: #ffffff;
263 border-radius: 16px;
264 max-width: 800px;
265 width: 100%;
266 max-height: 90vh;
267 overflow-y: auto;
268 padding: 24px;
269 position: relative;
270 `;
271
272 const closeButton = document.createElement('button');
273 closeButton.innerHTML = '✕';
274 closeButton.style.cssText = `
275 position: absolute;
276 top: 16px;
277 right: 16px;
278 background: transparent;
279 color: #8b98a5;
280 border: none;
281 font-size: 24px;
282 cursor: pointer;
283 padding: 8px;
284 line-height: 1;
285 `;
286 closeButton.addEventListener('click', () => modal.remove());
287
288 const title = document.createElement('h2');
289 title.textContent = `Saved Posts (${savedPosts.length})`;
290 title.style.cssText = `
291 margin: 0 0 20px 0;
292 font-size: 24px;
293 font-weight: 700;
294 `;
295
296 const postsContainer = document.createElement('div');
297
298 if (savedPosts.length === 0) {
299 postsContainer.innerHTML = '<p style="text-align: center; color: #8b98a5; padding: 40px;">No saved posts yet. Click "Save" on any post to save it!</p>';
300 } else {
301 savedPosts.forEach((post, index) => {
302 const postElement = document.createElement('div');
303 postElement.style.cssText = `
304 border: 1px solid #38444d;
305 border-radius: 12px;
306 padding: 16px;
307 margin-bottom: 16px;
308 background: #192734;
309 `;
310
311 const postHeader = document.createElement('div');
312 postHeader.style.cssText = `
313 display: flex;
314 justify-content: space-between;
315 align-items: start;
316 margin-bottom: 12px;
317 `;
318
319 const authorInfo = document.createElement('div');
320 authorInfo.innerHTML = `
321 <div style="font-weight: 700; font-size: 15px;">${post.author || 'Unknown'}</div>
322 <div style="color: #8b98a5; font-size: 13px;">${post.username || ''}</div>
323 <div style="color: #8b98a5; font-size: 12px; margin-top: 4px;">${new Date(post.timestamp).toLocaleString()}</div>
324 `;
325
326 const deleteButton = document.createElement('button');
327 deleteButton.innerHTML = '🗑️';
328 deleteButton.style.cssText = `
329 background: transparent;
330 border: none;
331 color: #f4212e;
332 cursor: pointer;
333 font-size: 18px;
334 padding: 4px 8px;
335 `;
336 deleteButton.addEventListener('click', async () => {
337 savedPosts.splice(index, 1);
338 await GM.setValue('saved_posts', savedPosts);
339 modal.remove();
340 showSavedPostsModal();
341 });
342
343 postHeader.appendChild(authorInfo);
344 postHeader.appendChild(deleteButton);
345
346 const postContent = document.createElement('div');
347 postContent.textContent = post.content;
348 postContent.style.cssText = `
349 font-size: 15px;
350 line-height: 1.5;
351 margin-bottom: 12px;
352 white-space: pre-wrap;
353 `;
354
355 const postStats = document.createElement('div');
356 postStats.style.cssText = `
357 display: flex;
358 gap: 16px;
359 color: #8b98a5;
360 font-size: 13px;
361 `;
362 postStats.innerHTML = `
363 <span>💬 ${post.stats.replies}</span>
364 <span>🔄 ${post.stats.reposts}</span>
365 <span>❤️ ${post.stats.likes}</span>
366 <span>👁️ ${post.stats.views}</span>
367 `;
368
369 postElement.appendChild(postHeader);
370 postElement.appendChild(postContent);
371 if (post.images && post.images.length > 0) {
372 const imagesDiv = document.createElement('div');
373 imagesDiv.style.cssText = 'margin: 12px 0;';
374 imagesDiv.innerHTML = `<div style="color: #8b98a5; font-size: 13px;">📷 ${post.images.length} image(s) attached</div>`;
375 postElement.appendChild(imagesDiv);
376 }
377 postElement.appendChild(postStats);
378
379 postsContainer.appendChild(postElement);
380 });
381 }
382
383 modalContent.appendChild(closeButton);
384 modalContent.appendChild(title);
385 modalContent.appendChild(postsContainer);
386 modal.appendChild(modalContent);
387
388 modal.addEventListener('click', (e) => {
389 if (e.target === modal) {
390 modal.remove();
391 }
392 });
393
394 document.body.appendChild(modal);
395 }
396
397 // Process all tweets on the page
398 function processTweets() {
399 const articles = document.querySelectorAll('article[data-testid="tweet"]');
400 articles.forEach(article => {
401 createSaveButton(article);
402 });
403 }
404
405 // Initialize the extension
406 function init() {
407 console.log('Starting X Post Scraper...');
408
409 // Create view saved posts button
410 createViewSavedButton();
411
412 // Process existing tweets
413 processTweets();
414
415 // Watch for new tweets being added
416 const observer = new MutationObserver(debounce(() => {
417 processTweets();
418 }, 500));
419
420 observer.observe(document.body, {
421 childList: true,
422 subtree: true
423 });
424
425 console.log('X Post Scraper ready!');
426 }
427
428 // Wait for page to be ready
429 if (document.readyState === 'loading') {
430 document.addEventListener('DOMContentLoaded', init);
431 } else {
432 init();
433 }
434})();