Sakugabooru Browse Fix (Alt+D Download, Alt+C copy)

Swap sample img→video (MP4/WEBM) when appropriate; download/copy ORIGINAL source via keyboard

您需要先安裝使用者腳本管理器擴展,如 TampermonkeyGreasemonkeyViolentmonkey 之後才能安裝該腳本。

You will need to install an extension such as Tampermonkey to install this script.

您需要先安裝使用者腳本管理器擴充功能,如 TampermonkeyViolentmonkey 後才能安裝該腳本。

您需要先安裝使用者腳本管理器擴充功能,如 TampermonkeyUserscripts 後才能安裝該腳本。

你需要先安裝一款使用者腳本管理器擴展,比如 Tampermonkey,才能安裝此腳本

您需要先安裝使用者腳本管理器擴充功能後才能安裝該腳本。

(我已經安裝了使用者腳本管理器,讓我安裝!)

你需要先安裝一款使用者樣式管理器擴展,比如 Stylus,才能安裝此樣式

你需要先安裝一款使用者樣式管理器擴展,比如 Stylus,才能安裝此樣式

你需要先安裝一款使用者樣式管理器擴展,比如 Stylus,才能安裝此樣式

你需要先安裝一款使用者樣式管理器擴展後才能安裝此樣式

你需要先安裝一款使用者樣式管理器擴展後才能安裝此樣式

你需要先安裝一款使用者樣式管理器擴展後才能安裝此樣式

(我已經安裝了使用者樣式管理器,讓我安裝!)

// ==UserScript==
// @name         Sakugabooru Browse Fix (Alt+D Download, Alt+C copy)
// @namespace    http://tampermonkey.net/
// @version      0.75
// @description  Swap sample img→video (MP4/WEBM) when appropriate; download/copy ORIGINAL source via keyboard
// @match        *://www.sakugabooru.com/post/browse*
// @license      MIT
// @grant        GM_download
// @grant        GM_xmlhttpRequest
// @connect      *
// ==/UserScript==

