您需要先安装一个扩展,例如 篡改猴、Greasemonkey 或 暴力猴,之后才能安装此脚本。
您需要先安装一个扩展,例如 篡改猴 或 暴力猴,之后才能安装此脚本。
您需要先安装一个扩展,例如 篡改猴 或 暴力猴,之后才能安装此脚本。
您需要先安装一个扩展,例如 篡改猴 或 Userscripts ,之后才能安装此脚本。
您需要先安装一款用户脚本管理器扩展,例如 Tampermonkey,才能安装此脚本。
您需要先安装用户脚本管理器扩展后才能安装此脚本。
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()); })();