X.com/Twitter Original Images + Save with Metadata (Cromite)

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, '&lt;')
      .replace(/>/g, '&gt;')
      .replace(/"/g, '&quot;')
      .replace(/'/g, '&apos;');
  }

  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 };
  };

})();