(function () {
  'use strict';

  // -------------------- Debug toggle (optional) --------------------
  const DBG = false;
  const log = (...a) => DBG && console.log('[Sakuga]', ...a);

  // -------------------- UI: centered toast --------------------
  function toast(msg) {
    console.log('[Sakuga]', msg);
    try {
      const n = document.createElement('div');
      n.textContent = msg;
      Object.assign(n.style, {
        position: 'fixed',
        top: '50%',
        left: '50%',
        transform: 'translate(-50%, -50%)',
        padding: '10px 14px',
        background: 'rgba(0,0,0,0.8)',
        color: '#fff',
        fontSize: '14px',
        borderRadius: '8px',
        zIndex: 999999,
        pointerEvents: 'none',
        textAlign: 'center',
        whiteSpace: 'nowrap',
        boxShadow: '0 2px 6px rgba(0,0,0,0.5)',
        transition: 'opacity 0.3s ease'
      });
      n.style.opacity = '0';
      document.body.appendChild(n);
      requestAnimationFrame(() => { n.style.opacity = '1'; });
      setTimeout(() => {
        n.style.opacity = '0';
        setTimeout(() => n.remove(), 300);
      }, 1000);
    } catch {}
  }

  // -------------------- Generic helpers --------------------
  const EXT_PRIORITY_VIDEO = ['.mp4', '.webm'];
  const EXT_PRIORITY_IMAGE = ['.png', '.jpg', '.jpeg', '.gif', '.webp'];

  const uniq = (arr) => {
    const s = new Set(); const out = [];
    for (const v of arr) if (v && !s.has(v)) s.add(v), out.push(v);
    return out;
  };

  const endsWithAny = (u, exts) => exts.some(e => u.toLowerCase().endsWith(e));

  const filenameFromUrl = (url) => {
    try {
      const u = new URL(url, location.href);
      return decodeURIComponent(u.pathname.split('/').filter(Boolean).pop() || 'download');
    } catch {
      const clean = (url || '').split(/[?#]/)[0];
      return decodeURIComponent(clean.split('/').pop() || 'download');
    }
  };

  const guessMime = (url) => {
    const l = url.toLowerCase();
    if (l.endsWith('.mp4')) return 'video/mp4';
    if (l.endsWith('.webm')) return 'video/webm';
    if (l.endsWith('.png')) return 'image/png';
    if (l.endsWith('.jpg') || l.endsWith('.jpeg')) return 'image/jpeg';
    if (l.endsWith('.gif')) return 'image/gif';
    if (l.endsWith('.webp')) return 'image/webp';
    return 'application/octet-stream';
  };

  // -------------------- “Active post” detection --------------------
  function rectVisibleArea(r) {
    const vw = window.innerWidth, vh = window.innerHeight;
    const x1 = Math.max(0, Math.min(vw, r.left));
    const y1 = Math.max(0, Math.min(vh, r.top));
    const x2 = Math.max(0, Math.min(vw, r.right));
    const y2 = Math.max(0, Math.min(vh, r.bottom));
    const w = Math.max(0, x2 - x1);
    const h = Math.max(0, y2 - y1);
    return w * h;
  }

  // Return the media element (img.main-image / img#image / video.vjs-tech) that is MOST visible.
  // Fallback: element at the screen center → nearest media.
  function getActiveMediaElement() {
    const mediaNodes = Array.from(document.querySelectorAll('img#image, img.main-image, video.vjs-tech'));
    let best = null, bestArea = -1;
    for (const n of mediaNodes) {
      const r = n.getBoundingClientRect();
      const area = rectVisibleArea(r);
      if (area > bestArea) { best = n; bestArea = area; }
    }
    if (best && bestArea > 0) return best;

    // Center fallback
    const cx = Math.floor(window.innerWidth / 2);
    const cy = Math.floor(window.innerHeight / 2);
    let el = document.elementFromPoint(cx, cy);
    const isMedia = (e) => e && (e.matches?.('img#image, img.main-image, video.vjs-tech'));
    for (let cur = el; cur && cur !== document.body; cur = cur.parentElement) {
      if (isMedia(cur)) return cur;
      const found = cur.querySelector?.('img#image, img.main-image, video.vjs-tech');
      if (found) return found;
    }
    return null;
  }

  // Choose a container for scoping to THIS post only.
  function getMediaRoot(mediaEl) {
    if (!mediaEl) return document;
    const root = mediaEl.closest?.('.post, .content, .image-container, .image, .post-content') ||
                 mediaEl.parentElement ||
                 document;
    return root;
  }

  // -------------------- Kind detection (scoped) --------------------
  function getDesiredKindInRoot(root) {
    const vid = root.querySelector('video.vjs-tech');
    if (vid) return 'video';
    const img = root.querySelector('img#image, img.main-image');
    const src = (img && (img.getAttribute('src') || '')) || '';
    if (/\.(mp4|webm)(\?|#|$)/i.test(src)) return 'video';
    return 'image';
  }

  // -------------------- Sample→Original upgrade --------------------
  const isLikelySample = (url) =>
    /\/sample\//i.test(url) || /\/preview\//i.test(url) || /(^|\/)sample_/i.test(url);

  const upgradeSampleOnce = (url) => {
    let s = url;
    s = s.replace(/\/preview\//i, '/data/');
    s = s.replace(/\/sample\//i, '/data/');
    s = s.replace(/\/sample_([^/]+)$/i, '/$1');
    s = s.replace(/\/(sample_)([^/]+?)(\.[a-z0-9]+)$/i, '/$2$3');
    return s !== url ? s : null;
  };

  // -------------------- Existence / Content-Type probing --------------------
  // Returns { ok: boolean, type: string|null }
  function headType(url) {
    return new Promise((resolve) => {
      const finish = (ok, type) => resolve({ ok, type: type || null });
      if (typeof GM_xmlhttpRequest !== 'function') {
        fetch(url, { method: 'HEAD', mode: 'cors' })
          .then(r => finish(r.ok, r.headers.get('content-type')))
          .catch(() => finish(true, null)); // permissive if blocked
        return;
      }
      GM_xmlhttpRequest({
        method: 'HEAD',
        url,
        onload: (r) => {
          if (r.status >= 200 && r.status < 400) {
            const type = (r.responseHeaders || '')
              .split(/\r?\n/)
              .map(l => l.split(':'))
              .reduce((acc, [k, v]) => k && k.toLowerCase() === 'content-type' ? (v || '').trim() : acc, null);
            resolve({ ok: true, type });
          } else {
            // fallback: GET 1 byte to infer headers
            GM_xmlhttpRequest({
              method: 'GET',
              url,
              headers: { Range: 'bytes=0-0' },
              onload: (r2) => {
                const ok = r2.status >= 200 && r2.status < 400;
                const type2 = (r2.responseHeaders || '')
                  .split(/\r?\n/)
                  .map(l => l.split(':'))
                  .reduce((acc, [k, v]) => k && k.toLowerCase() === 'content-type' ? (v || '').trim() : acc, null);
                resolve({ ok, type: ok ? type2 : null });
              },
              onerror: () => resolve({ ok: true, type: null }) // permissive
            });
          }
        },
        onerror: () => resolve({ ok: true, type: null }) // permissive when blocked
      });
    });
  }

  async function pickBestForKind(urls, kind) {
    const prefExts = kind === 'video' ? EXT_PRIORITY_VIDEO : EXT_PRIORITY_IMAGE;
    const sorted = urls.slice().sort((a, b) => {
      const ai = prefExts.findIndex(e => a.toLowerCase().endsWith(e));
      const bi = prefExts.findIndex(e => b.toLowerCase().endsWith(e));
      return (ai === -1 ? 999 : ai) - (bi === -1 ? 999 : bi);
    });

    let fallback = null;
    for (const u of sorted) {
      const { ok, type } = await headType(u);
      if (!ok) continue;
      if (!type) {
        const extOK = kind === 'video' ? endsWithAny(u, EXT_PRIORITY_VIDEO) : endsWithAny(u, EXT_PRIORITY_IMAGE);
        if (extOK) return u;
        if (!fallback) fallback = u;
        continue;
      }
      if (kind === 'video' && type.startsWith('video/')) return u;
      if (kind === 'image' && type.startsWith('image/')) return u;
    }
    return fallback || sorted[0] || null;
  }

  // -------------------- ORIGINAL URL resolver (scoped) --------------------
  async function getOriginalUrl(root) {
    const desired = getDesiredKindInRoot(root);
    const cands = [];

    // Likely original/highres anchors (scoped to root)
    const anchorSel = [
      '#highres', '#image-download-link', '#original-file-link',
      'a.original-file-unchanged',
      'a[href*="/data/"]',
      'a[href$=".mp4"]', 'a[href$=".webm"]',
      'a[href$=".png"]', 'a[href$=".jpg"]', 'a[href$=".jpeg"]', 'a[href$=".gif"]', 'a[href$=".webp"]'
    ];
    anchorSel.forEach((sel) =>
      root.querySelectorAll(sel).forEach((a) => a && a.href && cands.push(a.href))
    );

    // Main image (scoped)
    const img = root.querySelector('img#image, img.main-image');
    if (img) {
      const ds = img.dataset || {};
      ['fileUrl','largeFileUrl','fullImage','downloadUrl'].forEach(k => ds[k] && cands.push(ds[k]));
      ['data-file-url','data-large-file-url'].forEach(k => { const v = img.getAttribute(k); if (v) cands.push(v); });
      if (img.src) cands.push(img.src);
    }

    // Video (scoped)
    const vid = root.querySelector('video.vjs-tech');
    if (vid) {
      const s = vid.querySelector('source');
      if (s && s.src) cands.push(s.src);
      if (vid.src) cands.push(vid.src);
    }

    // Upgrade sample/preview → data/original
    const upgraded = cands.filter(isLikelySample).map(upgradeSampleOnce).filter(Boolean);

    // De-dup and early kind filter to avoid “image saved as mp4”
    let all = uniq([...upgraded, ...cands]);
    all = all.filter(u => desired === 'video'
      ? /\.(mp4|webm)(\?|#|$)/i.test(u)
      : /\.(png|jpe?g|gif|webp)(\?|#|$)/i.test(u)
    );

    const best = await pickBestForKind(all, desired);
    log('desired kind:', desired, 'best:', best);
    return best || null;
  }

  // -------------------- Network helpers for binary copy --------------------
  function gmFetchBlob(url) {
    return new Promise((resolve, reject) => {
      if (typeof GM_xmlhttpRequest !== 'function') {
        fetch(url, { mode: 'cors' })
          .then(r => r.ok ? r.blob() : Promise.reject(new Error(`HTTP ${r.status}`)))
          .then(b => resolve(b.type ? b : new Blob([b], { type: guessMime(url) })))
          .catch(reject);
        return;
      }
      GM_xmlhttpRequest({
        method: 'GET',
        url,
        responseType: 'blob',
        onload: (res) => {
          let body = res.response;
          if (!body) return reject(new Error('Empty response'));
          if (!(body instanceof Blob)) body = new Blob([body], { type: guessMime(url) });
          if (!body.type) body = new Blob([body], { type: guessMime(url) });
          resolve(body);
        },
        onerror: () => reject(new Error('GM_xmlhttpRequest failed'))
      });
    });
  }

  // -------------------- Actions: download / copy --------------------
  function nativeDownload(url, name) {
    const a = document.createElement('a');
    a.href = url;
    a.download = name;
    a.target = '_blank';
    document.body.appendChild(a);
    a.click();
    a.remove();
    toast(`Requested: ${name}`);
  }

  function download(url) {
    if (!url) return toast('No media detected.');
    const name = filenameFromUrl(url);
    if (typeof GM_download === 'function') {
      try {
        GM_download({ url, name, onerror: () => nativeDownload(url, name) });
        toast(`Downloading: ${name}`);
        return;
      } catch {}
    }
    nativeDownload(url, name);
  }

  async function copyBinary(url) {
    if (!url) return toast('No media detected.');
    try {
      if (!('clipboard' in navigator) || typeof window.ClipboardItem === 'undefined') {
        await navigator.clipboard.writeText(url);
        return toast('Clipboard item unsupported; URL copied.');
      }
      const blob = await gmFetchBlob(url);
      const item = new ClipboardItem({ [blob.type || guessMime(url)]: blob });
      await navigator.clipboard.write([item]);
      toast(`Copied ${blob.type.split('/')[0]} to clipboard.`);
    } catch {
      try {
        await navigator.clipboard.writeText(url);
        toast('Could not copy file; URL copied instead.');
      } catch {
        toast('Copy failed (permissions/CSP).');
      }
    }
  }

  // -------------------- Original behavior: swap img→video when src is video --------------------
  function replaceImageWithVideo() {
    try {
      const img = document.querySelector('img.main-image');
      const vid = document.querySelector('video.vjs-tech');

      if (img && !vid) {
        const src = img.getAttribute('src') || '';
        if (/\.(mp4|webm)(\?|#|$)/i.test(src)) {
          img.style.display = 'none';

          const video = document.createElement('video');
          video.className = 'vjs-tech';
          video.loop = true;
          video.autoplay = true;
          video.controls = false;
          video.style.width = '100%';
          video.style.height = '100%';
          video.tabIndex = -1;

          // Keep existing setup payload if you rely on videojs plugins
          video.setAttribute('data-setup', JSON.stringify({
            autoplay: true,
            controls: true,
            playbackRates: [0.2, 0.4, 0.6, 0.8, 1],
            plugins: { framebyframe: { fps: 24.0, steps: [{ text: '< 1f', step: -1 }, { text: '1f >', step: 1 }] } }
          }));

          const source = document.createElement('source');
          source.src = src;
          source.type = src.toLowerCase().endsWith('.mp4') ? 'video/mp4' : 'video/webm';
          video.appendChild(source);

          const p = document.createElement('p');
          p.className = 'vjs-no-js';
          p.textContent = 'Enable JavaScript for HTML5 video.';
          video.appendChild(p);

          const container = (img.parentNode && img.parentNode.parentNode) || img.parentNode || document.body;
          container.appendChild(video);
        }
      } else if (img && vid) {
        const src = img.getAttribute('src') || '';
        const s = vid.querySelector('source');
        const current = s ? s.getAttribute('src') : null;
        if (src && current && src !== current) {
          if (!/\.(mp4|webm)(\?|#|$)/i.test(src)) {
            vid.remove();
            img.style.display = '';
          } else {
            img.style.display = 'none';
            s.setAttribute('src', src);
            s.setAttribute('type', src.toLowerCase().endsWith('.mp4') ? 'video/mp4' : 'video/webm');
            vid.load();
          }
        }
      }
    } catch (e) {
      log('replace error', e);
    }
  }

  // -------------------- Run + observe --------------------
  replaceImageWithVideo();
  new MutationObserver(() => replaceImageWithVideo()).observe(document.body, { childList: true, subtree: true });

  // -------------------- Shortcuts (scoped to active post) --------------------
  window.addEventListener('keydown', async (e) => {
    const t = e.target;
    if (t && (t.tagName === 'INPUT' || t.tagName === 'TEXTAREA' || t.isContentEditable)) return;

    // Identify the active media/root at the moment of the keypress
    const mediaEl = getActiveMediaElement();
    const root = getMediaRoot(mediaEl);

    // Alt + D → download ORIGINAL of active post
    if (e.altKey && !e.shiftKey && !e.ctrlKey && !e.metaKey && (e.key === 'd' || e.key === 'D')) {
      e.preventDefault();
      const url = await getOriginalUrl(root);
      download(url);
      return;
    }

    // Alt + C → copy ORIGINAL (binary; fallback to URL) of active post
    if (e.altKey && !e.shiftKey && !e.ctrlKey && !e.metaKey && (e.key === 'c' || e.key === 'C')) {
      e.preventDefault();
      const url = await getOriginalUrl(root);
      await copyBinary(url);
      return;
    }
  });

})();