☰

Booru Bulk Downloader

Adds a checkbox + download button to every thumbnail on a booru gallery. Fetches the full-resolution ORIGINAL and names the file from the post's tags. Batch select, progress, optional ZIP. Never leaves the gallery page.

You will need to install an extension such as Tampermonkey, Greasemonkey or Violentmonkey to install this script.

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

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

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

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

You will need to install a user script manager extension to install this script.

(У мене вже є менеджер скриптів, дайте мені встановити його!)

You will need to install an extension such as Stylus to install this style.

You will need to install an extension such as Stylus to install this style.

You will need to install an extension such as Stylus to install this style.

You will need to install a user style manager extension to install this style.

You will need to install a user style manager extension to install this style.

You will need to install a user style manager extension to install this style.

(I already have a user style manager, let me install it!)

// ==UserScript==
// @name         Booru Bulk Downloader
// @namespace    https://aug.quest/booru-bulk-downloader
// @version      1.0.1
// @description  Adds a checkbox + download button to every thumbnail on a booru gallery. Fetches the full-resolution ORIGINAL and names the file from the post's tags. Batch select, progress, optional ZIP. Never leaves the gallery page.
// @author       Aug
// @license      MIT
// @homepageURL  https://github.com/Reduxed/booru-bulk-downloader
// @supportURL   https://github.com/Reduxed/booru-bulk-downloader/issues
// @run-at       document-idle
// @match        *://*.gelbooru.com/*
// @match        *://*.safebooru.org/*
// @match        *://*.rule34.xxx/*
// @match        *://*.xbooru.com/*
// @match        *://*.tbib.org/*
// @match        *://*.realbooru.com/*
// @match        *://*.danbooru.donmai.us/*
// @match        *://*.betabooru.donmai.us/*
// @match        *://*.yande.re/*
// @match        *://*.konachan.com/*
// @match        *://*.konachan.net/*
// @match        *://*.e621.net/*
// @grant        GM_xmlhttpRequest
// @grant        GM_download
// @grant        GM_setValue
// @grant        GM_getValue
// @connect      *
// @noframes
// ==/UserScript==

/*
 *  HOW IT FINDS THE ORIGINAL FILE  (three tiers, cheapest first)
 *    1. DOM attribute  -> e.g. danbooru's img[data-file-url]. No extra request.
 *    2. Site JSON API  -> booru dapi /  post.json?tags=id:N   => file_url + tags
 *    3. Post page HTML -> fetch the post page, read <meta property="og:image">
 *                         plus its tag links. Universal, needs no API key.
 *
 *  TO ADD A SITE: append one object to ADAPTERS. Minimum: host, itemSel,
 *  idFrom(), linkFrom(). Add origFrom()/tagsFromDom() for zero-request mode and
 *  api()/parseApi() for tier 2. Tier 3 is automatic for any adapter.
 */

