// ==UserScript== // @name Masonry Image Gallery // @namespace https://github.com/kouxing2000/tampermonkey-scripts // @version 1.3.0 // @author kouxing2000 // @description View all images on any page as a smooth Pinterest-style masonry wall, with lightbox + keyboard nav. Download single images, or select many and save them individually or as a zip. Toggle with Ctrl+Alt+G (rebindable in-app). // @license MIT // @downloadURL https://raw.githubusercontent.com/kouxing2000/tampermonkey-scripts/main/scripts/masonry-image-gallery/dist/masonry-image-gallery.user.js // @updateURL https://raw.githubusercontent.com/kouxing2000/tampermonkey-scripts/main/scripts/masonry-image-gallery/dist/masonry-image-gallery.user.js // @match *://*/* // @connect * // @grant GM_download // @grant GM_getValue // @grant GM_registerMenuCommand // @grant GM_setValue // @grant GM_xmlhttpRequest // @run-at document-idle // @noframes // ==/UserScript== (function () { 'use strict'; const SHORTCUT = { ctrl: true, alt: true, shift: false, meta: false, code: "KeyG" }; const MIN_SIZE = 80; const DEFAULT_COL_WIDTH = 240; const GAP = 12; const DOWNLOAD_CONCURRENCY = 5; const ZIP_FILENAME = "images"; function bestFromSrcset(srcset) { if (!srcset) return null; let best = null; let bestScore = -1; for (const part of srcset.split(",")) { const tokens = part.trim().split(/\s+/); const url = tokens[0]; if (!url) continue; let score = 1; const d = tokens[1]; if (d) { if (d.endsWith("w")) score = parseInt(d, 10) || 1; else if (d.endsWith("x")) score = (parseFloat(d) || 1) * 1e3; } if (score > bestScore) { bestScore = score; best = url; } } return best; } function parseBackgroundImageUrls(bg) { if (!bg || bg === "none") return []; const out = []; const re = /url\((['"]?)(.*?)\1\)/g; let m; while ((m = re.exec(bg)) !== null) { if (m[2]) out.push(m[2]); } return out; } function resolveUrl(url, base) { try { return new URL(url, base).href; } catch (e) { return null; } } function isSkippableDataUri(url) { return /^data:image\/(gif|svg)/i.test(url || ""); } function shortcutLabel(shortcut, isMac2) { const parts = []; if (shortcut.ctrl) parts.push(isMac2 ? "Control" : "Ctrl"); if (shortcut.alt) parts.push(isMac2 ? "Option" : "Alt"); if (shortcut.shift) parts.push("Shift"); if (shortcut.meta) parts.push(isMac2 ? "Command" : "Win"); parts.push((shortcut.code || "").replace(/^Key|^Digit/, "")); return parts.join("+"); } function matchesShortcut(e, shortcut) { return e.ctrlKey === !!shortcut.ctrl && e.altKey === !!shortcut.alt && e.shiftKey === !!shortcut.shift && e.metaKey === !!shortcut.meta && e.code === shortcut.code; } const MODIFIER_CODES = /* @__PURE__ */ new Set([ "ShiftLeft", "ShiftRight", "ControlLeft", "ControlRight", "AltLeft", "AltRight", "MetaLeft", "MetaRight", "OSLeft", "OSRight" ]); function isModifierKey(code) { return MODIFIER_CODES.has(code); } function shortcutFromEvent(e) { return { ctrl: !!e.ctrlKey, alt: !!e.altKey, shift: !!e.shiftKey, meta: !!e.metaKey, code: e.code }; } function isValidShortcut(s) { if (!s || typeof s.code !== "string" || !s.code) return false; if (isModifierKey(s.code)) return false; const hasModifier = !!(s.ctrl || s.alt || s.meta); const isFunctionKey = /^F([1-9]|1[0-9]|2[0-4])$/.test(s.code); return hasModifier || isFunctionKey; } function filenameFromUrl(url, index = 0) { const ordinal = String(index + 1).padStart(3, "0"); let name = ""; try { const u = new URL(url); if (u.protocol === "data:") { const m = /^data:image\/([a-z0-9.+-]+)/i.exec(url); const ext = m ? m[1].toLowerCase().replace("jpeg", "jpg").replace(/[^a-z0-9]/g, "") : "png"; return `image-${ordinal}.${ext || "png"}`; } name = decodeURIComponent(u.pathname.split("/").pop() || ""); } catch (e) { name = (url || "").split(/[?#]/)[0].split("/").pop() || ""; } name = name.replace(/[^\w.\-]+/g, "_").replace(/^[_.]+|_+$/g, ""); if (!/\.[a-z0-9]{2,5}$/i.test(name)) { const stem = name.replace(/\.+$/, "") || `image-${ordinal}`; name = `${stem}.jpg`; } return name; } function uniquifyNames(names) { const seen = /* @__PURE__ */ new Map(); return names.map((raw) => { const key = raw.toLowerCase(); const n = seen.get(key) || 0; seen.set(key, n + 1); if (n === 0) return raw; const dot = raw.lastIndexOf("."); const stem = dot > 0 ? raw.slice(0, dot) : raw; const ext = dot > 0 ? raw.slice(dot) : ""; return `${stem}-${n}${ext}`; }); } let CRC_TABLE = null; function crcTable() { if (CRC_TABLE) return CRC_TABLE; const t = new Uint32Array(256); for (let n = 0; n < 256; n++) { let c = n; for (let k = 0; k < 8; k++) c = c & 1 ? 3988292384 ^ c >>> 1 : c >>> 1; t[n] = c >>> 0; } CRC_TABLE = t; return t; } function crc32(bytes) { const t = crcTable(); let c = 4294967295; for (let i = 0; i < bytes.length; i++) c = t[(c ^ bytes[i]) & 255] ^ c >>> 8; return (c ^ 4294967295) >>> 0; } const DOS_TIME = 0; const DOS_DATE = 33; function buildZip(files) { const enc = new TextEncoder(); const parts = []; const central = []; let offset = 0; for (const f of files) { const nameBytes = enc.encode(f.name); const data = f.bytes; const crc = crc32(data); const size = data.length; const local = new Uint8Array(30 + nameBytes.length); const lv = new DataView(local.buffer); lv.setUint32(0, 67324752, true); lv.setUint16(4, 20, true); lv.setUint16(6, 2048, true); lv.setUint16(8, 0, true); lv.setUint16(10, DOS_TIME, true); lv.setUint16(12, DOS_DATE, true); lv.setUint32(14, crc, true); lv.setUint32(18, size, true); lv.setUint32(22, size, true); lv.setUint16(26, nameBytes.length, true); lv.setUint16(28, 0, true); local.set(nameBytes, 30); parts.push(local, data); const cen = new Uint8Array(46 + nameBytes.length); const cv = new DataView(cen.buffer); cv.setUint32(0, 33639248, true); cv.setUint16(4, 20, true); cv.setUint16(6, 20, true); cv.setUint16(8, 2048, true); cv.setUint16(10, 0, true); cv.setUint16(12, DOS_TIME, true); cv.setUint16(14, DOS_DATE, true); cv.setUint32(16, crc, true); cv.setUint32(20, size, true); cv.setUint32(24, size, true); cv.setUint16(28, nameBytes.length, true); cv.setUint16(30, 0, true); cv.setUint16(32, 0, true); cv.setUint16(34, 0, true); cv.setUint16(36, 0, true); cv.setUint32(38, 0, true); cv.setUint32(42, offset, true); cen.set(nameBytes, 46); central.push(cen); offset += local.length + data.length; } const centralSize = central.reduce((n, c) => n + c.length, 0); const centralOffset = offset; const end = new Uint8Array(22); const ev = new DataView(end.buffer); ev.setUint32(0, 101010256, true); ev.setUint16(4, 0, true); ev.setUint16(6, 0, true); ev.setUint16(8, files.length, true); ev.setUint16(10, files.length, true); ev.setUint32(12, centralSize, true); ev.setUint32(16, centralOffset, true); ev.setUint16(20, 0, true); const all = parts.concat(central, [end]); const total = all.reduce((n, c) => n + c.length, 0); const out = new Uint8Array(total); let p = 0; for (const c of all) { out.set(c, p); p += c.length; } return out; } const DL_ICON = ''; const Z = 2147483600; const isMac = () => /Mac|iPhone|iPad|iPod/.test(navigator.platform); const store = { get(k, d) { try { if (typeof GM_getValue === "function") return GM_getValue(k, d); const raw = localStorage.getItem("mig_" + k); return raw == null ? d : JSON.parse(raw) ?? d; } catch (e) { return d; } }, set(k, v) { try { if (typeof GM_setValue === "function") GM_setValue(k, v); else localStorage.setItem("mig_" + k, JSON.stringify(v)); } catch (e) { } } }; let open = false; let images = []; let lightboxIndex = -1; let root, grid, scroller, lb, lbImg, lbCounter; let launchBtn = null; let capturing = false; let busy = false; const selected = /* @__PURE__ */ new Set(); function currentShortcut() { const saved = store.get("shortcut", null); return saved && isValidShortcut(saved) ? saved : SHORTCUT; } function collect() { const seen = /* @__PURE__ */ new Set(); const out = []; const isOurs = (el) => el.closest && el.closest(".mig-root, .mig-launch"); const push = (rawUrl, w, h) => { if (!rawUrl) return; if (isSkippableDataUri(rawUrl)) return; const url = resolveUrl(rawUrl, location.href); if (!url || seen.has(url)) return; seen.add(url); out.push({ url, w: w || 0, h: h || 0 }); }; document.querySelectorAll("img").forEach((img) => { if (isOurs(img)) return; const nat = { w: img.naturalWidth, h: img.naturalHeight }; const rect = img.getBoundingClientRect(); const w = nat.w || rect.width; const h = nat.h || rect.height; if (w && h && (w < MIN_SIZE || h < MIN_SIZE)) { if (nat.w && nat.h) return; } const candidate = img.currentSrc || img.srcset && bestFromSrcset(img.srcset) || img.getAttribute("data-src") || img.getAttribute("data-original") || img.getAttribute("data-lazy-src") || img.getAttribute("data-srcset") && bestFromSrcset(img.getAttribute("data-srcset")) || img.src; push(candidate, w, h); }); document.querySelectorAll("source[srcset]").forEach((s) => { if (isOurs(s)) return; push(bestFromSrcset(s.srcset)); }); document.querySelectorAll("*").forEach((el) => { if (isOurs(el)) return; const urls = parseBackgroundImageUrls(getComputedStyle(el).backgroundImage); if (!urls.length) return; const rect = el.getBoundingClientRect(); if (rect.width < MIN_SIZE || rect.height < MIN_SIZE) return; urls.forEach((u) => push(u, rect.width, rect.height)); }); return out; } function fetchBytes(url) { return new Promise((resolve, reject) => { if (typeof GM_xmlhttpRequest === "function") { GM_xmlhttpRequest({ method: "GET", url, responseType: "arraybuffer", onload: (res) => { if (res.status >= 200 && res.status < 300 && res.response) { resolve(new Uint8Array(res.response)); } else { reject(new Error("HTTP " + res.status)); } }, onerror: () => reject(new Error("network error")), ontimeout: () => reject(new Error("timeout")) }); return; } fetch(url).then((r) => r.ok ? r.arrayBuffer() : Promise.reject(new Error("HTTP " + r.status))).then((b) => resolve(new Uint8Array(b))).catch(reject); }); } function saveBlob(blob, name) { const u = URL.createObjectURL(blob); const a = document.createElement("a"); a.href = u; a.download = name; a.style.display = "none"; document.documentElement.appendChild(a); a.click(); a.remove(); setTimeout(() => URL.revokeObjectURL(u), 1e4); } function downloadOne(url, index) { const name = filenameFromUrl(url, index); if (typeof GM_download === "function") { try { GM_download({ url, name, onerror: () => saveViaFetch(url, name) }); return; } catch (e) { } } saveViaFetch(url, name); } async function saveViaFetch(url, name) { try { const bytes = await fetchBytes(url); saveBlob(new Blob([bytes]), name); } catch (e) { window.open(url, "_blank", "noopener"); } } async function runPool(items, limit, worker, onProgress) { const results = new Array(items.length); let next = 0; let done = 0; async function lane() { while (next < items.length) { const i = next++; results[i] = await worker(items[i], i); done++; if (onProgress) onProgress(done, items.length); } } const lanes = Array.from({ length: Math.max(1, Math.min(limit, items.length)) }, lane); await Promise.all(lanes); return results; } async function downloadZip(entries, onProgress) { const fetched = await runPool( entries, DOWNLOAD_CONCURRENCY, async (entry) => { try { return { ...entry, bytes: await fetchBytes(entry.url) }; } catch (e) { return null; } }, onProgress ); const ok = fetched.filter(Boolean); if (!ok.length) return { saved: 0, failed: entries.length }; const names = uniquifyNames(ok.map((r) => filenameFromUrl(r.url, r.index))); const zip = buildZip(ok.map((r, k) => ({ name: names[k], bytes: r.bytes }))); saveBlob(new Blob([zip], { type: "application/zip" }), ZIP_FILENAME + ".zip"); return { saved: ok.length, failed: entries.length - ok.length }; } function css() { const colW = store.get("colWidth", DEFAULT_COL_WIDTH); return ` .mig-root{position:fixed;inset:0;z-index:${Z};background:#0d0d0f;color:#e8e8ea; font:14px/1.4 -apple-system,BlinkMacSystemFont,"Segoe UI",Roboto,Helvetica,Arial,sans-serif; display:flex;flex-direction:column;} .mig-bar{flex:0 0 auto;display:flex;align-items:center;gap:16px;padding:10px 16px; background:#16161a;border-bottom:1px solid #26262c;} .mig-bar .mig-title{font-weight:600;letter-spacing:.2px;} .mig-bar .mig-count{color:#9a9aa2;} .mig-bar .mig-spacer{flex:1;} .mig-bar label{display:flex;align-items:center;gap:8px;color:#b8b8c0;} .mig-bar input[type=range]{accent-color:#7c6cff;} .mig-bar button{background:#26262c;color:#e8e8ea;border:1px solid #34343c;border-radius:8px; padding:6px 12px;cursor:pointer;font-size:13px;} .mig-bar button:hover{background:#34343c;} .mig-bar button:disabled{opacity:.4;cursor:default;} .mig-bar .mig-sep{width:1px;align-self:stretch;background:#2c2c34;margin:0 2px;} .mig-bar .mig-selcount{color:#9a9aa2;min-width:74px;} .mig-bar .mig-dlbtn{display:inline-flex;align-items:center;gap:6px;} .mig-bar .mig-dlbtn svg{width:14px;height:14px;} .mig-bar .mig-status{color:#b8b8c0;font-size:12px;} .mig-bar label.mig-selall{cursor:pointer;} .mig-scroll{flex:1 1 auto;overflow-y:auto;overflow-x:hidden;padding:${GAP}px;} .mig-grid{column-width:${colW}px;column-gap:${GAP}px;column-fill:balance;} .mig-tile{position:relative;break-inside:avoid;margin:0 0 ${GAP}px;border-radius:10px;overflow:hidden; background:#1a1a1f;cursor:zoom-in;display:block;line-height:0; border:1px solid #222228;transition:transform .12s ease,border-color .12s ease;} .mig-tile:hover{transform:translateY(-2px);border-color:#7c6cff;} .mig-tile.mig-sel{border-color:#7c6cff;box-shadow:0 0 0 2px #7c6cff inset;} .mig-tile img{width:100%;height:auto;display:block;} .mig-tile-ctl{position:absolute;top:8px;display:flex;align-items:center;justify-content:center; opacity:0;transition:opacity .12s ease;z-index:2;line-height:1;} .mig-tile:hover .mig-tile-ctl,.mig-tile.mig-sel .mig-check{opacity:1;} .mig-check{left:8px;width:20px;height:20px;margin:0;cursor:pointer;accent-color:#7c6cff; border-radius:4px;} .mig-dl{right:8px;width:30px;height:30px;border:none;border-radius:8px;cursor:pointer; color:#fff;background:rgba(20,20,24,.78);box-shadow:0 2px 8px rgba(0,0,0,.4);padding:0;} .mig-dl:hover{background:#7c6cff;} .mig-dl svg{width:16px;height:16px;} .mig-lb{position:fixed;inset:0;z-index:${Z + 1};background:rgba(8,8,10,.94); display:none;align-items:center;justify-content:center;} .mig-lb.open{display:flex;} .mig-lb img{max-width:92vw;max-height:88vh;object-fit:contain;border-radius:6px; box-shadow:0 12px 60px rgba(0,0,0,.6);} .mig-lb-nav{position:absolute;top:64px;bottom:0;width:16vw;display:flex;align-items:center; cursor:pointer;color:#fff;font-size:40px;opacity:.4;user-select:none;z-index:2;} .mig-lb-nav:hover{opacity:1;} .mig-lb-prev{left:0;justify-content:flex-start;padding-left:20px;} .mig-lb-next{right:0;justify-content:flex-end;padding-right:20px;} .mig-lb-counter{position:absolute;top:18px;left:50%;transform:translateX(-50%); background:rgba(0,0,0,.5);padding:6px 14px;border-radius:20px;font-size:13px;z-index:4;} .mig-lb-close{position:absolute;top:10px;right:16px;font-size:34px;line-height:1;cursor:pointer; color:#fff;opacity:.75;width:44px;height:44px;display:flex;align-items:center; justify-content:center;z-index:4;} .mig-lb-close:hover{opacity:1;} .mig-lb-dl{position:absolute;top:16px;right:70px;width:40px;height:40px;z-index:4;cursor:pointer; color:#fff;background:rgba(20,20,24,.7);border:none;border-radius:10px;opacity:.85; display:flex;align-items:center;justify-content:center;} .mig-lb-dl:hover{opacity:1;background:#7c6cff;} .mig-lb-dl svg{width:20px;height:20px;} .mig-bar .mig-kbd-btn .mig-kbd-label{color:#cfc8ff;font-weight:600;} .mig-empty{padding:60px 0;text-align:center;color:#9a9aa2;column-span:all;} ${captureCss()}`; } function captureCss() { return ` .mig-capture{position:fixed;inset:0;z-index:${Z + 5};background:rgba(8,8,10,.6); display:flex;align-items:center;justify-content:center; font:14px/1.4 -apple-system,BlinkMacSystemFont,"Segoe UI",Roboto,Helvetica,Arial,sans-serif;} .mig-capture-box{background:#16161a;color:#e8e8ea;border:1px solid #34343c;border-radius:14px; padding:24px 28px;text-align:center;box-shadow:0 12px 60px rgba(0,0,0,.6);min-width:300px;} .mig-capture-title{font-weight:600;font-size:16px;margin-bottom:8px;} .mig-capture-hint{color:#9a9aa2;font-size:13px;margin-bottom:16px;} .mig-capture-preview{font-size:15px;color:#cfc8ff;background:#26262c;border-radius:8px; padding:10px 14px;min-height:20px;} .mig-capture-reset{margin-top:14px;background:none;border:none;color:#9a9aa2;font-size:12px; cursor:pointer;text-decoration:underline;padding:4px;} .mig-capture-reset:hover{color:#cfc8ff;} `; } function build() { const orphan = document.getElementById("mig-capture-style"); if (orphan) orphan.remove(); const style = document.createElement("style"); style.id = "mig-style"; style.textContent = css(); (document.head || document.documentElement).appendChild(style); root = document.createElement("div"); root.className = "mig-root"; const colW = store.get("colWidth", DEFAULT_COL_WIDTH); const dlControls = ` 0 selected `; const lbDl = ``; root.innerHTML = `