Forked from Chooks22/kemono-party-downloader.user.js
Created
October 22, 2023 01:51
-
-
Save fernadogomezmartin/c51533a986fe0f7d488962ab6af2724e to your computer and use it in GitHub Desktop.
Bulk Downloader for Kemono.Party
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
// ==UserScript== | |
// @name Download Post | |
// @namespace http://kemono.party/ | |
// @version 3.2.0 | |
// @description Download kemono.party posts as zip files. | |
// @updateURL https://gist.githubusercontent.com/Choooks22/6f28904bebe7f7cdc0a2a2b4cf6a6df1/raw | |
// @downloadURL https://gist.githubusercontent.com/Choooks22/6f28904bebe7f7cdc0a2a2b4cf6a6df1/raw | |
// @author Chooks22 <chooksdev@gmail.com> (https://github.com/Choooks22) | |
// @match https://kemono.party/*/user/* | |
// @match https://beta.kemono.party/*/user/* | |
// @icon https://www.google.com/s2/favicons?sz=64&domain=kemono.party | |
// @require https://cdnjs.cloudflare.com/ajax/libs/jszip/3.8.0/jszip.min.js | |
// @require https://cdnjs.cloudflare.com/ajax/libs/FileSaver.js/2.0.5/FileSaver.min.js | |
// @require https://cdnjs.cloudflare.com/ajax/libs/tslib/2.4.0/tslib.min.js | |
// @grant GM.xmlHttpRequest | |
// @connect kemono.party | |
// @run-at document-idle | |
// ==/UserScript== | |
'use strict'; | |
// #region path | |
function normalize(path) { | |
return path.replace(/\/+/, '/'); | |
} | |
function dirname(path) { | |
const _path = normalize(path); | |
const sep = _path.lastIndexOf('/'); | |
return sep < 0 | |
? _path | |
: _path.slice(0, sep); | |
} | |
function basename(path) { | |
const _path = normalize(path); | |
const sep = _path.lastIndexOf('/'); | |
return sep < 0 | |
? _path | |
: _path.slice(sep + 1); | |
} | |
// #endregion | |
// #region utils | |
function whitespace() { | |
return document.createTextNode(' '); | |
} | |
function span(textContent) { | |
const el = document.createElement('span'); | |
el.textContent = textContent; | |
return el; | |
} | |
function styles(styleObj) { | |
let style = ''; | |
for (const prop in styleObj) { | |
if (!Object.hasOwn(styleObj, prop)) | |
continue; | |
const value = styleObj[prop]; | |
if (value) { | |
style += `${prop}:${value};`; | |
} | |
} | |
return style; | |
} | |
function finalizeZip(jobs, zip, filename) { | |
return __awaiter(this, void 0, void 0, function* () { | |
const job = jobs.newJob().progress('Generating zip...'); | |
const file = yield zip.generateAsync({ type: 'blob' }); | |
job.done('Zip generated!'); | |
saveAs(file, filename); | |
}); | |
} | |
function getItemsFromPost(post) { | |
return { | |
size: Number(Boolean(post.file.name)) + post.attachments.length, | |
*getAllItems() { | |
if (post.file.name !== undefined) { | |
yield [post.file, 'thumbnail']; | |
} | |
if (post.attachments.length > 0) { | |
yield* post.attachments.map((file, i) => [file, i.toString()]); | |
} | |
}, | |
}; | |
} | |
function* processQueue(jobs, queue, n) { | |
let i = 0; | |
for (const item of queue) { | |
const job = jobs.newJob().progress(`Downloading ${++i} of ${n}...`); | |
yield item; | |
job.done(`Finished downloading ${i} items.`); | |
} | |
} | |
// #endregion | |
// #region ui lib | |
class JobItem { | |
constructor(container) { | |
this.container = container; | |
this.item = document.createElement('li'); | |
this.item.style.minWidth = '18rem'; | |
this.item.style.padding = '0.25rem 0.5rem'; | |
this.item.style.borderRadius = '0.25rem'; | |
this.item.style.boxShadow = '0 4px 6px -1px rgba(0,0,0,.1),0 2px 4px -1px rgba(0,0,0,.06)'; | |
this.container.prepend(this.item); | |
} | |
update(bg, color, text) { | |
this.item.style.backgroundColor = bg; | |
this.item.style.color = color; | |
this.item.textContent = text; | |
} | |
remove(timeout) { | |
setTimeout(() => this.item.remove(), timeout); | |
} | |
progress(text) { | |
this.update('#FEF9C3', '#A16207', text); | |
return this; | |
} | |
error(text) { | |
this.update('#FEE2E2', '#B91C1C', text); | |
this.remove(3000); | |
} | |
done(text) { | |
this.update('#DCFCE7', '#15803D', text); | |
this.remove(3000); | |
} | |
} | |
class JobContainer { | |
constructor() { | |
this.jobs = []; | |
this.container = document.createElement('ul'); | |
this.container.style.display = 'flex'; | |
this.container.style.flexDirection = 'column'; | |
this.container.style.gap = '0.5rem'; | |
this.container.style.position = 'fixed'; | |
this.container.style.margin = '0.25rem 0.75rem'; | |
this.container.style.zIndex = '100'; | |
document.body.append(this.container); | |
} | |
newJob() { | |
const job = new JobItem(this.container); | |
this.jobs.push(job); | |
return job; | |
} | |
} | |
function createDownloadBtn(parent, className, onClick, labelText = 'Download') { | |
const btnDownload = document.createElement('button'); | |
btnDownload.classList.add(className); | |
btnDownload.addEventListener('click', onClick); | |
btnDownload.append(span('⬇'), whitespace(), span(labelText)); | |
parent.append(btnDownload); | |
} | |
function toTarget(postId) { | |
return postId && `/post/${postId}`; | |
} | |
function getPost(target = '') { | |
return __awaiter(this, void 0, void 0, function* () { | |
const res = yield fetch(`/api/${location.pathname}${target}`); | |
const data = yield res.json(); | |
return data[0]; | |
}); | |
} | |
function getAllPosts() { | |
return __asyncGenerator(this, arguments, function* getAllPosts_1() { | |
let posts; | |
let o = 0; | |
do { | |
const res = yield __await(fetch(`/api/${location.pathname}?o=${o}`)); | |
posts = (yield __await(res.json())); | |
yield __await(yield* __asyncDelegator(__asyncValues(posts))); | |
o += posts.length; | |
} while (posts.length > 0); | |
}); | |
} | |
function download(attachment, prefix) { | |
return __awaiter(this, void 0, void 0, function* () { | |
// use tampermonkey's xhr to get around cors | |
const file = yield GM.xmlHttpRequest({ | |
url: attachment.path, | |
responseType: 'arraybuffer', | |
}); | |
return { | |
name: `${prefix}-${attachment.name}`, | |
contents: file.response, | |
}; | |
}); | |
} | |
// #endregion | |
function downloadPost(jobs, postId) { | |
return __awaiter(this, void 0, void 0, function* () { | |
const post = yield getPost(toTarget(postId)); | |
const items = getItemsFromPost(post); | |
if (items.size === 0) { | |
jobs.newJob().error('No files to download!'); | |
return false; | |
} | |
try { | |
const zip = new JSZip(); | |
const tasks = processQueue(jobs, items.getAllItems(), items.size); | |
for (const task of tasks) { | |
const file = yield download(...task); | |
zip.file(file.name, file.contents); | |
} | |
yield finalizeZip(jobs, zip, `${post.id}.zip`); | |
return true; | |
} | |
catch (error) { | |
console.error(error); | |
return false; | |
} | |
}); | |
} | |
function downloadCurrentPost(jobs) { | |
return __awaiter(this, void 0, void 0, function* () { | |
const job = jobs.newJob().progress('Downloading current post...'); | |
const ok = yield downloadPost(jobs); | |
if (ok) { | |
job.done('Finished downloading current post!'); | |
} | |
else { | |
job.error('Could not download current post!'); | |
} | |
}); | |
} | |
function downloadAllPosts(jobs) { | |
var e_1, _a; | |
return __awaiter(this, void 0, void 0, function* () { | |
try { | |
for (var _b = __asyncValues(getAllPosts()), _c; _c = yield _b.next(), !_c.done;) { | |
const post = _c.value; | |
const job = jobs.newJob().progress(`Downloading post ${post.id}...`); | |
const ok = yield downloadPost(jobs, post.id); | |
if (ok) { | |
job.done(`Finished downloading post ${post.id}!`); | |
} | |
else { | |
job.error(`Could not download post ${post.id}!`); | |
} | |
} | |
} | |
catch (e_1_1) { e_1 = { error: e_1_1 }; } | |
finally { | |
try { | |
if (_c && !_c.done && (_a = _b.return)) yield _a.call(_b); | |
} | |
finally { if (e_1) throw e_1.error; } | |
} | |
}); | |
} | |
function createPostDownloader(onClick, options) { | |
return (card) => { | |
const postId = options.getId(card); | |
const container = options.getContainer(card); | |
const btn = document.createElement('a'); | |
btn.addEventListener('click', e => { | |
e.preventDefault(); | |
onClick(postId); | |
}); | |
btn.textContent = 'Download'; | |
btn.classList.add('fancy-link'); | |
// @ts-ignore we're using css vars | |
btn.style = styles({ | |
'cursor': 'pointer', | |
'color': 'var(--local-colour1-primary)', | |
'--local-colour1-primary': 'var(--anchour-internal-colour1-primary)', | |
'--local-colour1-secondary': 'var(--anchour-internal-colour1-secondary)', | |
'--local-colour2-primary': 'var(--anchour-internal-colour2-primary)', | |
'--local-colour2-secondary': 'var(--anchour-internal-colour2-secondary)', | |
}); | |
container.append(btn); | |
}; | |
} | |
(function () { | |
const jobs = new JobContainer(); | |
if (dirname(location.pathname).endsWith('post')) { | |
const parent = document.querySelector('.post__actions'); | |
createDownloadBtn(parent, 'post__fav', () => downloadCurrentPost(jobs)); | |
} | |
else { | |
const parent = document.querySelector('.user-header__actions'); | |
createDownloadBtn(parent, 'user-header__favourite', () => downloadAllPosts(jobs), 'Download All'); | |
let opts; | |
if (location.hostname.startsWith('beta')) { | |
opts = { | |
getContainer: card => card.querySelector('footer.post-card__footer'), | |
getId: card => basename(card.querySelector('a.image-link').href), | |
}; | |
} | |
else { | |
opts = { | |
getContainer: card => card.querySelector('.post-card__link'), | |
getId: card => basename(card.querySelector('a.fancy-link').href), | |
}; | |
} | |
const cards = document.querySelectorAll('article.post-card'); | |
cards.forEach(createPostDownloader(postId => downloadPost(jobs, postId), opts)); | |
} | |
}()); |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
// ==UserScript== | |
// @name Download Post | |
// @namespace http://kemono.party/ | |
// @version 3.2.0 | |
// @description Download kemono.party posts as zip files. | |
// @updateURL https://gist.githubusercontent.com/Choooks22/6f28904bebe7f7cdc0a2a2b4cf6a6df1/raw | |
// @downloadURL https://gist.githubusercontent.com/Choooks22/6f28904bebe7f7cdc0a2a2b4cf6a6df1/raw | |
// @author Chooks22 <chooksdev@gmail.com> (https://github.com/Choooks22) | |
// @match https://kemono.party/*/user/* | |
// @match https://beta.kemono.party/*/user/* | |
// @icon https://www.google.com/s2/favicons?sz=64&domain=kemono.party | |
// @require https://cdnjs.cloudflare.com/ajax/libs/jszip/3.8.0/jszip.min.js | |
// @require https://cdnjs.cloudflare.com/ajax/libs/FileSaver.js/2.0.5/FileSaver.min.js | |
// @require https://cdnjs.cloudflare.com/ajax/libs/tslib/2.4.0/tslib.min.js | |
// @grant GM.xmlHttpRequest | |
// @connect kemono.party | |
// @run-at document-idle | |
// ==/UserScript== | |
'use strict' | |
type JSZip = import('jszip') | |
declare const saveAs: typeof import('file-saver').saveAs | |
declare const JSZip: JSZip | |
// #region path | |
function normalize(path: string) { | |
return path.replace(/\/+/, '/') | |
} | |
function dirname(path: string) { | |
const _path = normalize(path) | |
const sep = _path.lastIndexOf('/') | |
return sep < 0 | |
? _path | |
: _path.slice(0, sep) | |
} | |
function basename(path: string) { | |
const _path = normalize(path) | |
const sep = _path.lastIndexOf('/') | |
return sep < 0 | |
? _path | |
: _path.slice(sep + 1) | |
} | |
// #endregion | |
// #region utils | |
function whitespace() { | |
return document.createTextNode(' ') | |
} | |
function span(textContent: string) { | |
const el = document.createElement('span') | |
el.textContent = textContent | |
return el | |
} | |
function styles(styleObj: Record<string, string>) { | |
let style = '' | |
for (const prop in styleObj) { | |
if (!Object.hasOwn(styleObj, prop)) continue | |
const value = styleObj[prop] | |
if (value) { | |
style += `${prop}:${value};` | |
} | |
} | |
return style | |
} | |
async function finalizeZip(jobs: JobContainer, zip: JSZip, filename: string) { | |
const job = jobs.newJob().progress('Generating zip...') | |
const file = await zip.generateAsync({ type: 'blob' }) | |
job.done('Zip generated!') | |
saveAs(file, filename) | |
} | |
function getItemsFromPost(post: Post) { | |
return { | |
size: Number(Boolean(post.file.name)) + post.attachments.length, | |
*getAllItems(): Generator<[attachment: Attachment, prefix: string], void, undefined> { | |
if (post.file.name !== undefined) { | |
yield [post.file, 'thumbnail'] | |
} | |
if (post.attachments.length > 0) { | |
yield* post.attachments.map((file, i): [Attachment, string] => [file, i.toString()]) | |
} | |
}, | |
} | |
} | |
function* processQueue<T>(jobs: JobContainer, queue: Iterable<T>, n: number) { | |
let i = 0 | |
for (const item of queue) { | |
const job = jobs.newJob().progress(`Downloading ${++i} of ${n}...`) | |
yield item | |
job.done(`Finished downloading ${i} items.`) | |
} | |
} | |
// #endregion | |
// #region ui lib | |
class JobItem { | |
public item = document.createElement('li') | |
public constructor(public container: Element) { | |
this.item.style.minWidth = '18rem' | |
this.item.style.padding = '0.25rem 0.5rem' | |
this.item.style.borderRadius = '0.25rem' | |
this.item.style.boxShadow = '0 4px 6px -1px rgba(0,0,0,.1),0 2px 4px -1px rgba(0,0,0,.06)' | |
this.container.prepend(this.item) | |
} | |
private update(bg: string, color: string, text: string) { | |
this.item.style.backgroundColor = bg | |
this.item.style.color = color | |
this.item.textContent = text | |
} | |
public remove(timeout: number) { | |
setTimeout(() => this.item.remove(), timeout) | |
} | |
public progress(text: string) { | |
this.update('#FEF9C3', '#A16207', text) | |
return this | |
} | |
public error(text: string) { | |
this.update('#FEE2E2', '#B91C1C', text) | |
this.remove(3000) | |
} | |
public done(text: string) { | |
this.update('#DCFCE7', '#15803D', text) | |
this.remove(3000) | |
} | |
} | |
class JobContainer { | |
public jobs: JobItem[] = [] | |
public container = document.createElement('ul') | |
public constructor() { | |
this.container.style.display = 'flex' | |
this.container.style.flexDirection = 'column' | |
this.container.style.gap = '0.5rem' | |
this.container.style.position = 'fixed' | |
this.container.style.margin = '0.25rem 0.75rem' | |
this.container.style.zIndex = '100' | |
document.body.append(this.container) | |
} | |
public newJob() { | |
const job = new JobItem(this.container) | |
this.jobs.push(job) | |
return job | |
} | |
} | |
function createDownloadBtn(parent: Element, className: string, onClick: () => void, labelText = 'Download') { | |
const btnDownload = document.createElement('button') | |
btnDownload.classList.add(className) | |
btnDownload.addEventListener('click', onClick) | |
btnDownload.append( | |
span('⬇'), | |
whitespace(), | |
span(labelText), | |
) | |
parent.append(btnDownload) | |
} | |
// #endregion | |
// #region kemono lib | |
interface Attachment { | |
name: string | |
path: string | |
} | |
interface Post { | |
id: string | |
title: string | |
attachments: Attachment[] | |
file: Attachment | |
} | |
interface FileAttachment { | |
name: string | |
contents: ArrayBuffer | |
} | |
function toTarget(postId: string | undefined) { | |
return postId && `/post/${postId}` as const | |
} | |
async function getPost(target: '' | `/post/${string}` = '') { | |
const res = await fetch(`/api/${location.pathname}${target}`) | |
const data = await res.json() as [Post] | |
return data[0] | |
} | |
async function* getAllPosts() { | |
let posts: Post[] | |
let o = 0 | |
do { | |
const res = await fetch(`/api/${location.pathname}?o=${o}`) | |
posts = await res.json() as Post[] | |
yield* posts | |
o += posts.length | |
} while (posts.length > 0) | |
} | |
async function download(attachment: Attachment, prefix: string): Promise<FileAttachment> { | |
// use tampermonkey's xhr to get around cors | |
const file = await GM.xmlHttpRequest({ | |
url: attachment.path, | |
responseType: 'arraybuffer', | |
}) | |
return { | |
name: `${prefix}-${attachment.name}`, | |
contents: file.response as ArrayBuffer, | |
} | |
} | |
// #endregion | |
async function downloadPost(jobs: JobContainer, postId?: string) { | |
const post = await getPost(toTarget(postId)) | |
const items = getItemsFromPost(post) | |
if (items.size === 0) { | |
jobs.newJob().error('No files to download!') | |
return false | |
} | |
try { | |
const zip = new JSZip() | |
const tasks = processQueue(jobs, items.getAllItems(), items.size) | |
for (const task of tasks) { | |
const file = await download(...task) | |
zip.file(file.name, file.contents) | |
} | |
await finalizeZip(jobs, zip, `${post.id}.zip`) | |
return true | |
} catch (error) { | |
console.error(error) | |
return false | |
} | |
} | |
async function downloadCurrentPost(jobs: JobContainer) { | |
const job = jobs.newJob().progress('Downloading current post...') | |
const ok = await downloadPost(jobs) | |
if (ok) { | |
job.done('Finished downloading current post!') | |
} else { | |
job.error('Could not download current post!') | |
} | |
} | |
async function downloadAllPosts(jobs: JobContainer) { | |
for await (const post of getAllPosts()) { | |
const job = jobs.newJob().progress(`Downloading post ${post.id}...`) | |
const ok = await downloadPost(jobs, post.id) | |
if (ok) { | |
job.done(`Finished downloading post ${post.id}!`) | |
} else { | |
job.error(`Could not download post ${post.id}!`) | |
} | |
} | |
} | |
interface Opts { | |
getId: (card: Element) => string | |
getContainer: (card: Element) => Element | |
} | |
function createPostDownloader(onClick: (postId: string) => void, options: Opts) { | |
return (card: Element) => { | |
const postId = options.getId(card) | |
const container = options.getContainer(card) | |
const btn = document.createElement('a') | |
btn.addEventListener('click', e => { | |
e.preventDefault() | |
onClick(postId) | |
}) | |
btn.textContent = 'Download' | |
btn.classList.add('fancy-link') | |
// @ts-ignore we're using css vars | |
btn.style = styles({ | |
'cursor': 'pointer', | |
'color': 'var(--local-colour1-primary)', | |
'--local-colour1-primary': 'var(--anchour-internal-colour1-primary)', | |
'--local-colour1-secondary': 'var(--anchour-internal-colour1-secondary)', | |
'--local-colour2-primary': 'var(--anchour-internal-colour2-primary)', | |
'--local-colour2-secondary': 'var(--anchour-internal-colour2-secondary)', | |
}) | |
container.append(btn) | |
} | |
} | |
(function() { | |
const jobs = new JobContainer() | |
if (dirname(location.pathname).endsWith('post')) { | |
const parent = document.querySelector('.post__actions')! | |
createDownloadBtn(parent, 'post__fav', () => downloadCurrentPost(jobs)) | |
} else { | |
const parent = document.querySelector('.user-header__actions')! | |
createDownloadBtn(parent, 'user-header__favourite', () => downloadAllPosts(jobs), 'Download All') | |
let opts: Opts | |
if (location.hostname.startsWith('beta')) { | |
opts = { | |
getContainer: card => card.querySelector('footer.post-card__footer')!, | |
getId: card => basename(card.querySelector<HTMLAnchorElement>('a.image-link')!.href), | |
} | |
} else { | |
opts = { | |
getContainer: card => card.querySelector('.post-card__link')!, | |
getId: card => basename(card.querySelector<HTMLAnchorElement>('a.fancy-link')!.href), | |
} | |
} | |
const cards = document.querySelectorAll('article.post-card') | |
cards.forEach(createPostDownloader(postId => downloadPost(jobs, postId), opts)) | |
} | |
}()) |
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment