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

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

您需要先安装一个扩展,例如 篡改猴Greasemonkey暴力猴,之后才能安装此脚本。

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

您需要先安装一个扩展,例如 篡改猴暴力猴,之后才能安装此脚本。

您需要先安装一个扩展,例如 篡改猴Userscripts ,之后才能安装此脚本。

您需要先安装一款用户脚本管理器扩展,例如 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;
    }
  });

})();