BooruKit Grabber

Downloads selected booru posts through the BooruKit userscript suite.

このスクリプトは単体で利用できません。右のようなメタデータを含むスクリプトから、ライブラリとして読み込まれます: // @require https://update.sleazyfork.org/scripts/589429/1890615/BooruKit%20Grabber.js

スクリプトをインストールするには、Tampermonkey, GreasemonkeyViolentmonkey のような拡張機能のインストールが必要です。

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

スクリプトをインストールするには、TampermonkeyViolentmonkey のような拡張機能のインストールが必要です。

スクリプトをインストールするには、TampermonkeyUserscripts のような拡張機能のインストールが必要です。

このスクリプトをインストールするには、Tampermonkeyなどの拡張機能をインストールする必要があります。

このスクリプトをインストールするには、ユーザースクリプト管理ツールの拡張機能をインストールする必要があります。

(ユーザースクリプト管理ツールは設定済みなのでインストール!)

このスタイルをインストールするには、Stylusなどの拡張機能をインストールする必要があります。

このスタイルをインストールするには、Stylus などの拡張機能をインストールする必要があります。

このスタイルをインストールするには、Stylus tなどの拡張機能をインストールする必要があります。

このスタイルをインストールするには、ユーザースタイル管理用の拡張機能をインストールする必要があります。

このスタイルをインストールするには、ユーザースタイル管理用の拡張機能をインストールする必要があります。

このスタイルをインストールするには、ユーザースタイル管理用の拡張機能をインストールする必要があります。

(ユーザースタイル管理ツールは設定済みなのでインストール!)

このスクリプトの質問や評価の投稿はこちら通報はこちらへお寄せください
// ==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,
        };
    }
})();