SaveAsZip for Patreon

Download post images and save as a ZIP file.

  1. // ==UserScript==
  2. // @name SaveAsZip for Patreon
  3. // @name:ja SaveAsZip for Patreon
  4. // @name::zh-cn SaveAsZip for Patreon
  5. // @name::zh-tw SaveAsZip for Patreon
  6. // @description Download post images and save as a ZIP file.
  7. // @description:ja 投稿の画像をZIPファイルとして保存する。
  8. // @description:zh-cn 一键下载帖子内所有图片,并保存为ZIP文件。
  9. // @description:zh-tw 一鍵下載帖子内所有圖片,並保存為ZIP文件。
  10. // @version 1.17
  11. // @namespace none
  12. // @match https://*.patreon.com/*
  13. // @require https://cdnjs.cloudflare.com/ajax/libs/jszip/3.1.5/jszip.min.js
  14. // @grant none
  15. // @license MIT
  16. // ==/UserScript==
  17. /* jshint esversion: 8 */
  18.  
  19. let preset_zip_name = '{user_name}_{user_id}_{post_id}_{created}_{post_title}_images.zip';
  20.  
  21. const JSZip = window.JSZip;
  22. const is_post_page = location.pathname.indexOf('/posts/') == 0;
  23. const is_user_page = location.pathname == '/user' || location.pathname.split('/').pop() == 'posts' || !is_post_page && document.querySelector('main#renderPageContentWrapper');
  24. let observer;
  25.  
  26. addStyle();
  27. addButton();
  28.  
  29. function addButton() {
  30. if (is_post_page) findPostsIn(document);
  31. else if (is_user_page) {
  32. observer = new MutationObserver(() => findPostsList());
  33. observer.observe(document.body, {childList: true, subtree: true});
  34. }
  35. }
  36.  
  37. function findPostsList() {
  38. let posts_list = document.querySelector('div[data-tag="all-posts-layout"], div[data-tag="post-stream-container"]');
  39. if (posts_list) {
  40. observer.disconnect();
  41. //monitor tab change in user page
  42. observer.observe(posts_list.parentNode.parentNode, {childList: true, subtree: false});
  43. findPostsIn(posts_list);
  44. if (is_user_page) {
  45. //on load more posts
  46. let observer_list = new MutationObserver(ms => ms.forEach(m => {
  47. if (m.addedNodes.length) findPostsIn(m.addedNodes[0]);
  48. }));
  49. observer_list.observe(posts_list, {childList: true});
  50. //on change post list
  51. let is_stream = posts_list.dataset.tag == 'post-stream-container';
  52. new MutationObserver(ms => ms.forEach(m => {
  53. if (m.addedNodes.length && (is_stream ? m.addedNodes[0].tagName == 'UL' : m.addedNodes[0].dataset.tag == 'all-posts-layout')) {
  54. findPostsIn(m.addedNodes[0]);
  55. observer_list.disconnect();
  56. observer_list.observe(m.addedNodes[0], {childList: true});
  57. }
  58. })).observe(is_stream ? posts_list.querySelector(':scope>div:last-child') : posts_list.parentNode, {childList: true});
  59. }
  60. }
  61. }
  62.  
  63. function findPostsIn(doc) {
  64. let posts = doc.querySelectorAll('div[data-tag="post-card"], div[data-tag="post"]');
  65. posts.forEach(post => {
  66. let has_images = post.querySelector('div[data-tag="chip-container"]');
  67. let is_visible = !post.querySelector('a[href^="/checkout/"]');
  68. if (has_images && is_visible) addButtonTo(post);
  69. });
  70. }
  71.  
  72. function addButtonTo(post) {
  73. let btn = document.createElement('div');
  74. btn.classList.add('saveaszip');
  75. btn.innerHTML = '<label><span class="btn-icon">📥</span><span class="btn-text">ZIP</span></label>';
  76. btn.onclick = () => SaveAsZip(btn, post);
  77. let btn_group = post.querySelector('div[data-tag="chip-container"]').parentNode;
  78. btn_group.appendChild(btn);
  79. //prevent removal of zip button
  80. //in some case, zip button will remove on comments loaded, reason are unknown for now.
  81. if (is_post_page) {
  82. new MutationObserver(ms => ms.forEach(m => {
  83. if (m.removedNodes.length && m.removedNodes[0] == btn) btn_group.appendChild(btn);
  84. })).observe(post, {childList: true, subtree: true});
  85. }
  86. }
  87.  
  88. async function SaveAsZip(btn, post) {
  89. if (btn.classList.contains('down')) return;
  90. else btn.classList.add('down');
  91. let btn_text = btn.querySelector('.btn-text');
  92. const status = text => (btn_text.innerText = text);
  93. //get post json
  94. let post_info = window.patreon && window.patreon.bootstrap.post; //post page
  95. if (!post_info) {
  96. let post_href = post.querySelector('a[href^="/posts/"]').href;
  97. let post_page = await (await fetch(post_href)).text();
  98. let post_data = post_page.match(/{"props".*?(?=<\/script>)/);
  99. if (post_data) post_info = JSON.parse(post_data).props.pageProps.bootstrapEnvelope.bootstrap.post;
  100. else return console.error('get post_info failed');
  101. }
  102. //extract post info
  103. let invalid_chars = {'\\': '\', '/': '/', '|': '|', '<': '<', '>': '>', ':': ':', '*': '*', '?': '?', '"': '"'};
  104. let info = {};
  105. info.post_id = post_info.data.id;
  106. info.post_title = post_info.data.attributes.title.replace(/[\/|<>:*?"\u200d]/g, v => invalid_chars[v] || '');
  107. info.user_id = post_info.included.find(i => i.type == 'user').id;
  108. info.user_name = post_info.included.find(i => i.type == 'campaign').attributes.name.replace(/[\/|<>:*?"\u200d]/g, v => invalid_chars[v] || '');
  109. let created_format = preset_zip_name.match(/{created:[^{}]+}/) ? preset_zip_name.match(/{created:([^{}]+)}/)[1] : 'YYYY-MM-DD';
  110. info.created = formatDate(post_info.data.attributes.created_at, created_format);
  111. //create zip and set filename
  112. let zip = new JSZip();
  113. let zip_name = preset_zip_name.replace(/{([^{}:]+)(:[^{}]+)?}/g, (match, name) => info[name]);
  114. //zip.file('post_content.txt', post.data.attributes.content);
  115. //find images
  116. let images = post_info.included.filter(i => i.type == 'media');
  117. let image_order = post_info.data.attributes.post_metadata.image_order;
  118. for (let i = 0; i < images.length; i++) {
  119. status(`${i + 1} / ${images.length}`);
  120. //download image and add to zip
  121. let image = images[i];
  122. let order = ('000' + (image_order ? image_order.indexOf(image.id) + 1 : i + 1)).slice(-3);
  123. let image_blob = await (await fetch(image.attributes.download_url)).blob();
  124. zip.file(`${order}_${image.id}_${image.attributes.file_name}`, image_blob);
  125. }
  126. //save
  127. status('Save');
  128. let zip_blob = await zip.generateAsync({type: 'blob'});
  129. let zip_url = URL.createObjectURL(zip_blob);
  130. //GM_download has some bug in tampermonkey, browser will freeze few second each download
  131. //GM_download({url: zip_url, name: zip_name, onload: () => URL.revokeObjectURL(zip_url)});
  132. let link = document.createElement('a');
  133. link.href = zip_url;
  134. link.download = zip_name;
  135. link.dispatchEvent(new MouseEvent('click'));
  136. setTimeout(() => URL.revokeObjectURL(zip_url), 100);
  137. //done
  138. btn.classList.remove('down');
  139. btn.classList.add('done');
  140. status('Done');
  141. }
  142.  
  143. function formatDate(i, o) {
  144. let d = new Date(i);
  145. let v = {
  146. YYYY: d.getUTCFullYear().toString(),
  147. MM: d.getUTCMonth() + 1,
  148. DD: d.getUTCDate(),
  149. hh: d.getUTCHours(),
  150. mm: d.getUTCMinutes()
  151. };
  152. return o.replace(/(YYYY|MM|DD|hh|mm)/g, n => ('0' + v[n]).substr(-n.length));
  153. }
  154.  
  155. function addStyle() {
  156. let css = `
  157. .saveaszip {display: inline-flex; gap: 2px; margin-left: 8px; vertical-align: top;}
  158. .saveaszip label {display: inline-flex; gap: 6px; align-items: center;}
  159. .saveaszip label {background: #0008; border: 1px solid #0000; border-radius: 4px; height: 26px; padding: 0px 6px;}
  160. .saveaszip label span {color: white; font-size: 14px; line-height: 1.3;}
  161. .saveaszip label span.btn-icon {color: #0000; text-shadow: white 0 0;}
  162. .saveaszip:hover label {background: #000a; border-color: #fff3;}
  163. .saveaszip.done label:nth-child(1) {background: #060a; border-color: #fff3;}
  164. .saveaszip.down label:nth-child(1) {background: #000a; border-color: #fff3;}
  165. /* progress bar animation */
  166. .saveaszip.down label:nth-child(1) {background-image: linear-gradient(-45deg, #fff2 0%, #fff2 25%, #0000 25%, #0000 50%, #fff2 50%, #fff2 75%, #0000 75%, #0000 100%); background-size: 32px 32px; animation: progress 2s linear infinite;}
  167. @keyframes progress {0% {background-position:0 0} 100% {background-position:32px 32px}}
  168. `;
  169. document.head.insertAdjacentHTML('beforeend', `<style>${css}</style>`);
  170. }