您需要先安装一个扩展,例如 篡改猴、Greasemonkey 或 暴力猴,之后才能安装此脚本。
您需要先安装一个扩展,例如 篡改猴 或 暴力猴,之后才能安装此脚本。
您需要先安装一个扩展,例如 篡改猴 或 暴力猴,之后才能安装此脚本。
您需要先安装一个扩展,例如 篡改猴 或 Userscripts ,之后才能安装此脚本。
您需要先安装一款用户脚本管理器扩展,例如 Tampermonkey,才能安装此脚本。
您需要先安装用户脚本管理器扩展后才能安装此脚本。
Add a screenshot button to Twitter/X post menus
// ==UserScript== // @name Twitter Screenshot Button // @namespace http://tampermonkey.net/ // @version 1.1 // @description Add a screenshot button to Twitter/X post menus // @author You // @match https://twitter.com/* // @match https://x.com/* // @grant GM_addStyle // @require https://cdnjs.cloudflare.com/ajax/libs/dom-to-image/2.6.0/dom-to-image.min.js // @license MIT // ==/UserScript== (function() { 'use strict'; // Add only necessary button styles GM_addStyle(` .screenshot-button { display: flex; align-items: center; flex-direction: row; width: 100%; padding: 12px 16px; cursor: pointer; font-size: 15px; transition-property: background-color, box-shadow; transition-duration: 0.2s; outline-style: none; box-sizing: border-box; min-height: 0px; min-width: 0px; border: 0 solid black; background-color: rgba(0, 0, 0, 0); margin: 0px; } .screenshot-button:hover { background-color: rgba(15, 20, 25, 0.1); } .screenshot-icon { margin-right: 0px; /* Keep margin 0, alignment handled by flex */ width: 18.75px; height: 18.75px; /* font-weight: bold; Removed as it doesn't apply well to SVG stroke */ vertical-align: text-bottom; /* Align icon better with text */ } .screenshot-notification { position: fixed; top: 20px; left: 50%; transform: translateX(-50%); background-color: #1DA1F2; color: white; padding: 10px 20px; border-radius: 20px; z-index: 9999; box-shadow: 0 2px 10px rgba(0,0,0,0.2); opacity: 1; transition: opacity 0.5s ease-out; } .screenshot-notification.fade-out { opacity: 0; } `); function findTweetMainContent(menuButton) { const article = menuButton.closest('article[role="article"]'); if (!article) return null; return article; } function takeScreenshot(menuButton) { const notification = document.createElement('div'); notification.className = 'screenshot-notification'; notification.innerHTML = 'Taking screenshot...'; document.body.appendChild(notification); try { const tweetContainer = findTweetMainContent(menuButton); if (!tweetContainer) { throw new Error('Could not find tweet content'); } // Save original styles const originalStyles = { background: tweetContainer.style.background, backgroundColor: tweetContainer.style.backgroundColor, margin: tweetContainer.style.margin, border: tweetContainer.style.border, borderRadius: tweetContainer.style.borderRadius }; // Optimize clarity settings const scale = window.devicePixelRatio * 2; // --- Start Background Color Detection --- let bgColor = 'rgb(255, 255, 255)'; // Default to white try { const bodyStyle = window.getComputedStyle(document.body); bgColor = bodyStyle.backgroundColor || bgColor; // If body has no color (transparent), try a main container if (!bgColor || bgColor === 'rgba(0, 0, 0, 0)' || bgColor === 'transparent') { const mainContent = document.querySelector('main') || document.querySelector('#react-root'); // Common containers if (mainContent) { bgColor = window.getComputedStyle(mainContent).backgroundColor || 'rgb(255, 255, 255)'; } } // Final fallback if detection fails if (bgColor === 'rgba(0, 0, 0, 0)' || bgColor === 'transparent') { bgColor = 'rgb(255, 255, 255)'; } } catch (bgError) { console.warn("Could not detect background color, defaulting to white.", bgError); bgColor = 'rgb(255, 255, 255)'; } // --- End Background Color Detection --- const config = { height: tweetContainer.offsetHeight * scale, width: tweetContainer.offsetWidth * scale, style: { transform: `scale(${scale})`, transformOrigin: 'top left', width: `${tweetContainer.offsetWidth}px`, height: `${tweetContainer.offsetHeight}px`, margin: 0, // Ensure no extra margin affects layout border: 'none', // Remove borders for stitching borderRadius: 0 // Remove border radius for stitching }, quality: 1.0 }; // --- Add bgcolor to config --- config.bgcolor = bgColor; // --- End add bgcolor --- // Use dom-to-image for high-quality screenshot domtoimage.toBlob(tweetContainer, config) .then(function(blob) { // Copy to clipboard navigator.clipboard.write([ new ClipboardItem({ 'image/png': blob }) ]).then(() => { notification.innerHTML = ` <div>Screenshot copied to clipboard!</div> <button class="download-btn" style=" background: white; color: #1DA1F2; border: none; padding: 5px 10px; border-radius: 15px; margin-top: 5px; cursor: pointer; ">Download</button> `; notification.style.backgroundColor = '#17BF63'; // Add download button functionality const downloadBtn = notification.querySelector('.download-btn'); downloadBtn.addEventListener('click', () => { const link = document.createElement('a'); link.download = `twitter-post-${Date.now()}.png`; link.href = URL.createObjectURL(blob); link.click(); URL.revokeObjectURL(link.href); notification.remove(); }); // 设置3秒后渐隐消失 setTimeout(() => { notification.classList.add('fade-out'); setTimeout(() => notification.remove(), 500); }, 1500); }); }) .catch(function(error) { console.error('Screenshot failed:', error); notification.textContent = 'Screenshot failed'; notification.style.backgroundColor = '#E0245E'; setTimeout(() => notification.remove(), 2000); }); } catch (error) { console.error('Error during screenshot:', error); notification.textContent = 'Screenshot failed'; notification.style.backgroundColor = '#E0245E'; setTimeout(() => notification.remove(), 2000); } } function createScreenshotIcon() { const svgNS = "http://www.w3.org/2000/svg"; const svg = document.createElementNS(svgNS, "svg"); svg.setAttribute("xmlns", svgNS); svg.setAttribute("viewBox", "0 0 24 24"); svg.setAttribute("width", "18.75"); svg.setAttribute("height", "18.75"); svg.setAttribute("fill", "none"); // Use fill=none for line icons svg.setAttribute("stroke", "currentColor"); // Inherit color via stroke svg.setAttribute("stroke-width", "2"); svg.setAttribute("stroke-linecap", "round"); svg.setAttribute("stroke-linejoin", "round"); svg.classList.add("screenshot-icon"); // Feather Icons: camera const path = document.createElementNS(svgNS, "path"); path.setAttribute("d", "M23 19a2 2 0 0 1-2 2H3a2 2 0 0 1-2-2V8a2 2 0 0 1 2-2h4l2-3h6l2 3h4a2 2 0 0 1 2 2z"); const circle = document.createElementNS(svgNS, "circle"); circle.setAttribute("cx", "12"); circle.setAttribute("cy", "13"); circle.setAttribute("r", "4"); svg.appendChild(path); svg.appendChild(circle); return svg; } function createThreadIcon() { const svgNS = "http://www.w3.org/2000/svg"; const svg = document.createElementNS(svgNS, "svg"); svg.setAttribute("xmlns", svgNS); svg.setAttribute("viewBox", "0 0 24 24"); svg.setAttribute("width", "18.75"); svg.setAttribute("height", "18.75"); svg.setAttribute("fill", "none"); svg.setAttribute("stroke", "currentColor"); svg.setAttribute("stroke-width", "2"); svg.setAttribute("stroke-linecap", "round"); svg.setAttribute("stroke-linejoin", "round"); svg.classList.add("screenshot-icon"); // Reuse same class for basic styling // Simple thread icon (line connecting dots) const path1 = document.createElementNS(svgNS, "path"); path1.setAttribute("d", "M6 3v12"); const circle1 = document.createElementNS(svgNS, "circle"); circle1.setAttribute("cx", "6"); circle1.setAttribute("cy", "3"); circle1.setAttribute("r", "1"); const circle2 = document.createElementNS(svgNS, "circle"); circle2.setAttribute("cx", "6"); circle2.setAttribute("cy", "9"); circle2.setAttribute("r", "1"); const circle3 = document.createElementNS(svgNS, "circle"); circle3.setAttribute("cx", "6"); circle3.setAttribute("cy", "15"); circle3.setAttribute("r", "1"); // Add a parallel element to suggest thread const path2 = document.createElementNS(svgNS, "path"); path2.setAttribute("d", "M18 9v12"); const circle4 = document.createElementNS(svgNS, "circle"); circle4.setAttribute("cx", "18"); circle4.setAttribute("cy", "9"); circle4.setAttribute("r", "1"); const circle5 = document.createElementNS(svgNS, "circle"); circle5.setAttribute("cx", "18"); circle5.setAttribute("cy", "15"); circle5.setAttribute("r", "1"); const circle6 = document.createElementNS(svgNS, "circle"); circle6.setAttribute("cx", "18"); circle6.setAttribute("cy", "21"); circle6.setAttribute("r", "1"); svg.appendChild(path1); svg.appendChild(circle1); svg.appendChild(circle2); svg.appendChild(circle3); svg.appendChild(path2); svg.appendChild(circle4); svg.appendChild(circle5); svg.appendChild(circle6); return svg; } async function captureThread(menuButton) { const notification = document.createElement('div'); notification.className = 'screenshot-notification'; notification.innerHTML = 'Capturing thread... Finding author and posts...'; document.body.appendChild(notification); try { // 1. Find original tweet and author const originalArticle = findTweetMainContent(menuButton); if (!originalArticle) { throw new Error('Could not find the starting tweet.'); } // Find author's handle (needs a robust selector, this is an example) // Twitter structure changes, this might need adjustment. const userElement = originalArticle.querySelector('[data-testid="User-Name"]'); // Try Test ID first let authorHandle = null; if (userElement) { // Find the span containing the handle like '@handle' const spans = userElement.querySelectorAll('span'); for (const span of spans) { if (span.textContent.startsWith('@')) { authorHandle = span.textContent; break; } } } // Fallback if data-testid not found or handle not in spans if (!authorHandle) { const authorLink = originalArticle.querySelector('a[href*="/status/"][dir="ltr"]'); if (authorLink) { const linkParts = authorLink.href.split('/'); // Usually the handle is the 3rd part like ['https:', '', 'twitter.com', 'handle', 'status', 'id'] if (linkParts.length > 3) { authorHandle = '@' + linkParts[3]; } } } if (!authorHandle) { throw new Error('Could not reliably determine the author\'s handle.'); } notification.innerHTML = `Capturing thread by ${authorHandle}... Expanding replies...`; console.log(`Author Handle: ${authorHandle}`); // 2. Find and click "Show more replies" repeatedly const conversationContainer = originalArticle.closest('div[data-testid="conversation"]'); // Find the container holding the thread let showMoreButton; const maxClicks = 15; // Limit clicks to prevent infinite loops let clicks = 0; const showMoreSelector = 'span.css-1jxf684.r-bcqeeo.r-1ttztb7.r-qvutc0.r-poiln3'; // User provided selector while (clicks < maxClicks) { // Find the button within the conversation context if possible showMoreButton = conversationContainer ? conversationContainer.querySelector(showMoreSelector) : document.querySelector(showMoreSelector); // Fallback to document search // Check if the button text actually indicates more replies if (showMoreButton && showMoreButton.textContent.includes('Show') && showMoreButton.closest('div[role="button"]')) { // Check text and if it's clickable console.log(`Clicking "Show more" (${clicks + 1}/${maxClicks})`); notification.innerHTML = `Capturing thread by ${authorHandle}... Expanding replies (${clicks + 1})...`; showMoreButton.closest('div[role="button"]').click(); // Click the clickable parent clicks++; // Wait for content to load - adjust delay as needed await new Promise(resolve => setTimeout(resolve, 1500)); // Wait 1.5 seconds } else { console.log("No more 'Show more' buttons found or button text doesn't match."); break; // Exit loop if no more buttons or limit reached } } if (clicks === maxClicks) { console.warn("Reached maximum 'Show more' clicks limit."); } notification.innerHTML = `Capturing thread by ${authorHandle}... Finding all posts...`; // 3. Filter replies by original author // Select all articles *after* the initial expansion const allArticles = Array.from(document.querySelectorAll('article[role="article"]')); const authorTweets = allArticles.filter(article => { // Re-check author handle for each potential tweet in the thread const userElement = article.querySelector('[data-testid="User-Name"]'); let currentHandle = null; if (userElement) { const spans = userElement.querySelectorAll('span'); for (const span of spans) { if (span.textContent.startsWith('@')) { currentHandle = span.textContent; break; } } } // Fallback check if (!currentHandle) { const authorLink = article.querySelector('a[href*="/status/"][dir="ltr"]'); if (authorLink) { const linkParts = authorLink.href.split('/'); if (linkParts.length > 3) { currentHandle = '@' + linkParts[3]; } } } return currentHandle === authorHandle; }); if (authorTweets.length === 0) { // If filtering removed everything, at least include the original tweet authorTweets.push(originalArticle); } // Ensure tweets are in order (usually they are by DOM order, but sort just in case) // This relies on DOM order being correct. A more robust way might involve timestamps if available. authorTweets.sort((a, b) => a.compareDocumentPosition(b) & Node.DOCUMENT_POSITION_FOLLOWING ? -1 : 1); console.log(`Found ${authorTweets.length} tweets by ${authorHandle}`); notification.innerHTML = `Taking ${authorTweets.length} screenshots... (0%)`; // 4. Screenshot each tweet individually const blobs = []; const scale = window.devicePixelRatio * 1.5; // Slightly lower scale for potentially long images for (let i = 0; i < authorTweets.length; i++) { const tweet = authorTweets[i]; const percentage = Math.round(((i + 1) / authorTweets.length) * 100); notification.innerHTML = `Taking ${authorTweets.length} screenshots... (${percentage}%)`; // Ensure tweet is visible for screenshot (scrollIntoView might be needed sometimes) // tweet.scrollIntoView({ block: 'nearest' }); // await new Promise(resolve => setTimeout(resolve, 100)); // Small delay after scroll try { // ---> New: Check for and click internal "Show more" button within the tweet text const internalShowMoreButton = tweet.querySelector('button[data-testid="tweet-text-show-more-link"]'); if (internalShowMoreButton) { console.log(`Clicking internal "Show more" for tweet ${i + 1}`); internalShowMoreButton.click(); // Wait a short moment for the text to expand await new Promise(resolve => setTimeout(resolve, 500)); // 0.5 second delay } // <--- End new section // --- Start Background Color Detection (for thread) --- let threadBgColor = 'rgb(255, 255, 255)'; // Default to white try { const bodyStyle = window.getComputedStyle(document.body); threadBgColor = bodyStyle.backgroundColor || threadBgColor; if (!threadBgColor || threadBgColor === 'rgba(0, 0, 0, 0)' || threadBgColor === 'transparent') { const mainContent = document.querySelector('main') || document.querySelector('#react-root'); if (mainContent) { threadBgColor = window.getComputedStyle(mainContent).backgroundColor || 'rgb(255, 255, 255)'; } } if (threadBgColor === 'rgba(0, 0, 0, 0)' || threadBgColor === 'transparent') { threadBgColor = 'rgb(255, 255, 255)'; } } catch (bgError) { console.warn("Could not detect background color for thread tweet, defaulting to white.", bgError); threadBgColor = 'rgb(255, 255, 255)'; } // --- End Background Color Detection --- const config = { height: tweet.offsetHeight * scale, width: tweet.offsetWidth * scale, style: { transform: `scale(${scale})`, transformOrigin: 'top left', width: `${tweet.offsetWidth}px`, height: `${tweet.offsetHeight}px`, margin: 0, // Ensure no extra margin affects layout border: 'none', // Remove borders for stitching borderRadius: 0 // Remove border radius for stitching }, quality: 0.95 // Slightly lower quality for performance/size }; // --- Add bgcolor to config (for thread) --- config.bgcolor = threadBgColor; // --- End add bgcolor --- const blob = await domtoimage.toBlob(tweet, config); blobs.push(blob); } catch (screenshotError) { console.error(`Failed to screenshot tweet ${i + 1}:`, screenshotError); // Optionally skip this tweet or stop the process notification.innerHTML = `Error screenshotting tweet ${i + 1}. Skipping.`; await new Promise(resolve => setTimeout(resolve, 1500)); } } if (blobs.length === 0) { throw new Error("No screenshots were successfully taken."); } notification.innerHTML = `Combining ${blobs.length} screenshots...`; // 5. Combine images using Canvas const canvas = document.createElement('canvas'); const ctx = canvas.getContext('2d'); let totalHeight = 0; let maxWidth = 0; const images = []; // Convert blobs to Image objects to get dimensions for (const blob of blobs) { const img = new Image(); const url = URL.createObjectURL(blob); img.src = url; await new Promise(resolve => { img.onload = resolve; }); // Wait for image data to load images.push(img); totalHeight += img.height; maxWidth = Math.max(maxWidth, img.width); // URL.revokeObjectURL(url); // Revoke later after drawing } // Set canvas dimensions canvas.width = maxWidth; canvas.height = totalHeight; // Draw images onto canvas let currentY = 0; for (const img of images) { ctx.drawImage(img, 0, currentY); currentY += img.height; URL.revokeObjectURL(img.src); // Revoke URL now } // 6. Get final blob from canvas canvas.toBlob(function(finalBlob) { // 7. Handle final blob (copy/download/notification) navigator.clipboard.write([ new ClipboardItem({ 'image/png': finalBlob }) ]).then(() => { notification.innerHTML = ` <div>Thread screenshot copied! (${images.length} posts)</div> <button class="download-btn" style="background: white; color: #1DA1F2; border: none; padding: 5px 10px; border-radius: 15px; margin-top: 5px; cursor: pointer;">Download</button> `; notification.style.backgroundColor = '#17BF63'; const downloadBtn = notification.querySelector('.download-btn'); downloadBtn.addEventListener('click', () => { const link = document.createElement('a'); link.download = `twitter-thread-${authorHandle.substring(1)}-${Date.now()}.png`; link.href = URL.createObjectURL(finalBlob); link.click(); URL.revokeObjectURL(link.href); // Keep notification open after download click for a bit setTimeout(() => { notification.classList.add('fade-out'); setTimeout(() => notification.remove(), 500); }, 1500); }); // Auto fade out after longer time for thread capture setTimeout(() => { if (!notification.classList.contains('fade-out')) { // Avoid double fade if download clicked notification.classList.add('fade-out'); setTimeout(() => notification.remove(), 500); } }, 4000); // Keep notification longer }).catch(err => { console.error('Failed to copy final image:', err); notification.textContent = 'Failed to copy thread screenshot.'; notification.style.backgroundColor = '#E0245E'; setTimeout(() => notification.remove(), 3000); }); }, 'image/png', 0.9); // Specify type and quality } catch (error) { console.error('Capture Thread failed:', error); notification.textContent = `Capture Thread failed: ${error.message}`; notification.style.backgroundColor = '#E0245E'; setTimeout(() => { notification.classList.add('fade-out'); setTimeout(() => notification.remove(), 500); }, 3000); } } function addScreenshotButtonToMenu(menuButton) { const menu = document.querySelector('[role="menu"]'); // Check if buttons already exist if (!menu || menu.querySelector('.screenshot-button') || menu.querySelector('.capture-thread-button')) return; // --- Screenshot Button --- const screenshotButton = document.createElement('div'); screenshotButton.className = 'screenshot-button'; // Keep original class for styling screenshotButton.setAttribute('role', 'menuitem'); screenshotButton.setAttribute('tabindex', '0'); screenshotButton.appendChild(createScreenshotIcon()); const textScreenshot = document.createElement('span'); textScreenshot.textContent = 'Screenshot'; textScreenshot.style.marginLeft = '12px'; textScreenshot.style.fontSize = '15px'; textScreenshot.style.fontWeight = 'bold'; screenshotButton.appendChild(textScreenshot); screenshotButton.addEventListener('click', (event) => { event.stopPropagation(); // Prevent menu closing immediately if something goes wrong takeScreenshot(menuButton); // Attempt to close the menu after action setTimeout(() => { const closeButton = document.querySelector('[data-testid="Dropdown"] [aria-label="Close"]'); // More specific selector if (closeButton) closeButton.click(); // Fallback for menu itself if close button not found reliably else if (menu && menu.style.display !== 'none') { // Heuristic: Clicking away might close it, or find a parent dismiss layer // This part is tricky due to varying menu implementations } }, 100); // Small delay }); menu.insertBefore(screenshotButton, menu.firstChild); // Insert at the top // --- Capture Thread Button --- const captureThreadButton = document.createElement('div'); // Use screenshot-button class for base styles, add specific class if needed captureThreadButton.className = 'screenshot-button capture-thread-button'; captureThreadButton.setAttribute('role', 'menuitem'); captureThreadButton.setAttribute('tabindex', '0'); captureThreadButton.appendChild(createThreadIcon()); const textThread = document.createElement('span'); textThread.textContent = 'Capture Thread'; textThread.style.marginLeft = '12px'; textThread.style.fontSize = '15px'; textThread.style.fontWeight = 'bold'; captureThreadButton.appendChild(textThread); captureThreadButton.addEventListener('click', (event) => { event.stopPropagation(); captureThread(menuButton); // Attempt to close the menu after action setTimeout(() => { const closeButton = document.querySelector('[data-testid="Dropdown"] [aria-label="Close"]'); if (closeButton) closeButton.click(); else if (menu && menu.style.display !== 'none') { // Fallback... } }, 100); }); // Insert Capture Thread button below the Screenshot button if (screenshotButton.nextSibling) { menu.insertBefore(captureThreadButton, screenshotButton.nextSibling); } else { menu.appendChild(captureThreadButton); } } function addScreenshotButtons() { const observer = new MutationObserver((mutations) => { mutations.forEach((mutation) => { if (mutation.addedNodes.length) { mutation.addedNodes.forEach((node) => { // Check if the added node itself is a menu or contains one if (node.nodeType === 1) { // Check if it's an element node const menu = node.matches('[role="menu"]') ? node : node.querySelector('[role="menu"]'); if (menu) { // Find the button that triggered this menu const menuButton = document.querySelector('[aria-haspopup="menu"][aria-expanded="true"]'); // IMPORTANT CHECK: Ensure the menu was triggered by the "More" button (three dots) // within an article, typically identified by data-testid="caret". if (menuButton && menuButton.closest('article[role="article"]') && menuButton.getAttribute('data-testid') === 'caret') { console.log("Detected 'More' menu, adding buttons."); addScreenshotButtonToMenu(menuButton); } else { // Optional: Log why buttons weren't added // console.log("Detected menu, but not triggered by the target 'More' button or not within an article."); } } } }); } }); }); observer.observe(document.body, { childList: true, subtree: true }); } addScreenshotButtons(); })();