Wallhaven Batch Tools

Select multiple wallpapers for batch download and batch bookmark on wallhaven.cc

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

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

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

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

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

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

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

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

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

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

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

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

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

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

// ==UserScript==
// @name         Wallhaven Batch Tools
// @namespace    https://wallhaven.cc/
// @version      1.0.1
// @description  Select multiple wallpapers for batch download and batch bookmark on wallhaven.cc
// @author       saltyegg
// @license      MIT
// @match        https://wallhaven.cc/*
// @grant        GM_getValue
// @grant        GM_setValue
// @run-at       document-end
// @icon         https://wallhaven.cc/favicon.ico
// ==/UserScript==

(function () {
  "use strict";

  // Only activate on listing pages (search, latest, toplist, collections, etc.)
  if (!document.querySelector(".thumb-listing-page")) return;

  // ─── State ────────────────────────────────────────────────────────────────

  const state = {
    selectMode: false,
    selected: new Set(), // Set of wallpaper ID strings
    apiKey: GM_getValue("wbt_apikey", ""),
    username: GM_getValue("wbt_username", ""),
    collections: [],
    chosenColId: "",
  };

  // ─── Wallpaper helpers ────────────────────────────────────────────────────

  function getWpId(li) {
    const fig = li.querySelector("figure[data-wallpaper-id]");
    return fig ? fig.dataset.wallpaperId : null;
  }

  function getWpUrl(id, li) {
    const pre = id.substring(0, 2);
    const ext = li.querySelector("span.png") ? "png" : "jpg";
    return `https://w.wallhaven.cc/full/${pre}/wallhaven-${id}.${ext}`;
  }

  function allLiItems() {
    return Array.from(document.querySelectorAll(".thumb-listing-page li"));
  }

  // ─── API helpers ──────────────────────────────────────────────────────────

  async function apiFetch(path, options = {}) {
    const sep = path.includes("?") ? "&" : "?";
    const res = await fetch(
      `https://wallhaven.cc${path}${sep}apikey=${state.apiKey}`,
      options,
    );
    if (!res.ok) {
      const text = await res.text();
      throw new Error(`HTTP ${res.status}: ${text.slice(0, 200)}`);
    }
    return res.json();
  }

  // Extract username from the page header (no API call needed)
  function getUsernameFromPage() {
    const links = document.querySelectorAll("#header a, nav a, .topbar a");
    for (const link of links) {
      const m = (link.href || "").match(/wallhaven\.cc\/user\/([^/?#\s]+)/);
      if (m) return m[1];
    }
    return "";
  }

  async function fetchUsernameFromApi() {
    const data = await apiFetch("/api/v1/user/settings");
    return data.data?.username || "";
  }

  async function fetchCollections() {
    const data = await apiFetch("/api/v1/collections");
    state.collections = data.data || [];
    return state.collections;
  }

  // ─── Web-interface bookmark (session-based, no API write endpoint exists) ──

  // URL templates cached after the first successful probe, reused for the whole batch.
  // __WP__ = wallpaper ID placeholder, __COL__ = collection ID placeholder.
  const bk = {
    addFavTemplate: null, // for already-favorited wallpapers: POST to assign collection
    favTemplate: null, // for not-yet-favorited wallpapers: POST to add to favorites
    moveUrl: null, // drag-drop endpoint (.collections-list[data-target])
  };

  function csrf() {
    return document.querySelector('meta[name="csrf-token"]')?.content || "";
  }

  async function webPost(url, body) {
    const headers = {
      "X-CSRF-TOKEN": csrf(),
      "X-Requested-With": "XMLHttpRequest",
      Accept: "application/json",
    };
    if (body) headers["Content-Type"] = "application/x-www-form-urlencoded";
    const res = await fetch(url, {
      method: "POST",
      headers,
      body: body ? new URLSearchParams(body).toString() : undefined,
      credentials: "include",
    });
    if (!res.ok) throw new Error(`HTTP ${res.status}`);
    return res.json().catch(() => ({ status: false }));
  }

  // Fetch a wallpaper page while logged in and extract URL templates from its HTML.
  // .add-fav links are server-rendered per-collection assignment links.
  // .collections-list[data-target] is JS-rendered so often absent in the static fetch.
  async function probeWallpaperPage(wallpaperId) {
    const res = await fetch(`/w/${wallpaperId}`, { credentials: "include" });
    const html = await res.text();
    const doc = new DOMParser().parseFromString(html, "text/html");

    const addFavLinks = [...doc.querySelectorAll(".add-fav[href]")];
    const favBtnHref = doc
      .querySelector("#fav-button[href]")
      ?.getAttribute("href");
    const colListTarget = doc.querySelector(".collections-list[data-target]")
      ?.dataset.target;

    console.log(
      "[WBT] probe",
      wallpaperId,
      "| status:",
      res.status,
      "| .add-button:",
      !!doc.querySelector(".add-button"),
      "| .add-fav count:",
      addFavLinks.length,
      "| .add-fav hrefs:",
      addFavLinks.map((l) => l.getAttribute("href")),
      "| #fav-button[href]:",
      favBtnHref,
      "| .collections-list[data-target]:",
      colListTarget,
    );

    // Extract addFavTemplate from .add-fav links.
    // Each link's href encodes both the wallpaper ID and a collection ID,
    // e.g. /favorites/gw2ewl/add/672724 → template: /favorites/__WP__/add/__COL__
    if (!bk.addFavTemplate && addFavLinks.length > 0) {
      for (const link of addFavLinks) {
        const href = link.getAttribute("href");
        // Try to match a known collection ID in the href
        for (const col of state.collections) {
          if (href.includes(String(col.id))) {
            bk.addFavTemplate = href
              .replace(wallpaperId, "__WP__")
              .replace(String(col.id), "__COL__");
            break;
          }
        }
        // Fallback: replace wallpaper ID only; collection slot is whatever remains
        if (!bk.addFavTemplate) {
          bk.addFavTemplate = href.replace(wallpaperId, "__WP__");
          // We'll substitute __COL__ at a trailing numeric segment when using it
        }
        if (bk.addFavTemplate) break;
      }
    }

    // favTemplate: #fav-button is a plain anchor when the wallpaper is NOT in favorites
    if (!bk.favTemplate && favBtnHref) {
      bk.favTemplate = favBtnHref.replace(wallpaperId, "__WP__");
    }

    // moveUrl: drag-drop collection assignment (only present in JS-rendered pages)
    if (!bk.moveUrl && colListTarget) {
      bk.moveUrl = colListTarget;
    }
  }

  async function addOneToCollection(wallpaperId, collectionId) {
    const colIdStr = String(collectionId);

    // ── Fast path: use cached templates ────────────────────────────────────
    if (bk.addFavTemplate) {
      const url = bk.addFavTemplate
        .replace("__WP__", wallpaperId)
        .replace("__COL__", colIdStr);
      const d = await webPost(url);
      if (d.status) return;
    }
    if (bk.moveUrl) {
      const d = await webPost(bk.moveUrl, {
        _token: csrf(),
        wallpaper_id: wallpaperId,
        collection_id: collectionId,
      });
      if (d.status) return;
    }

    // ── Probe to populate templates ─────────────────────────────────────────
    await probeWallpaperPage(wallpaperId);

    if (bk.addFavTemplate) {
      const url = bk.addFavTemplate
        .replace("__WP__", wallpaperId)
        .replace("__COL__", colIdStr);
      const d = await webPost(url);
      if (d.status) return;
    }
    if (bk.moveUrl) {
      const d = await webPost(bk.moveUrl, {
        _token: csrf(),
        wallpaper_id: wallpaperId,
        collection_id: collectionId,
      });
      if (d.status) return;
    }

    // ── Not yet in favorites — add first, then assign ───────────────────────
    if (!bk.favTemplate) {
      throw new Error(
        `${wallpaperId}: no endpoint detected — see [WBT] probe in console`,
      );
    }
    const favUrl = bk.favTemplate.replace("__WP__", wallpaperId);
    const favData = await webPost(favUrl);
    if (!favData.status)
      throw new Error(`${wallpaperId}: failed to add to favorites`);

    // After favoriting, the server returns {status, view} where view is the updated
    // button HTML — it may now contain .add-fav links we can use
    if (favData.view) {
      const vd = new DOMParser().parseFromString(favData.view, "text/html");
      const newLinks = [...vd.querySelectorAll(".add-fav[href]")];
      // Find direct match or derive template
      const match = newLinks.find((l) =>
        l.getAttribute("href").includes(colIdStr),
      );
      if (match) {
        const d = await webPost(match.getAttribute("href"));
        if (d.status) return;
      }
      if (!bk.addFavTemplate && newLinks.length > 0) {
        const href = newLinks[0].getAttribute("href");
        for (const col of state.collections) {
          if (href.includes(String(col.id))) {
            bk.addFavTemplate = href
              .replace(wallpaperId, "__WP__")
              .replace(String(col.id), "__COL__");
            break;
          }
        }
      }
    }

    if (bk.addFavTemplate) {
      const url = bk.addFavTemplate
        .replace("__WP__", wallpaperId)
        .replace("__COL__", colIdStr);
      const d = await webPost(url);
      if (d.status) return;
    }

    throw new Error(`${wallpaperId}: all bookmark strategies failed`);
  }

  async function batchBookmark(collectionId, ids) {
    let ok = 0,
      fail = 0;
    for (let i = 0; i < ids.length; i++) {
      setStatus(`Bookmarking ${i + 1} / ${ids.length}…`);
      try {
        await addOneToCollection(ids[i], collectionId);
        ok++;
      } catch (e) {
        fail++;
        console.warn("[WBT] bookmark failed for", ids[i], e.message);
      }
      if (i < ids.length - 1) await sleep(200);
    }
    return { ok, fail };
  }

  // ─── Download ─────────────────────────────────────────────────────────────

  function downloadImage(url, filename) {
    return new Promise((resolve) => {
      const xhr = new XMLHttpRequest();
      xhr.open("GET", url, true);
      xhr.responseType = "blob";
      xhr.onload = () => {
        const blobUrl = URL.createObjectURL(xhr.response);
        const a = document.createElement("a");
        a.href = blobUrl;
        a.download = filename;
        document.body.appendChild(a);
        a.click();
        document.body.removeChild(a);
        setTimeout(() => URL.revokeObjectURL(blobUrl), 15000);
        resolve();
      };
      xhr.onerror = () => resolve(); // skip on error, don't stall queue
      xhr.send();
    });
  }

  function sleep(ms) {
    return new Promise((r) => setTimeout(r, ms));
  }

  async function batchDownload() {
    const ids = [...state.selected];
    if (!ids.length) return;

    // Build id→li map for URL resolution
    const liMap = {};
    allLiItems().forEach((li) => {
      const id = getWpId(li);
      if (id) liMap[id] = li;
    });

    dlBtn.disabled = true;
    for (let i = 0; i < ids.length; i++) {
      const id = ids[i];
      const li = liMap[id];
      if (!li) continue;
      setStatus(`Downloading ${i + 1} / ${ids.length}…`);
      await downloadImage(getWpUrl(id, li), `wallhaven-${id}`);
      // Small gap between downloads to avoid browser tab overload
      if (i < ids.length - 1) await sleep(350);
    }
    setStatus(`Downloaded ${ids.length} wallpaper(s) ✓`);
    dlBtn.disabled = false;
  }

  // ─── Selection UI ─────────────────────────────────────────────────────────

  function ensureCheckbox(li) {
    if (li.querySelector(".wbt-check")) return;

    const check = document.createElement("span");
    check.className = "wbt-check";
    check.textContent = "✓";
    li.appendChild(check);

    // Capture phase so we intercept before wallhaven's own click handlers
    li.addEventListener(
      "click",
      (e) => {
        if (!state.selectMode) return;
        e.preventDefault();
        e.stopPropagation();
        e.stopImmediatePropagation();
        const id = getWpId(li);
        if (id) toggleSelect(id, li);
      },
      true,
    );
  }

  function toggleSelect(id, li) {
    if (state.selected.has(id)) {
      state.selected.delete(id);
      li.classList.remove("wbt-sel");
    } else {
      state.selected.add(id);
      li.classList.add("wbt-sel");
    }
    syncToolbar();
  }

  function selectAll() {
    allLiItems().forEach((li) => {
      const id = getWpId(li);
      if (id) {
        state.selected.add(id);
        li.classList.add("wbt-sel");
      }
    });
    syncToolbar();
  }

  function deselectAll() {
    state.selected.clear();
    document
      .querySelectorAll(".wbt-sel")
      .forEach((el) => el.classList.remove("wbt-sel"));
    syncToolbar();
  }

  function initOverlays() {
    allLiItems().forEach((li) => ensureCheckbox(li));
  }

  // ─── Toolbar ──────────────────────────────────────────────────────────────

  let statusEl, modeBtn, dlBtn, bkBtn, colSelect;

  function buildToolbar() {
    const bar = document.createElement("div");
    bar.id = "wbt-bar";
    bar.innerHTML = `
            <span id="wbt-status">Batch Tools</span>
            <button id="wbt-mode-btn">☐ Select</button>
            <button id="wbt-all-btn">All</button>
            <button id="wbt-none-btn">None</button>
            <button id="wbt-dl-btn" disabled>⬇ Download (0)</button>
            <select id="wbt-col-sel"><option value="">— Collection —</option></select>
            <button id="wbt-bk-btn" disabled>🔖 Bookmark (0)</button>
            <button id="wbt-cfg-btn" title="Settings">⚙</button>
        `;
    document.body.appendChild(bar);

    statusEl = document.getElementById("wbt-status");
    modeBtn = document.getElementById("wbt-mode-btn");
    dlBtn = document.getElementById("wbt-dl-btn");
    bkBtn = document.getElementById("wbt-bk-btn");
    colSelect = document.getElementById("wbt-col-sel");

    modeBtn.addEventListener("click", toggleMode);
    document.getElementById("wbt-all-btn").addEventListener("click", selectAll);
    document
      .getElementById("wbt-none-btn")
      .addEventListener("click", deselectAll);
    dlBtn.addEventListener("click", batchDownload);
    bkBtn.addEventListener("click", handleBookmark);
    document
      .getElementById("wbt-cfg-btn")
      .addEventListener("click", openSettings);
    colSelect.addEventListener("change", () => {
      state.chosenColId = colSelect.value;
      syncToolbar();
    });
  }

  function toggleMode() {
    state.selectMode = !state.selectMode;
    modeBtn.textContent = state.selectMode ? "☑ Select ON" : "☐ Select";
    modeBtn.classList.toggle("wbt-on", state.selectMode);
    document.body.classList.toggle("wbt-selecting", state.selectMode);
    if (!state.selectMode) deselectAll();
    initOverlays();
  }

  function syncToolbar() {
    const n = state.selected.size;
    dlBtn.textContent = `⬇ Download (${n})`;
    dlBtn.disabled = n === 0;
    bkBtn.textContent = `🔖 Bookmark (${n})`;
    bkBtn.disabled = n === 0 || !state.chosenColId;
  }

  function setStatus(msg) {
    statusEl.textContent = msg;
  }

  function fillCollectionSelect(cols) {
    colSelect.innerHTML = '<option value="">— Collection —</option>';
    cols.forEach((col) => {
      const opt = document.createElement("option");
      opt.value = col.id;
      opt.textContent = `${col.label} (${col.count || 0})`;
      colSelect.appendChild(opt);
    });
  }

  // ─── Bookmark ─────────────────────────────────────────────────────────────

  async function handleBookmark() {
    if (!state.chosenColId) {
      setStatus("Select a collection first");
      return;
    }

    const ids = [...state.selected];
    setStatus(`Bookmarking ${ids.length}…`);
    bkBtn.disabled = true;

    try {
      const { ok, fail } = await batchBookmark(state.chosenColId, ids);
      setStatus(
        fail > 0
          ? `Done: ${ok} bookmarked, ${fail} failed (see console)`
          : `Bookmarked ${ok} wallpaper(s) ✓`,
      );
    } catch (e) {
      setStatus(`Bookmark error: ${e.message}`);
      console.error("[WBT] bookmark error", e);
    }

    bkBtn.disabled = false;
    syncToolbar();
  }

  // ─── Settings modal ───────────────────────────────────────────────────────

  function openSettings() {
    const existing = document.getElementById("wbt-modal");
    if (existing) {
      existing.remove();
      return;
    }

    const modal = document.createElement("div");
    modal.id = "wbt-modal";
    modal.innerHTML = `
            <div id="wbt-mbox">
                <button id="wbt-mclose">✕</button>
                <h3>Wallhaven Batch Tools — Settings</h3>
                <label>
                    API Key
                    <input id="wbt-api-input" type="text"
                           value="${state.apiKey}"
                           placeholder="Your wallhaven.cc API key"
                           autocomplete="off" spellcheck="false" />
                </label>
                <p class="wbt-hint">
                    Get your key at
                    <a href="https://wallhaven.cc/settings/account" target="_blank">
                        Settings → Account
                    </a>
                    (scroll to "API Key")
                </p>
                <div id="wbt-mbtn-row">
                    <button id="wbt-api-save">Save &amp; Load Collections</button>
                </div>
                <div id="wbt-minfo"></div>
            </div>
        `;
    document.body.appendChild(modal);

    modal.addEventListener("click", (e) => {
      if (e.target === modal) modal.remove();
    });
    document
      .getElementById("wbt-mclose")
      .addEventListener("click", () => modal.remove());
    document
      .getElementById("wbt-api-save")
      .addEventListener("click", saveAndLoadCollections);
  }

  async function saveAndLoadCollections() {
    const key = document.getElementById("wbt-api-input").value.trim();
    if (!key) return;
    state.apiKey = key;
    GM_setValue("wbt_apikey", key);

    const infoEl = document.getElementById("wbt-minfo");
    infoEl.textContent = "Loading…";

    try {
      // Prefer pulling username from the live page DOM (no extra API call)
      let username = getUsernameFromPage();
      if (!username) username = await fetchUsernameFromApi();
      state.username = username;
      GM_setValue("wbt_username", username);

      const cols = await fetchCollections();
      fillCollectionSelect(cols);
      infoEl.innerHTML = `Logged in as <strong>${username}</strong> — ${cols.length} collection(s) loaded`;
      setStatus(`Ready — ${cols.length} collections`);
    } catch (e) {
      infoEl.textContent = `Error: ${e.message}`;
      console.error("[WBT] settings load error", e);
    }
  }

  // ─── Watch for new cards (infinite scroll / pagination) ───────────────────

  function watchForNewCards() {
    const ul = document.querySelector(".thumb-listing-page ul");
    if (!ul) return;
    new MutationObserver(() => {
      if (state.selectMode) initOverlays();
    }).observe(ul, { childList: true });
  }

  // ─── CSS ──────────────────────────────────────────────────────────────────

  function injectCSS() {
    const style = document.createElement("style");
    style.textContent = `
            /* ── Floating toolbar ── */
            #wbt-bar {
                position: fixed; bottom: 18px; left: 50%;
                transform: translateX(-50%); z-index: 99999;
                display: flex; align-items: center; gap: 6px; flex-wrap: wrap;
                background: rgba(14,14,14,0.94);
                padding: 8px 14px; border-radius: 10px;
                box-shadow: 0 4px 28px rgba(0,0,0,0.65);
                font: 13px/1 'Segoe UI', Arial, sans-serif;
                color: #ddd; user-select: none;
                backdrop-filter: blur(8px);
                max-width: 92vw;
            }
            #wbt-bar button {
                background: #272727; color: #ddd; border: 1px solid #3e3e3e;
                border-radius: 5px; padding: 5px 10px; cursor: pointer;
                font-size: 12px; white-space: nowrap;
                transition: background .15s;
            }
            #wbt-bar button:hover:not(:disabled) { background: #353535; }
            #wbt-bar button:disabled { opacity: .35; cursor: default; }
            #wbt-bar button.wbt-on { background: #1a6ee8; border-color: #1a6ee8; color: #fff; }
            #wbt-bar select {
                background: #272727; color: #ddd; border: 1px solid #3e3e3e;
                border-radius: 5px; padding: 5px 7px; font-size: 12px;
                cursor: pointer; max-width: 170px;
            }
            #wbt-status { font-size: 11px; color: #888; min-width: 110px; }

            /* ── Per-thumbnail checkbox ── */
            .thumb-listing-page li { position: relative; }
            .wbt-check {
                position: absolute; top: 6px; left: 6px;
                width: 20px; height: 20px; line-height: 18px; text-align: center;
                background: rgba(0,0,0,0.52); border: 2px solid rgba(255,255,255,0.45);
                border-radius: 4px; font-size: 13px; font-weight: bold;
                color: transparent; z-index: 30; display: none; pointer-events: none;
                transition: background .1s, color .1s;
            }
            .wbt-selecting .wbt-check { display: block; }
            .wbt-sel .wbt-check { background: #1a6ee8; border-color: #1a6ee8; color: #fff; }

            /* Highlight border on selected card */
            .wbt-sel figure { outline: 3px solid #1a6ee8; outline-offset: -3px; border-radius: 3px; }

            /* Pointer cursor while in select mode */
            .wbt-selecting .thumb-listing-page li { cursor: pointer; }

            /* ── Settings modal ── */
            #wbt-modal {
                position: fixed; inset: 0; z-index: 100000;
                background: rgba(0,0,0,0.68);
                display: flex; align-items: center; justify-content: center;
            }
            #wbt-mbox {
                background: #181818; color: #ddd;
                border-radius: 10px; padding: 26px 30px; width: 420px; max-width: 94vw;
                box-shadow: 0 8px 44px rgba(0,0,0,0.85);
                font: 13px/1.65 'Segoe UI', Arial, sans-serif; position: relative;
            }
            #wbt-mbox h3 { margin: 0 0 18px; font-size: 15px; color: #fff; }
            #wbt-mbox label { display: block; font-size: 12px; color: #999; }
            #wbt-api-input {
                display: block; width: 100%; margin-top: 5px; padding: 8px 10px;
                background: #242424; border: 1px solid #383838; border-radius: 5px;
                color: #eee; font-size: 13px; box-sizing: border-box;
                font-family: monospace;
            }
            #wbt-api-input:focus { outline: 1px solid #1a6ee8; border-color: #1a6ee8; }
            .wbt-hint { font-size: 11px; color: #585858; margin: 7px 0 0; }
            .wbt-hint a { color: #5a9fd4; text-decoration: none; }
            .wbt-hint a:hover { text-decoration: underline; }
            #wbt-mbtn-row { margin-top: 16px; }
            #wbt-api-save {
                padding: 8px 18px; background: #1a6ee8; color: #fff;
                border: none; border-radius: 5px; cursor: pointer; font-size: 13px;
                transition: background .15s;
            }
            #wbt-api-save:hover { background: #1558c8; }
            #wbt-minfo { margin-top: 14px; font-size: 12px; color: #777; min-height: 18px; }
            #wbt-minfo strong { color: #ccc; }
            #wbt-mclose {
                position: absolute; top: 12px; right: 14px;
                background: transparent; border: none; color: #555;
                font-size: 16px; cursor: pointer; padding: 3px 7px; border-radius: 4px;
                line-height: 1;
            }
            #wbt-mclose:hover { background: #2a2a2a; color: #bbb; }
        `;
    document.head.appendChild(style);
  }

  // ─── Init ─────────────────────────────────────────────────────────────────

  async function init() {
    injectCSS();
    buildToolbar();
    watchForNewCards();

    if (state.apiKey) {
      setStatus("Loading collections…");
      try {
        // Resolve username: prefer DOM, fall back to API
        if (!state.username) {
          state.username =
            getUsernameFromPage() || (await fetchUsernameFromApi());
          GM_setValue("wbt_username", state.username);
        }
        const cols = await fetchCollections();
        fillCollectionSelect(cols);
        setStatus(`Ready — ${cols.length} collections`);
      } catch (e) {
        setStatus("API error — check ⚙ settings");
        console.error("[WBT] init error", e);
      }
    } else {
      setStatus("Set API key in ⚙");
    }
  }

  init();
})();