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))
}
})
@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