// ==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.3
// @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/jszip@3.9.1/dist/jszip.min.js
// @require https://cdn.jsdelivr.net/npm/file-saver@2.0.5/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
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')
}
}
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 imgs = document.querySelectorAll('.gridCentered 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('.gridCentered 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) { }
}
})
} 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)}` +
` | ![${escapeMD(entry.description || entry.fileName)}](${entry.fileUrl})` +
` | ${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'
}
})
}