-
-
Save Chooks22/6f28904bebe7f7cdc0a2a2b4cf6a6df1 to your computer and use it in GitHub Desktop.
// ==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)) | |
} | |
}) |
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
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
@Chooks22 Could you, as simple as you can, explain how all of this works so that even a code-illiterate like me can understand?
Thank you sooooo much for this updating! So fast!