BooruKit Grabber

Downloads selected booru posts through the BooruKit userscript suite.

此腳本不應該直接安裝,它是一個供其他腳本使用的函式庫。欲使用本函式庫,請在腳本 metadata 寫上: // @require https://update.sleazyfork.org/scripts/589429/1890615/BooruKit%20Grabber.js

您需要先安裝使用者腳本管理器擴展,如 TampermonkeyGreasemonkeyViolentmonkey 之後才能安裝該腳本。

You will need to install an extension such as Tampermonkey to install this script.

您需要先安裝使用者腳本管理器擴充功能,如 TampermonkeyViolentmonkey 後才能安裝該腳本。

您需要先安裝使用者腳本管理器擴充功能,如 TampermonkeyUserscripts 後才能安裝該腳本。

你需要先安裝一款使用者腳本管理器擴展,比如 Tampermonkey,才能安裝此腳本

您需要先安裝使用者腳本管理器擴充功能後才能安裝該腳本。

(我已經安裝了使用者腳本管理器,讓我安裝!)

你需要先安裝一款使用者樣式管理器擴展,比如 Stylus,才能安裝此樣式

你需要先安裝一款使用者樣式管理器擴展,比如 Stylus,才能安裝此樣式

你需要先安裝一款使用者樣式管理器擴展,比如 Stylus,才能安裝此樣式

你需要先安裝一款使用者樣式管理器擴展後才能安裝此樣式

你需要先安裝一款使用者樣式管理器擴展後才能安裝此樣式

你需要先安裝一款使用者樣式管理器擴展後才能安裝此樣式

(我已經安裝了使用者樣式管理器,讓我安裝!)

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