Greasy Fork

来自缓存

Greasy Fork is available in English.

Pinterest Full

View & download original full size images (no login required) and a pleasing UI

您需要先安装一款用户脚本管理器扩展,例如 Tampermonkey 篡改猴Greasemonkey 油猴子Violentmonkey 暴力猴,才能安装此脚本。

您需要先安装一款用户脚本管理器扩展,例如 Tampermonkey 篡改猴,才能安装此脚本。

您需要先安装一款用户脚本管理器扩展,例如 Tampermonkey 篡改猴Violentmonkey 暴力猴,才能安装此脚本。

您需要先安装一款用户脚本管理器扩展,例如 Tampermonkey 篡改猴Userscripts ,才能安装此脚本。

您需要先安装一款用户脚本管理器扩展,例如 Tampermonkey 篡改猴,才能安装此脚本。

您需要先安装一款用户脚本管理器扩展后才能安装此脚本。

(我已经安装了用户脚本管理器,让我安装!)

您需要先安装一款用户样式管理器扩展,比如 Stylus,才能安装此样式。

您需要先安装一款用户样式管理器扩展,比如 Stylus,才能安装此样式。

您需要先安装一款用户样式管理器扩展,比如 Stylus,才能安装此样式。

您需要先安装一款用户样式管理器扩展后才能安装此样式。

您需要先安装一款用户样式管理器扩展后才能安装此样式。

您需要先安装一款用户样式管理器扩展后才能安装此样式。

(我已经安装了用户样式管理器,让我安装!)

// ==UserScript==
// @name         Pinterest Full
// @namespace    https://github.com/ShrekBytes
// @description  View & download original full size images (no login required) and a pleasing UI
// @version      3.0.0
// @author       ShrekBytes
// @match        https://*.pinterest.com/*
// @match        https://*.pinterest.at/*
// @match        https://*.pinterest.ca/*
// @match        https://*.pinterest.ch/*
// @match        https://*.pinterest.cl/*
// @match        https://*.pinterest.co.kr/*
// @match        https://*.pinterest.co.uk/*
// @match        https://*.pinterest.com.au/*
// @match        https://*.pinterest.com.mx/*
// @match        https://*.pinterest.de/*
// @match        https://*.pinterest.dk/*
// @match        https://*.pinterest.es/*
// @match        https://*.pinterest.fr/*
// @match        https://*.pinterest.ie/*
// @match        https://*.pinterest.info/*
// @match        https://*.pinterest.it/*
// @match        https://*.pinterest.jp/*
// @match        https://*.pinterest.nz/*
// @match        https://*.pinterest.ph/*
// @match        https://*.pinterest.pt/*
// @match        https://*.pinterest.se/*
// @icon         https://raw.githubusercontent.com/ShrekBytes/pinterest-full/refs/heads/main/pinterest.png
// @grant        GM_openInTab
// @grant        GM_download
// @run-at       document-start
// @license      GPL-3.0
// @noframes
// @homepageURL  https://github.com/ShrekBytes/pinterest-full
// @supportURL   https://github.com/ShrekBytes/pinterest-full/issues
// ==/UserScript==

