Automatically merge 4-part split images on X (Twitter) in strict 1-2-3-4 order, bypassing CORS issues.
// ==UserScript==
// @name X Split Image Merger (Pro)_EN
// @namespace http://tampermonkey.net/
// @version 1.0
// @description Automatically merge 4-part split images on X (Twitter) in strict 1-2-3-4 order, bypassing CORS issues.
// @author https://x.com/Jiangshi_i
// @match *://*.x.com/*
// @match *://*.twitter.com/*
// @grant GM_xmlhttpRequest
// @connect pbs.twimg.com
// @icon https://abs.twimg.com/favicons/twitter.3.ico
// @license MIT
// ==/UserScript==
(function() {
'use strict';
// 1. Use MutationObserver instead of setInterval for better performance
const observer = new MutationObserver(checkForImages);
observer.observe(document.body, { childList: true, subtree: true });
function checkForImages() {
// Accurately locate the main tweet content to avoid grabbing profile pictures
const posts = document.querySelectorAll('article[data-testid="tweet"]');
posts.forEach(post => {
// Must be an img inside tweetPhoto to ensure it's a media image in the post
const images = post.querySelectorAll('[data-testid="tweetPhoto"] img');
// If there are exactly 4 images and no button has been added yet
if (images.length === 4 && !post.querySelector('.merge-btn-pro')) {
addMergeButton(post, images);
}
});
}
function addMergeButton(post, images) {
const buttonGroup = post.querySelector('[role="group"]'); // Usually the row with Retweet/Like buttons
const button = document.createElement('button');
button.textContent = 'Merge Images ⬇️';
button.className = 'merge-btn-pro';
// Simple UI styling
button.style.cssText = `
margin: 10px 0;
padding: 8px 16px;
background-color: #1d9bf0;
color: white;
border: none;
border-radius: 9999px;
font-weight: bold;
cursor: pointer;
font-size: 14px;
`;
button.onclick = (e) => {
e.stopPropagation(); // Prevent accidentally clicking into the tweet
button.textContent = 'Processing...';
button.style.backgroundColor = '#536471';
mergeImages(images, button);
};
// Insert the button below the images, above the interaction buttons
if (buttonGroup) {
buttonGroup.parentElement.insertBefore(button, buttonGroup);
} else {
post.appendChild(button);
}
}
// Bypass CORS restrictions using Tampermonkey's built-in request to get Blob data
function fetchImageAsBase64(url) {
return new Promise((resolve, reject) => {
// Replace the image quality parameter with 'orig' to get the highest resolution
const highResUrl = url.replace(/&name=[^&]+/, '&name=orig');
GM_xmlhttpRequest({
method: 'GET',
url: highResUrl,
responseType: 'blob',
onload: function(response) {
const reader = new FileReader();
reader.onloadend = () => resolve(reader.result);
reader.readAsDataURL(response.response);
},
onerror: reject
});
});
}
async function mergeImages(imageNodes, btnElement) {
try {
// Convert NodeList to an array. The order here is strictly: 0(Top-Left), 1(Top-Right), 2(Bottom-Left), 3(Bottom-Right)
const imgUrls = Array.from(imageNodes).map(img => img.src);
// 1. Fetch all image Base64 data concurrently (Promise.all ensures the returned array order matches imgUrls)
const base64List = await Promise.all(imgUrls.map(fetchImageAsBase64));
// 2. Convert Base64 to Image objects for Canvas drawing
const loadedImages = await Promise.all(base64List.map(base64 => {
return new Promise(resolve => {
const img = new Image();
img.onload = () => resolve(img);
img.src = base64;
});
}));
// 3. Calculate canvas dimensions
// Assuming all images have the same width, use the first image's width as the baseline
const targetWidth = loadedImages[0].naturalWidth;
let totalHeight = 0;
// Calculate the proportional height for each segment based on the target width and accumulate total height
loadedImages.forEach(img => {
const scale = targetWidth / img.naturalWidth;
totalHeight += img.naturalHeight * scale;
});
// 4. Create canvas and draw in strict order
const canvas = document.createElement('canvas');
const ctx = canvas.getContext('2d');
canvas.width = targetWidth;
canvas.height = totalHeight;
let currentY = 0;
// Since loadedImages is in 1-2-3-4 order, iterating and drawing directly results in top-to-bottom order
loadedImages.forEach(img => {
const scale = targetWidth / img.naturalWidth;
const drawHeight = img.naturalHeight * scale;
ctx.drawImage(img, 0, currentY, targetWidth, drawHeight);
currentY += drawHeight; // Move Y coordinate downwards
});
// 5. Trigger download
downloadCanvas(canvas);
// Restore button state
btnElement.textContent = 'Merged ✅';
btnElement.style.backgroundColor = '#00ba7c';
setTimeout(() => {
btnElement.textContent = 'Merge Images ⬇️';
btnElement.style.backgroundColor = '#1d9bf0';
}, 3000);
} catch (error) {
console.error("Failed to merge images:", error);
btnElement.textContent = 'Failed ❌';
btnElement.style.backgroundColor = '#f4212e';
alert('Image download failed. This might be due to network issues or CORS restrictions. Press F12 to check the console for errors.');
}
}
function downloadCanvas(canvas) {
const link = document.createElement('a');
link.download = `X_Merged_Image_${new Date().getTime()}.png`;
link.href = canvas.toDataURL('image/png', 1.0);
link.click();
}
})();