您需要先安装一个扩展,例如 篡改猴、Greasemonkey 或 暴力猴,之后才能安装此脚本。
您需要先安装一个扩展,例如 篡改猴 或 暴力猴,之后才能安装此脚本。
您需要先安装一个扩展,例如 篡改猴 或 暴力猴,之后才能安装此脚本。
您需要先安装一个扩展,例如 篡改猴 或 Userscripts ,之后才能安装此脚本。
您需要先安装一款用户脚本管理器扩展,例如 Tampermonkey,才能安装此脚本。
您需要先安装用户脚本管理器扩展后才能安装此脚本。
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.
// ==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 }; }; })();