(() => {
  'use strict';

  // ===== CONFIGURATION =====
  const CONFIG = {
    ROUTE_DEBOUNCE_MS: 150,
    DOWNLOAD_FEEDBACK_MS: 500,
    API_TIMEOUT_MS: 10000,
    SWIPE_THRESHOLD_PX: 50,
    FILENAME_MAX_LENGTH: 80,
    BATCH_DOWNLOAD_DELAY_MS: 600,
    TOAST_DURATION_MS: 3000,
    MUTATION_DEBOUNCE_MS: 200,
  };

  // ===== UTILITIES =====
  const sleep = (ms) => new Promise(r => setTimeout(r, ms));
  const qs = (sel, root = document) => root.querySelector(sel);

  /**
   * Debounce function to limit how often a function is called
   * @param {Function} fn - Function to debounce
   * @param {number} delay - Delay in milliseconds
   * @returns {Function} Debounced function
   */
  function debounce(fn, delay) {
    let timer;
    return function(...args) {
      clearTimeout(timer);
      timer = setTimeout(() => fn.apply(this, args), delay);
    };
  }

  /**
   * Extract file extension from URL
   * @param {string} url - Image URL
   * @returns {string} File extension with dot (e.g., '.jpg')
   */
  function getFileExtension(url) {
    if (!url) return '.jpg';
    const cleanUrl = url.split('?')[0];
    const match = cleanUrl.match(/\.(jpg|jpeg|png|gif|webp)$/i);
    return match ? match[0] : '.jpg';
  }

  /**
   * Get file extension type for display (without dot, uppercase)
   * @param {string} url - Image URL
   * @returns {string} Extension type (e.g., 'JPEG')
   */
  function getExtensionType(url) {
    const ext = getFileExtension(url).substring(1).toUpperCase();
    return ext === 'JPG' ? 'JPEG' : ext;
  }

  /**
   * Format file size in human-readable format
   * @param {number} bytes - File size in bytes
   * @returns {string} Formatted size string
   */
  function formatFileSize(bytes) {
    if (!bytes || bytes === 0) return '';
    if (bytes < 1024) return bytes + ' B';
    if (bytes < 1024 * 1024) return (bytes / 1024).toFixed(1) + ' KB';
    return (bytes / (1024 * 1024)).toFixed(1) + ' MB';
  }

  /**
   * Sanitize filename to remove invalid characters
   * @param {string} filename - Original filename
   * @returns {string} Sanitized filename
   */
  function sanitizeFilename(filename) {
    if (!filename) return 'pinterest';
    return filename.replace(/[\/\\?%*:|"<>]/g, '-').slice(0, CONFIG.FILENAME_MAX_LENGTH) || 'pinterest';
  }

  /**
   * Download a file using GM_download or fallback to anchor element
   * @param {string} url - File URL
   * @param {string} filename - Desired filename
   * @returns {Promise<boolean>} Success status
   */
  async function downloadFile(url, filename) {
    if (!url) return false;
    
    try {
      const sanitized = sanitizeFilename(filename);
      const fullName = sanitized + getFileExtension(url);
      
      if (typeof GM_download === 'function') {
        GM_download({ url, name: fullName });
      } else {
        const a = document.createElement('a');
        a.href = url;
        a.download = fullName;
        document.body.appendChild(a);
        a.click();
        a.remove();
      }
      return true;
    } catch (e) {
      console.error('Download failed:', e);
      return false;
    }
  }

  /**
   * Open URL in new tab using GM_openInTab or fallback
   * @param {string} url - URL to open
   */
  function openInNewTab(url) {
    if (!url) return;
    
    if (typeof GM_openInTab === 'function') {
      GM_openInTab(url, { active: true, insert: true });
    } else if (typeof GM?.openInTab === 'function') {
      GM.openInTab(url, { active: true, insert: true });
    } else {
      window.open(url, '_blank');
    }
  }

  /**
   * Fetch file size from URL using HEAD request
   * @param {string} url - File URL
   * @returns {Promise<number|null>} File size in bytes or null
   */
  async function getFileSize(url) {
    if (!url) return null;
    
    try {
      const response = await fetch(url, { method: 'HEAD' });
      if (!response.ok) return null;
      const size = response.headers.get('content-length');
      return size ? parseInt(size, 10) : null;
    } catch {
      return null;
    }
  }

  /**
   * Manage button loading state
   * @param {HTMLElement} btn - Button element
   * @param {boolean} isLoading - Whether button is in loading state
   * @param {string} loadingText - Text to display during loading
   */
  function setButtonState(btn, isLoading, loadingText = 'Loading...') {
    if (!btn) return;
    
    if (isLoading) {
      btn.dataset.originalText = btn.textContent;
      btn.textContent = loadingText;
      btn.disabled = true;
    } else {
      btn.textContent = btn.dataset.originalText || btn.textContent;
      btn.disabled = false;
      delete btn.dataset.originalText;
    }
  }

  /**
   * Create a button element with common attributes
   * @param {Object} config - Button configuration
   * @returns {HTMLElement} Button element
   */
  function createButton({ id, text, ariaLabel, className = 'pp-btn' }) {
    const btn = document.createElement('button');
    if (id) btn.id = id;
    btn.className = className;
    btn.textContent = text;
    btn.setAttribute('aria-label', ariaLabel || text);
    btn.setAttribute('role', 'button');
    return btn;
  }

  // ===== TOAST NOTIFICATION SYSTEM =====
  const Toast = (() => {
    let container;

    /**
     * Initialize toast container
     */
    function init() {
      if (container) return;
      container = document.createElement('div');
      container.className = 'pp-toast-container';
      document.body.appendChild(container);
    }

    /**
     * Show a toast notification
     * @param {string} message - Message to display
     * @param {string} type - Toast type: 'success', 'error', 'warning', 'info'
     * @param {number} duration - Display duration in milliseconds
     */
    function show(message, type = 'info', duration = CONFIG.TOAST_DURATION_MS) {
      if (!message) return;
      
      init();
      
      const toast = document.createElement('div');
      toast.className = `pp-toast pp-toast-${type}`;
      toast.textContent = message;
      
      container.appendChild(toast);
      
      // Trigger animation
      setTimeout(() => toast.classList.add('pp-toast-show'), 10);
      
      // Auto dismiss
      setTimeout(() => {
        toast.classList.remove('pp-toast-show');
        setTimeout(() => toast.remove(), 300);
      }, duration);
    }

    return { show };
  })();

  // ===== CSS =====
  const CSS = `
  /* ===== Pinterest Full Modern CSS ===== */
  .pp-btn {
    all: unset;
    display: inline-flex; align-items: center; gap: .5rem;
    font-weight: 700; cursor: pointer; user-select: none;
    border-radius: 9999px; padding: .5rem .9rem; line-height: 1;
    box-shadow: 0 4px 12px rgba(0,0,0,.15);
    transition: transform .12s ease, background .2s ease, opacity .2s ease;
    font-family: ui-sans-serif, system-ui, -apple-system, Segoe UI, Roboto, Helvetica, Arial;
    background: #e60023; color: #fff;
  }
  .pp-btn:hover { background: #ad081b; }
  .pp-btn:disabled { 
    opacity: 0.6; 
    cursor: not-allowed; 
    background: #666; 
  }
  .pp-btn:disabled:hover { background: #666; }
  
  .pp-btn-sm {
    padding: .4rem .7rem;
    font-size: 13px;
  }
  
  #pp-main-btn { margin-right: 8px; }

  .pp-overlay {
    position: fixed; 
    top: 0; right: 0; bottom: 0; left: 0;
    background: rgba(0,0,0,.85); 
    z-index: 2147483647;
    display: grid; 
    grid-template-rows: auto 1fr auto;
    opacity: 0; 
    pointer-events: none; 
    transition: opacity .2s ease;
  }
  .pp-overlay.open { opacity: 1; pointer-events: auto; }

  .pp-head {
    display:flex; align-items:center; justify-content: space-between; padding: 10px 14px;
    background: rgba(20,20,20,.6); backdrop-filter: blur(4px);
    flex-wrap: wrap; gap: 8px;
  }
  .pp-head .pp-actions { display:flex; gap:8px; align-items:center; flex-wrap: wrap; }
  .pp-head .pp-info { display:flex; gap:8px; align-items:center; flex-wrap: wrap; }
  .pp-chip { 
    font-size:12px; 
    background:#222; 
    color:#fff; 
    padding:.3rem .6rem; 
    border-radius:999px;
    white-space: nowrap;
  }

  .pp-stage {
    display:grid; place-items:center; overflow:auto; padding: 16px;
  }
  .pp-img { 
    max-width: 95vw; 
    max-height: 82vh; 
    border-radius: 12px; 
    box-shadow: 0 12px 48px rgba(0,0,0,.4);
  }

  .pp-footer {
    display:flex; align-items:center; justify-content:center; gap:8px; padding:10px; 
    background: rgba(20,20,20,.6);
    flex-wrap: wrap;
  }
  .pp-thumb {
    width: 72px; height: 72px; object-fit: cover; border-radius: 8px; 
    opacity:.7; cursor:pointer; border:2px solid transparent;
    transition: opacity .2s ease, border-color .2s ease;
  }
  .pp-thumb.active { opacity:1; border-color:#fff; }
  .pp-thumb:hover { opacity: 0.9; }

  .pp-toast-container {
    position: fixed;
    top: 20px;
    right: 20px;
    z-index: 2147483648;
    display: flex;
    flex-direction: column;
    gap: 10px;
    pointer-events: none;
  }

  .pp-toast {
    padding: 12px 20px;
    border-radius: 8px;
    font-family: ui-sans-serif, system-ui, -apple-system, Segoe UI, Roboto, Helvetica, Arial;
    font-size: 14px;
    font-weight: 500;
    color: #fff;
    box-shadow: 0 4px 12px rgba(0,0,0,.15);
    opacity: 0;
    transform: translateX(100px);
    transition: opacity .3s ease, transform .3s ease;
    pointer-events: auto;
    max-width: 300px;
  }

  .pp-toast-show {
    opacity: 1;
    transform: translateX(0);
  }

  .pp-toast-success { background: #059669; }
  .pp-toast-error { background: #dc2626; }
  .pp-toast-warning { background: #f59e0b; }
  .pp-toast-info { background: #3b82f6; }

  @media (max-width: 640px) {
    .pp-head {
      flex-direction: column;
      align-items: stretch;
    }
    
    .pp-head .pp-actions,
    .pp-head .pp-info {
      justify-content: center;
    }
    
    .pp-toast-container {
      left: 20px;
      right: 20px;
    }
    
    .pp-toast {
      max-width: none;
    }
  }
  `;

  /**
   * Inject CSS into the page once
   */
  function ensureCSS() {
    if (qs('#pp-css')) return;
    const style = document.createElement('style');
    style.id = 'pp-css';
    style.textContent = CSS;
    (document.head || document.documentElement).appendChild(style);
  }

  // ===== PINTEREST API & DATA EXTRACTION =====

  /**
   * Derive original URL from image element (fallback method)
   * @param {HTMLImageElement} img - Image element
   * @returns {string|null} Original image URL
   */
  function fromSrcOrSrcset(img) {
    if (!img) return null;
    
    // Prefer largest from srcset
    if (img.srcset) {
      const parts = img.srcset.split(',').map(p => p.trim());
      let best = null, bestW = 0;
      for (const p of parts) {
        const [url, size] = p.split(' ');
        const w = parseInt(size || '0', 10) || 0;
        if (w >= bestW) { best = url; bestW = w; }
      }
      if (best) return best.replace(/\/\d+x\//, '/originals/');
    }
    
    if (img.src) return img.src.replace(/\/\d+x\//, '/originals/');
    return null;
  }

  /**
   * Extract pin ID from URL
   * @param {string} url - URL to parse
   * @returns {string|null} Pin ID
   */
  function getPinIdFromUrl(url = location.href) {
    const m = url?.match(/\/pin\/([^\/?#]+)/i);
    return m ? m[1] : null;
  }

  /**
   * Fetch pin data from Pinterest's internal API
   * @param {string} pinId - Pinterest pin ID
   * @returns {Promise<Object|null>} Pin data or null on failure
   */
  async function fetchPinData(pinId) {
    if (!pinId) return null;
    
    try {
      const t = Date.now();
      const u = `https://${location.host}/resource/PinResource/get/?source_url=%2Fpin%2F${encodeURIComponent(pinId)}%2F&data=%7B%22options%22%3A%7B%22id%22%3A%22${encodeURIComponent(pinId)}%22%2C%22field_set_key%22%3A%22detailed%22%2C%22noCache%22%3Atrue%7D%2C%22context%22%3A%7B%7D%7D&_=${t}`;
      
      // Create AbortController for timeout (browser compatibility)
      const controller = new AbortController();
      const timeoutId = setTimeout(() => controller.abort(), CONFIG.API_TIMEOUT_MS);
      
      const res = await fetch(u, {
        headers: { 'X-Pinterest-PWS-Handler': 'www/pin/[id].js' },
        credentials: 'include',
        signal: controller.signal
      });
      
      clearTimeout(timeoutId);
      
      if (!res.ok) throw new Error(`API returned ${res.status}`);
      const json = await res.json();
      if (json?.resource_response?.status !== 'success') throw new Error('Invalid API response');
      return json.resource_response.data;
    } catch (e) {
      console.warn('Failed to fetch pin data:', e.message);
      if (e.name !== 'AbortError') {
        Toast.show('Failed to load pin data from API', 'warning');
      }
      return null;
    }
  }

  /**
   * Extract best quality images from pin data
   * @param {Object} pin - Pin data from API
   * @returns {Object} Pack with items array and title
   */
  function getBestFromPinData(pin) {
    const pack = { items: [], title: (pin?.grid_title || pin?.title || '').trim() };
    if (!pin) return pack;

    // Story pins (multi-page content)
    if (pin.story_pin_data?.pages?.length) {
      for (const page of pin.story_pin_data.pages) {
        const url = page?.image?.images?.originals?.url
                 || page?.blocks?.[0]?.image?.images?.originals?.url
                 || page?.blocks?.[0]?.image?.images?.orig?.url;
        if (url) pack.items.push({ url, width: 0, height: 0, thumb: url });
      }
    }

    // Regular pin original image
    const orig = pin.images?.orig;
    if (orig?.url) {
      if (!pack.items.length) {
        pack.items.push({ url: orig.url, width: orig.width || 0, height: orig.height || 0, thumb: orig.url });
      } else if (!pack.items.some(i => i.url === orig.url)) {
        // Ensure main original is present (dedupe)
        pack.items.unshift({ url: orig.url, width: orig.width || 0, height: orig.height || 0, thumb: orig.url });
      }
    }

    // Deduplicate
    const seen = new Set();
    pack.items = pack.items.filter(i => i.url && !seen.has(i.url) && (seen.add(i.url) || true));
    return pack;
  }

  /**
   * Derive image from DOM as fallback
   * @returns {Array} Array of image items
   */
  function deriveFromDomAsFallback() {
    const closeup = qs("div[data-test-id='CloseupMainPin'], div.reactCloseupScrollContainer") || document;
    const img = qs('img[srcset], img[src]', closeup);
    const url = fromSrcOrSrcset(img);
    return url ? [{ url, width: 0, height: 0, thumb: url }] : [];
  }

  // ===== OVERLAY GALLERY =====
  const Overlay = (() => {
    let root, stage, footer, titleEl, resEl, counterEl, metaEl;
    let currentIndex = 0;
    let items = [];

    /**
     * Build overlay DOM structure
     */
    function build() {
      if (root) return;
      
      root = document.createElement('div');
      root.className = 'pp-overlay';
      root.innerHTML = `
        <div class="pp-head">
          <div class="pp-actions">
            <button class="pp-btn pp-btn-sm" id="pp-download">Download</button>
            <button class="pp-btn pp-btn-sm" id="pp-download-all" style="display:none;">Download All</button>
            <button class="pp-btn pp-btn-sm" id="pp-open">Open</button>
          </div>
          <div class="pp-info">
            <span id="pp-counter" class="pp-chip" style="display:none;"></span>
            <span id="pp-title" class="pp-chip"></span>
            <span id="pp-meta" class="pp-chip"></span>
            <span id="pp-res" class="pp-chip"></span>
            <button class="pp-btn pp-btn-sm" id="pp-close">Close</button>
          </div>
        </div>
        <div class="pp-stage"></div>
        <div class="pp-footer"></div>
      `;
      document.body.appendChild(root);
      
      stage = qs('.pp-stage', root);
      footer = qs('.pp-footer', root);
      titleEl = qs('#pp-title', root);
      resEl = qs('#pp-res', root);
      counterEl = qs('#pp-counter', root);
      metaEl = qs('#pp-meta', root);

      setupEventListeners();
    }

    /**
     * Setup all event listeners for overlay
     */
    function setupEventListeners() {
      // Close button
      qs('#pp-close', root).addEventListener('click', close);
      
      // Download current
      qs('#pp-download', root).addEventListener('click', async () => {
        const btn = qs('#pp-download', root);
        if (btn.disabled) return;
        
        setButtonState(btn, true, 'Downloading...');
        
        try {
          await downloadCurrent();
          await sleep(CONFIG.DOWNLOAD_FEEDBACK_MS);
          Toast.show('Download started!', 'success');
        } catch (error) {
          Toast.show('Download failed', 'error');
        } finally {
          setButtonState(btn, false);
        }
      });

      // Download all
      qs('#pp-download-all', root).addEventListener('click', async () => {
        const btn = qs('#pp-download-all', root);
        if (btn.disabled) return;
        
        setButtonState(btn, true, 'Preparing...');
        
        try {
          await downloadAll(btn);
          Toast.show('All downloads completed!', 'success');
        } catch (error) {
          Toast.show('Some downloads failed', 'error');
        } finally {
          setButtonState(btn, false);
        }
      });

      // Open in new tab
      qs('#pp-open', root).addEventListener('click', openCurrent);

      // Keyboard navigation
      document.addEventListener('keydown', (e) => {
        if (!isOpen()) return;
        
        switch(e.key) {
          case 'Escape':
            close();
            break;
          case 'ArrowRight':
            next();
            break;
          case 'ArrowLeft':
            prev();
            break;
        }
        
        if (e.key.toLowerCase() === 'd') {
          e.preventDefault();
          downloadCurrent();
        }
      }, { capture: true });

      // Swipe gestures (mobile)
      let touchX = 0;
      stage.addEventListener('touchstart', (e) => {
        touchX = e.touches[0].clientX;
      }, { passive: true });
      
      stage.addEventListener('touchend', (e) => {
        const dx = e.changedTouches[0].clientX - touchX;
        if (Math.abs(dx) > CONFIG.SWIPE_THRESHOLD_PX) {
          dx < 0 ? next() : prev();
        }
      });
    }

    /**
     * Open overlay with image pack
     * @param {Object} pack - Pack containing items and title
     */
    function open(pack) {
      build();
      items = pack?.items || [];
      
      if (items.length === 0) {
        Toast.show('No images found', 'warning');
        return;
      }
      
      titleEl.textContent = pack.title || '';
      currentIndex = 0;
      
      // Show/hide download all button
      const downloadAllBtn = qs('#pp-download-all', root);
      downloadAllBtn.style.display = items.length > 1 ? '' : 'none';
      
      render();
      root.classList.add('open');
    }

    /**
     * Close overlay
     */
    function close() {
      root?.classList.remove('open');
    }

    /**
     * Check if overlay is open
     * @returns {boolean}
     */
    function isOpen() {
      return root?.classList.contains('open') || false;
    }

    /**
     * Render current image and UI
     */
    async function render() {
      stage.innerHTML = '';
      const cur = items[currentIndex];
      if (!cur) return;

      // Update counter
      counterEl.style.display = items.length > 1 ? '' : 'none';
      if (items.length > 1) {
        counterEl.textContent = `${currentIndex + 1} / ${items.length}`;
      }

      // Create and load image
      const el = document.createElement('img');
      el.className = 'pp-img';
      el.alt = titleEl.textContent || 'Image';
      el.src = cur.url;
      
      el.addEventListener('load', async () => {
        const w = el.naturalWidth || cur.width || 0;
        const h = el.naturalHeight || cur.height || 0;
        resEl.textContent = w && h ? `${w}×${h}` : '';
        
        // Display file format
        const ext = getExtensionType(cur.url);
        metaEl.textContent = ext;
        
        // Fetch file size asynchronously
        const size = await getFileSize(cur.url);
        if (size) {
          metaEl.textContent = `${ext} • ${formatFileSize(size)}`;
        }
      }, { once: true });

      stage.appendChild(el);

      // Render thumbnails
      renderThumbnails();
    }

    /**
     * Render thumbnail navigation
     */
    function renderThumbnails() {
      footer.innerHTML = '';
      
      if (items.length <= 1) return;
      
      items.forEach((it, i) => {
        const t = document.createElement('img');
        t.className = 'pp-thumb' + (i === currentIndex ? ' active' : '');
        t.src = it.thumb || it.url;
        t.alt = `Image ${i + 1}`;
        t.loading = 'lazy';
        t.addEventListener('click', () => {
          currentIndex = i;
          render();
        });
        footer.appendChild(t);
      });
    }

    /**
     * Navigate to next image
     */
    function next() {
      if (currentIndex < items.length - 1) {
        currentIndex++;
        render();
      }
    }

    /**
     * Navigate to previous image
     */
    function prev() {
      if (currentIndex > 0) {
        currentIndex--;
        render();
      }
    }

    /**
     * Get current image item
     * @returns {Object|null}
     */
    function current() {
      return items[currentIndex] || null;
    }

    /**
     * Download current image
     */
    async function downloadCurrent() {
      const c = current();
      if (!c) throw new Error('No image to download');
      
      const filename = titleEl.textContent || 'pinterest';
      const success = await downloadFile(c.url, filename);
      
      if (!success) {
        throw new Error('Download failed');
      }
    }

    /**
     * Download all images sequentially
     * @param {HTMLElement} btn - Button element to update
     */
    async function downloadAll(btn) {
      const total = items.length;
      const baseTitle = titleEl.textContent || 'pinterest';
      
      for (let i = 0; i < total; i++) {
        if (btn) {
          btn.textContent = `Downloading ${i + 1}/${total}...`;
        }
        
        const filename = total > 1 ? `${baseTitle}_page_${i + 1}` : baseTitle;
        const success = await downloadFile(items[i].url, filename);
        
        if (!success) {
          Toast.show(`Failed to download image ${i + 1}`, 'error');
        }
        
        // Delay between downloads to avoid browser blocking
        if (i < total - 1) {
          await sleep(CONFIG.BATCH_DOWNLOAD_DELAY_MS);
        }
      }
    }

    /**
     * Open current image in new tab
     */
    function openCurrent() {
      const c = current();
      if (c) openInNewTab(c.url);
    }

    return { open, close, isOpen };
  })();

  // ===== MAIN APP LOGIC =====
  const App = (() => {
    let routeObserverSetup = false;
    let domObserver;
    const injectedButtons = new WeakSet();

    /**
     * Initialize the application
     */
    async function init() {
      ensureCSS();
      setupRouteObserver();
      setupDomObserver();
      onRoute();
    }

    /**
     * Setup SPA route detection
     */
    function setupRouteObserver() {
      if (routeObserverSetup) return;
      
      routeObserverSetup = true;
      const push = history.pushState;
      const replace = history.replaceState;
      
      history.pushState = function(...args) {
        const r = push.apply(this, args);
        onRoute();
        return r;
      };
      
      history.replaceState = function(...args) {
        const r = replace.apply(this, args);
        onRoute();
        return r;
      };
      
      window.addEventListener('popstate', onRoute, { passive: true });
    }

    /**
     * Setup DOM mutation observer with debouncing
     */
    function setupDomObserver() {
      if (domObserver) return;
      
      const debouncedInject = debounce(() => {
        if (getPinIdFromUrl()) {
          injectCloseupButton();
        }
      }, CONFIG.MUTATION_DEBOUNCE_MS);

      domObserver = new MutationObserver((mutations) => {
        if (!getPinIdFromUrl()) return;
        
        const hasRelevantChanges = mutations.some(m =>
          m.type === 'childList' &&
          (m.target.matches?.('[data-test-id*="Closeup"]') ||
           m.target.matches?.('[data-test-id*="share"]') ||
           m.target.closest?.('[data-test-id*="Closeup"]'))
        );
        
        if (hasRelevantChanges) {
          debouncedInject();
        }
      });
      
      domObserver.observe(document.documentElement, { childList: true, subtree: true });
    }

    /**
     * Handle route changes
     */
    async function onRoute() {
      await sleep(CONFIG.ROUTE_DEBOUNCE_MS);
      injectCloseupButton();
    }

    /**
     * Inject View and Download buttons into Pinterest UI
     */
    function injectCloseupButton() {
      if (!getPinIdFromUrl()) return;

      const bar = findActionBar();
      if (!bar || injectedButtons.has(bar) || qs('#pp-main-btn', bar)) return;

      injectedButtons.add(bar);
      
      injectViewButton(bar);
      injectDownloadButton(bar);
    }

    /**
     * Find Pinterest action bar
     * @returns {HTMLElement|null}
     */
    function findActionBar() {
      return qs("div[data-test-id='share-button']")?.parentElement ||
             qs("div[data-test-id='closeupActionBar']>div>div") ||
             qs("div[data-test-id='CloseupDetails']") ||
             qs("div[data-test-id='CloseupMainPin'] div:has(button)") ||
             null;
    }

    /**
     * Handle pack resolution with loading state
     * @param {HTMLElement} btn - Button element
     * @param {Function} callback - Callback to execute with resolved pack
     */
    async function handlePackAction(btn, callback) {
      if (btn.disabled) return;
      
      setButtonState(btn, true);
      
      try {
        const pack = await resolveCurrentPinPack();
        if (pack?.items?.length) {
          await callback(pack);
        } else {
          Toast.show('No images found', 'warning');
        }
      } catch (error) {
        console.error('Pack action failed:', error);
        Toast.show('Failed to load images', 'error');
      } finally {
        setButtonState(btn, false);
      }
    }

    /**
     * Inject View button
     * @param {HTMLElement} bar - Action bar element
     */
    function injectViewButton(bar) {
      const btn = createButton({
        id: 'pp-main-btn',
        text: 'View',
        ariaLabel: 'View full size image'
      });

      // Left click = open overlay
      btn.addEventListener('mousedown', (e) => {
        e.preventDefault();
        if (e.button === 0) {
          handlePackAction(btn, pack => Overlay.open(pack));
        } else if (e.button === 1) {
          // Middle click = open in tab
          resolveCurrentPinPack().then(pack => {
            if (pack?.items?.[0]) openInNewTab(pack.items[0].url);
          });
        }
      }, { passive: false });

      // Mobile support
      btn.addEventListener('touchend', () => {
        handlePackAction(btn, pack => Overlay.open(pack));
      }, { passive: true });

      bar.appendChild(btn);
    }

    /**
     * Inject Download button
     * @param {HTMLElement} bar - Action bar element
     */
    function injectDownloadButton(bar) {
      if (qs('#pp-mini-download', bar)) return;
      
      const btn = createButton({
        id: 'pp-mini-download',
        text: 'Download',
        ariaLabel: 'Download current image'
      });
      
      btn.addEventListener('click', async () => {
        if (btn.disabled) return;
        
        setButtonState(btn, true, 'Downloading...');
        
        try {
          const pack = await resolveCurrentPinPack();
          if (!pack?.items?.length) {
            Toast.show('No images found', 'warning');
            return;
          }
          
          const success = await downloadFile(pack.items[0].url, pack.title || 'pinterest');
          if (success) {
            Toast.show('Download started!', 'success');
          } else {
            throw new Error('Download failed');
          }
        } catch (error) {
          console.error('Download failed:', error);
          Toast.show('Download failed', 'error');
        } finally {
          setButtonState(btn, false);
        }
      });
      
      bar.appendChild(btn);
    }

    /**
     * Resolve current pin pack with images
     * @returns {Promise<Object>} Pack with items and title
     */
    async function resolveCurrentPinPack() {
      const pinId = getPinIdFromUrl();
      
      if (!pinId) {
        const items = deriveFromDomAsFallback();
        return { title: '', items };
      }
      
      const data = await fetchPinData(pinId);
      const pack = getBestFromPinData(data);
      
      if (!pack.items.length) {
        pack.items = deriveFromDomAsFallback();
      }
      
      if (!pack.title) {
        const img = qs('img[alt]');
        if (img?.alt) pack.title = img.alt;
      }
      
      pack.title = sanitizeFilename(pack.title);
      return pack;
    }

    return { init };
  })();

  // ===== INITIALIZE =====
  if (document.readyState === 'loading') {
    window.addEventListener('DOMContentLoaded', () => App.init());
  } else {
    App.init();
  }
})();