Pinterest Full

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

// ==UserScript==
// @name         Pinterest Full
// @namespace    https://github.com/ShrekBytes
// @description  View & download original full size images/videos (no login required) and a pleasing UI
// @version      1.0.1
// @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';
  
    const sleep = (ms) => new Promise(r => setTimeout(r, ms));
    const qs  = (sel, root=document) => root.querySelector(sel);
  

  
    const CSS = `
    /* ===== Pinterest Plus 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; }
    
    /* Add margin between View and Download buttons */
    #pp-main-btn { margin-right: 8px; }
  
    .pp-overlay {
      position: fixed; inset: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);
    }
    .pp-head .pp-actions { display:flex; gap:8px; align-items:center; }
    .pp-chip { font-size:12px; background:#222; color:#fff; padding:.3rem .6rem; border-radius:999px; }
  
    .pp-stage {
      display:grid; place-items:center; overflow:auto; padding: 16px;
    }
    .pp-img, .pp-video { 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;
    }
    .pp-thumb.active { opacity:1; border-color:#fff; }
    `;
  
    // Inject CSS once
    function ensureCSS() {
      if (qs('#pp-css')) return;
      const style = document.createElement('style');
      style.id = 'pp-css';
      style.textContent = CSS;
      document.head.appendChild(style);
    }
  
    // Helpers to derive “original” URL from <img> (fallback path)
    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 location
    function getPinIdFromUrl(url = location.href) {
      // /pin/1234567890/   OR  /pin/some-slug/
      const m = url.match(/\/pin\/([^\/?#]+)/i);
      return m ? m[1] : null;
    }
  
      async function fetchPinData(pinId) {
        // Use Pinterest internal resource endpoint (best quality + videos/story pages)
        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}`;
          const res = await fetch(u, { 
            headers: { 'X-Pinterest-PWS-Handler': 'www/pin/[id].js' }, 
            credentials: 'include',
            signal: AbortSignal.timeout(10000) // 10 second timeout
          });
          if (!res.ok) throw new Error('Pin API not ok: ' + res.status);
          const json = await res.json();
          if (json?.resource_response?.status !== 'success') throw new Error('Pin API bad payload');
          return json.resource_response.data; // contains images.orig, videos, story_pin_data.pages, etc.
        } catch (e) {
          // Silent fail for network errors
          return null;
        }
      }
  
    function getBestFromPinData(pin) {
      /** Returns {items: [{type:'image'|'video', url, width, height, thumb?}], title?} */
      const pack = { items: [], title: (pin?.grid_title || pin?.title || '').trim() || '' };
      if (!pin) return pack;
  
      if (pin.videos?.video_list) {
        // choose the largest video by width
        const entries = Object.values(pin.videos.video_list);
        entries.sort((a,b)=> (b.width||0)-(a.width||0));
        const v = entries[0];
        if (v?.url) pack.items.push({ type:'video', url: v.url, width: v.width, height: v.height, thumb: pin.images?.['orig']?.url || '' });
      }
  
      if (pin.story_pin_data?.pages?.length) {
        for (const page of pin.story_pin_data.pages) {
          // story pages can place image in different keys; try a few
          let 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({ type:'image', url, width: 0, height: 0, thumb: url });
        }
      }
  
      const orig = pin.images?.orig;
      if (orig?.url) {
        // If we already pushed story/video, keep this as first (cover) if items is empty
        if (!pack.items.length) {
          pack.items.push({ type:'image', url: orig.url, width: orig.width||0, height: orig.height||0, thumb: orig.url });
        } else {
          // ensure main orig is present once (dedupe)
          if (!pack.items.some(i => i.url === orig.url)) {
            pack.items.unshift({ type:'image', url: orig.url, width: orig.width||0, height: orig.height||0, thumb: orig.url });
          }
        }
      }
  
      // Dedupe
      const seen = new Set();
      pack.items = pack.items.filter(i => i.url && !seen.has(i.url) && (seen.add(i.url) || true));
      return pack;
    }
  
    function deriveFromDomAsFallback() {
      // Try nearest image from the closeup
      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 ? [{ type:'image', url, width: 0, height: 0, thumb: url }] : [];
    }
  
    // Overlay (persistent gallery)
    const Overlay = (() => {
      let root, stage, footer, head, titleEl, resEl;
      let currentIndex = 0;
      let items = [];
  
      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" id="pp-download">Download</button>
              <button class="pp-btn" id="pp-open">Open</button>
            </div>
            <div style="display:flex; align-items:center; gap:8px;">
              <span id="pp-title" class="pp-chip"></span>
              <span id="pp-res" class="pp-chip"></span>
              <button class="pp-btn" 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);
        head    = qs('.pp-head', root);
        titleEl = qs('#pp-title', root);
        resEl   = qs('#pp-res', root);
  
        // Events
        qs('#pp-close', root).addEventListener('click', () => close());
        qs('#pp-download', root).addEventListener('click', async () => {
          const btn = qs('#pp-download', root);
          const originalText = btn.textContent;
          btn.textContent = 'Downloading...';
          btn.disabled = true;
          
          try {
            await downloadCurrent();
            // Add a small delay to make the loading state visible
            await sleep(500);
          } catch (error) {
            btn.textContent = 'Error';
            setTimeout(() => {
              btn.textContent = originalText;
              btn.disabled = false;
            }, 2000);
            return;
          }
          
          // Restore button state
          btn.textContent = originalText;
          btn.disabled = false;
        });
        qs('#pp-open', root).addEventListener('click', () => openCurrent());
  
        // Keyboard nav
        document.addEventListener('keydown', (e) => {
          if (!isOpen()) return;
          if (e.key === 'Escape') close();
          if (e.key === 'ArrowRight') next();
          if (e.key === 'ArrowLeft') prev();
          if (e.key.toLowerCase() === 'd') downloadCurrent();
        }, { capture:true });
        // Swipe (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) > 50) dx < 0 ? next() : prev();
        });
      }
  
      function open(pack) {
        build();
        items = pack.items || [];
        titleEl.textContent = pack.title || '';
        currentIndex = 0;
        render();
        root.classList.add('open');
      }
  
      function close() {
        root?.classList.remove('open');
      }
  
      function isOpen() { return root?.classList.contains('open'); }
  
      function render() {
        // Stage
        stage.innerHTML = '';
        const cur = items[currentIndex];
        if (!cur) return;
  
        let el;
        if (cur.type === 'video') {
          el = document.createElement('video');
          el.className = 'pp-video';
          el.controls = true;
          el.src = cur.url;
        } else {
          el = document.createElement('img');
          el.className = 'pp-img';
          el.alt = titleEl.textContent || 'Image';
          el.src = cur.url;
        }
        el.addEventListener('load', () => {
          const w = (el.videoWidth || el.naturalWidth || cur.width || 0);
          const h = (el.videoHeight || el.naturalHeight || cur.height || 0);
          resEl.textContent = w && h ? `${w}×${h}` : '';
        }, { once:true });
  
        stage.appendChild(el);
  
        // Footer thumbnails
        footer.innerHTML = '';
        items.forEach((it, i) => {
          const t = document.createElement('img');
          t.className = 'pp-thumb' + (i===currentIndex ? ' active' : '');
          t.src = it.thumb || it.url;
          t.title = (i+1) + '/' + items.length;
          t.addEventListener('click', () => { currentIndex = i; render(); });
          footer.appendChild(t);
        });
      }
  
      function next() { if (currentIndex < items.length-1) { currentIndex++; render(); } }
      function prev() { if (currentIndex > 0) { currentIndex--; render(); } }
  
      function current() { return items[currentIndex]; }
  
      async function download(url, filenameHint='image') {
        try {
          const name = filenameHint.replace(/[\/\\?%*:|"<>]/g, '-').slice(0,80) || 'pinterest';
          if (typeof GM_download === 'function') {
            GM_download({ url, name: name + getExt(url) });
          } else {
            const a = document.createElement('a');
            a.href = url; a.download = name + getExt(url);
            document.body.appendChild(a); a.click(); a.remove();
          }
        } catch (e) {
          // Silent fail for download errors
        }
      }
  
      function getExt(u) {
        const q = u.split('?')[0];
        const m = q.match(/\.(mp4|webm|jpg|jpeg|png|gif)$/i);
        return m ? m[0] : (u.includes('mp4') ? '.mp4' : '.jpg');
      }
  
      async function downloadCurrent() {
        const c = current();
        if (!c) return;
        await download(c.url, titleEl.textContent || 'pinterest');
      }
  
      function openCurrent() {
        const c = current();
        if (!c) return;
        if (typeof GM_openInTab === 'function') {
          GM_openInTab(c.url, { active:true, insert:true });
        } else if (typeof GM?.openInTab === 'function') {
          GM.openInTab(c.url, { active:true, insert:true });
        } else {
          window.open(c.url, '_blank');
        }
      }

      return { open, close, isOpen, next, prev, build };
    })();
  
    // Main page logic
    const App = (() => {
      let routeObserverSetup = false;
      let domObserver;
  
      async function init() {
        ensureCSS();
  
        // SPA route detection: patch pushState/replaceState + popstate
        if (!routeObserverSetup) {
          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 });
        }
  
        // DOM observer (adds buttons when UI mounts/changes)
        if (!domObserver) {
          domObserver = new MutationObserver((mutations) => {
            // Only process if we're on a pin page and mutations contain relevant nodes
            if (getPinIdFromUrl() && 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"]'))
            )) {
              injectCloseupButton();
            }
          });
          domObserver.observe(document.documentElement, { childList:true, subtree:true });
        }
  
        // Initial pass
        onRoute();
      }
  
      async function onRoute() {
        // slight debounce wait for pinterest to draw
        await sleep(150);
        injectCloseupButton();
      }
  
      function injectCloseupButton() {
        if (!getPinIdFromUrl()) return; // not on a pin closeup
        // Find a stable action area
        const bar =
          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;
  
        if (!bar) return;
        if (qs('#pp-main-btn', bar)) return;
  
        const btn = document.createElement('button');
        btn.id = 'pp-main-btn';
        btn.className = 'pp-btn';
        btn.textContent = 'View';
        btn.setAttribute('aria-label', 'View full size image or video');
        btn.setAttribute('role', 'button');
  
        // Click behaviors
        btn.addEventListener('mousedown', async (e) => {
          e.preventDefault();
          
          // Prevent multiple rapid clicks
          if (btn.disabled) return;
          
          // Left = open overlay
          if (e.button === 0) {
            const pack = await resolveCurrentPinPack();
            if (pack.items.length) Overlay.open(pack);
          }
          // Middle = open first in tab
          if (e.button === 1) {
            const pack = await resolveCurrentPinPack();
            if (pack.items[0]) {
              if (typeof GM_openInTab === 'function') {
                GM_openInTab(pack.items[0].url, { active:true, insert:true });
              } else if (typeof GM?.openInTab === 'function') {
                GM.openInTab(pack.items[0].url, { active:true, insert:true });
              } else {
                window.open(pack.items[0].url, '_blank');
              }
            }
          }
        }, { passive:false });
  
        // Mobile support: tap = open
        btn.addEventListener('touchend', async (e) => {
          const pack = await resolveCurrentPinPack();
          if (pack.items.length) Overlay.open(pack);
        }, { passive:true });
  
        bar.appendChild(btn);
  
        // Also add a small secondary "Download" button next to it
        if (!qs('#pp-mini-download', bar)) {
          const d = document.createElement('button');
          d.id = 'pp-mini-download';
          d.className = 'pp-btn';
          d.textContent = 'Download';
          d.setAttribute('aria-label', 'Download current image or video');
          d.setAttribute('role', 'button');
          d.addEventListener('click', async () => {
            // Prevent multiple rapid clicks
            if (d.disabled) return;
            
            // Show loading state
            const originalText = d.textContent;
            d.textContent = 'Downloading...';
            d.disabled = true;
            
            try {
              const pack = await resolveCurrentPinPack();
              if (!pack.items.length) return;
              const cur = pack.items[0];
              if (typeof GM_download === 'function') {
                GM_download({ url: cur.url, name: (pack.title || 'pinterest') + (cur.url.includes('.mp4')?'.mp4':'.jpg') });
              } else {
                const a = document.createElement('a');
                a.href = cur.url; a.download = (pack.title || 'pinterest');
                document.body.appendChild(a); a.click(); a.remove();
              }
            } catch (error) {
              // Handle errors gracefully
              d.textContent = 'Error';
              setTimeout(() => {
                d.textContent = originalText;
                d.disabled = false;
              }, 2000);
              return;
            }
            
            // Restore button state
            d.textContent = originalText;
            d.disabled = false;
          });
          bar.appendChild(d);
        }
      }

      async function resolveCurrentPinPack() {
        const pinId = getPinIdFromUrl();
        if (!pinId) {
          // fallback from DOM
          const items = deriveFromDomAsFallback();
          return { title:'', items };
        }
        const data = await fetchPinData(pinId);
        const pack = getBestFromPinData(data);
        if (!pack.items.length) {
          // fallback to DOM
          const items = deriveFromDomAsFallback();
          pack.items = items;
        }
        if (!pack.title) {
          // Try alt text near image
          const img = qs('img[alt]');
          if (img?.alt?.length) pack.title = img.alt;
        }
        pack.title = (pack.title || '').replace(/[\/\\?%*:|"<>]/g, '-').slice(0, 80);
        return pack;
      }
  
      return { init };
    })();
  
    // Initialize
    window.addEventListener('load', () => App.init());
  })();