(function () {
  'use strict';

  /* ===================================================== 0. configuration */

  var DEFAULTS = {
    template: '{{tags}}__{{id}}',
    maxTags: 20,
    tagSep: '_',
    concurrency: 3,
    gapMs: 350,
    forceBlob: false,
    preferApi: true,
    sendReferer: false,
    apiKey: '',
    apiUserId: '',
    collapsed: false,
    panelX: null,
    panelY: null
  };

  function loadCfg() {
    var c = {}, k;
    for (k in DEFAULTS) { if (Object.prototype.hasOwnProperty.call(DEFAULTS, k)) c[k] = DEFAULTS[k]; }
    try {
      var saved = (typeof GM_getValue === 'function') ? GM_getValue('bbd-cfg', null) : null;
      if (!saved) { var raw = localStorage.getItem('bbd-cfg'); if (raw) saved = JSON.parse(raw); }
      if (saved && typeof saved === 'object') {
        for (k in saved) { if (Object.prototype.hasOwnProperty.call(saved, k)) c[k] = saved[k]; }
      }
    } catch (e) {}
    return c;
  }
  function saveCfg() {
    try { if (typeof GM_setValue === 'function') GM_setValue('bbd-cfg', CFG); } catch (e) {}
    try { localStorage.setItem('bbd-cfg', JSON.stringify(CFG)); } catch (e2) {}
  }

  var CFG = loadCfg();

  /* ===================================================== 1. site adapters */

  function host() { return location.hostname.replace(/^www\./, ''); }

  var ADAPTERS = [
    /* ---- Danbooru family: the gallery <img> already carries everything ---- */
    {
      id: 'danbooru',
      label: 'Danbooru',
      host: /(^|\.)(danbooru\.donmai\.us|betabooru\.donmai\.us|hijiribe\.donmai\.us|sonohara\.donmai\.us|safebooru\.donmai\.us)$/,
      itemSel: '#posts article.post-preview, #posts div.post-preview, article.post-preview, div.post-preview, li.post-preview',
      idFrom: function (item, img) {
        var a = item.querySelector('a[href*="/posts/"]');
        var m = a && ((a.getAttribute('href') || '').match(/\/posts\/(\d+)/));
        if (m) return m[1];
        return item.getAttribute('data-id') || (img && img.getAttribute('data-id')) || null;
      },
      linkFrom: function (item) {
        var a = item.querySelector('a[href*="/posts/"]');
        return a ? a.href : null;
      },
      origFrom: function (item) {
        var img = item.querySelector('img[data-file-url]');
        return img ? img.getAttribute('data-file-url') : null;
      },
      tagsFromDom: function (item) {
        var img = item.querySelector('img[data-tags]');
        var t = img ? (img.getAttribute('data-tags') || '') : '';
        return t ? t.trim().split(/\s+/) : null;
      },
      ratingFromDom: function (item) {
        var img = item.querySelector('img[data-rating]');
        return img ? img.getAttribute('data-rating') : null;
      },
      api: function (id) { return location.origin + '/posts/' + id + '.json'; },
      parseApi: function (j) {
        if (!j || typeof j !== 'object') return null;
        return {
          fileUrl: j.file_url || j.large_file_url || null,
          tags: (j.tag_string || '').split(/\s+/).filter(Boolean),
          rating: j.rating || null,
          id: j.id || null,
          user: j.uploader_name || null,
          ext: j.file_ext || null
        };
      }
    },

    /* ---- Gelbooru family: thumbnail title holds the tags; API for the file ---- */
    {
      id: 'gelbooru',
      label: 'Gelbooru / Safebooru / Rule34',
      host: /(^|\.)(gelbooru\.com|safebooru\.org|rule34\.xxx|xbooru\.com|tbib\.org|realbooru\.com|hypnohub\.net|allgirlbooru\.com)$/,
      itemSel: 'span.thumb, div.thumbnail-preview, article.thumbnail-preview, .image-list-item, .thumb',
      idFrom: function (item, img) {
        var own = item.id || '';
        var m = own.match(/^[sp](\d+)$/);
        if (m) return m[1];
        var a = item.querySelector('a[href]');
        var h = a ? (a.getAttribute('href') || '') : '';
        m = h.match(/[?&]id=(\d+)/) || h.match(/\/posts?\/(\d+)/) || h.match(/\/post\/show\/(\d+)/);
        if (m) return m[1];
        // fall back to the thumbnail filename: /thumbnails/339/thumbnail_<md5>.jpg?<id>
        var src = (img && (img.getAttribute('src') || '')) || '';
        m = src.match(/\?(\d+)$/);
        return m ? m[1] : null;
      },
      linkFrom: function (item) {
        var a = item.querySelector('a[href]');
        return a ? a.href : null;
      },
      tagsFromDom: function (item) {
        var img = item.querySelector('img');
        if (!img) return null;
        var t = img.getAttribute('title') || img.getAttribute('alt') || '';
        t = t.replace(/\b(score|rating|user|parent|source|status|id):[^\s]*/gi, ' ');
        t = t.replace(/^Tags:\s*/i, ' ');
        t = t.replace(/\s+User:.*$/i, ' ');
        var tags = t.trim().split(/\s+/).filter(Boolean);
        return tags.length ? tags : null;
      },
      ratingFromDom: function (item) {
        var img = item.querySelector('img');
        var t = img ? (img.getAttribute('title') || '') : '';
        var m = t.match(/\brating:([a-z]+)/i);
        return m ? m[1] : null;
      },
      apiBase: function () {
        if (/rule34\.xxx$/.test(host())) return 'https://api.rule34.xxx/index.php';
        return location.origin + '/index.php';
      },
      api: function (id) {
        var u = this.apiBase() + '?page=dapi&s=post&q=index&json=1&id=' + encodeURIComponent(id);
        if (CFG.apiKey) u += '&api_key=' + encodeURIComponent(CFG.apiKey);
        if (CFG.apiUserId) u += '&user_id=' + encodeURIComponent(CFG.apiUserId);
        return u;
      },
      parseApi: function (j) {
        if (Array.isArray(j)) j = j[0];
        else if (j && j.post) j = Array.isArray(j.post) ? j.post[0] : j.post;
        if (!j || typeof j !== 'object' || (j.id === undefined && !j.file_url)) return null;
        return {
          fileUrl: j.file_url || j.sample_url || null,
          tags: (j.tags || '').split(/\s+/).filter(Boolean),
          rating: j.rating || null,
          id: j.id || null,
          user: j.owner || null,
          md5: j.hash || null,
          ext: (j.image || '').split('.').pop() || null
        };
      }
    },

    /* ---- Moebooru family: li#p<id>, thumb title = "Rating: X Score: N Tags: ... User: Y" ---- */
    {
      id: 'moebooru',
      label: 'Moebooru (yande.re / konachan)',
      host: /(^|\.)(yande\.re|konachan\.com|konachan\.net|sakugabooru\.com|moe\.imouto\.us|lolibooru\.moe)$/,
      itemSel: 'ul#post-list-posts > li, #post-list > div.post, div.post',
      idFrom: function (item, img) {
        var m = (item.id || '').match(/^p(\d+)$/);
        if (m) return m[1];
        var a = item.querySelector('a[href*="/post/show/"]') || item.querySelector('a[href*="/post/"]');
        m = a && ((a.getAttribute('href') || '').match(/\/post\/(?:show\/)?(\d+)/));
        return m ? m[1] : null;
      },
      linkFrom: function (item) {
        var a = item.querySelector('a[href*="/post/show/"]') || item.querySelector('a[href*="/post/"]');
        return a ? a.href : null;
      },
      tagsFromDom: function (item) {
        var img = item.querySelector('img');
        var t = img ? (img.getAttribute('title') || img.getAttribute('alt') || '') : '';
        var m = t.match(/\bTags:\s*(.+?)(?:\s+User:.*)?$/i);
        return m ? m[1].trim().split(/\s+/).filter(Boolean) : null;
      },
      ratingFromDom: function (item) {
        var img = item.querySelector('img');
        var t = img ? (img.getAttribute('title') || '') : '';
        var m = t.match(/\bRating:\s*([A-Za-z]+)/i);
        return m ? m[1].toLowerCase() : null;
      },
      api: function (id) { return location.origin + '/post.json?tags=id:' + encodeURIComponent(id); },
      parseApi: function (j) {
        var p = Array.isArray(j) ? j[0] : (j && j.posts ? j.posts[0] : j);
        if (!p || !p.id) return null;
        return {
          fileUrl: p.file_url || p.jpeg_url || p.sample_url || null,
          tags: (p.tags || '').split(/\s+/).filter(Boolean),
          rating: p.rating || null,
          id: p.id,
          user: p.author || null,
          md5: p.md5 || null,
          ext: (p.file_url || '').split('.').pop() || null
        };
      }
    },

    /* ---- e621: gallery thumbs carry data-* urls; API needs a real UA ---- */
    {
      id: 'e621',
      label: 'e621',
      host: /(^|\.)e621\.net$/,
      itemSel: 'article.post-preview, div.post-preview, .post-preview',
      idFrom: function (item, img) {
        var a = item.querySelector('a[href*="/posts/"]');
        var m = a && ((a.getAttribute('href') || '').match(/\/posts\/(\d+)/));
        return item.getAttribute('data-id') || (img && img.getAttribute('data-id')) || (m ? m[1] : null);
      },
      linkFrom: function (item) {
        var a = item.querySelector('a[href*="/posts/"]');
        return a ? a.href : null;
      },
      origFrom: function (item) {
        var img = item.querySelector('img[data-file-url]');
        return img ? img.getAttribute('data-file-url') : null;
      },
      tagsFromDom: function (item) {
        var img = item.querySelector('img[data-tags]');
        var t = img ? (img.getAttribute('data-tags') || '') : '';
        return t ? t.trim().split(/\s+/) : null;
      },
      ratingFromDom: function () { return null; },
      api: function (id) { return location.origin + '/posts/' + id + '.json'; },
      parseApi: function (j) {
        var p = j && j.post;
        if (!p) return null;
        return {
          fileUrl: (p.file && p.file.url) || null,
          tags: [].concat(p.tags && p.tags.general || [], p.tags && p.tags.species || []),
          rating: p.rating || null,
          id: p.id || null,
          ext: (p.file && p.file.ext) || null
        };
      }
    },

    /* ---- Generic: anything exposing the original in a data-* attribute ---- */
    {
      id: 'generic',
      label: 'Generic booru',
      host: /.*/,
      itemSel: 'img[data-file-url], img[data-large-file-url], img[data-original], img[data-original-url]',
      imageLevel: true,
      idFrom: function (item, img) {
        var a = img.closest('a[href]');
        var h = a ? (a.getAttribute('href') || '') : '';
        var m = h.match(/(?:posts?|view)[^0-9]*(\d{2,})/) || h.match(/(\d{3,})\s*$/);
        return m ? m[1] : null;
      },
      linkFrom: function (item, img) {
        var a = img.closest('a[href]');
        return a ? a.href : null;
      },
      origFrom: function (item, img) {
        return img.getAttribute('data-file-url') || img.getAttribute('data-large-file-url') ||
               img.getAttribute('data-original') || img.getAttribute('data-original-url');
      },
      tagsFromDom: function (item, img) {
        var t = img.getAttribute('data-tags') || '';
        return t ? t.trim().split(/\s+/) : null;
      },
      ratingFromDom: function () { return null; }
    }
  ];

  function adapter() {
    var h = host(), i;
    for (i = 0; i < ADAPTERS.length; i++) {
      var a = ADAPTERS[i];
      if (a.id !== 'generic' && a.host.test(h)) return a;
    }
    // Unknown host: fall back to the generic data-* adapter, so a new booru only
    // needs an extra @match line when its thumbnails already expose a data-* url.
    return ADAPTERS[ADAPTERS.length - 1];
  }

  /* ====================================================== 2. small helpers */

  function safe(fn) { try { return fn(); } catch (e) { return null; } }
  function sleep(ms) { return new Promise(function (r) { setTimeout(r, ms); }); }
  function log() { try { console.log.apply(console, ['[BBD]'].concat([].slice.call(arguments))); } catch (e) {} }

  function gmRequest(opts) {
    return new Promise(function (resolve, reject) {
      if (typeof GM_xmlhttpRequest !== 'function') {
        return reject(new Error('GM_xmlhttpRequest unavailable - check the @grant block'));
      }
      var o = {};
      for (var k in opts) { if (Object.prototype.hasOwnProperty.call(opts, k)) o[k] = opts[k]; }
      o.onload = function (r) { resolve(r); };
      o.onerror = function () { reject(new Error('network error')); };
      o.ontimeout = function () { reject(new Error('timeout')); };
      GM_xmlhttpRequest(o);
    });
  }

  function stripQuery(u) { return String(u || '').split('#')[0].split('?')[0]; }

  function extFromUrl(u) {
    var m = stripQuery(u).match(/\.([a-z0-9]{2,5})$/i);
    return m ? m[1].toLowerCase() : 'jpg';
  }

  /* ================================================= 3. filename pipeline */

  var WIN_RESERVED = /^(con|prn|aux|nul|com[1-9]|lpt[1-9])$/i;

  function sanitize(s) {
    s = String(s == null ? '' : s);
    s = s.replace(/[\u0000-\u001f\u007f]/g, '');
    s = s.replace(/[\\/:*?"<>|]/g, '_');
    s = s.replace(/\s+/g, '_');
    s = s.replace(/_{2,}/g, '_');
    s = s.replace(/^[.\s_]+/, '').replace(/[.\s_]+$/, '');
    if (WIN_RESERVED.test(s)) s = '_' + s;
    return s;
  }

  function truncate(s, n) {
    if (s.length <= n) return s;
    s = s.slice(0, n);
    var cut = s.lastIndexOf('_');
    if (cut > n * 0.6) s = s.slice(0, cut);
    return s.replace(/[.\s_]+$/, '');
  }

  function makeNamer() {
    var used = Object.create(null);
    return {
      build: function (meta) {
        var m = meta || {};
        var tags = (m.tags || []).slice(0, Math.max(0, CFG.maxTags));
        var map = {
          tags: tags.join(CFG.tagSep || '_'),
          id: m.id == null ? '' : String(m.id),
          rating: m.rating || '',
          site: adapter() ? adapter().id : host(),
          user: m.user || '',
          md5: m.md5 || '',
          ext: m.ext || extFromUrl(m.fileUrl)
        };
        var name = String(CFG.template || DEFAULTS.template)
          .replace(/\{\{\s*(\w+)\s*\}\}/g, function (_m, k) {
            return Object.prototype.hasOwnProperty.call(map, k) ? String(map[k]) : '';
          });
        name = sanitize(name);
        if (!name) name = 'image_' + (m.id || Date.now());
        var ext = sanitize(map.ext || '').replace(/^_+|_+$/g, '') || extFromUrl(m.fileUrl);
        name = name.replace(/\.[a-z0-9]{1,5}$/i, '');
        name = truncate(name, 170);
        if (!name) name = 'image_' + (m.id || Date.now());
        var final = name, n = 2;
        while (used[final.toLowerCase() + '.' + ext]) { final = name + '_' + n; n++; }
        used[(final.toLowerCase() + '.' + ext)] = true;
        return final + '.' + ext;
      }
    };
  }

  /* ==================================================== 4. metadata resolve */

  var META_CACHE = new Map();   // key -> promise
  var ERRORS = [];

  function parsePostHtml(html, baseUrl) {
    var out = {};
    var doc = new DOMParser().parseFromString(html, 'text/html');

    // highest-priority explicit "original" links first
    // Moebooru: a.original-file-unchanged -> /image/<md5>/<name>.<ext> (the real original)
    //           a.original-file-changed / a#highres -> /jpeg/... (a "larger version", NOT the source)
    var cands = [
      'a.original-file-unchanged', 'a[href*="/image/"]', 'a#png',
      'a.original-file-changed', 'a#highres', 'a#image-link', 'a[download]'
    ];
    for (var i = 0; i < cands.length && !out.fileUrl; i++) {
      var el = doc.querySelector(cands[i]);
      if (el && el.getAttribute('href')) out.fileUrl = new URL(el.getAttribute('href'), baseUrl).href;
    }
    if (!out.fileUrl) {
      var og = doc.querySelector('meta[property="og:image"], meta[name="og:image"]');
      if (og && og.getAttribute('content')) out.fileUrl = new URL(og.getAttribute('content'), baseUrl).href;
    }
    if (!out.fileUrl) {
      var im = doc.querySelector('img#image, img#image-resize, #image-container img');
      if (im && im.getAttribute('src')) out.fileUrl = new URL(im.getAttribute('src'), baseUrl).href;
    }

    var tags = [];
    var tagSels = ['ul#tag-sidebar a', 'section#tags a', '#tags-list a', 'a.tag',
                   'a[href*="tags="]', 'a[href*="/posts?tags="]', '.tag-list a'];
    for (var s = 0; s < tagSels.length && tags.length < 4; s++) {
      doc.querySelectorAll(tagSels[s]).forEach(function (a) {
        var t = (a.textContent || '').replace(/\s+/g, ' ').trim().replace(/ /g, '_');
        if (!t || t.length > 80) return;
        if (/[?&=<>\\\/:]/.test(t)) return;   // url fragment, not a tag
        if (!/[a-z0-9]/i.test(t)) return;      // punctuation only
        if (/^[-+]/.test(t)) return;
        if (tags.indexOf(t) >= 0 || tags.length >= 60) return;
        tags.push(t);
      });
    }
    if (tags.length) out.tags = tags;

    var r = doc.querySelector('a[href*="rating:"]');
    if (r) { var rm = (r.getAttribute('href') || '').match(/rating:([a-z]+)/i); if (rm) out.rating = rm[1]; }
    return out;
  }

  function mergeMeta(a, b) {
    if (!b) return a;
    var m = {};
    m.fileUrl = (a && a.fileUrl) || b.fileUrl || null;
    m.tags = (a && a.tags && a.tags.length) ? a.tags : (b.tags || []);
    m.rating = (a && a.rating) || b.rating || null;
    m.id = (a && a.id) || b.id || null;
    m.user = (a && a.user) || b.user || null;
    m.md5 = (a && a.md5) || b.md5 || null;
    m.ext = (a && a.ext) || b.ext || null;
    m.site = (a && a.site) || b.site || null;
    return m;
  }

  async function resolveMeta(post) {
    var key = post.site + ':' + (post.id || post.origUrl || post.uid);
    if (META_CACHE.has(key)) return META_CACHE.get(key);
    var p = (async function () {
      var ad = adapter() || { };
      var meta = {
        fileUrl: post.origUrl || null,
        tags: post.domTags || [],
        rating: post.rating || null,
        id: post.id || null,
        site: post.site,
        user: null, md5: null, ext: null
      };

      // tier 2: site JSON API
      if (CFG.preferApi && ad.api && post.id && (!meta.fileUrl || !meta.tags.length)) {
        try {
          var r = await gmRequest({
            method: 'GET',
            url: ad.api.call(ad, post.id),
            headers: { Accept: 'application/json, text/plain, */*' },
            timeout: 20000
          });
          if (r.status >= 200 && r.status < 300 && r.responseText) {
            var txt = r.responseText.replace(/^\uFEFF/, '').trim();
            var j = JSON.parse(txt);
            var parsed = ad.parseApi ? ad.parseApi(j) : null;
            if (parsed) meta = mergeMeta(meta, parsed);
          }
        } catch (e) { log('api tier failed', post.id, e && e.message); }
      }

      // tier 3: post page HTML (og:image + tag links)
      if ((!meta.fileUrl || !meta.tags.length) && post.href) {
        try {
          var r2 = await gmRequest({ method: 'GET', url: post.href, timeout: 25000 });
          if (r2.status >= 200 && r2.status < 300 && r2.responseText) {
            meta = mergeMeta(meta, parsePostHtml(r2.responseText, post.href));
          }
        } catch (e2) { log('html tier failed', post.href, e2 && e2.message); }
      }

      if (!meta.fileUrl) throw new Error('could not resolve an original file url');
      meta.fileUrl = meta.fileUrl.replace(/&amp;/g, '&');
      if (!meta.ext) meta.ext = extFromUrl(meta.fileUrl);
      return meta;
    })();
    META_CACHE.set(key, p);
    p.catch(function () { META_CACHE.delete(key); });
    return p;
  }

  /* ==================================================== 5. download engine */

  function anchorSave(blob, filename) {
    var url = URL.createObjectURL(blob);
    var a = document.createElement('a');
    a.href = url;
    a.download = filename;
    a.style.display = 'none';
    document.body.appendChild(a);
    a.click();
    setTimeout(function () { URL.revokeObjectURL(url); a.remove(); }, 120000);
  }

  function refererHeaders() {
    return CFG.sendReferer ? { Referer: location.origin + '/' } : undefined;
  }

  function downloadOne(meta, filename) {
    return new Promise(function (resolve, reject) {
      if (!CFG.forceBlob && typeof GM_download === 'function') {
        GM_download({
          url: meta.fileUrl,
          name: filename,
          saveAs: false,
          headers: refererHeaders(),
          onload: function () { resolve('GM_download'); },
          onerror: function (e) {
            reject(new Error('GM_download: ' + ((e && (e.error || e.details)) || 'failed')));
          },
          ontimeout: function () { reject(new Error('GM_download timed out')); }
        });
        return;
      }
      gmRequest({
        method: 'GET',
        url: meta.fileUrl,
        responseType: 'blob',
        headers: refererHeaders(),
        timeout: 60000
      }).then(function (r) {
        if (r.status < 200 || r.status >= 300) return reject(new Error('HTTP ' + r.status));
        if (!r.response) return reject(new Error('empty response'));
        anchorSave(r.response, filename);
        resolve('blob');
      }).catch(reject);
    });
  }

  function fetchBlob(url) {
    return gmRequest({ method: 'GET', url: url, responseType: 'blob', headers: refererHeaders(), timeout: 120000 })
      .then(function (r) {
        if (r.status < 200 || r.status >= 300) throw new Error('HTTP ' + r.status);
        if (!r.response) throw new Error('empty response');
        return r.response;
      });
  }

  /* ------------------------------------------- 5b. zip writer (no deps) */
  /* Store-only ZIP: no compression, no third-party code, works offline.
     Every booru file format is already compressed, so 'stored' costs ~0%.
     ZIP64 is deliberately not implemented; the caller guards the 4 GB limit. */

  var CRC_TABLE = (function () {
    var t = new Uint32Array(256), n, k, c;
    for (n = 0; n < 256; n++) {
      c = n;
      for (k = 0; k < 8; k++) c = (c & 1) ? (0xEDB88320 ^ (c >>> 1)) : (c >>> 1);
      t[n] = c >>> 0;
    }
    return t;
  })();

  function crc32(bytes) {
    var c = 0xFFFFFFFF;
    for (var i = 0; i < bytes.length; i++) c = CRC_TABLE[(c ^ bytes[i]) & 0xFF] ^ (c >>> 8);
    return (c ^ 0xFFFFFFFF) >>> 0;
  }

  function dosStamp(d) {
    return {
      time: ((d.getHours() & 31) << 11) | ((d.getMinutes() & 63) << 5) | (Math.floor(d.getSeconds() / 2) & 31),
      date: (((d.getFullYear() - 1980) & 127) << 9) | (((d.getMonth() + 1) & 15) << 5) | (d.getDate() & 31)
    };
  }

  function ZipWriter() {
    this.parts = [];
    this.entries = [];
    this.size = 0;
  }

  ZipWriter.prototype.add = function (name, bytes) {
    var nameBytes = new TextEncoder().encode(name);
    var crc = crc32(bytes);
    var dt = dosStamp(new Date());
    var head = new Uint8Array(30 + nameBytes.length);
    var v = new DataView(head.buffer);
    v.setUint32(0, 0x04034b50, true);
    v.setUint16(4, 20, true);
    v.setUint16(6, 0x0800, true);      // flags: UTF-8 filename
    v.setUint16(8, 0, true);           // method: 0 = stored
    v.setUint16(10, dt.time, true);
    v.setUint16(12, dt.date, true);
    v.setUint32(14, crc, true);
    v.setUint32(18, bytes.length, true);
    v.setUint32(22, bytes.length, true);
    v.setUint16(26, nameBytes.length, true);
    v.setUint16(28, 0, true);          // extra field length
    head.set(nameBytes, 30);
    this.entries.push({ name: nameBytes, crc: crc, size: bytes.length, offset: this.size, time: dt.time, date: dt.date });
    this.parts.push(head, bytes);
    this.size += head.length + bytes.length;
  };

  ZipWriter.prototype.finish = function () {
    var cd = [], cdSize = 0, i, e, ch, v;
    for (i = 0; i < this.entries.length; i++) {
      e = this.entries[i];
      ch = new Uint8Array(46 + e.name.length);
      v = new DataView(ch.buffer);
      v.setUint32(0, 0x02014b50, true);
      v.setUint16(4, 20, true);        // version made by
      v.setUint16(6, 20, true);        // version needed
      v.setUint16(8, 0x0800, true);    // flags
      v.setUint16(10, 0, true);        // method
      v.setUint16(12, e.time, true);
      v.setUint16(14, e.date, true);
      v.setUint32(16, e.crc, true);
      v.setUint32(20, e.size, true);
      v.setUint32(24, e.size, true);
      v.setUint16(28, e.name.length, true);
      v.setUint16(30, 0, true);        // extra
      v.setUint16(32, 0, true);        // comment
      v.setUint16(34, 0, true);        // disk start
      v.setUint16(36, 0, true);        // internal attrs
      v.setUint32(38, 0, true);        // external attrs
      v.setUint32(42, e.offset, true);
      ch.set(e.name, 46);
      cd.push(ch);
      cdSize += ch.length;
    }
    var end = new Uint8Array(22);
    var v2 = new DataView(end.buffer);
    v2.setUint32(0, 0x06054b50, true);
    v2.setUint16(4, 0, true);
    v2.setUint16(6, 0, true);
    v2.setUint16(8, this.entries.length, true);
    v2.setUint16(10, this.entries.length, true);
    v2.setUint32(12, cdSize, true);
    v2.setUint32(16, this.size, true);
    v2.setUint16(20, 0, true);
    return new Blob(this.parts.concat(cd, [end]), { type: 'application/zip' });
  };

  /* =========================================================== 6. the panel */

  var CSS = [
    '.bbd-box{position:absolute;top:6px;left:6px;z-index:2147483000;display:flex;gap:4px;align-items:center;font:12px/1.2 system-ui,-apple-system,"Segoe UI",sans-serif;opacity:.35;transition:opacity .12s}',
    '.bbd-host:hover > .bbd-box,.bbd-box:focus-within,.bbd-box.bbd-on{opacity:1}',
    '.bbd-host{position:relative}',
    '.bbd-check{width:16px;height:16px;margin:0;cursor:pointer;accent-color:#22c55e}',
    '.bbd-btn{all:unset;box-sizing:border-box;cursor:pointer;width:20px;height:20px;display:flex;align-items:center;justify-content:center;border-radius:4px;background:rgba(15,23,42,.8);color:#e2e8f0;border:1px solid rgba(148,163,184,.5);font-size:12px;line-height:1;text-align:center}',
    '.bbd-btn:hover{background:#2563eb;border-color:#2563eb;color:#fff}',
    '.bbd-btn.bbd-busy{background:#f59e0b;border-color:#f59e0b;color:#111}',
    '.bbd-btn.bbd-ok{background:#22c55e;border-color:#22c55e;color:#06210f}',
    '.bbd-btn.bbd-err{background:#ef4444;border-color:#ef4444;color:#fff}',
    '.bbd-host.bbd-sel{outline:3px solid #22c55e;outline-offset:-3px;border-radius:4px}',
    '#bbd-panel{position:fixed;right:14px;bottom:14px;z-index:2147483001;width:286px;background:#0f172a;color:#e2e8f0;border:1px solid #334155;border-radius:10px;box-shadow:0 10px 30px rgba(0,0,0,.45);font:12px/1.45 system-ui,-apple-system,"Segoe UI",sans-serif}',
    '#bbd-panel *{box-sizing:border-box}',
    '#bbd-panel .h{display:flex;align-items:center;gap:6px;padding:8px 10px;background:#1e293b;border-radius:9px 9px 0 0;cursor:move;user-select:none;font-weight:600}',
    '#bbd-panel .h .sp{margin-left:auto;cursor:pointer;opacity:.7;padding:0 4px}',
    '#bbd-panel .h .sp:hover{opacity:1}',
    '#bbd-panel .b{padding:10px}',
    '#bbd-panel.min .b{display:none}',
    '#bbd-panel .row{display:flex;gap:6px;flex-wrap:wrap;margin-bottom:8px}',
    '#bbd-panel button.act{all:unset;cursor:pointer;padding:5px 9px;border-radius:6px;background:#1e293b;border:1px solid #334155;color:#e2e8f0;text-align:center}',
    '#bbd-panel button.act:hover{background:#334155}',
    '#bbd-panel button.act.pri{background:#2563eb;border-color:#2563eb;color:#fff}',
    '#bbd-panel button.act.pri:hover{background:#1d4ed8}',
    '#bbd-panel .count{font-weight:700;color:#4ade80}',
    '#bbd-panel details{margin:2px 0 8px}',
    '#bbd-panel summary{cursor:pointer;opacity:.85;padding:2px 0}',
    '#bbd-panel label.f{display:block;margin:6px 0 2px;opacity:.75}',
    '#bbd-panel input[type=text],#bbd-panel input[type=number]{width:100%;padding:4px 6px;border-radius:5px;border:1px solid #334155;background:#020617;color:#e2e8f0;font:inherit}',
    '#bbd-panel input[type=checkbox]{accent-color:#22c55e}',
    '#bbd-panel .status{margin-top:4px;padding-top:7px;border-top:1px solid #1e293b;opacity:.85;min-height:16px;word-break:break-word}',
    '#bbd-panel .status.err{color:#fca5a5}',
    '#bbd-panel .hint{opacity:.55;font-size:11px;margin-top:6px}'
  ].join('\n');

  var PANEL = null, STATUS_EL = null, COUNT_EL = null;

  function setStatus(msg, isErr) {
    if (!STATUS_EL) { log('status:', msg); return; }
    STATUS_EL.textContent = msg;
    STATUS_EL.className = 'status' + (isErr ? ' err' : '');
  }

  function buildPanel() {
    if (PANEL) return PANEL;
    var st = document.createElement('style');
    st.textContent = CSS;
    (document.head || document.documentElement).appendChild(st);

    var p = document.createElement('div');
    p.id = 'bbd-panel';
    if (CFG.collapsed) p.classList.add('min');

    var html = '';
    html += '<div class="h"><span>&#11015; Booru Bulk</span><span class="sp" id="bbd-min">&#8722;</span></div>';
    html += '<div class="b">';
    html += '<div class="row"><span>Selected: <span class="count" id="bbd-count">0</span></span></div>';
    html += '<div class="row">';
    html += '<button class="act pri" id="bbd-go">Download selected</button>';
    html += '<button class="act" id="bbd-zip">Save as .zip</button>';
    html += '</div>';
    html += '<div class="row">';
    html += '<button class="act" id="bbd-all">Select all</button>';
    html += '<button class="act" id="bbd-none">Clear</button>';
    html += '<button class="act" id="bbd-retry">Retry failed</button>';
    html += '</div>';
    html += '<details id="bbd-opts"><summary>Filename &amp; options</summary>';
    html += '<label class="f">Filename template</label>';
    html += '<input type="text" id="bbd-tpl" value="">';
    html += '<label class="f">Max tags</label><input type="number" id="bbd-maxtags" min="1" max="200">';
    html += '<label class="f">Tag separator</label><input type="text" id="bbd-sep" maxlength="3">';
    html += '<label class="f">Parallel downloads</label><input type="number" id="bbd-conc" min="1" max="8">';
    html += '<label class="f"><input type="checkbox" id="bbd-api"> Prefer site JSON API</label>';
    html += '<label class="f"><input type="checkbox" id="bbd-ref"> Send Referer header</label>';
    html += '<label class="f"><input type="checkbox" id="bbd-forceblob"> Always use in-page blob download</label>';
    html += '<label class="f">Gelbooru/Rule34 api_key (optional)</label><input type="text" id="bbd-key">';
    html += '<label class="f">&#8230; user_id (optional)</label><input type="text" id="bbd-uid">';
    html += '<div class="hint">Tokens: {{tags}} {{id}} {{rating}} {{site}} {{user}} {{md5}} {{ext}}</div>';
    html += '</details>';
    html += '<div class="status" id="bbd-status">Ready.</div>';
    html += '</div>';
    p.innerHTML = html;
    document.body.appendChild(p);
    PANEL = p;

    STATUS_EL = p.querySelector('#bbd-status');
    COUNT_EL = p.querySelector('#bbd-count');

    var tpl = p.querySelector('#bbd-tpl');
    var maxTags = p.querySelector('#bbd-maxtags');
    var sep = p.querySelector('#bbd-sep');
    var conc = p.querySelector('#bbd-conc');
    var api = p.querySelector('#bbd-api');
    var ref = p.querySelector('#bbd-ref');
    var fblob = p.querySelector('#bbd-forceblob');
    var key = p.querySelector('#bbd-key');
    var uid = p.querySelector('#bbd-uid');

    tpl.value = CFG.template;
    maxTags.value = CFG.maxTags;
    sep.value = CFG.tagSep;
    conc.value = CFG.concurrency;
    api.checked = !!CFG.preferApi;
    ref.checked = !!CFG.sendReferer;
    fblob.checked = !!CFG.forceBlob;
    key.value = CFG.apiKey || '';
    uid.value = CFG.apiUserId || '';

    function sync() {
      CFG.template = tpl.value || DEFAULTS.template;
      CFG.maxTags = Math.max(1, parseInt(maxTags.value, 10) || DEFAULTS.maxTags);
      CFG.tagSep = sep.value || '_';
      CFG.concurrency = Math.min(8, Math.max(1, parseInt(conc.value, 10) || 1));
      CFG.preferApi = !!api.checked;
      CFG.sendReferer = !!ref.checked;
      CFG.forceBlob = !!fblob.checked;
      CFG.apiKey = key.value.trim();
      CFG.apiUserId = uid.value.trim();
      saveCfg();
      META_CACHE.clear();
    }
    [tpl, maxTags, sep, conc, api, ref, key, uid, fblob].forEach(function (el) {
      el.addEventListener('change', sync);
      el.addEventListener('input', function () { if (el.type === 'text') sync(); });
    });

    p.querySelector('#bbd-min').addEventListener('click', function (e) {
      e.stopPropagation();
      p.classList.toggle('min');
      CFG.collapsed = p.classList.contains('min');
      saveCfg();
    });
    p.querySelector('#bbd-go').addEventListener('click', function () { batch(false); });
    p.querySelector('#bbd-zip').addEventListener('click', function () { batch(true); });
    p.querySelector('#bbd-all').addEventListener('click', selectAll);
    p.querySelector('#bbd-none').addEventListener('click', clearSel);
    p.querySelector('#bbd-retry').addEventListener('click', retryFailed);

    makeDraggable(p, p.querySelector('.h'));
    if (CFG.panelX != null && CFG.panelY != null) {
      p.style.left = CFG.panelX + 'px';
      p.style.top = CFG.panelY + 'px';
      p.style.right = 'auto';
      p.style.bottom = 'auto';
    }
    refreshCount();
    return p;
  }

  function makeDraggable(el, handle) {
    var sx = 0, sy = 0, ox = 0, oy = 0, dragging = false;
    handle.addEventListener('mousedown', function (e) {
      if (e.target && e.target.id === 'bbd-min') return;
      dragging = true;
      sx = e.clientX; sy = e.clientY;
      var r = el.getBoundingClientRect();
      ox = r.left; oy = r.top;
      e.preventDefault();
    });
    window.addEventListener('mousemove', function (e) {
      if (!dragging) return;
      var x = ox + (e.clientX - sx), y = oy + (e.clientY - sy);
      el.style.left = x + 'px'; el.style.top = y + 'px';
      el.style.right = 'auto'; el.style.bottom = 'auto';
    });
    window.addEventListener('mouseup', function () {
      if (!dragging) return;
      dragging = false;
      var r = el.getBoundingClientRect();
      CFG.panelX = Math.round(r.left); CFG.panelY = Math.round(r.top);
      saveCfg();
    });
  }

  /* ========================================================= 7. selection */

  var POSTS = Object.create(null);
  var ORDER = [];
  var SEL = new Set();
  var LAST = -1;
  var UID = 0;
  var FAILED = [];

  function refreshCount() {
    if (COUNT_EL) COUNT_EL.textContent = String(SEL.size);
    ORDER.forEach(function (u) {
      var post = POSTS[u];
      if (!post || !post.el) return;
      var on = SEL.has(u);
      post.el.classList.toggle('bbd-sel', on);
      if (post.cb && post.cb.checked !== on) post.cb.checked = on;
      if (post.box) post.box.classList.toggle('bbd-on', on);
    });
  }

  function toggle(uid, on) {
    if (on) { SEL.add(uid); } else { SEL.delete(uid); }
    var post = POSTS[uid];
    if (post && post.box) post.box.classList.toggle('bbd-on', !!on || !!(post.cb && post.cb.checked));
    refreshCount();
  }

  function rangeSelect(a, b) {
    var lo = Math.min(a, b), hi = Math.max(a, b);
    for (var i = lo; i <= hi; i++) if (ORDER[i]) SEL.add(ORDER[i]);
    refreshCount();
  }

  function selectAll() {
    ORDER.forEach(function (u) { SEL.add(u); });
    refreshCount();
    setStatus('Selected ' + SEL.size + ' across ' + ORDER.length + ' items.');
  }

  function clearSel() {
    SEL.clear();
    refreshCount();
    setStatus('Selection cleared.');
  }

  /* ========================================================= 8. injection */

  function injectUI(item, post) {
    var uid = 'bbd-' + (++UID);
    post.uid = uid;
    post.el = item;
    POSTS[uid] = post;
    ORDER.push(uid);

    item.classList.add('bbd-host');

    var box = document.createElement('div');
    box.className = 'bbd-box';

    var cb = document.createElement('input');
    cb.type = 'checkbox';
    cb.className = 'bbd-check';
    cb.title = 'Add to batch (shift-click for a range)';
    cb.addEventListener('click', function (e) {
      e.stopPropagation();
      var idx = ORDER.indexOf(uid);
      if (e.shiftKey && LAST >= 0) { rangeSelect(LAST, idx); cb.checked = true; }
      LAST = idx;
    });
    cb.addEventListener('change', function (e) { e.stopPropagation(); toggle(uid, cb.checked); });
    cb.addEventListener('mousedown', function (e) { e.stopPropagation(); });

    var btn = document.createElement('button');
    btn.type = 'button';
    btn.className = 'bbd-btn';
    btn.textContent = '\u2913';
    btn.title = 'Download this image at full resolution';
    btn.addEventListener('click', function (e) {
      e.preventDefault(); e.stopPropagation();
      one(post, btn);
    });
    btn.addEventListener('mousedown', function (e) { e.stopPropagation(); });

    box.appendChild(cb);
    box.appendChild(btn);
    item.appendChild(box);

    post.cb = cb;
    post.btn = btn;
    post.box = box;
    return uid;
  }

  function scan() {
    var ad = adapter();
    if (!ad) return 0;
    var added = 0;
    var items = document.querySelectorAll(ad.itemSel);
    for (var i = 0; i < items.length; i++) {
      var item = items[i];
      if (item.nodeType !== 1) continue;
      var holder = ad.imageLevel ? item.parentElement : item;
      if (!holder || holder.dataset.bbdReady === '1') continue;
      var img = ad.imageLevel ? item : item.querySelector('img');
      if (!img) continue;
      var id = safe(function () { return ad.idFrom(item, img); });
      var origUrl = safe(function () { return ad.origFrom ? ad.origFrom(item, img) : null; });
      if (!id && !origUrl) continue;
      var post = {
        site: ad.id,
        id: id,
        href: safe(function () { return ad.linkFrom ? ad.linkFrom(item, img) : null; }),
        origUrl: origUrl,
        domTags: safe(function () { return ad.tagsFromDom ? ad.tagsFromDom(item, img) : null; }),
        rating: safe(function () { return ad.ratingFromDom ? ad.ratingFromDom(item, img) : null; })
      };
      if (post.href && !/^https?:/i.test(post.href)) post.href = null;
      holder.dataset.bbdReady = '1';
      injectUI(holder, post);
      added++;
    }
    if (added) log('added controls to', added, 'items');
    return added;
  }

  /* ==================================================== 9. batch machinery */

  function setBtn(btn, cls) {
    if (!btn) return;
    btn.classList.remove('bbd-busy', 'bbd-ok', 'bbd-err');
    if (cls) btn.classList.add(cls);
  }

  async function one(post, btn) {
    setBtn(btn, 'bbd-busy');
    try {
      setStatus('Resolving ' + (post.id || 'image') + '\u2026');
      var meta = await resolveMeta(post);
      var name = makeNamer().build(meta);
      setStatus('Saving ' + name + '\u2026');
      await downloadOne(meta, name);
      setBtn(btn, 'bbd-ok');
      setStatus('Saved: ' + name);
    } catch (err) {
      setBtn(btn, 'bbd-err');
      setStatus('Failed (' + (post.id || '?') + '): ' + (err && err.message || err), true);
      FAILED.push(post);
    }
  }

  async function pool(items, limit, worker) {
    var next = 0, done = 0, failed = 0;
    async function runner() {
      for (;;) {
        var my = next++;
        if (my >= items.length) return;
        try { await worker(items[my], my, done + failed); }
        catch (e) {
          failed++;
          log('item failed', e && e.message);
        }
        done++;
        setStatus('Working\u2026 ' + (done + failed) + '/' + items.length + (failed ? ' (' + failed + ' failed)' : ''));
        await sleep(Math.max(0, CFG.gapMs));
      }
    }
    var runners = [];
    for (var i = 0; i < Math.min(Math.max(1, limit), items.length); i++) runners.push(runner());
    await Promise.all(runners);
    return { failed: failed, done: done };
  }

  async function batch(asZip) {
    var posts = ORDER.filter(function (u) { return SEL.has(u); }).map(function (u) { return POSTS[u]; });
    if (!posts.length) { setStatus('Nothing selected yet.', true); return; }
    var namer = makeNamer();
    var collected = [];
    FAILED = [];

    if (asZip) {
      var zw = new ZipWriter();
      var res = await pool(posts, CFG.concurrency, async function (post) {
        var meta = await resolveMeta(post);
        var name = namer.build(meta);
        var blob = await fetchBlob(meta.fileUrl);
        var bytes = new Uint8Array(await blob.arrayBuffer());
        if (zw.size + bytes.length > 3800000000) {
          throw new Error('this archive would pass 3.8 GB - use Download selected instead');
        }
        zw.add(name, bytes);
        collected.push(name);
        if (post.btn) setBtn(post.btn, 'bbd-ok');
      });
      setStatus('Packaging ' + collected.length + ' files\u2026');
      var zipName = 'booru_selection_' + new Date().toISOString().slice(0, 19).replace(/[:T]/g, '-') + '.zip';
      anchorSave(zw.finish(), zipName);
      setStatus('ZIP built: ' + zipName + ' (' + collected.length + ' files, ' + res.failed + ' failed)');
      return;
    }

    var res2 = await pool(posts, CFG.concurrency, async function (post) {
      if (post.btn) setBtn(post.btn, 'bbd-busy');
      try {
        var meta = await resolveMeta(post);
        var name = namer.build(meta);
        await downloadOne(meta, name);
        if (post.btn) setBtn(post.btn, 'bbd-ok');
      } catch (e) {
        if (post.btn) setBtn(post.btn, 'bbd-err');
        FAILED.push(post);
        throw e;
      }
    });
    setStatus('Done: ' + (posts.length - res2.failed) + ' saved' + (res2.failed ? ', ' + res2.failed + ' failed (see Retry failed)' : '') + '.', !!res2.failed);
  }

  async function retryFailed() {
    if (!FAILED.length) { setStatus('No failed items to retry.'); return; }
    var list = FAILED.slice();
    FAILED = [];
    list.forEach(function (post) {
      if (post.uid) { SEL.add(post.uid); }
    });
    refreshCount();
    setStatus('Retrying ' + list.length + '\u2026');
    var namer = makeNamer();
    var res = await pool(list, CFG.concurrency, async function (post) {
      var meta = await resolveMeta(post);
      var name = namer.build(meta);
      await downloadOne(meta, name);
      if (post.btn) setBtn(post.btn, 'bbd-ok');
    });
    setStatus('Retry finished: ' + (list.length - res.failed) + ' ok, ' + res.failed + ' still failing.', !!res.failed);
  }

  /* ============================================================== 10. boot */

  function boot() {
    if (document.getElementById('bbd-panel')) return;
    buildPanel();
    var n = scan();
    setStatus(n ? ('Found ' + n + ' image' + (n === 1 ? '' : 's') + ' on this page.') : 'No supported images found on this page yet.');

    var pending = null;
    var mo = new MutationObserver(function () {
      if (pending) return;
      pending = setTimeout(function () { pending = null; scan(); refreshCount(); }, 400);
    });
    mo.observe(document.body, { childList: true, subtree: true });

    var lastUrl = location.href;
    setInterval(function () {
      if (location.href !== lastUrl) {
        lastUrl = location.href;
        META_CACHE.clear();
        POSTS = Object.create(null); ORDER = []; SEL = new Set(); LAST = -1;
        refreshCount();
        setTimeout(function () { scan(); refreshCount(); }, 700);
      }
    }, 800);

    window.addEventListener('keydown', function (e) {
      if (!e.altKey) return;
      if (e.key === 'd' || e.key === 'D') { e.preventDefault(); batch(false); }
      if (e.key === 'a' || e.key === 'A') { e.preventDefault(); selectAll(); }
    });

    log('ready on', host());
  }

  if (document.body) boot();
  else window.addEventListener('DOMContentLoaded', boot);
})();