// ==UserScript==
// @name X.com/Twitter Original Images + Save with Metadata (Cromite)
// @namespace http://cromite.local
// @version 1.5
// @description Load original images on X and allow "Save with metadata" (embed XMP description). Compatible with Cromite (no @grant). Title left empty, description contains DisplayName@handle + post text.
// @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);
// prefer the 'format' query param if available
const fmt = u.searchParams.get('format');
const ext = fmt ? fmt : (contentType && contentType.includes('png') ? 'png' : (contentType && contentType.includes('webp') ? 'webp' : 'jpg'));
// last pathname segment
let seg = u.pathname.split('/').pop() || 'image';
// strip query or hash if any
seg = seg.split('?')[0].split('#')[0];
if (!seg.includes('.')) {
return `${seg}.${ext}`;
} else {
return seg;
}
} catch (e) {
return `image_${Date.now()}.jpg`;
}
}
// Build XMP packet bytes containing EMPTY title and filled description only
function buildXmpPacketBytesWithDescriptionOnly(description) {
// title intentionally left empty (avoids Aves using it as filename)
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);
}
// Insert APP1 XMP after SOI. Returns new ArrayBuffer.
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).');
}
// APP1 header per spec (null-terminated)
const headerBytes = new TextEncoder().encode('http://ns.adobe.com/xap/1.0/\x00');
const app1Len = headerBytes.length + xmpBytes.length + 2; // +2 for length field itself
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 marker
app1[2] = (app1Len >> 8) & 0xFF;
app1[3] = app1Len & 0xFF;
app1.set(headerBytes, 4);
app1.set(xmpBytes, 4 + headerBytes.length);
// Compose: SOI (first 2 bytes) + app1 + rest (from offset 2)
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 (robust) ----------
function extractUserHandleFromTweet(tweet) {
try {
// Prefer anchors with href like "/username"
const anchors = tweet.querySelectorAll('a[href]');
for (const a of anchors) {
const href = a.getAttribute('href');
if (!href) continue;
// exclude links that include '/status/' or '/i/' or other paths
if (/^\/[A-Za-z0-9_]{1,30}\/?$/.test(href) && !href.includes('/status') && !href.includes('/i/')) {
return href.replace(/^\//, '').replace(/\/$/, '');
}
}
// fallback: find span that starts with @
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 '';
}
// ---------- Core save-with-metadata ----------
async function saveImageWithMetadataFetch(img, origUrl) {
const tweet = img.closest('article');
// display name (human readable)
const userEl = tweet ? tweet.querySelector('div[dir="ltr"] span') : null;
const displayName = userEl ? userEl.innerText.trim() : '';
// handle
const handle = tweet ? extractUserHandleFromTweet(tweet) : '';
// tweet text
const textEl = tweet ? tweet.querySelector('div[data-testid="tweetText"]') : null;
const tweetText = textEl ? textEl.innerText.trim() : '';
// build description: DisplayName@handle newline post text + tweet URL
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;
// 获取推文 URL
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}`;
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) {
// Build XMP with description only (title empty)
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 {
// fallback: download original blob, no embedding
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: add save button into parent container (try attach to nearest <a> if present) ----------
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;
// choose container: if parent is <a>, attach to that; else parentElement
let container = img.parentElement;
if (!container) container = img;
// ensure positioned container
ensureRelative(container);
// create button element
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'
});
// click handler
btn.addEventListener('click', async (ev) => {
ev.stopPropagation();
ev.preventDefault();
btn.textContent = '…';
btn.style.opacity = '0.7';
try {
const origUrl = toOrigUrl(img.src || img.getAttribute('src') || '');
const res = await saveImageWithMetadataFetch(img, origUrl);
// simple feedback
if (res && res.ok) {
// briefly flash green then restore icon
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);
try {
// fallback: normal download of URL (may be blocked by CORS)
const origUrl = toOrigUrl(img.src || img.getAttribute('src') || '');
const fallbackResp = await fetch(origUrl, { mode: 'cors' });
const blob = await fallbackResp.blob();
const fname = filenameFromUrl(origUrl, blob.type || '');
downloadBlob(blob, fname);
alert('Saved original image (XMP embedding failed). See console for details.');
} catch (e2) {
alert('Save failed. See console for details.');
}
btn.textContent = '💾';
btn.style.opacity = '1';
}
}, { passive: false });
container.appendChild(btn);
img.dataset.saveBtnAdded = 'true';
} catch (e) {
console.warn('addSaveButtonToImage error', e);
}
}
// ---------- Process images on page (orig replace + attach button + DOM attrs) ----------
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() : '';
// Keep DOM attributes for user inspection, but we will NOT write title into XMP
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;
// replace to orig for viewing
img.src = toOrigUrl(img.src || img.getAttribute('src') || '');
// write DOM attributes for quick inspection
addMetaAttributes(img);
// add Save button
addSaveButtonToImage(img);
img.dataset.origProcessed = 'true';
} catch (e) {
console.warn('processImageElement error', e);
}
}
function scanAndProcess() {
document.querySelectorAll('img[src*="twimg.com/media/"]').forEach(processImageElement);
}
// ---------- initial + observer ----------
scanAndProcess();
const observer = new MutationObserver(() => { scanAndProcess(); });
observer.observe(document.body, { childList: true, subtree: true });
// small helper for debugging
window.__x_meta_save_stats = function () {
const imgs = document.querySelectorAll('img[src*="twimg.com/media/"]');
let total = imgs.length, processed = 0, metaAdded = 0, btns = 0;
imgs.forEach(i => {
if (i.dataset.origProcessed) processed++;
if (i.dataset.metaAdded) metaAdded++;
if (i.dataset.saveBtnAdded) btns++;
});
return { total, processed, metaAdded, btns };
};
})();