Downloads selected booru posts through the BooruKit userscript suite.
Tento skript by nemal byť nainštalovaný priamo. Je to knižnica pre ďalšie skripty, ktorú by mali používať cez meta príkaz // @require https://update.sleazyfork.org/scripts/589429/1890615/BooruKit%20Grabber.js
// ==UserScript==
// @name BooruKit Grabber
// @namespace codeberg.org/0xdev/boorukit
// @version 0.1.0
// @description Downloads selected booru posts through the BooruKit userscript suite.
// @author 0xdev
// @license LGPL-3.0-or-later
// ==/UserScript==
// THIS FILE IS AUTO-GENERATED — DO NOT MANUALLY EDIT.
// Source: boorukit/boorukit-grabber.library.js
(() => {
'use strict';
function siteShortFromHostname(hostname) {
const normalized = hostname.toLowerCase().replace(/^www\./, '');
return normalized.split('.')[0] || normalized;
}
function dapiBase(origin, flavor) {
const json = flavor === 'json' ? '&json=1' : '';
return `${origin}/index.php?page=dapi&s=post&q=index${json}`;
}
function buildDapiUrlById(origin, flavor, id) {
return `${dapiBase(origin, flavor)}&id=${encodeURIComponent(id)}`;
}
function buildDapiUrlByMd5(origin, flavor, md5) {
return `${dapiBase(origin, flavor)}&tags=md5:${encodeURIComponent(md5)}`;
}
function toAbsoluteUrl(url) {
return url.startsWith('//') ? `https:${url}` : url;
}
function extFromFileUrl(fileUrl) {
const clean = fileUrl.split(/[?#]/)[0];
const match = /\.([a-z0-9]+)$/i.exec(clean);
return match ? match[1].toLowerCase() : 'bin';
}
function numOrNull(value) {
if (value == null || value === '')
return null;
const number = Number(value);
return Number.isFinite(number) ? number : null;
}
function recordFromRaw(site, raw) {
const id = raw.id == null ? '' : String(raw.id);
const rawUrl = typeof raw.file_url === 'string' ? raw.file_url : '';
if (id === '' || rawUrl === '')
return null;
const fileUrl = toAbsoluteUrl(rawUrl);
const md5 = typeof raw.md5 === 'string' && raw.md5 !== ''
? raw.md5
: typeof raw.hash === 'string' ? raw.hash : '';
const tags = typeof raw.tags === 'string'
? raw.tags.trim().split(/\s+/).filter(Boolean)
: [];
return {
site,
id,
md5,
fileUrl,
ext: extFromFileUrl(fileUrl),
tags,
rating: typeof raw.rating === 'string' ? raw.rating : '',
score: numOrNull(raw.score),
source: typeof raw.source === 'string' ? raw.source : '',
width: numOrNull(raw.width),
height: numOrNull(raw.height),
};
}
function postsFromDapiJson(site, jsonText) {
let parsed;
try {
parsed = JSON.parse(jsonText);
}
catch {
return [];
}
const list = Array.isArray(parsed)
? parsed
: parsed && typeof parsed === 'object'
? parsed.post
: null;
if (!Array.isArray(list))
return [];
const records = [];
for (const item of list) {
if (!item || typeof item !== 'object')
continue;
const record = recordFromRaw(site, item);
if (record)
records.push(record);
}
return records;
}
function postsFromDapiXml(site, xmlText, parse) {
let doc;
try {
doc = parse(xmlText);
}
catch {
return [];
}
const records = [];
for (const element of Array.from(doc.querySelectorAll('post'))) {
const raw = {};
for (const name of [
'id',
'md5',
'file_url',
'tags',
'rating',
'score',
'source',
'width',
'height',
]) {
const value = element.getAttribute(name);
if (value !== null)
raw[name] = value;
}
const record = recordFromRaw(site, raw);
if (record)
records.push(record);
}
return records;
}
function postsFromE621Json(site, jsonText) {
let parsed;
try {
parsed = JSON.parse(jsonText);
}
catch {
return [];
}
const list = parsed && typeof parsed === 'object'
? parsed.posts
: null;
if (!Array.isArray(list))
return [];
const records = [];
for (const item of list) {
if (!item || typeof item !== 'object')
continue;
const post = item;
const file = (post.file ?? {});
const fileUrl = typeof file.url === 'string' ? file.url : '';
const id = post.id == null ? '' : String(post.id);
if (id === '' || fileUrl === '')
continue;
const tagGroups = (post.tags ?? {});
const tags = [];
for (const group of Object.values(tagGroups)) {
if (!Array.isArray(group))
continue;
for (const tag of group) {
if (typeof tag === 'string')
tags.push(tag);
}
}
const score = (post.score ?? {});
const preview = (post.preview ?? {});
const sources = Array.isArray(post.sources)
? post.sources
: [];
const rawExtension = typeof file.ext === 'string'
? file.ext.toLowerCase()
: '';
records.push({
site,
id,
md5: typeof file.md5 === 'string' ? file.md5 : '',
fileUrl,
ext: /^[a-z0-9]+$/.test(rawExtension)
? rawExtension
: extFromFileUrl(fileUrl),
tags,
rating: typeof post.rating === 'string' ? post.rating : '',
score: typeof score.total === 'number' ? score.total : null,
source: typeof sources[0] === 'string' ? sources[0] : '',
width: typeof file.width === 'number' ? file.width : null,
height: typeof file.height === 'number' ? file.height : null,
thumbUrl: typeof preview.url === 'string' ? preview.url : '',
});
}
return records;
}
function sidecarBaseFor(record) {
const safeId = record.id.replace(/[^A-Za-z0-9_-]/g, '') || 'unknown';
return `${siteShortFromHostname(record.site)}_${safeId}`;
}
function fileNameFor(record) {
return `${sidecarBaseFor(record)}.${record.ext}`;
}
function sidecarJsonFor(record) {
return JSON.stringify(record, null, 2);
}
function tagsTxtFor(record) {
return record.tags.join(', ');
}
function makeQueueItem(id, md5) {
return Object.freeze({ id, md5 });
}
async function runGrabberQueue(queue, tasks, items, effects, onProgress) {
return tasks.run('grabber.download', (signal, report) => queue.run(items, async (item) => {
const record = await effects.resolve(item);
await effects.download(record);
return record;
}, {
attempts: 2,
throttleMs: 1000,
retryDelayMs: 2000,
signal,
sleep: effects.sleep,
onProgress: (item, state, completed, total, error) => {
report(completed, total);
onProgress(item, state, completed, total, error);
},
}));
}
function resolveGrabberSiteConfig(hostname) {
const normalized = hostname.toLowerCase().replace(/^www\./, '');
if (normalized === 'e621.net' || normalized === 'e926.net') {
return Object.freeze({ dapiFlavor: 'e621' });
}
if (normalized === 'rule34.xxx'
|| normalized === 'safebooru.org'
|| normalized === 'gelbooru.com'
|| normalized === 'hypnohub.net') {
return Object.freeze({ dapiFlavor: 'json' });
}
if (normalized.endsWith('.booru.org')) {
return Object.freeze({ dapiFlavor: 'xml' });
}
return null;
}
function buildResolveUrl(origin, config, id) {
if (config.dapiFlavor === 'e621') {
return `${origin}/posts.json?tags=id:${encodeURIComponent(id)}&limit=1`;
}
return buildDapiUrlById(origin, config.dapiFlavor, id);
}
function legacyGrabberSettings(raw) {
if (raw === null)
return { writeTagsTxt: false };
try {
const parsed = JSON.parse(raw);
if (!parsed || typeof parsed !== 'object' || Array.isArray(parsed)) {
return { writeTagsTxt: false };
}
const record = parsed;
if (Object.keys(record).some((key) => key !== 'writeTagsTxt')
|| typeof record.writeTagsTxt !== 'boolean') {
return { writeTagsTxt: false };
}
return { writeTagsTxt: record.writeTagsTxt };
}
catch {
return { writeTagsTxt: false };
}
}
async function migrateLegacyGrabberSettings(context) {
const snapshot = context.settings.snapshot();
if (snapshot.migrations.bbdSettings) {
return snapshot.grabber ?? { writeTagsTxt: false };
}
if (snapshot.grabber !== null) {
const saved = await context.settings.update((current) => ({
...current,
migrations: { ...current.migrations, bbdSettings: true },
}));
return saved.grabber ?? { writeTagsTxt: false };
}
const raw = typeof localStorage === 'undefined'
? null
: localStorage.getItem('bbd-settings');
const imported = legacyGrabberSettings(raw);
const saved = await context.settings.update((current) => {
if (current.grabber !== null || current.migrations.bbdSettings) {
return current;
}
return {
...current,
grabber: imported,
migrations: { ...current.migrations, bbdSettings: true },
};
});
return saved.grabber ?? { writeTagsTxt: false };
}
function gmDownloadAsync(details) {
return new Promise((resolve, reject) => {
GM_download({
...details,
onload: () => resolve(),
onerror: (error) => reject(new Error(error?.error ? `download ${error.error}` : 'download error')),
ontimeout: () => reject(new Error('download timeout')),
});
});
}
async function downloadSidecar(doc, name, text, mime) {
const blob = new Blob([text], { type: mime });
const url = URL.createObjectURL(blob);
try {
await gmDownloadAsync({ url, name });
}
catch {
const anchor = doc.createElement('a');
anchor.href = url;
anchor.download = name;
doc.body.appendChild(anchor);
anchor.click();
anchor.remove();
}
finally {
setTimeout(() => URL.revokeObjectURL(url), 10_000);
}
}
function resolveHttpError(status) {
if (status === 401 || status === 403) {
return new Error('authentication required to resolve posts');
}
return new Error(`resolve HTTP ${status}`);
}
function normalizedMd5(value) {
const normalized = value.trim().toLowerCase();
return /^[0-9a-f]{32}$/.test(normalized) ? normalized : null;
}
async function resolvePost(origin, site, config, item) {
const parse = (text) => new DOMParser().parseFromString(text, 'text/xml');
const fromBody = (body) => {
if (config.dapiFlavor === 'e621') {
return postsFromE621Json(site, body);
}
if (config.dapiFlavor === 'json') {
return postsFromDapiJson(site, body);
}
return postsFromDapiXml(site, body, parse);
};
let response = await fetch(buildResolveUrl(origin, config, item.id), {
credentials: 'same-origin',
});
if (!response.ok)
throw resolveHttpError(response.status);
const idPosts = fromBody(await response.text());
const idMatch = idPosts.find((candidate) => candidate.id === item.id);
if (idMatch)
return idMatch;
const expectedMd5 = normalizedMd5(item.md5);
if (expectedMd5 !== null) {
const md5Url = config.dapiFlavor === 'e621'
? `${origin}/posts.json?tags=md5:${encodeURIComponent(item.md5)}&limit=1`
: buildDapiUrlByMd5(origin, config.dapiFlavor, item.md5);
response = await fetch(md5Url, { credentials: 'same-origin' });
if (!response.ok)
throw resolveHttpError(response.status);
const md5Posts = fromBody(await response.text());
const md5Match = md5Posts.find((candidate) => normalizedMd5(candidate.md5) === expectedMd5);
if (md5Match)
return md5Match;
}
throw new Error('post not found');
}
async function downloadPost(doc, settings, record) {
await gmDownloadAsync({ url: record.fileUrl, name: fileNameFor(record) });
await downloadSidecar(doc, `${sidecarBaseFor(record)}.json`, sidecarJsonFor(record), 'application/json');
if (settings.writeTagsTxt) {
await downloadSidecar(doc, `${sidecarBaseFor(record)}.txt`, tagsTxtFor(record), 'text/plain');
}
}
async function runGrabber(context, settings, selection) {
if (selection.length === 0)
return;
const config = resolveGrabberSiteConfig(context.page.hostname);
if (config === null)
return;
const items = selection.map((selected) => makeQueueItem(selected.id, selected.md5));
await runGrabberQueue(context.queue, context.tasks, items, {
resolve: (item) => resolvePost(context.page.origin, context.page.normalizedHostname, config, item),
download: (record) => downloadPost(document, settings, record),
sleep: (milliseconds) => new Promise((resolve) => {
setTimeout(resolve, milliseconds);
}),
}, (item, state, _completed, _total, error) => {
if (state === 'done') {
context.deck.setItemState(item.id, 'done');
}
else if (state === 'failed') {
const reason = error?.message ?? 'unknown error';
context.deck.setItemState(item.id, 'failed', reason);
context.diagnostics.report('grabber', error ?? new Error(reason));
}
else {
context.deck.setItemState(item.id, 'running');
}
});
}
function createGrabberDescriptor() {
return {
kind: 'feature',
id: 'grabber',
label: 'Grabber',
apiVersion: 1,
defaultEnabled: true,
supports: (page) => resolveGrabberSiteConfig(page.hostname) !== null,
activate: async (context) => {
const settings = await migrateLegacyGrabberSettings(context);
context.deck.registerAction({
id: 'grabber.download',
label: 'Download',
run: (selection) => runGrabber(context, settings, selection),
});
context.deck.registerSetting({
id: 'grabber.write-tags',
label: 'Also write tags .txt',
checked: settings.writeTagsTxt,
reloadRequired: true,
onChange: async (checked) => {
await context.settings.update((current) => ({
...current,
grabber: { writeTagsTxt: checked },
}));
},
});
},
};
}
function registerGlobalGrabber() {
const key = Symbol.for('codeberg.org/0xdev/boorukit/runtime/v1');
const runtimes = globalThis;
const runtime = runtimes[key];
if (!runtime || runtime.apiVersion !== 1) {
throw new Error('BooruKit Core API 1 is not available');
}
runtime.register(createGrabberDescriptor());
}
if (typeof window !== 'undefined')
registerGlobalGrabber();
if (typeof window === 'undefined'
&& typeof module !== 'undefined'
&& module.exports) {
module.exports = {
buildDapiUrlById,
buildDapiUrlByMd5,
buildResolveUrl,
createGrabberDescriptor,
downloadPost,
extFromFileUrl,
fileNameFor,
makeQueueItem,
migrateLegacyGrabberSettings,
postsFromDapiJson,
postsFromDapiXml,
postsFromE621Json,
resolveGrabberSiteConfig,
resolvePost,
runGrabber,
runGrabberQueue,
sidecarBaseFor,
sidecarJsonFor,
siteShortFromHostname,
tagsTxtFor,
toAbsoluteUrl,
};
}
})();