// ==UserScript==
// @name X.com/Twitter Original Images + Save with Metadata + Auto-Like (Cromite)
// @namespace http://cromite.local
// @version 1.6
// @description Load original images on X and allow "Save with metadata" (embed DisplayName@handle + post text + media tags into XMP description in saved file) and Auto-Like post when saving image. Compatible with Cromite (no @grant).
// @match https://x.com/*
// @match https://twitter.com/*
// @run-at document-end
// @license MIT
// ==/UserScript==
(function () {
'use strict';
// ---------- Helpers ----------
function xmlEscape(s) {
return String(s || '').replace(/&/g, '&')
.replace(/</g, '<')
.replace(/>/g, '>')
.replace(/"/g, '"')
.replace(/'/g, ''');
}
function toOrigUrl(url) {
if (!url) return url;
try {
const u = new URL(url, location.href);
if (u.hostname.includes('twimg.com') && u.searchParams.has('name')) {
u.searchParams.set('name', 'orig');
return u.toString();
}
} catch (e) { /* ignore */ }
return url;
}
function filenameFromUrl(url, contentType) {
try {
const u = new URL(url, location.href);
const fmt = u.searchParams.get('format');
const ext = fmt ? fmt : (contentType && contentType.includes('png') ? 'png' : (contentType && contentType.includes('webp') ? 'webp' : 'jpg'));
let seg = u.pathname.split('/').pop() || 'image';
seg = seg.split('?')[0].split('#')[0];
if (!seg.includes('.')) {
return `${seg}.${ext}`;
} else {
return seg;
}
} catch (e) {
return `image_${Date.now()}.jpg`;
}
}
function buildXmpPacketBytesWithDescriptionOnly(description) {
const xmp = `<?xpacket begin='' id='W5M0MpCehiHzreSzNTczkc9d'?>
<x:xmpmeta xmlns:x='adobe:ns:meta/'>
<rdf:RDF xmlns:rdf='http://www.w3.org/1999/02/22-rdf-syntax-ns#'>
<rdf:Description rdf:about='' xmlns:dc='http://purl.org/dc/elements/1.1/'>
<dc:title><rdf:Alt><rdf:li xml:lang='x-default'></rdf:li></rdf:Alt></dc:title>
<dc:description><rdf:Alt><rdf:li xml:lang='x-default'>${xmlEscape(description)}</rdf:li></rdf:Alt></dc:description>
</rdf:Description>
</rdf:RDF>
</x:xmpmeta>
<?xpacket end='w'?>`;
return new TextEncoder().encode(xmp);
}
function insertXmpIntoJpegArrayBuffer(arrayBuffer, xmpBytes) {
const data = new Uint8Array(arrayBuffer);
if (data.length < 2 || data[0] !== 0xFF || data[1] !== 0xD8) {
throw new Error('Not a JPEG (missing SOI).');
}
const headerBytes = new TextEncoder().encode('http://ns.adobe.com/xap/1.0/\x00');
const app1Len = headerBytes.length + xmpBytes.length + 2;
if (app1Len > 0xFFFF) throw new Error('XMP too large');
const app1 = new Uint8Array(2 + 2 + headerBytes.length + xmpBytes.length);
app1[0] = 0xFF; app1[1] = 0xE1;
app1[2] = (app1Len >> 8) & 0xFF;
app1[3] = app1Len & 0xFF;
app1.set(headerBytes, 4);
app1.set(xmpBytes, 4 + headerBytes.length);
const rest = data.subarray(2);
const out = new Uint8Array(2 + app1.length + rest.length);
out.set(data.subarray(0, 2), 0);
out.set(app1, 2);
out.set(rest, 2 + app1.length);
return out.buffer;
}
function downloadBlob(blob, filename) {
const url = URL.createObjectURL(blob);
const a = document.createElement('a');
a.href = url;
a.download = filename || 'image';
document.body.appendChild(a);
a.click();
a.remove();
setTimeout(() => URL.revokeObjectURL(url), 5000);
}
// ---------- Extract user handle ----------
function extractUserHandleFromTweet(tweet) {
try {
const anchors = tweet.querySelectorAll('a[href]');
for (const a of anchors) {
const href = a.getAttribute('href');
if (!href) continue;
if (/^\/[A-Za-z0-9_]{1,30}\/?$/.test(href) && !href.includes('/status') && !href.includes('/i/')) {
return href.replace(/^\//, '').replace(/\/$/, '');
}
}
const spans = tweet.querySelectorAll('span');
for (const s of spans) {
const t = (s.textContent || '').trim();
if (t.startsWith('@')) {
return t.replace(/^@/, '').split(' ')[0];
}
}
} catch (e) { /* ignore */ }
return '';
}
// ---------- Extract media tags ----------
function extractMediaTags(tweet) {
try {
const tagsAnchor = tweet.querySelector('a[href*="/media_tags"]');
if (!tagsAnchor) return '';
const spans = tagsAnchor.querySelectorAll('span');
const names = [];
spans.forEach(s => {
const t = (s.textContent || '').trim();
if (t && t.toLowerCase() !== 'and') {
names.push(t);
}
});
if (names.length > 0) {
return names.join(', ');
}
} catch (e) { /* ignore */ }
return '';
}
// ---------- Auto-like tweet ----------
// ---------- Auto-like tweet ----------
function autoLikeTweet(tweet) {
try {
if (!tweet) return;
// X 新版 DOM:button[data-testid="like"] 才是真正的按钮
const likeBtn = tweet.querySelector('button[data-testid="like"]');
if (likeBtn) {
likeBtn.click();
console.log('Auto-liked tweet ✅');
} else {
console.log('Tweet already liked or like button not found.');
}
} catch (e) {
console.warn('Auto-like failed:', e);
}
}
// ---------- Core save-with-metadata ----------
async function saveImageWithMetadataFetch(img, origUrl) {
const tweet = img.closest('article');
const userEl = tweet ? tweet.querySelector('div[dir="ltr"] span') : null;
const displayName = userEl ? userEl.innerText.trim() : '';
const handle = tweet ? extractUserHandleFromTweet(tweet) : '';
const textEl = tweet ? tweet.querySelector('div[data-testid="tweetText"]') : null;
const tweetText = textEl ? textEl.innerText.trim() : '';
const head = handle ? (displayName ? `${displayName}@${handle}` : `@${handle}`) : (displayName || '');
let description = '';
if (head && tweetText) description = `${head}\n${tweetText}`;
else if (head) description = head;
else if (tweetText) description = tweetText;
let tweetUrl = '';
try {
const anchor = tweet.querySelector('a[href*="/status/"]');
if (anchor) tweetUrl = new URL(anchor.getAttribute('href'), location.origin).href;
} catch (e) {}
if (tweetUrl) description += `\n${tweetUrl}`;
// media tags
const mediaTags = extractMediaTags(tweet);
if (mediaTags) {
description += `\nMedia tags: ${mediaTags}`;
}
let resp;
try {
resp = await fetch(origUrl, { mode: 'cors' });
} catch (e) {
throw new Error('Fetch failed (possible CORS): ' + e);
}
if (!resp.ok) throw new Error('Image fetch failed: ' + resp.status);
const contentType = (resp.headers.get('Content-Type') || resp.headers.get('content-type') || '').toLowerCase();
const arrayBuffer = await resp.arrayBuffer();
const isJpeg = contentType.includes('jpeg') || contentType.includes('jpg') ||
origUrl.toLowerCase().includes('.jpg') || origUrl.toLowerCase().includes('format=jpg');
if (isJpeg) {
const xmpBytes = buildXmpPacketBytesWithDescriptionOnly(description || '');
let newBuffer;
try {
newBuffer = insertXmpIntoJpegArrayBuffer(arrayBuffer, xmpBytes);
} catch (err) {
throw new Error('Insert XMP failed: ' + err.message);
}
const newBlob = new Blob([newBuffer], { type: 'image/jpeg' });
const fname = filenameFromUrl(origUrl, 'image/jpeg');
downloadBlob(newBlob, fname);
return { ok: true, method: 'xmp', filename: fname };
} else {
const blob = new Blob([arrayBuffer], { type: contentType || 'application/octet-stream' });
const fname = filenameFromUrl(origUrl, contentType || '');
downloadBlob(blob, fname);
return { ok: true, method: 'raw', filename: fname };
}
}
// ---------- UI ----------
function ensureRelative(el) {
if (!el) return;
const cs = getComputedStyle(el);
if (cs.position === 'static' || !cs.position) el.style.position = 'relative';
}
function addSaveButtonToImage(img) {
try {
if (img.dataset.saveBtnAdded) return;
let container = img.parentElement;
if (!container) container = img;
ensureRelative(container);
const btn = document.createElement('div');
btn.className = 'x-save-meta-btn';
btn.textContent = '💾';
Object.assign(btn.style, {
position: 'absolute',
top: '4px',
right: '4px',
zIndex: '99999',
background: 'rgba(0,0,0,0.6)',
color: 'white',
padding: '3px 6px',
borderRadius: '4px',
fontSize: '16px',
cursor: 'pointer',
lineHeight: '1',
pointerEvents: 'auto'
});
btn.addEventListener('click', async (ev) => {
ev.stopPropagation();
ev.preventDefault();
btn.textContent = '…';
btn.style.opacity = '0.7';
try {
const tweet = img.closest('article');
autoLikeTweet(tweet); // 自动点赞
const origUrl = toOrigUrl(img.src || img.getAttribute('src') || '');
const res = await saveImageWithMetadataFetch(img, origUrl);
if (res && res.ok) {
btn.style.background = 'rgba(0,150,0,0.8)';
setTimeout(() => {
btn.style.background = 'rgba(0,0,0,0.6)';
btn.textContent = '💾';
btn.style.opacity = '1';
}, 1000);
} else {
btn.textContent = '💾';
btn.style.opacity = '1';
}
} catch (err) {
console.error('Save with metadata error:', err);
btn.textContent = '💾';
btn.style.opacity = '1';
}
}, { passive: false });
container.appendChild(btn);
img.dataset.saveBtnAdded = 'true';
} catch (e) {
console.warn('addSaveButtonToImage error', e);
}
}
function addMetaAttributes(img) {
if (img.dataset.metaAdded) return;
try {
const tweet = img.closest('article');
if (!tweet) return;
const userEl = tweet.querySelector('div[dir="ltr"] span');
const userName = userEl ? userEl.innerText.trim() : '';
const textEl = tweet.querySelector('div[data-testid="tweetText"]');
const tweetText = textEl ? textEl.innerText.trim() : '';
if (userName) img.title = userName;
if (tweetText) img.setAttribute('data-description', tweetText);
img.dataset.metaAdded = 'true';
} catch (e) { /* ignore */ }
}
function processImageElement(img) {
try {
if (img.dataset.origProcessed) return;
img.src = toOrigUrl(img.src || img.getAttribute('src') || '');
addMetaAttributes(img);
addSaveButtonToImage(img);
img.dataset.origProcessed = 'true';
} catch (e) {
console.warn('processImageElement error', e);
}
}
function scanAndProcess() {
document.querySelectorAll('img[src*="twimg.com/media/"]').forEach(processImageElement);
}
scanAndProcess();
const observer = new MutationObserver(() => { scanAndProcess(); });
observer.observe(document.body, { childList: true, subtree: true });
})();