Download all original images from your Pinterest.com profile. Creates an entry in the Greasemonkey menu, just go to one of your boards, scroll down to the last image and click the option in the menu.
// ==UserScript== // @name Pinterest.com Backup Original Files // @description Download all original images from your Pinterest.com profile. Creates an entry in the Greasemonkey menu, just go to one of your boards, scroll down to the last image and click the option in the menu. // @namespace cuzi // @license MIT // @version 19.0.4 // @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.net/* // @match https://*.pinterest.nz/* // @match https://*.pinterest.ph/* // @match https://*.pinterest.pt/* // @match https://*.pinterest.ru/* // @match https://*.pinterest.se/* // @grant GM_xmlhttpRequest // @grant GM_registerMenuCommand // @grant GM.xmlHttpRequest // @grant GM.registerMenuCommand // @require https://greasemonkey.github.io/gm4-polyfill/gm4-polyfill.js // @require https://cdn.jsdelivr.net/npm/[email protected]/dist/jszip.min.js // @require https://cdn.jsdelivr.net/npm/[email protected]/dist/FileSaver.min.js // @connect pinterest.com // @connect pinterest.de // @connect pinimg.com // @icon https://s.pinimg.com/webapp/logo_trans_144x144-5e37c0c6.png // ==/UserScript== /* globals JSZip, saveAs, GM, MouseEvent */ // Time to wait between every scroll to the bottom (in milliseconds) const scrollPause = 1000 let scrollIV = null let lastScrollY = null let noChangesFor = 0 let lastImageListLength = -1 let noImageListLengthChangesFor = 0 function prepareForDownloading () { if (scrollIV !== null) { return } document.scrollingElement.scrollTo(0, 0) collectActive = true scrollIV = true collectImages() if (!window.confirm('The script needs to scroll down to the end of the page. It will start downloading once the end is reached.\n\nOnly images that are already visible can be downloaded.\n\n\u2757 Keep this tab open (visible) \u2757')) { return } const div = document.querySelector('.downloadoriginal123button') div.style.position = 'fixed' div.style.top = '30%' div.style.zIndex = 100 div.innerHTML = 'Collecting images... (keep this tab visible)<br>' const startDownloadButton = div.appendChild(document.createElement('button')) startDownloadButton.appendChild(document.createTextNode('Stop scrolling & start downloading')) startDownloadButton.addEventListener('click', function () { window.clearInterval(scrollIV) downloadOriginals() }) const statusImageCollector = div.appendChild(document.createElement('div')) statusImageCollector.setAttribute('id', 'statusImageCollector') document.scrollingElement.scrollTo(0, document.scrollingElement.scrollHeight) window.setTimeout(function () { scrollIV = window.setInterval(scrollDown, scrollPause) }, 1000) } function scrollDown () { if (document.hidden) { // Tab is hidden, don't do anyhting return } if (noChangesFor > 2) { console.log('noChangesFor > 2') window.clearInterval(scrollIV) window.setTimeout(downloadOriginals, 1000) } else { console.log('noChangesFor <= 2') document.scrollingElement.scrollTo(0, document.scrollingElement.scrollTop + 500) if (document.scrollingElement.scrollTop === lastScrollY) { noChangesFor++ console.log('noChangesFor++') } else { noChangesFor = 0 console.log('noChangesFor = 0') } if (entryList.length !== lastImageListLength) { lastImageListLength = entryList.length noImageListLengthChangesFor = 0 } else { console.log('noImageListLengthChangesFor =', noImageListLengthChangesFor) noImageListLengthChangesFor++ if (noImageListLengthChangesFor > 5) { window.clearInterval(scrollIV) window.setTimeout(downloadOriginals, 1000) } } } lastScrollY = document.scrollingElement.scrollTop } let entryList = [] let url = document.location.href let collectActive = false let boardName = '' let boardNameEscaped = '' let userName = '' let userNameEscaped = '' const startTime = new Date() const entryTemplate = { images: [], title: null, link: null, description: null, note: null, sourceLink: null } function collectImages () { if (!collectActive) return if (url !== document.location.href) { // Reset on new page url = document.location.href entryList = [] } const masonry = document.querySelector('[data-test-id="board-feed"] .masonryContainer') if (!masonry) { return } const imgs = masonry.querySelectorAll('a[href^="/pin/"] img') for (let i = 0; i < imgs.length; i++) { if (imgs[i].clientWidth < 100) { // Skip small images, these are user profile photos continue } if (!('mouseOver' in imgs[i].dataset)) { // Fake mouse over to load source link const mouseOverEvent = new MouseEvent('mouseover', { bubbles: true, cancelable: true }) imgs[i].dispatchEvent(mouseOverEvent) imgs[i].dataset.mouseOver = true } const entry = Object.assign({}, entryTemplate) entry.images = [imgs[i].src.replace(/\/\d+x\//, '/originals/'), imgs[i].src] if (imgs[i].alt) { entry.description = imgs[i].alt } const pinWrapper = parentQuery(imgs[i], '[data-test-id="pinWrapper"]') || parentQuery(imgs[i], '[role="listitem"]') || parentQuery(imgs[i], '[draggable="true"]') if (pinWrapper) { // find metadata const aText = Array.from(pinWrapper.querySelectorAll('a[href*="/pin/"]')).filter(a => a.firstChild.nodeType === a.TEXT_NODE) if (aText.length > 0 && aText[0]) { entry.title = aText[0].textContent.trim() entry.link = aText[0].href.toString() } else if (pinWrapper.querySelector('a[href*="/pin/"]')) { entry.link = pinWrapper.querySelector('a[href*="/pin/"]').href.toString() } const aNotes = Array.from(pinWrapper.querySelectorAll('a[href*="/pin/"]')).filter(a => a.querySelector('div[title]')) if (aNotes.length > 0 && aNotes[0]) { entry.note = aNotes[0].textContent.trim() } if (pinWrapper.querySelector('[data-test-id="pinrep-source-link"] a')) { entry.sourceLink = pinWrapper.querySelector('[data-test-id="pinrep-source-link"] a').href.toString() } } if (imgs[i].srcset) { // e.g. srcset="https://i-h2.pinimg.com/236x/15/87/ae/abcdefg1234.jpg 1x, https://i-h2.pinimg.com/474x/15/87/ae/abcdefg1234.jpg 2x, https://i-h2.pinimg.com/736x/15/87/ae/abcdefg1234.jpg 3x, https://i-h2.pinimg.com/originals/15/87/ae/abcdefg1234.png 4x" let goodUrl = false let quality = -1 const srcset = imgs[i].srcset.split(', ') for (let j = 0; j < srcset.length; j++) { const pair = srcset[j].split(' ') const q = parseInt(pair[1].replace('x')) if (q > quality) { goodUrl = pair[0] quality = q } if (pair[0].indexOf('/originals/') !== -1) { break } } if (goodUrl && quality !== -1) { entry.images[0] = goodUrl } } let exists = false for (let j = 0; j < entryList.length; j++) { if (entryList[j].images[0] === entry.images[0] && entryList[j].images[1] === entry.images[1]) { exists = true entryList[j] = entry // replace with newer entry break } } if (!exists) { entryList.push(entry) console.debug(imgs[i].parentNode) console.debug(entry) } } const statusImageCollector = document.getElementById('statusImageCollector') if (statusImageCollector) { statusImageCollector.innerHTML = `Collected ${entryList.length} images` } } function addButton () { if (document.querySelector('.downloadoriginal123button')) { return } if (document.querySelector('[data-test-id="board-tools"],[data-test-id="board-header"]') && document.querySelectorAll('[data-test-id="board-feed"] a[href^="/pin/"] img').length) { const button = document.createElement('div') button.type = 'button' button.classList.add('downloadoriginal123button') button.setAttribute('style', ` position: absolute; display: block; background: white; border: none; padding: 5px; text-align: center; cursor:pointer; `) button.innerHTML = ` <div class="buttonText" style="background: #efefef;border: #efefef 1px solid;border-radius: 24px;padding: 5px;font-size: xx-large;color: #111;width: 62px; height: 58px;">\u2B73</div> <div style="font-weight: 700;color: #111;font-size: 12px;">Download<br>originals</div> ` button.addEventListener('click', prepareForDownloading) document.querySelector('[data-test-id="board-tools"],[data-test-id="board-header"]').appendChild(button) try { const buttons = document.querySelectorAll('[role="button"] a[href*="/more-ideas/"],[data-test-id="board-header"] [role="button"]') const rect = buttons[buttons.length - 1].getBoundingClientRect() button.style.top = rect.top - 2 + 'px' button.style.left = rect.left - rect.width + 300 + 'px' } catch (e) { console.warn(e) try { const title = document.querySelector('h1') const rect = title.getBoundingClientRect() button.style.top = rect.top - 2 + 'px' button.style.left = rect.left - 120 + 'px' } catch (e) { console.warn(e) } } } } GM.registerMenuCommand('Pinterest.com - backup originals', prepareForDownloading) addButton() window.setInterval(addButton, 1000) window.setInterval(collectImages, 400) function downloadOriginals () { try { boardName = document.querySelector('h1').textContent.trim() boardNameEscaped = boardName.replace(/[^a-z0-9]/gi, '_') } catch (e1) { try { boardName = document.location.pathname.replace(/^\//, '').replace(/\/$/, '').split('/').pop() boardNameEscaped = boardName.replace(/[^a-z0-9]/gi, '_') } catch (e2) { boardName = 'board-' + Math.random() boardNameEscaped = boardName } } try { userName = document.location.href.match(/\.(\w{2,3})\/(.*?)\//)[2] userNameEscaped = userName.replace(/[^a-z0-9]/gi, '_') } catch (e) { try { userName = document.location.pathname.replace(/^\//, '').replace(/\/$/, '').split('/').shift() userNameEscaped = userName.replace(/[^a-z0-9]/gi, '_') } catch (e2) { userName = 'user' userNameEscaped = userName } } collectImages() collectActive = false const lst = entryList.slice() const total = lst.length let zip = new JSZip() const fileNameSet = new Set() // Create folders const imagesFolder = zip.folder('images') const errorFolder = zip.folder('error_thumbnails') const markdownOut = [] const htmlOut = [] document.body.style.padding = '3%' document.body.innerHTML = '<h1><span id="counter">' + (total - lst.length) + '</span>/' + total + ' downloaded</h1><br>(Keep this tab visible)<br>' + '</div><progress id="status"></progress> image download<br><progress id="total" value="0" max="' + total + '"></progress> total progress<pre id="statusmessage"></pre>' document.scrollingElement.scrollTo(0, 0) const pre = document.getElementById('statusmessage') const statusbar = document.getElementById('status') const totalbar = document.getElementById('total') const h1 = document.getElementById('counter'); (async function work () { document.title = (total - lst.length) + '/' + total + ' downloaded' h1.innerHTML = totalbar.value = total - lst.length statusbar.removeAttribute('value') statusbar.removeAttribute('max') if (lst.length === 0) { document.title = 'Generating zip file...' document.body.innerHTML = '<h1>Generating zip file...</h1><progress id="gen_zip_progress"></progress>' } if (lst.length > 0) { const entry = lst.pop() const urls = entry.images let fileName = null const prettyFilename = (s) => safeFileName(s.substr(0, 200)).substr(0, 110).replace(/^[^\w]+/, '').replace(/[^\w]+$/, '') if (entry.title) { fileName = prettyFilename(entry.title) } else if (entry.description) { fileName = prettyFilename(entry.description) } else if (entry.note) { fileName = prettyFilename(entry.note) } else if (entry.sourceLink) { fileName = prettyFilename(entry.sourceLink.split('/').slice(3).join('-')) } if (!fileName) { fileName = urls[0].split('/').pop() } else { fileName = fileName + '.' + urls[0].split('/').pop().split('.').pop() } while (fileNameSet.has(fileName.toLowerCase())) { const parts = fileName.split('.') parts.splice(parts.length - 1, 0, parseInt(Math.random() * 10000).toString()) fileName = parts.join('.') } fileNameSet.add(fileName.toLowerCase()) pre.innerHTML = fileName GM.xmlHttpRequest({ method: 'GET', url: urls[0], responseType: 'arraybuffer', onload: async function (response) { const s = String.fromCharCode.apply(null, new Uint8Array(response.response.slice(0, 125))) if (s.indexOf('<Error>') !== -1) { // Download thumbnail to error folder if (!('isError' in entry) || !entry.isError) { const errorEntry = Object.assign({}, entry) errorEntry.images = [urls[1]] errorEntry.isError = true // TODO change title? of error entry lst.push(errorEntry) } } else { // Save file to zip entry.fileName = fileName entry.fileNameUrl = markdownEncodeURIComponent(fileName) if (!('isError' in entry) || !entry.isError) { imagesFolder.file(fileName, response.response) entry.filePath = 'images/' + fileName entry.fileUrl = 'images/' + entry.fileNameUrl await addMetadata('successful', entry, htmlOut, markdownOut) } else { errorFolder.file(fileName, response.response) entry.filePath = 'error_thumbnails/' + fileName entry.fileUrl = 'error_thumbnails/' + entry.fileNameUrl await addMetadata('error', entry, htmlOut, markdownOut) } } work() }, onprogress: function (progress) { try { statusbar.max = progress.total statusbar.value = progress.loaded } catch (e) { } }, onerror: async function (response) { console.error('Error downloading image:', response) entry.filePath = '' entry.fileUrl = 'https://github.com/cvzi/pinterest-Backup-Original-Files/blob/master/error.svg' entry.note = 'Failed to download from: \'' + urls[0] + '\': ' + ('error' in response ? response.error : response.toString()) await addMetadata('error', entry, htmlOut, markdownOut) work() } }) } else { // Create html and markdown overview htmlOut.unshift(` <style> th,td { word-wrap: break-word; max-width: 25em } tr:nth-child(2n+2){ background-color:#f0f0f0 } </style> <h1>${escapeXml(boardName)}</h1> <h3> ${escapeXml(userName)} <br> <time datetime="${startTime.toISOString()}" title=""${startTime.toString()}"> ${startTime.toLocaleDateString(undefined, { year: 'numeric', month: 'long', day: 'numeric' })} </time>: <a href="${escapeXml(document.location.href)}">${escapeXml(document.location.href)}</a> </h3> <table border="1"> <tr> <th>Title</th> <th>Image</th> <th>Pinterest</th> <th>Source</th> <th>Description</th> <th>Notes</th> </tr> `) htmlOut.push('</table>') zip.file('index.html', htmlOut.join('\n')) markdownOut.unshift(` # ${escapeMD(boardName)} ### ${escapeXml(userName)} ${startTime.toLocaleDateString(undefined, { year: 'numeric', month: 'long', day: 'numeric' })}: ${document.location.href} | Title | Image | Pinterest | Source | Description | Notes | |---|---|---|---|---|---|`) zip.file('README.md', markdownOut.join('\n')) // Done. Open ZIP file let zipfilename try { const d = startTime || new Date() zipfilename = userNameEscaped + '_' + boardNameEscaped + '_' + d.getFullYear() + '-' + ((d.getMonth() + 1) > 9 ? '' : '0') + (d.getMonth() + 1) + '-' + (d.getDate() > 9 ? '' : '0') + d.getDate() + '_' + (d.getHours() > 9 ? '' : '0') + d.getHours() + '-' + (d.getMinutes() > 9 ? '' : '0') + d.getMinutes() } catch (e) { zipfilename = 'board' } zipfilename += '.zip' const content = await zip.generateAsync({ type: 'blob' }) // TODO catch errors zip = null const h = document.createElement('h1') h.appendChild(document.createTextNode('Click here to Download')) h.style = 'cursor:pointer; color:blue; background:white; text-decoration:underline' document.body.appendChild(h) const genZipProgress = document.getElementById('gen_zip_progress') if (genZipProgress) { genZipProgress.remove() } h.addEventListener('click', function () { saveAs(content, zipfilename) }) saveAs(content, zipfilename) } })() } function addMetadata (status, e, htmlOut, markdownOut) { return new Promise((resolve) => { writeMetadata(status, e, htmlOut, markdownOut) resolve() }) } function writeMetadata (status, entry, htmlOut, markdownOut) { // XML escape all values for html const entryEscaped = Object.fromEntries(Object.entries(entry).map(entry => { const escapedValue = escapeXml(entry[1]) return [entry[0], escapedValue] })) // Shorten source link title let sourceA = '' if (entry.sourceLink) { let sourceTitle = decodeURI(entry.sourceLink) if (sourceTitle.length > 160) { sourceTitle = sourceTitle.substring(0, 155) + '\u2026' } sourceA = `<a href="${entryEscaped.sourceLink}">${escapeXml(sourceTitle)}</a>` } // HTML table entry htmlOut.push(` <tr> <th id="${entryEscaped.fileNameUrl}"> <a href="#${entryEscaped.fileNameUrl}">${entryEscaped.title || entryEscaped.description || entryEscaped.fileName}</a </th> <td> <a href="${entryEscaped.fileUrl}"> <img style="max-width:250px; max-height:250px" src="${entryEscaped.fileUrl}" alt="${entryEscaped.description || entryEscaped.filePath}"> </a> </td> <td> <a href="${entryEscaped.link}">${entryEscaped.link}</a> </td> <td> ${sourceA} </td> <td>${entryEscaped.description}</td> <td>${entryEscaped.note}</td> </tr> `) // Shorten source link title let sourceLink = entry.sourceLink || '' if (entry.sourceLink) { let sourceTitle = decodeURI(entry.sourceLink) if (sourceTitle.length > 160) { sourceTitle = sourceTitle.substring(0, 155) + '\u2026' } sourceLink = `[${escapeMD(sourceTitle)}](${entry.sourceLink})` } // Markdown markdownOut.push(`| ${escapeMD(entry.title || entry.description || entry.fileName)}` + ` | ` + ` | ${entry.link || ''}` + ` | ${sourceLink}` + ` | ${escapeMD(entry.description || '')}` + ` | ${escapeMD(entry.note || '')}` + ' |') } function parentQuery (node, q) { const parents = [node.parentElement] node = node.parentElement.parentElement while (node) { const lst = node.querySelectorAll(q) for (let i = 0; i < lst.length; i++) { if (parents.indexOf(lst[i]) !== -1) { return lst[i] } } parents.push(node) node = node.parentElement } return null } function safeFileName (s) { const blacklist = /[<>:'"/\\|?*\u0000\n\r\t]/g // eslint-disable-line no-control-regex s = s.replace(blacklist, ' ').trim().replace(/^\.+/, '').replace(/\.+$/, '') return s.replace(/\s+/g, ' ').trim() } function escapeXml (unsafe) { // https://stackoverflow.com/a/27979933/ const s = (unsafe || '').toString() return s.replace(/[<>&'"\n\t]/gim, function (c) { switch (c) { case '<': return '<' case '>': return '>' case '&': return '&' case '\'': return ''' case '"': return '"' case '\n': return '<br>' case '\t': return ' ' } }) } function escapeMD (unsafe) { // Markdown escape const s = (unsafe || '').toString() return s.replace(/\W/gim, function (c) { switch (c) { case '<': return '<' case '>': return '>' case '&': return '&' case '\'': return '\\\'' case '"': return '\\"' case '*': return '\\*' case '[': return '\\[' case ']': return '\\]' case '(': return '\\(' case ')': return '\\)' case '{': return '\\{' case '}': return '\\}' case '`': return '\\`' case '!': return '\\!' case '|': return '\\|' case '#': return '\\#' case '+': return '\\+' case '-': return '\\-' case '\r': return ' ' case '\n': return '<br>' default: return c } }).trim() } function markdownEncodeURIComponent (s) { return encodeURIComponent(s).replace(/[[\](){}`!]/g, function (c) { switch (c) { case '[': return '%5B' case ']': return '%5D' case '(': return '%28' case ')': return '%29' case '{': return '%7B' case '}': return '%7D' case '`': return '%60' case '!': return '%21' } }) }