// ==UserScript==
// @name SaveAsZip for Patreon
// @name:ja SaveAsZip for Patreon
// @name::zh-cn SaveAsZip for Patreon
// @name::zh-tw SaveAsZip for Patreon
// @description Download post images and save as a ZIP file.
// @description:ja 投稿の画像をZIPファイルとして保存する。
// @description:zh-cn 一键下载帖子内所有图片,并保存为ZIP文件。
// @description:zh-tw 一鍵下載帖子内所有圖片,並保存為ZIP文件。
// @version 1.01
// @namespace none
// @match https://*.patreon.com/*
// @require https://cdnjs.cloudflare.com/ajax/libs/jszip/3.1.5/jszip.min.js
// @grant none
// @license MIT
// ==/UserScript==
/* jshint esversion: 8 */
let observer;
let is_post_page = location.pathname.indexOf('/posts/') == 0;
let is_post_list = location.pathname.indexOf('/posts') > 0;
let is_user_home = document.body.id == 'creator_membership';
addStyle();
addSaveButton();
function addSaveButton() {
if (is_post_page) findCardsIn(document);
else if (is_post_list || is_user_home) {
observer = new MutationObserver(() => findContainer());
observer.observe(document.body, {childList: true, subtree: true});
}
}
function findContainer() {
let container = document.querySelector('div[data-tag="post-stream-container"] ul, div[data-tag="creator-public-page-recent-posts"]');
if (container) {
observer.disconnect();
findCardsIn(container);
if (is_post_list) {
//on load more posts
let observer_list = new MutationObserver(ms => ms.forEach(m => {
if (m.addedNodes.length) findCardsIn(m.addedNodes[0]);
}));
observer_list.observe(container, {childList: true});
//on change post list
new MutationObserver(ms => ms.forEach(m => {
if (m.addedNodes.length && m.addedNodes[0].tagName == 'UL') {
findCardsIn(m.addedNodes[0]);
observer_list.disconnect();
observer_list.observe(m.addedNodes[0], {childList: true});
}
})).observe(container.parentNode, {childList: true});
}
}
}
function findCardsIn(container) {
let post_cards = container.querySelectorAll('div[data-tag="post-card"], div[data-tag="post"]');
post_cards.forEach(post_card => {
let is_visible = !post_card.querySelector('a[href^="/join/"]');
let has_images = !post_card.querySelector('div[overflow="visible"]>span:first-child') && !post_card.querySelector(':scope>div>a>div:first-child');
if (is_visible && has_images) addSaveButtonTo(post_card);
});
}
function addSaveButtonTo(post_card) {
let btn_save = document.createElement('div');
btn_save.classList.add('saveaszip');
btn_save.innerHTML = '<label><span class="save-icon">📥</span><span class="save-text">ZIP</span></label>';
btn_save.onclick = () => savePost(btn_save, post_card);
post_card.insertBefore(btn_save, post_card.firstChild);
}
async function savePost(btn_save, post_card) {
if (btn_save.classList.contains('busy')) return;
else btn_save.classList.add('busy');
let btn_text = btn_save.querySelector('.save-text');
const status = text => (btn_text.innerText = text);
//get post json
let post = window.patreon.bootstrap.post; //post page
if (!post) {
let post_href = post_card.querySelector('a[href^="/posts/"]').href;
let post_page = await (await fetch(post_href)).text();
post_page = post_page.split('Object.assign(window.patreon.bootstrap, ')[1].split(');\n Object.assign')[0];
post = JSON.parse(post_page).post;
}
//extract post info
let invalid_chars = {'\\': '\', '/': '/', '|': '|', '<': '<', '>': '>', ':': ':', '*': '*', '?': '?', '"': '"'};
let post_id = post.data.id;
let post_title = post.data.attributes.title.replace(/[\/|<>:*?"\u200d]/g, v => invalid_chars[v] || '');
let user_id = post.included.find(i => i.type == 'user').id;
let user_name = post.included.find(i => i.type == 'campaign').attributes.name.replace(/[\/|<>:*?"\u200d]/g, v => invalid_chars[v] || '');
let user_name_full = post.included.find(i => i.type == 'user').attributes.full_name.replace(/[\/|<>:*?"\u200d]/g, v => invalid_chars[v] || '');
let created = formatDate(post.data.attributes.created_at, 'YYYY-MM-DD');
//create zip and set filename
let save_zip = new JSZip();
let save_zip_name = `patreon_${user_name}_${user_id}_${post_id}_${post_title}_images.zip`;
//find medias
let medias = post.included.filter(i => i.type == 'media');
let image_order = post.data.attributes.post_metadata.image_order;
for (let i=0; i<medias.length; i++) {
status(`${i+1} / ${medias.length}`);
//download image and add to zip
let order = ('000' + (image_order ? image_order.indexOf(medias[i].id) + 1 : i + 1)).slice(-3);
let blob = await (await fetch(medias[i].attributes.download_url)).blob();
save_zip.file(`${order}_${medias[i].id}_${medias[i].attributes.file_name}`, blob);
}
//save zip
status('Save');
let blob = await save_zip.generateAsync({type: 'blob'});
let blob_url = URL.createObjectURL(blob);
//GM_download has some bug in tampermonkey, browser will freeze few second each download
//GM_download({url: blob_url, name: save2zip_name, onload: () => URL.revokeObjectURL(blob_url)});
let link = document.createElement('a');
link.href = blob_url;
link.download = save_zip_name;
link.dispatchEvent(new MouseEvent('click'));
setTimeout(() => URL.revokeObjectURL(blob_url), 5000);
//done
btn_save.classList.remove('busy');
btn_save.classList.add('done');
status('Done');
}
function formatDate(i, o) {
let d = new Date(i);
let v = {
YYYY: d.getUTCFullYear().toString(),
MM: d.getUTCMonth() + 1,
DD: d.getUTCDate(),
hh: d.getUTCHours(),
mm: d.getUTCMinutes()
};
return o.replace(/(YYYY|MM|DD|hh|mm)/g, n => ('0' + v[n]).substr(-n.length));
}
function addStyle() {
let css = `
.saveaszip {position: absolute; z-index: 1; padding: 16px;}
[data-tag="post"] .saveaszip {left: 1px; top: 1px;}
.saveaszip > label {display: flex; gap: 8px; background: #0008; border: 1px solid #0000; border-radius: 99px; padding: 3px 11px;}
.saveaszip > label > span {color: white; font-size: 75%; line-height: 1.4;}
.saveaszip .save-icon {color: transparent; text-shadow: white 0 0;}
.saveaszip:hover > label {background: #000a; border-color: #fff3;}
.saveaszip.done > label {background: #060a; border-color: #fff3;}
.saveaszip.busy > label {background: #000a; border-color: #fff3;}
/* progress bar animation */
.saveaszip.busy > label {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;}
@keyframes progress {0% {background-position:0 0} 100% {background-position:32px 32px}}
`;
document.head.insertAdjacentHTML('beforeend', `<style>${css}</style>`);
}