BooruKit Grabber

Downloads selected booru posts through the BooruKit userscript suite.

Este script não deve ser instalado diretamente. Este script é uma biblioteca de outros scripts para incluir com o diretório meta // @require https://update.sleazyfork.org/scripts/589429/1890615/BooruKit%20Grabber.js

Você precisará instalar uma extensão como Tampermonkey, Greasemonkey ou Violentmonkey para instalar este script.

Você precisará instalar uma extensão como Tampermonkey para instalar este script.

Você precisará instalar uma extensão como Tampermonkey ou Violentmonkey para instalar este script.

Você precisará instalar uma extensão como Tampermonkey ou Userscripts para instalar este script.

Você precisará instalar uma extensão como o Tampermonkey para instalar este script.

Você precisará instalar um gerenciador de scripts de usuário para instalar este script.

(Eu já tenho um gerenciador de scripts de usuário, me deixe instalá-lo!)

Você precisará instalar uma extensão como o Stylus para instalar este estilo.

Você precisará instalar uma extensão como o Stylus para instalar este estilo.

Você precisará instalar uma extensão como o Stylus para instalar este estilo.

Você precisará instalar um gerenciador de estilos de usuário para instalar este estilo.

Você precisará instalar um gerenciador de estilos de usuário para instalar este estilo.

Você precisará instalar um gerenciador de estilos de usuário para instalar este estilo.

(Eu já possuo um gerenciador de estilos de usuário, me deixar fazer a instalação!)

// ==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,
        };
    }
})();