Greasy Fork is available in English.

Pinterest.com Backup Original Files

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.

  1. // ==UserScript==
  2. // @name Pinterest.com Backup Original Files
  3. // @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.
  4. // @namespace cuzi
  5. // @license MIT
  6. // @version 19.0.4
  7. // @match https://*.pinterest.com/*
  8. // @match https://*.pinterest.at/*
  9. // @match https://*.pinterest.ca/*
  10. // @match https://*.pinterest.ch/*
  11. // @match https://*.pinterest.cl/*
  12. // @match https://*.pinterest.co.kr/*
  13. // @match https://*.pinterest.co.uk/*
  14. // @match https://*.pinterest.com.au/*
  15. // @match https://*.pinterest.com.mx/*
  16. // @match https://*.pinterest.de/*
  17. // @match https://*.pinterest.dk/*
  18. // @match https://*.pinterest.es/*
  19. // @match https://*.pinterest.fr/*
  20. // @match https://*.pinterest.ie/*
  21. // @match https://*.pinterest.info/*
  22. // @match https://*.pinterest.it/*
  23. // @match https://*.pinterest.jp/*
  24. // @match https://*.pinterest.net/*
  25. // @match https://*.pinterest.nz/*
  26. // @match https://*.pinterest.ph/*
  27. // @match https://*.pinterest.pt/*
  28. // @match https://*.pinterest.ru/*
  29. // @match https://*.pinterest.se/*
  30. // @grant GM_xmlhttpRequest
  31. // @grant GM_registerMenuCommand
  32. // @grant GM.xmlHttpRequest
  33. // @grant GM.registerMenuCommand
  34. // @require https://greasemonkey.github.io/gm4-polyfill/gm4-polyfill.js
  35. // @require https://cdn.jsdelivr.net/npm/jszip@3.9.1/dist/jszip.min.js
  36. // @require https://cdn.jsdelivr.net/npm/file-saver@2.0.5/dist/FileSaver.min.js
  37. // @connect pinterest.com
  38. // @connect pinterest.de
  39. // @connect pinimg.com
  40. // @icon https://s.pinimg.com/webapp/logo_trans_144x144-5e37c0c6.png
  41. // ==/UserScript==
  42.  
  43. /* globals JSZip, saveAs, GM, MouseEvent */
  44.  
  45. // Time to wait between every scroll to the bottom (in milliseconds)
  46. const scrollPause = 1000
  47.  
  48. let scrollIV = null
  49. let lastScrollY = null
  50. let noChangesFor = 0
  51. let lastImageListLength = -1
  52. let noImageListLengthChangesFor = 0
  53.  
  54. function prepareForDownloading () {
  55. if (scrollIV !== null) {
  56. return
  57. }
  58.  
  59. document.scrollingElement.scrollTo(0, 0)
  60. collectActive = true
  61. scrollIV = true
  62. collectImages()
  63.  
  64. 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')) {
  65. return
  66. }
  67.  
  68. const div = document.querySelector('.downloadoriginal123button')
  69. div.style.position = 'fixed'
  70. div.style.top = '30%'
  71. div.style.zIndex = 100
  72. div.innerHTML = 'Collecting images... (keep this tab visible)<br>'
  73.  
  74. const startDownloadButton = div.appendChild(document.createElement('button'))
  75. startDownloadButton.appendChild(document.createTextNode('Stop scrolling & start downloading'))
  76. startDownloadButton.addEventListener('click', function () {
  77. window.clearInterval(scrollIV)
  78. downloadOriginals()
  79. })
  80.  
  81. const statusImageCollector = div.appendChild(document.createElement('div'))
  82. statusImageCollector.setAttribute('id', 'statusImageCollector')
  83.  
  84. document.scrollingElement.scrollTo(0, document.scrollingElement.scrollHeight)
  85.  
  86. window.setTimeout(function () {
  87. scrollIV = window.setInterval(scrollDown, scrollPause)
  88. }, 1000)
  89. }
  90.  
  91. function scrollDown () {
  92. if (document.hidden) {
  93. // Tab is hidden, don't do anyhting
  94. return
  95. }
  96. if (noChangesFor > 2) {
  97. console.log('noChangesFor > 2')
  98. window.clearInterval(scrollIV)
  99. window.setTimeout(downloadOriginals, 1000)
  100. } else {
  101. console.log('noChangesFor <= 2')
  102. document.scrollingElement.scrollTo(0, document.scrollingElement.scrollTop + 500)
  103. if (document.scrollingElement.scrollTop === lastScrollY) {
  104. noChangesFor++
  105. console.log('noChangesFor++')
  106. } else {
  107. noChangesFor = 0
  108. console.log('noChangesFor = 0')
  109. }
  110. if (entryList.length !== lastImageListLength) {
  111. lastImageListLength = entryList.length
  112. noImageListLengthChangesFor = 0
  113. } else {
  114. console.log('noImageListLengthChangesFor =', noImageListLengthChangesFor)
  115. noImageListLengthChangesFor++
  116. if (noImageListLengthChangesFor > 5) {
  117. window.clearInterval(scrollIV)
  118. window.setTimeout(downloadOriginals, 1000)
  119. }
  120. }
  121. }
  122. lastScrollY = document.scrollingElement.scrollTop
  123. }
  124.  
  125. let entryList = []
  126. let url = document.location.href
  127. let collectActive = false
  128. let boardName = ''
  129. let boardNameEscaped = ''
  130. let userName = ''
  131. let userNameEscaped = ''
  132. const startTime = new Date()
  133. const entryTemplate = {
  134. images: [],
  135. title: null,
  136. link: null,
  137. description: null,
  138. note: null,
  139. sourceLink: null
  140. }
  141.  
  142. function collectImages () {
  143. if (!collectActive) return
  144. if (url !== document.location.href) {
  145. // Reset on new page
  146. url = document.location.href
  147. entryList = []
  148. }
  149.  
  150. const masonry = document.querySelector('[data-test-id="board-feed"] .masonryContainer')
  151. if (!masonry) {
  152. return
  153. }
  154. const imgs = masonry.querySelectorAll('a[href^="/pin/"] img')
  155. for (let i = 0; i < imgs.length; i++) {
  156. if (imgs[i].clientWidth < 100) {
  157. // Skip small images, these are user profile photos
  158. continue
  159. }
  160. if (!('mouseOver' in imgs[i].dataset)) {
  161. // Fake mouse over to load source link
  162. const mouseOverEvent = new MouseEvent('mouseover', {
  163. bubbles: true,
  164. cancelable: true
  165. })
  166.  
  167. imgs[i].dispatchEvent(mouseOverEvent)
  168. imgs[i].dataset.mouseOver = true
  169. }
  170.  
  171. const entry = Object.assign({}, entryTemplate)
  172. entry.images = [imgs[i].src.replace(/\/\d+x\//, '/originals/'), imgs[i].src]
  173.  
  174. if (imgs[i].alt) {
  175. entry.description = imgs[i].alt
  176. }
  177.  
  178. const pinWrapper = parentQuery(imgs[i], '[data-test-id="pinWrapper"]') || parentQuery(imgs[i], '[role="listitem"]') || parentQuery(imgs[i], '[draggable="true"]')
  179. if (pinWrapper) {
  180. // find metadata
  181. const aText = Array.from(pinWrapper.querySelectorAll('a[href*="/pin/"]')).filter(a => a.firstChild.nodeType === a.TEXT_NODE)
  182. if (aText.length > 0 && aText[0]) {
  183. entry.title = aText[0].textContent.trim()
  184. entry.link = aText[0].href.toString()
  185. } else if (pinWrapper.querySelector('a[href*="/pin/"]')) {
  186. entry.link = pinWrapper.querySelector('a[href*="/pin/"]').href.toString()
  187. }
  188. const aNotes = Array.from(pinWrapper.querySelectorAll('a[href*="/pin/"]')).filter(a => a.querySelector('div[title]'))
  189. if (aNotes.length > 0 && aNotes[0]) {
  190. entry.note = aNotes[0].textContent.trim()
  191. }
  192.  
  193. if (pinWrapper.querySelector('[data-test-id="pinrep-source-link"] a')) {
  194. entry.sourceLink = pinWrapper.querySelector('[data-test-id="pinrep-source-link"] a').href.toString()
  195. }
  196. }
  197.  
  198. if (imgs[i].srcset) {
  199. // 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"
  200.  
  201. let goodUrl = false
  202. let quality = -1
  203. const srcset = imgs[i].srcset.split(', ')
  204. for (let j = 0; j < srcset.length; j++) {
  205. const pair = srcset[j].split(' ')
  206. const q = parseInt(pair[1].replace('x'))
  207. if (q > quality) {
  208. goodUrl = pair[0]
  209. quality = q
  210. }
  211. if (pair[0].indexOf('/originals/') !== -1) {
  212. break
  213. }
  214. }
  215. if (goodUrl && quality !== -1) {
  216. entry.images[0] = goodUrl
  217. }
  218. }
  219.  
  220. let exists = false
  221. for (let j = 0; j < entryList.length; j++) {
  222. if (entryList[j].images[0] === entry.images[0] && entryList[j].images[1] === entry.images[1]) {
  223. exists = true
  224. entryList[j] = entry // replace with newer entry
  225. break
  226. }
  227. }
  228. if (!exists) {
  229. entryList.push(entry)
  230. console.debug(imgs[i].parentNode)
  231. console.debug(entry)
  232. }
  233. }
  234. const statusImageCollector = document.getElementById('statusImageCollector')
  235. if (statusImageCollector) {
  236. statusImageCollector.innerHTML = `Collected ${entryList.length} images`
  237. }
  238. }
  239.  
  240. function addButton () {
  241. if (document.querySelector('.downloadoriginal123button')) {
  242. return
  243. }
  244.  
  245. 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) {
  246. const button = document.createElement('div')
  247. button.type = 'button'
  248. button.classList.add('downloadoriginal123button')
  249. button.setAttribute('style', `
  250. position: absolute;
  251. display: block;
  252. background: white;
  253. border: none;
  254. padding: 5px;
  255. text-align: center;
  256. cursor:pointer;
  257. `)
  258. button.innerHTML = `
  259. <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>
  260. <div style="font-weight: 700;color: #111;font-size: 12px;">Download<br>originals</div>
  261. `
  262. button.addEventListener('click', prepareForDownloading)
  263. document.querySelector('[data-test-id="board-tools"],[data-test-id="board-header"]').appendChild(button)
  264. try {
  265. const buttons = document.querySelectorAll('[role="button"] a[href*="/more-ideas/"],[data-test-id="board-header"] [role="button"]')
  266. const rect = buttons[buttons.length - 1].getBoundingClientRect()
  267. button.style.top = rect.top - 2 + 'px'
  268. button.style.left = rect.left - rect.width + 300 + 'px'
  269. } catch (e) {
  270. console.warn(e)
  271. try {
  272. const title = document.querySelector('h1')
  273. const rect = title.getBoundingClientRect()
  274. button.style.top = rect.top - 2 + 'px'
  275. button.style.left = rect.left - 120 + 'px'
  276. } catch (e) {
  277. console.warn(e)
  278. }
  279. }
  280. }
  281. }
  282.  
  283. GM.registerMenuCommand('Pinterest.com - backup originals', prepareForDownloading)
  284. addButton()
  285. window.setInterval(addButton, 1000)
  286. window.setInterval(collectImages, 400)
  287.  
  288. function downloadOriginals () {
  289. try {
  290. boardName = document.querySelector('h1').textContent.trim()
  291. boardNameEscaped = boardName.replace(/[^a-z0-9]/gi, '_')
  292. } catch (e1) {
  293. try {
  294. boardName = document.location.pathname.replace(/^\//, '').replace(/\/$/, '').split('/').pop()
  295. boardNameEscaped = boardName.replace(/[^a-z0-9]/gi, '_')
  296. } catch (e2) {
  297. boardName = 'board-' + Math.random()
  298. boardNameEscaped = boardName
  299. }
  300. }
  301. try {
  302. userName = document.location.href.match(/\.(\w{2,3})\/(.*?)\//)[2]
  303. userNameEscaped = userName.replace(/[^a-z0-9]/gi, '_')
  304. } catch (e) {
  305. try {
  306. userName = document.location.pathname.replace(/^\//, '').replace(/\/$/, '').split('/').shift()
  307. userNameEscaped = userName.replace(/[^a-z0-9]/gi, '_')
  308. } catch (e2) {
  309. userName = 'user'
  310. userNameEscaped = userName
  311. }
  312. }
  313.  
  314. collectImages()
  315. collectActive = false
  316.  
  317. const lst = entryList.slice()
  318.  
  319. const total = lst.length
  320. let zip = new JSZip()
  321. const fileNameSet = new Set()
  322.  
  323. // Create folders
  324. const imagesFolder = zip.folder('images')
  325. const errorFolder = zip.folder('error_thumbnails')
  326. const markdownOut = []
  327. const htmlOut = []
  328.  
  329. document.body.style.padding = '3%'
  330. 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>'
  331. document.scrollingElement.scrollTo(0, 0)
  332. const pre = document.getElementById('statusmessage')
  333. const statusbar = document.getElementById('status')
  334. const totalbar = document.getElementById('total')
  335. const h1 = document.getElementById('counter');
  336.  
  337. (async function work () {
  338. document.title = (total - lst.length) + '/' + total + ' downloaded'
  339. h1.innerHTML = totalbar.value = total - lst.length
  340. statusbar.removeAttribute('value')
  341. statusbar.removeAttribute('max')
  342.  
  343. if (lst.length === 0) {
  344. document.title = 'Generating zip file...'
  345. document.body.innerHTML = '<h1>Generating zip file...</h1><progress id="gen_zip_progress"></progress>'
  346. }
  347. if (lst.length > 0) {
  348. const entry = lst.pop()
  349. const urls = entry.images
  350. let fileName = null
  351. const prettyFilename = (s) => safeFileName(s.substr(0, 200)).substr(0, 110).replace(/^[^\w]+/, '').replace(/[^\w]+$/, '')
  352. if (entry.title) {
  353. fileName = prettyFilename(entry.title)
  354. } else if (entry.description) {
  355. fileName = prettyFilename(entry.description)
  356. } else if (entry.note) {
  357. fileName = prettyFilename(entry.note)
  358. } else if (entry.sourceLink) {
  359. fileName = prettyFilename(entry.sourceLink.split('/').slice(3).join('-'))
  360. }
  361.  
  362. if (!fileName) {
  363. fileName = urls[0].split('/').pop()
  364. } else {
  365. fileName = fileName + '.' + urls[0].split('/').pop().split('.').pop()
  366. }
  367.  
  368. while (fileNameSet.has(fileName.toLowerCase())) {
  369. const parts = fileName.split('.')
  370. parts.splice(parts.length - 1, 0, parseInt(Math.random() * 10000).toString())
  371. fileName = parts.join('.')
  372. }
  373. fileNameSet.add(fileName.toLowerCase())
  374.  
  375. pre.innerHTML = fileName
  376. GM.xmlHttpRequest({
  377. method: 'GET',
  378. url: urls[0],
  379. responseType: 'arraybuffer',
  380. onload: async function (response) {
  381. const s = String.fromCharCode.apply(null, new Uint8Array(response.response.slice(0, 125)))
  382. if (s.indexOf('<Error>') !== -1) {
  383. // Download thumbnail to error folder
  384. if (!('isError' in entry) || !entry.isError) {
  385. const errorEntry = Object.assign({}, entry)
  386. errorEntry.images = [urls[1]]
  387. errorEntry.isError = true
  388. // TODO change title? of error entry
  389. lst.push(errorEntry)
  390. }
  391. } else {
  392. // Save file to zip
  393. entry.fileName = fileName
  394. entry.fileNameUrl = markdownEncodeURIComponent(fileName)
  395. if (!('isError' in entry) || !entry.isError) {
  396. imagesFolder.file(fileName, response.response)
  397. entry.filePath = 'images/' + fileName
  398. entry.fileUrl = 'images/' + entry.fileNameUrl
  399. await addMetadata('successful', entry, htmlOut, markdownOut)
  400. } else {
  401. errorFolder.file(fileName, response.response)
  402. entry.filePath = 'error_thumbnails/' + fileName
  403. entry.fileUrl = 'error_thumbnails/' + entry.fileNameUrl
  404. await addMetadata('error', entry, htmlOut, markdownOut)
  405. }
  406. }
  407.  
  408. work()
  409. },
  410. onprogress: function (progress) {
  411. try {
  412. statusbar.max = progress.total
  413. statusbar.value = progress.loaded
  414. } catch (e) { }
  415. },
  416. onerror: async function (response) {
  417. console.error('Error downloading image:', response)
  418. entry.filePath = ''
  419. entry.fileUrl = 'https://github.com/cvzi/pinterest-Backup-Original-Files/blob/master/error.svg'
  420. entry.note = 'Failed to download from: \'' + urls[0] + '\': ' + ('error' in response ? response.error : response.toString())
  421. await addMetadata('error', entry, htmlOut, markdownOut)
  422.  
  423. work()
  424. }
  425. })
  426. } else {
  427. // Create html and markdown overview
  428. htmlOut.unshift(`
  429. <style>
  430. th,td {
  431. word-wrap: break-word;
  432. max-width: 25em
  433. }
  434. tr:nth-child(2n+2){
  435. background-color:#f0f0f0
  436. }
  437. </style>
  438.  
  439. <h1>${escapeXml(boardName)}</h1>
  440. <h3>
  441. ${escapeXml(userName)}
  442. <br>
  443. <time datetime="${startTime.toISOString()}" title=""${startTime.toString()}">
  444. ${startTime.toLocaleDateString(undefined, { year: 'numeric', month: 'long', day: 'numeric' })}
  445. </time>:
  446. <a href="${escapeXml(document.location.href)}">${escapeXml(document.location.href)}</a>
  447. </h3>
  448.  
  449. <table border="1">
  450. <tr>
  451. <th>Title</th>
  452. <th>Image</th>
  453. <th>Pinterest</th>
  454. <th>Source</th>
  455. <th>Description</th>
  456. <th>Notes</th>
  457. </tr>
  458. `)
  459. htmlOut.push('</table>')
  460. zip.file('index.html', htmlOut.join('\n'))
  461. markdownOut.unshift(`
  462. # ${escapeMD(boardName)}
  463.  
  464. ### ${escapeXml(userName)}
  465.  
  466. ${startTime.toLocaleDateString(undefined, { year: 'numeric', month: 'long', day: 'numeric' })}: ${document.location.href}
  467.  
  468. | Title | Image | Pinterest | Source | Description | Notes |
  469. |---|---|---|---|---|---|`)
  470.  
  471. zip.file('README.md', markdownOut.join('\n'))
  472.  
  473. // Done. Open ZIP file
  474. let zipfilename
  475. try {
  476. const d = startTime || new Date()
  477. zipfilename = userNameEscaped + '_' + boardNameEscaped + '_' + d.getFullYear() + '-' + ((d.getMonth() + 1) > 9 ? '' : '0') + (d.getMonth() + 1) + '-' + (d.getDate() > 9 ? '' : '0') + d.getDate() +
  478. '_' + (d.getHours() > 9 ? '' : '0') + d.getHours() + '-' + (d.getMinutes() > 9 ? '' : '0') + d.getMinutes()
  479. } catch (e) {
  480. zipfilename = 'board'
  481. }
  482. zipfilename += '.zip'
  483. const content = await zip.generateAsync({ type: 'blob' }) // TODO catch errors
  484. zip = null
  485. const h = document.createElement('h1')
  486. h.appendChild(document.createTextNode('Click here to Download'))
  487. h.style = 'cursor:pointer; color:blue; background:white; text-decoration:underline'
  488. document.body.appendChild(h)
  489. const genZipProgress = document.getElementById('gen_zip_progress')
  490. if (genZipProgress) {
  491. genZipProgress.remove()
  492. }
  493. h.addEventListener('click', function () {
  494. saveAs(content, zipfilename)
  495. })
  496. saveAs(content, zipfilename)
  497. }
  498. })()
  499. }
  500.  
  501. function addMetadata (status, e, htmlOut, markdownOut) {
  502. return new Promise((resolve) => {
  503. writeMetadata(status, e, htmlOut, markdownOut)
  504. resolve()
  505. })
  506. }
  507.  
  508. function writeMetadata (status, entry, htmlOut, markdownOut) {
  509. // XML escape all values for html
  510. const entryEscaped = Object.fromEntries(Object.entries(entry).map(entry => {
  511. const escapedValue = escapeXml(entry[1])
  512. return [entry[0], escapedValue]
  513. }))
  514.  
  515. // Shorten source link title
  516. let sourceA = ''
  517. if (entry.sourceLink) {
  518. let sourceTitle = decodeURI(entry.sourceLink)
  519. if (sourceTitle.length > 160) {
  520. sourceTitle = sourceTitle.substring(0, 155) + '\u2026'
  521. }
  522. sourceA = `<a href="${entryEscaped.sourceLink}">${escapeXml(sourceTitle)}</a>`
  523. }
  524.  
  525. // HTML table entry
  526. htmlOut.push(` <tr>
  527. <th id="${entryEscaped.fileNameUrl}">
  528. <a href="#${entryEscaped.fileNameUrl}">${entryEscaped.title || entryEscaped.description || entryEscaped.fileName}</a
  529. </th>
  530. <td>
  531. <a href="${entryEscaped.fileUrl}">
  532. <img style="max-width:250px; max-height:250px" src="${entryEscaped.fileUrl}" alt="${entryEscaped.description || entryEscaped.filePath}">
  533. </a>
  534. </td>
  535. <td>
  536. <a href="${entryEscaped.link}">${entryEscaped.link}</a>
  537. </td>
  538. <td>
  539. ${sourceA}
  540. </td>
  541. <td>${entryEscaped.description}</td>
  542. <td>${entryEscaped.note}</td>
  543. </tr>
  544. `)
  545.  
  546. // Shorten source link title
  547. let sourceLink = entry.sourceLink || ''
  548. if (entry.sourceLink) {
  549. let sourceTitle = decodeURI(entry.sourceLink)
  550. if (sourceTitle.length > 160) {
  551. sourceTitle = sourceTitle.substring(0, 155) + '\u2026'
  552. }
  553. sourceLink = `[${escapeMD(sourceTitle)}](${entry.sourceLink})`
  554. }
  555.  
  556. // Markdown
  557. markdownOut.push(`| ${escapeMD(entry.title || entry.description || entry.fileName)}` +
  558. ` | ![${escapeMD(entry.description || entry.fileName)}](${entry.fileUrl})` +
  559. ` | ${entry.link || ''}` +
  560. ` | ${sourceLink}` +
  561. ` | ${escapeMD(entry.description || '')}` +
  562. ` | ${escapeMD(entry.note || '')}` + ' |')
  563. }
  564.  
  565. function parentQuery (node, q) {
  566. const parents = [node.parentElement]
  567. node = node.parentElement.parentElement
  568. while (node) {
  569. const lst = node.querySelectorAll(q)
  570. for (let i = 0; i < lst.length; i++) {
  571. if (parents.indexOf(lst[i]) !== -1) {
  572. return lst[i]
  573. }
  574. }
  575. parents.push(node)
  576. node = node.parentElement
  577. }
  578. return null
  579. }
  580.  
  581. function safeFileName (s) {
  582. const blacklist = /[<>:'"/\\|?*\u0000\n\r\t]/g // eslint-disable-line no-control-regex
  583. s = s.replace(blacklist, ' ').trim().replace(/^\.+/, '').replace(/\.+$/, '')
  584. return s.replace(/\s+/g, ' ').trim()
  585. }
  586.  
  587. function escapeXml (unsafe) {
  588. // https://stackoverflow.com/a/27979933/
  589. const s = (unsafe || '').toString()
  590. return s.replace(/[<>&'"\n\t]/gim, function (c) {
  591. switch (c) {
  592. case '<': return '&lt;'
  593. case '>': return '&gt;'
  594. case '&': return '&amp;'
  595. case '\'': return '&apos;'
  596. case '"': return '&quot;'
  597. case '\n': return '<br>'
  598. case '\t': return ' '
  599. }
  600. })
  601. }
  602.  
  603. function escapeMD (unsafe) {
  604. // Markdown escape
  605. const s = (unsafe || '').toString()
  606. return s.replace(/\W/gim, function (c) {
  607. switch (c) {
  608. case '<': return '&lt;'
  609. case '>': return '&gt;'
  610. case '&': return '&amp;'
  611. case '\'': return '\\\''
  612. case '"': return '\\"'
  613. case '*': return '\\*'
  614. case '[': return '\\['
  615. case ']': return '\\]'
  616. case '(': return '\\('
  617. case ')': return '\\)'
  618. case '{': return '\\{'
  619. case '}': return '\\}'
  620. case '`': return '\\`'
  621. case '!': return '\\!'
  622. case '|': return '\\|'
  623. case '#': return '\\#'
  624. case '+': return '\\+'
  625. case '-': return '\\-'
  626. case '\r': return ' '
  627. case '\n': return '<br>'
  628. default: return c
  629. }
  630. }).trim()
  631. }
  632.  
  633. function markdownEncodeURIComponent (s) {
  634. return encodeURIComponent(s).replace(/[[\](){}`!]/g, function (c) {
  635. switch (c) {
  636. case '[': return '%5B'
  637. case ']': return '%5D'
  638. case '(': return '%28'
  639. case ')': return '%29'
  640. case '{': return '%7B'
  641. case '}': return '%7D'
  642. case '`': return '%60'
  643. case '!': return '%21'
  644. }
  645. })
  646. }