Downloads selected booru posts through the BooruKit userscript suite.
Tento skript by neměl být instalován přímo. Jedná se o knihovnu, kterou by měly jiné skripty využívat pomocí meta příkazu // @require https://update.sleazyfork.org/scripts/589429/1973234/BooruKit%20Grabber.js
// ==UserScript==
// @name BooruKit Grabber
// @namespace codeberg.org/0xdev/boorukit
// @version 0.4.1
// @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 postsApi() {
return typeof window === 'undefined'
? module.require('../../shared/src/posts.ts')
: globalThis[Symbol.for('boorukit/posts/v1')];
}
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);
}
}
async function downloadPost(doc, settings, record, referer) {
const { fileNameFor, sidecarBaseFor, sidecarJsonFor, tagsTxtFor, savedPicked } = postsApi();
const picked = savedPicked(settings.pickedTags);
await gmDownloadAsync({
url: record.fileUrl,
name: fileNameFor(record, settings.filenameFormat, picked),
...(referer === undefined ? {} : { headers: { Referer: referer } }),
});
await downloadSidecar(doc, `${sidecarBaseFor(record, settings.filenameFormat, picked)}.json`, sidecarJsonFor(record), 'application/json');
if (settings.writeTagsTxt) {
await downloadSidecar(doc, `${sidecarBaseFor(record, settings.filenameFormat, picked)}.txt`, tagsTxtFor(record), 'text/plain');
}
}
async function runGrabber(context, settings, selection) {
const { resolveGrabberSiteConfig, makeQueueItem, runGrabberQueue, resolvePost } = postsApi();
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));
// Gelbooru's image host refuses a request without the site as referrer. Other sites get none, so a
// userscript manager that rejects the header cannot break their downloads.
const referer = config.dapiFlavor === 'page' ? `${context.page.origin}/` : undefined;
await runGrabberQueue(context.queue, context.tasks, items, {
resolve: (item) => resolvePost(context.page.origin, context.page.normalizedHostname, config, item),
download: (record) => downloadPost(document, settings, record, referer),
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) => postsApi().resolveGrabberSiteConfig(page.hostname) !== null,
activate: async (context) => {
const { savedFormat } = postsApi();
let settings = await migrateLegacyGrabberSettings(context);
context.deck.registerSettingsPanel?.({
id: 'grabber.filename', label: 'Filename format',
render: (container) => {
const editor = globalThis[Symbol.for('boorukit/format-control/v1')];
editor.mount(container, { format: savedFormat(settings.filenameFormat), picked: settings.pickedTags ?? '' }, async ({ format, picked }) => {
const saved = await context.settings.update(current => ({ ...current,
grabber: { writeTagsTxt: false, ...current.grabber, filenameFormat: format, pickedTags: picked } }));
settings = saved.grabber ?? settings;
});
},
});
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: { ...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 = {
...postsApi(),
createGrabberDescriptor,
downloadPost,
migrateLegacyGrabberSettings,
runGrabber,
};
}
})();
// Shared filename and post-page metadata handling for Grabber and Firefox.
(() => {
const DEFAULT_FORMAT = '{artist} - {character} - {copyright} - {id} - {site}';
const FIELDS = ['artist', 'character', 'copyright', 'id', 'site', 'meta', 'rating', 'picked'];
const ROOTS = ['rule34.xxx', 'gelbooru.com', 'safebooru.org', 'hypnohub.net', 'e621.net', 'e926.net'];
function supportedSite(host) {
return ROOTS.includes(host.toLowerCase().replace(/^www\./, '')) || /^[a-z0-9-]+\.booru\.org$/i.test(host);
}
const MEDIA_EXTENSION = /\.(?:jpe?g|png|gif|webp|avif|webm|mp4|ogv)(?:[?#]|$)/i;
function mediaUrl(value, site) {
try {
const url = new URL(value);
const root = site.replace(/^www\./, '');
return ['https:', 'http:'].includes(url.protocol) && !url.username && !url.password
&& (url.hostname === root || url.hostname.endsWith(`.${root}`)
|| (root.endsWith('.booru.org') && url.hostname === 'img.booru.org'))
&& MEDIA_EXTENSION.test(url.href);
}
catch {
return false;
}
}
function validateFormat(value) {
if (!value.trim() || value.length > 500)
throw new Error('Enter a filename format of 1–500 characters.');
const rest = value.replace(/\{([a-z]+)\}/g, (_whole, field) => {
if (!FIELDS.includes(field))
throw new Error(`Unknown field: {${field}}.`);
return '';
});
if (/[{}]/.test(rest))
throw new Error('Use fields such as {artist} and {id}.');
return value;
}
// Filename controls include C0/C1, bidi controls and the portable reserved set.
const UNSAFE = /[\p{Cc}\p{Cf}<>:"/\\|?*]/gu;
function safePart(value) {
const safe = value.replace(UNSAFE, '_').replace(/^[. ]+|[. ]+$/g, '');
return /^(?:con|prn|aux|nul|com[1-9]|lpt[1-9])(?:\.|$)/i.test(safe) ? `_${safe}` : safe;
}
// Made on first use: loading the library must not need TextEncoder.
let encoder;
const bytes = (value) => (encoder ??= new TextEncoder()).encode(value).length;
// A line holds one or more tags, or "name = tags": the post matches the name or any of the tags,
// and the filename shows the name.
function parsePicked(text) {
if (text.length > 5000)
throw new Error('Enter at most 5,000 characters of picked tags.');
const lines = text.split(/\r?\n/);
if (lines.filter(line => line.trim()).length > 100)
throw new Error('Enter at most 100 lines of picked tags.');
const entries = [];
lines.forEach((line, index) => {
const tokens = line.split(/\s+/).filter(Boolean), at = tokens.indexOf('=');
if (at === -1) {
for (const tag of tokens)
entries.push({ name: tag, tags: [tag.toLowerCase()] });
return;
}
const problem = tokens.lastIndexOf('=') !== at ? 'use one = per line' : at === 0 ? 'put a name before ='
: at > 1 ? 'put one name before =' : at === tokens.length - 1 ? 'put tags after =' : '';
if (problem)
throw new Error(`Line ${index + 1}: ${problem}.`);
entries.push({ name: tokens[0], tags: [...new Set([tokens[0], ...tokens.slice(2)].map(tag => tag.toLowerCase()))] });
});
return entries;
}
// A stored list that no longer parses picks nothing rather than failing a download.
function savedPicked(value) {
if (typeof value !== 'string')
return [];
try {
return parsePicked(value);
}
catch {
return [];
}
}
function pickedNames(record, picked) {
if (!picked.length)
return '';
const has = new Set([...(record.tags ?? []), ...Object.values(record.tagGroups ?? {}).flat()]
.filter((tag) => typeof tag === 'string').map(tag => tag.toLowerCase()));
// A name listed twice keeps its first place, and either of its lines can match.
const names = new Map();
for (const entry of picked) {
const slot = names.get(entry.name.toLowerCase()) ?? { name: entry.name, matched: false };
slot.matched ||= entry.tags.some(tag => has.has(tag));
names.set(entry.name.toLowerCase(), slot);
}
return [...names.values()].filter(slot => slot.matched).map(slot => slot.name).join(', ');
}
function filename(record, format = DEFAULT_FORMAT, picked = []) {
validateFormat(format);
const id = safePart(record.id.replace(/[^A-Za-z0-9_-]/g, '')) || 'unknown';
const site = record.site.replace(/^www\./, '').split('.')[0];
const values = { id, site, rating: record.rating ?? '', picked: pickedNames(record, picked) };
for (const key of ['artist', 'character', 'copyright', 'meta'])
values[key] = (record.tagGroups?.[key] ?? []).join(', ');
const sections = format.split(' - ').map(template => ({ template, text: template.replace(/\{([a-z]+)\}/g, (_, key) => values[key]).trim() }));
const base = safePart(sections.map(section => section.text).filter(Boolean).join(' - ')) || `${site}_${id}`;
const ext = /^[a-z0-9]{1,10}$/i.test(record.ext) ? record.ext.toLowerCase() : 'bin';
if (bytes(`${base}.${ext}`) <= 220)
return `${base}.${ext}`;
const kept = shortenBeforeId(sections, ext);
if (kept)
return kept;
const suffix = ` - ${id}.${ext}`;
let shortened = '';
for (const char of base) {
if (bytes(shortened + char + suffix) > 220)
break;
shortened += char;
}
return `${safePart(shortened)}${suffix}`;
}
// The section holding {id}, the sections after it and every {picked} section stay whole; the others are cut
// from the last one back. Null when the format has no {id} or opens with it, or when the whole sections leave
// no room for the rest.
function shortenBeforeId(sections, ext) {
const idAt = sections.findIndex(section => section.template.includes('{id}'));
if (idAt < 1)
return null;
// A 220-byte name holds at most 220 code points of a section, so the rest is dropped before any cutting.
const parts = sections.map((section, index) => ({ text: /^[\s\S]{0,220}/u.exec(section.text)[0].replace(UNSAFE, '_'),
cut: index < idAt && !section.template.includes('{picked}') }));
const name = () => safePart(parts.map(part => part.text).filter(Boolean).join(' - '));
for (const part of [...parts].reverse()) {
const over = bytes(`${name()}.${ext}`) - 220;
if (over <= 0)
break;
if (!part.cut)
continue;
const chars = [...part.text];
for (let removed = 0; chars.length && removed < over;)
removed += bytes(chars.pop());
// A cut that lands between tags leaves no comma, dot or space at its end.
part.text = chars.join('').replace(/[,. ]+$/, '');
}
const result = `${name()}.${ext}`;
return parts.some(part => part.cut && part.text) && bytes(result) <= 220 ? result : null;
}
function groupsFromRaw(raw) {
const groups = {};
for (const [category, list] of Object.entries(raw)) {
if (!/^[a-z_]+$/.test(category) || !Array.isArray(list))
continue;
Object.defineProperty(groups, category, { value: [...new Set(list.filter((tag) => typeof tag === 'string'))], enumerable: true });
}
return groups;
}
// e621 stores tag names percent-encoded. decodeURIComponent keeps a literal '+' that a
// query-string decoder would read as a space, and throws on a '%' that is not an escape.
function decodedTag(value) {
try {
return decodeURIComponent(value);
}
catch {
return value;
}
}
const MAX_SOURCES = 50, MAX_SOURCE_LENGTH = 2048;
function limitSources(list) {
return list.filter(source => source.length <= MAX_SOURCE_LENGTH).slice(0, MAX_SOURCES);
}
// A source list from outside the extension's own pages is kept only whole and within the limits.
function validSources(value) {
// Array.from turns holes into undefined, which every() would otherwise skip.
const list = Array.isArray(value) ? Array.from(value) : [];
return list.length <= MAX_SOURCES && list.every(source => typeof source === 'string' && source.length <= MAX_SOURCE_LENGTH) ? list : [];
}
// Gelbooru-family boorus keep Source as one text field: several links become several entries, other text stays whole.
function splitSource(value) {
const text = value.trim(), parts = text.split(/\s+/);
if (!text)
return [];
return limitSources(parts.every(part => /^https?:\/\/\S+$/i.test(part)) ? parts : [text]);
}
function readSources(doc, url) {
if (['e621.net', 'e926.net'].includes(url.hostname.replace(/^www\./, ''))) {
return limitSources([...doc.querySelectorAll('.source-links .source-link')].map(item => {
const link = item.querySelector('a[href]'), struck = item.querySelector('s');
// e621 keeps a source that no longer works with a leading '-' and shows it struck through.
return link ? link.getAttribute('href') : struck ? `-${struck.textContent.trim()}` : item.textContent.trim();
}).filter(source => source && source !== '-'));
}
const field = doc.querySelector('input#source');
if (field)
return splitSource(field.getAttribute('value') ?? '');
const line = [...doc.querySelectorAll('#stats li, #tag-list li, #tag-sidebar li')].find(item => /^\s*Source:/.test(item.textContent));
if (!line)
return [];
// A line of links and nothing else keeps each link's URL; any other text is read as Source text.
const links = [...line.querySelectorAll('a[href]')];
const other = [...line.childNodes].filter(node => !(node.nodeType === 1 && ['A', 'BR'].includes(node.tagName)))
.map(node => node.textContent ?? '').join('').replace(/^\s*Source:/, '').trim();
return links.length && !other ? limitSources(links.map(link => link.getAttribute('href')))
: splitSource(line.textContent.replace(/^\s*Source:/, ''));
}
// e621 marks the original with #image-download-link and Rule34 with #highres; Gelbooru's link, and Rule34's on a
// video page, have only their text.
function originalLinks(doc) {
return [doc.querySelector('#image-download-link'), doc.querySelector('#highres'),
...[...doc.querySelectorAll('a[href]')].filter(link => /^\s*original image\s*$/i.test(link.textContent ?? ''))]
.filter((link) => !!link);
}
// The file name must be in the path: a redirect script naming it in its query answers with a page.
function originalHref(link, base, site) {
const raw = link.getAttribute('href');
if (!raw)
return null;
const url = new URL(raw, base);
return mediaUrl(url.href, site) && MEDIA_EXTENSION.test(url.pathname) ? url.href : null;
}
function readPost(doc, href) {
const url = new URL(href);
if (!supportedSite(url.hostname))
return null;
const id = /\/posts\/(\d+)\/?$/.exec(url.pathname)?.[1]
?? (url.searchParams.get('s') === 'view' ? url.searchParams.get('id') : null);
if (!id || !/^\d+$/.test(id))
return null;
const numeric = { '0': 'general', '1': 'artist', '3': 'copyright', '4': 'character', '5': 'species', '6': 'invalid', '7': 'meta', '8': 'lore' };
const tagGroups = {};
for (const li of doc.querySelectorAll('#tag-list li, #tag-sidebar li, #tag_list li')) {
const link = [...li.querySelectorAll('a[href]')].find(a => {
const target = new URL(a.getAttribute('href'), url);
return target.searchParams.has('tags');
});
const stored = li.getAttribute('data-name') || li.getAttribute('data-tag-name') || null;
const tag = stored !== null ? decodedTag(stored)
: (link ? new URL(link.getAttribute('href'), url).searchParams.get('tags') : null);
if (!tag || /\s/.test(tag))
continue;
const classes = `${li.className} ${li.parentElement?.className ?? ''}`;
const category = /tag-type-([a-z]+)/.exec(classes)?.[1]
?? numeric[/category-(\d+)/.exec(classes)?.[1] ?? '']
?? /\b([a-z]+)-tag-list\b/.exec(classes)?.[1] ?? 'general';
const key = category === 'metadata' ? 'meta' : category;
if (!Object.hasOwn(tagGroups, key))
Object.defineProperty(tagGroups, key, { value: [], enumerable: true });
if (!tagGroups[key].includes(tag))
tagGroups[key].push(tag);
}
const tags = [...new Set(Object.values(tagGroups).flat())];
if (!tags.length)
return null;
const urls = new Set();
for (const el of doc.querySelectorAll('#image, #image-container img, #image-container video, #image-container source, #gelcomVideoPlayer, #gelcomVideoPlayer source, #highres, #image-download-link')) {
const raw = el.getAttribute('src') || el.getAttribute('href');
if (raw) {
const value = new URL(raw, url).href;
if (mediaUrl(value, url.hostname))
urls.add(value);
}
}
// A fetched Rule34 video page has no player; its Original image link is the only one naming the file.
for (const link of originalLinks(doc)) {
const value = originalHref(link, url.href, url.hostname);
if (value)
urls.add(value);
}
const fileUrl = [...urls][0];
if (!fileUrl)
return null;
return { site: url.hostname, id, postUrl: url.href, sources: readSources(doc, url), fileUrl, mediaUrls: [...urls],
ext: /\.([a-z0-9]+)(?:[?#]|$)/i.exec(fileUrl)[1].toLowerCase(), tags, tagGroups,
rating: doc.querySelector('[data-rating]')?.getAttribute('data-rating') ?? '' };
}
// Without an original link the page shows the original itself, unless what it shows is a resized sample.
function originalPost(doc, post) {
for (const link of originalLinks(doc)) {
const original = originalHref(link, post.postUrl, post.site);
if (!original)
continue;
return { ...post, fileUrl: original, mediaUrls: [original, ...post.mediaUrls.filter(value => value !== original)],
ext: /\.([a-z0-9]+)(?:[?#]|$)/i.exec(original)[1].toLowerCase() };
}
return /\/samples?\//i.test(new URL(post.fileUrl).pathname) ? null : { ...post, mediaUrls: [...post.mediaUrls] };
}
const api = Object.freeze({ DEFAULT_FORMAT, FIELDS, validateFormat, parsePicked, savedPicked, safePart, filename, groupsFromRaw, supportedSite, mediaUrl,
splitSource, limitSources, validSources, readPost, originalPost });
globalThis[Symbol.for('boorukit/media/v1')] = api;
if (typeof module !== 'undefined' && module.exports)
module.exports = api;
})();
// Shared, accessible filename-format editor for the two download surfaces.
(() => {
// Gives each mounted editor its own element ids.
let mounted = 0;
function mount(container, settings, save, native = false) {
const media = globalThis[Symbol.for('boorukit/media/v1')];
const doc = container.ownerDocument;
const element = (tag, text = '') => {
const el = doc.createElement(tag);
el.textContent = text;
return el;
};
const root = element('div');
root.className = 'bk-filename';
const style = element('style');
style.textContent = `
.bk-filename{display:grid;gap:12px;max-width:620px;min-width:0;font:14px system-ui,sans-serif;color:var(--bk-text,#18202c)}
.bk-filename label{display:grid;gap:6px}.bk-filename input,.bk-filename textarea{box-sizing:border-box;width:100%;min-width:0;padding:10px;border:1px solid var(--bk-border,#435064);border-radius:5px;background:var(--bk-ink,#f1f4f8);color:inherit;font:13px ui-monospace,monospace}
.bk-filename textarea{resize:vertical;line-height:1.45}.bk-filename textarea[aria-invalid="true"]{border-color:var(--bk-danger,#c52f44)}.bk-filename .hint{color:var(--bk-muted,#516174);font-size:12px}
.bk-filename .fields{display:flex;flex-wrap:wrap;gap:7px}.bk-filename button{font:inherit;min-height:36px;padding:6px 11px;border:1px solid var(--bk-border,#435064);border-radius:5px;background:var(--bk-panel-raised,#edf1f6);color:inherit;cursor:pointer}
.bk-filename output{display:block;padding:10px;background:var(--bk-ink,#f1f4f8);border:1px solid var(--bk-border,#435064);border-radius:5px;overflow-wrap:anywhere;font-size:13px}
.bk-filename .error{color:var(--bk-danger,#c52f44)}.bk-filename .footer{display:flex;align-items:center;gap:12px}.bk-filename :focus-visible{outline:3px solid #58b8e8;outline-offset:2px}`;
const label = element('label', 'Filename format');
const input = element('input');
input.type = 'text';
input.value = settings.format;
input.spellcheck = false;
input.maxLength = 500;
label.append(input);
const fields = element('div');
fields.className = 'fields';
fields.setAttribute('aria-label', 'Filename fields');
const labels = { artist: 'Artist', character: 'Character', copyright: 'Copyright', id: 'Post ID', site: 'Site', meta: 'Meta tags', rating: 'Rating', picked: 'Picked tags' };
for (const key of media.FIELDS) {
const token = element('button', labels[key]);
token.type = 'button';
token.addEventListener('click', () => { input.setRangeText(`{${key}}`, input.selectionStart ?? input.value.length, input.selectionEnd ?? input.value.length, 'end'); input.focus(); preview(); });
fields.append(token);
}
const listLabel = element('label', 'Picked tags');
const list = element('textarea');
list.rows = 4;
list.value = settings.picked;
list.spellcheck = false;
list.placeholder = 'solo\n3d = 3d_(artwork)\naudio = sound has_audio';
listLabel.append(list);
const hint = element('div', 'One tag per line, or name = tags to show one name for several tags.');
hint.className = 'hint';
const id = ++mounted;
hint.id = `bk-picked-hint-${id}`;
const exampleLabel = element('div', 'Example filename');
const example = element('output');
example.setAttribute('aria-live', 'polite');
const note = element('div', 'Picked tags are not in the filename format.');
note.className = 'hint';
note.hidden = true;
note.setAttribute('aria-live', 'polite');
const error = element('div');
error.className = 'error';
error.setAttribute('role', 'alert');
error.id = `bk-filename-error-${id}`;
list.setAttribute('aria-describedby', `${hint.id} ${error.id}`);
const footer = element('div');
footer.className = 'footer';
const button = element('button', 'Save');
button.type = 'button';
const status = element('span');
status.setAttribute('role', 'status');
let saving = false;
function preview() {
status.textContent = '';
input.removeAttribute('aria-invalid');
list.removeAttribute('aria-invalid');
let checking = input;
try {
media.validateFormat(input.value);
checking = list;
const picked = media.parsePicked(list.value);
// The example post has every picked tag, so the example shows where they go.
example.textContent = media.filename({ site: 'e621.net', id: '12345', ext: 'png', rating: 'safe', tags: picked.map(entry => entry.name), tagGroups: { artist: ['artist_name'], character: ['character_name'], copyright: ['series_name'], meta: ['high_resolution'] } }, input.value, picked);
note.hidden = !picked.length || input.value.includes('{picked}');
error.textContent = '';
button.disabled = saving;
}
catch (reason) {
error.textContent = reason instanceof Error ? reason.message : 'Invalid filename format.';
checking.setAttribute('aria-invalid', 'true');
button.disabled = true;
example.textContent = '';
note.hidden = true;
}
}
input.addEventListener('input', preview);
list.addEventListener('input', preview);
const controls = () => [input, list, ...fields.querySelectorAll('button')];
button.addEventListener('click', async () => {
saving = true;
button.disabled = true;
button.textContent = 'Saving…';
for (const control of controls())
control.disabled = true;
try {
media.parsePicked(list.value);
await save({ format: media.validateFormat(input.value), picked: list.value });
status.textContent = native ? 'Saved. Reload open posts.' : 'Saved';
}
catch (reason) {
status.textContent = 'Could not save filename format. Try again.';
console.error('[BooruKit] Could not save filename format', reason);
}
finally {
saving = false;
button.disabled = false;
button.textContent = 'Save';
for (const control of controls())
control.disabled = false;
}
});
footer.append(button, status);
root.append(style, label, fields, listLabel, hint, exampleLabel, example, note, error, footer);
container.append(root);
preview();
}
const api = { mount };
globalThis[Symbol.for('boorukit/format-control/v1')] = api;
if (typeof module !== 'undefined' && module.exports)
module.exports = api;
})();
// Shared post resolution, tag normalization and download serialization.
(() => {
function mediaApi() {
return typeof window === 'undefined'
? module.require('./media.ts')
: globalThis[Symbol.for('boorukit/media/v1')];
}
function savedFormat(value) {
try {
return mediaApi().validateFormat(value ?? mediaApi().DEFAULT_FORMAT);
}
catch {
return mediaApi().DEFAULT_FORMAT;
}
}
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)
: [];
const source = typeof raw.source === 'string' ? raw.source : '';
return {
site,
id,
md5,
fileUrl,
ext: extFromFileUrl(fileUrl),
tags,
rating: typeof raw.rating === 'string' ? raw.rating : '',
score: numOrNull(raw.score),
source,
sources: mediaApi().splitSource(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,
tagGroups: mediaApi().groupsFromRaw(tagGroups),
rating: typeof post.rating === 'string' ? post.rating : '',
score: typeof score.total === 'number' ? score.total : null,
source: typeof sources[0] === 'string' ? sources[0] : '',
sources: e621Sources(sources),
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;
}
// e621 lists working sources before the ones it keeps with a leading '-'.
function e621Sources(list) {
const strings = list.filter((value) => typeof value === 'string');
return mediaApi().limitSources([
...strings.filter((value) => !value.startsWith('-')),
...strings.filter((value) => value.startsWith('-')),
]);
}
function savedPicked(value) {
return mediaApi().savedPicked(value);
}
function sidecarBaseFor(record, format, picked) {
return fileNameFor(record, format, picked).replace(/\.[^.]+$/, '');
}
function fileNameFor(record, format, picked) {
return mediaApi().filename(record, savedFormat(format), picked);
}
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' });
}
// Their dapi endpoints refuse requests without an API key.
if (normalized === 'rule34.xxx' || normalized === 'gelbooru.com') {
return Object.freeze({ dapiFlavor: 'page' });
}
if (normalized === 'safebooru.org' || 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`;
}
if (config.dapiFlavor === 'page') {
return `${origin}/index.php?page=post&s=view&id=${encodeURIComponent(id)}`;
}
return buildDapiUrlById(origin, config.dapiFlavor, id);
}
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 flavor = config.dapiFlavor;
if (flavor === 'page')
return resolveFromPostPage(origin, site, item);
const parse = (text) => new DOMParser().parseFromString(text, 'text/xml');
const fromBody = (body) => {
if (flavor === 'e621') {
return postsFromE621Json(site, body);
}
if (flavor === '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 enrichCategories(idMatch, origin, config);
const expectedMd5 = normalizedMd5(item.md5);
if (expectedMd5 !== null) {
const md5Url = flavor === 'e621'
? `${origin}/posts.json?tags=md5:${encodeURIComponent(item.md5)}&limit=1`
: buildDapiUrlByMd5(origin, flavor, 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 enrichCategories(md5Match, origin, config);
}
throw new Error('post not found');
}
async function resolveFromPostPage(origin, site, item) {
const response = await fetch(buildResolveUrl(origin, { dapiFlavor: 'page' }, item.id), { credentials: 'same-origin' });
if (!response.ok)
throw resolveHttpError(response.status);
const doc = new DOMParser().parseFromString(await response.text(), 'text/html');
// readPost takes the id from the URL, so the final one after redirects is what shows another page.
const read = mediaApi().readPost(doc, response.url);
if (!read || read.id !== item.id)
throw new Error('could not read post');
const post = mediaApi().originalPost(doc, read);
if (!post)
throw new Error('original file not found');
const fileMd5 = /\/([0-9a-f]{32})\.[a-z0-9]+(?:[?#]|$)/i.exec(post.fileUrl)?.[1].toLowerCase();
return {
site,
id: item.id,
md5: fileMd5 ?? normalizedMd5(item.md5) ?? '',
fileUrl: post.fileUrl,
ext: post.ext,
tags: post.tags,
tagGroups: post.tagGroups,
rating: post.rating ?? '',
score: null,
source: post.sources[0] ?? '',
sources: post.sources,
width: null,
height: null,
};
}
async function enrichCategories(record, origin, config) {
if (config.dapiFlavor === 'e621' || typeof DOMParser === 'undefined')
return record;
const href = `${origin}/index.php?page=post&s=view&id=${encodeURIComponent(record.id)}`;
const response = await fetch(href, { credentials: 'same-origin' });
if (!response.ok)
throw resolveHttpError(response.status);
const parsed = mediaApi().readPost(new DOMParser().parseFromString(await response.text(), 'text/html'), href);
if (!parsed || parsed.id !== record.id)
throw new Error('could not read post tag categories');
return { ...record, tagGroups: parsed.tagGroups, tags: [...new Set([...record.tags, ...parsed.tags])],
...(parsed.sources.length ? { sources: parsed.sources } : {}) };
}
const api = Object.freeze({ savedFormat, savedPicked, siteShortFromHostname, buildDapiUrlById, buildDapiUrlByMd5, toAbsoluteUrl, extFromFileUrl, postsFromDapiJson, postsFromDapiXml, postsFromE621Json, sidecarBaseFor, fileNameFor, sidecarJsonFor, tagsTxtFor, makeQueueItem, runGrabberQueue, resolveGrabberSiteConfig, buildResolveUrl, resolvePost });
globalThis[Symbol.for('boorukit/posts/v1')] = api;
if (typeof module !== 'undefined' && module.exports)
module.exports = api;
})();