Skip to content

Instantly share code, notes, and snippets.

@Chooks22
Last active July 11, 2024 17:05
Show Gist options
  • Save Chooks22/6f28904bebe7f7cdc0a2a2b4cf6a6df1 to your computer and use it in GitHub Desktop.
Save Chooks22/6f28904bebe7f7cdc0a2a2b4cf6a6df1 to your computer and use it in GitHub Desktop.
Bulk Downloader for Kemono.Party
// ==UserScript==
// @name Download Post
// @namespace http://kemono.su/
// @version 4.1.0
// @description Download kemono.su 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.su/*/user/*
// @icon https://www.google.com/s2/favicons?sz=64&domain=kemono.su
// @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
// @grant GM.setValue
// @grant GM.getValue
// @grant GM.registerMenuCommand
// @connect kemono.su
// @run-at document-idle
// ==/UserScript==
'use strict';
let name_style;
// #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;
}
async function finalizeZip(jobs, zip, filename) {
const job = jobs.newJob().progress('Generating zip...');
const file = await 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}`;
}
async function getPost(target = '') {
const res = await fetch(`/api/v1${location.pathname}${target}`);
return res.json();
}
function getAllPosts() {
return __asyncGenerator(this, arguments, function* getAllPosts_1() {
let posts;
let o = 0;
do {
const res = yield __await(fetch(`/api/v1${location.pathname}?o=${o}`));
posts = (yield __await(res.json()));
yield __await(yield* __asyncDelegator(__asyncValues(posts)));
o += posts.length;
} while (posts.length > 0);
});
}
async function download(attachment, prefix) {
// 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,
};
}
// #endregion
async function downloadPost(jobs, postId) {
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);
}
let filename;
switch (name_style) {
case 'id':
filename = `${post.id}.zip`;
break;
case 'title': filename = `${post.title}.zip`;
}
await finalizeZip(jobs, zip, filename);
return true;
}
catch (error) {
console.error(error);
return false;
}
}
async function downloadCurrentPost(jobs) {
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) {
var _a, e_1, _b, _c;
try {
for (var _d = true, _e = __asyncValues(getAllPosts()), _f; _f = await _e.next(), _a = _f.done, !_a; _d = true) {
_c = _f.value;
_d = false;
const post = _c;
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}!`);
}
}
}
catch (e_1_1) { e_1 = { error: e_1_1 }; }
finally {
try {
if (!_d && !_a && (_b = _e.return)) await _b.call(_e);
}
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);
};
}
async function register_config() {
var _a;
name_style !== null && name_style !== void 0 ? name_style : (name_style = (_a = await GM.getValue('post_style')) !== null && _a !== void 0 ? _a : 'id');
switch (name_style) {
default:
case 'id': {
const menu_id = await GM.registerMenuCommand('Use title as file name.', async () => {
void GM.setValue('post_style', name_style = 'title');
await GM.unregisterMenuCommand(menu_id);
void register_config();
});
break;
}
case 'title': {
const menu_id = await GM.registerMenuCommand('Use id as file name.', async () => {
void GM.setValue('post_style', name_style = 'id');
await GM.unregisterMenuCommand(menu_id);
void register_config();
});
}
}
}
void register_config().then(() => {
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');
const 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));
}
});
// ==UserScript==
// @name Download Post
// @namespace http://kemono.su/
// @version 4.1.0
// @description Download kemono.su 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.su/*/user/*
// @icon https://www.google.com/s2/favicons?sz=64&domain=kemono.su
// @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
// @grant GM.setValue
// @grant GM.getValue
// @grant GM.registerMenuCommand
// @connect kemono.su
// @run-at document-idle
// ==/UserScript==
'use strict'
type JSZip = import('jszip')
declare const saveAs: typeof import('file-saver').saveAs
declare const JSZip: JSZip
type NameStyle = 'id' | 'title'
let name_style!: NameStyle
// #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/v1${location.pathname}${target}`)
return res.json() as Promise<Post>
}
async function* getAllPosts() {
let posts: Post[]
let o = 0
do {
const res = await fetch(`/api/v1${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)
}
let filename: string
switch (name_style) {
case 'id': filename = `${post.id}.zip`
break
case 'title': filename = `${post.title}.zip`
}
await finalizeZip(jobs, zip, filename)
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)
}
}
async function register_config() {
name_style ??= await GM.getValue<NameStyle>('post_style') ?? 'id'
switch (name_style) {
default:
case 'id': {
const menu_id = await GM.registerMenuCommand('Use title as file name.', async () => {
void GM.setValue('post_style', name_style = 'title')
await GM.unregisterMenuCommand(menu_id)
void register_config()
})
break
}
case 'title': {
const menu_id = await GM.registerMenuCommand('Use id as file name.', async () => {
void GM.setValue('post_style', name_style = 'id')
await GM.unregisterMenuCommand(menu_id)
void register_config()
})
}
}
}
void register_config().then(() => {
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')
const opts = {
getContainer: card => card.querySelector('.post-card__link')!,
getId: card => basename(card.querySelector<HTMLAnchorElement>('a.fancy-link')!.href),
} satisfies Opts
const cards = document.querySelectorAll('article.post-card')
cards.forEach(createPostDownloader(postId => downloadPost(jobs, postId), opts))
}
})
@fernadogomezmartin
Copy link

The download doesn't work anymore :(

I haven't updated to the new url nor api version, I'll post an update soon

Thank you so much :D

@fernadogomezmartin
Copy link

When do you think it will work again?

@CaffuChin0
Copy link

hi, I wanna know if there's any function to make the filename to be titlename instead of postid while downloading?

@Chooks22
Copy link
Author

hi, I wanna know if there's any function to make the filename to be titlename instead of postid while downloading?

Posted an update, toggle between id and title from context menu (right click).

I changed it for the zip file's name only though, since the individual filenames come from kemono.

@CaffuChin0
Copy link

hi, I wanna know if there's any function to make the filename to be titlename instead of postid while downloading?

Posted an update, toggle between id and title from context menu (right click).

I changed it for the zip file's name only though, since the individual filenames come from kemono.

Thank you sooooo much for this updating! So fast!

@andrewshi910
Copy link

andrewshi910 commented Jun 20, 2024

Hello, I'm so sorry but i'm new to this. How can i get this to work? Do i add the .js file into temper monkey and go to the artist's kemono party page and activate the script?
when i clicked "use id as file name", it does not seem to provide a reaction

@Chooks22
Copy link
Author

How can i get this to work? Do i add the .js file into temper monkey and go to the artist's kemono party page and activate the script?

Click on the Raw button and tampermonkey should pick it up, if not then manually copy paste it in.

Each post should then have a Download button right beside the Favorite button

when i clicked "use id as file name", it does not seem to provide a reaction

Yes, it's just a toggle to change how the zip file is named mentioned above

@Paisa2000
Copy link

@Chooks22 Could you, as simple as you can, explain how all of this works so that even a code-illiterate like me can understand?

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment