Wallhaven Batch Tools

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

이 스크립트를 설치하려면 Tampermonkey, Greasemonkey 또는 Violentmonkey와 같은 확장 프로그램이 필요합니다.

이 스크립트를 설치하려면 Tampermonkey와 같은 확장 프로그램을 설치해야 합니다.

이 스크립트를 설치하려면 Tampermonkey 또는 Violentmonkey와 같은 확장 프로그램이 필요합니다.

이 스크립트를 설치하려면 Tampermonkey 또는 Userscripts와 같은 확장 프로그램이 필요합니다.

이 스크립트를 설치하려면 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();
})();