SpicyChat Export — JSON or .card.png

Fetches https://prod.nd-api.com/v2/characters/<id>, maps to SC Card v1, exports as .card.json or embeds metadata into avatar as .card.png. Bulk doesn't work so ignore or fix it and let me know. ;-)

ही स्क्रिप्ट इंस्टॉल करण्यासाठी तुम्हाला Tampermonkey, Greasemonkey किंवा Violentmonkey यासारखे एक्स्टेंशन इंस्टॉल करावे लागेल.

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

ही स्क्रिप्ट इंस्टॉल करण्यासाठी तुम्हाला Tampermonkey किंवा Violentmonkey यासारखे एक्स्टेंशन इंस्टॉल करावे लागेल..

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

ही स्क्रिप्ट इंस्टॉल करण्यासाठी तुम्हाला Tampermonkey यासारखे एक्स्टेंशन इंस्टॉल करावे लागेल..

ही स्क्रिप्ट इंस्टॉल करण्यासाठी तुम्हाला एक युझर स्क्रिप्ट व्यवस्थापक एक्स्टेंशन इंस्टॉल करावे लागेल.

(माझ्याकडे आधीच युझर स्क्रिप्ट व्यवस्थापक आहे, मला इंस्टॉल करू द्या!)

ही स्टाईल इंस्टॉल करण्यासाठी तुम्हाला Stylus सारखे एक्स्टेंशन इंस्टॉल करावे लागेल.

ही स्टाईल इंस्टॉल करण्यासाठी तुम्हाला Stylus सारखे एक्स्टेंशन इंस्टॉल करावे लागेल.

ही स्टाईल इंस्टॉल करण्यासाठी तुम्हाला Stylus सारखे एक्स्टेंशन इंस्टॉल करावे लागेल.

ही स्टाईल इंस्टॉल करण्यासाठी तुम्हाला एक युझर स्टाईल व्यवस्थापक इंस्टॉल करावे लागेल.

ही स्टाईल इंस्टॉल करण्यासाठी तुम्हाला एक युझर स्टाईल व्यवस्थापक इंस्टॉल करावे लागेल.

ही स्टाईल इंस्टॉल करण्यासाठी तुम्हाला एक युझर स्टाईल व्यवस्थापक इंस्टॉल करावे लागेल.

(माझ्याकडे आधीच युझर स्टाईल व्यवस्थापक आहे, मला इंस्टॉल करू द्या!)

// ==UserScript==
// @name         SpicyChat Export — JSON or .card.png
// @namespace    spicychat-export-v1
// @version      1.2.1
// @description  Fetches https://prod.nd-api.com/v2/characters/<id>, maps to SC Card v1, exports as .card.json or embeds metadata into avatar as .card.png. Bulk doesn't work so ignore or fix it and let me know. ;-)
// @match        https://spicychat.ai/chat/*
// @match        https://spicychat.ai/chatbot/*
// @match        https://spicychat.ai/my-creations/chatbots
// @grant        GM_download
// @grant        GM_addStyle
// @grant        GM_xmlhttpRequest
// @connect      prod.nd-api.com
// @connect      cdn.nd-api.com
// @require      https://cdnjs.cloudflare.com/ajax/libs/jszip/3.10.1/jszip.min.js
// ==/UserScript==

(() => {
  "use strict";

  const API_BASE = "https://prod.nd-api.com/v2/characters/";
  const AUTH_STORAGE_KEY = "sc_export_v1_auth"; // { token, guest }

  const BULK = {
    concurrency: 3,   // lower if you hit rate limits or memory issues
    idWaitMs: 12000,  // how long to wait for SPA render on my-creations page
  };

  // -----------------------------
  // UI
  // -----------------------------
  GM_addStyle(`
    .scx-toast {
      position: fixed; right: 18px; bottom: 78px; z-index: 999999;
      padding: 10px 12px; border-radius: 10px;
      background: rgba(20,20,22,0.92);
      border: 1px solid rgba(255,255,255,0.12);
      color: #fff;
      font: 12px/1.35 system-ui, -apple-system, Segoe UI, Roboto, Arial, sans-serif;
      max-width: 580px;
      box-shadow: 0 10px 30px rgba(0,0,0,0.45);
      display: none; white-space: pre-wrap;
    }
    .scx-btn {
      position: fixed; right: 18px; bottom: 18px; z-index: 999999;
      padding: 10px 14px; border-radius: 999px;
      border: 1px solid rgba(255,255,255,0.18);
      background: rgba(20,20,22,0.92); color: #fff;
      font: 13px/1.2 system-ui, -apple-system, Segoe UI, Roboto, Arial, sans-serif;
      cursor: pointer; box-shadow: 0 10px 30px rgba(0,0,0,0.45);
      user-select: none; white-space: nowrap;
      display: flex; align-items: center; gap: 10px;
    }
    .scx-btn:hover { filter: brightness(1.08); }

    .scx-menu {
      position: fixed; right: 18px; bottom: 62px; z-index: 1000000;
      width: 320px;
      background: rgba(20,20,22,0.96);
      border: 1px solid rgba(255,255,255,0.14);
      border-radius: 12px;
      box-shadow: 0 14px 44px rgba(0,0,0,0.55);
      padding: 10px;
      display: none;
      color: #fff;
      font: 13px/1.2 system-ui, -apple-system, Segoe UI, Roboto, Arial, sans-serif;
    }
    .scx-menu-title {
      font-weight: 700;
      margin: 4px 6px 10px 6px;
      opacity: 0.95;
    }
    .scx-menu button {
      width: 100%;
      padding: 10px 10px;
      border-radius: 10px;
      border: 1px solid rgba(255,255,255,0.14);
      background: rgba(255,255,255,0.06);
      color: #fff;
      cursor: pointer;
      text-align: left;
      margin: 6px 0;
    }
    .scx-menu button:hover { background: rgba(255,255,255,0.10); }
    .scx-menu small { display:block; opacity: 0.75; margin-top: 4px; }
    .scx-menu .scx-danger { border-color: rgba(255,120,120,0.25); }
    .scx-menu .scx-row { display:flex; gap: 8px; margin-top: 8px; }
    .scx-menu .scx-row button { width: 50%; text-align:center; }
    .scx-sep {
      height: 1px;
      background: rgba(255,255,255,0.10);
      margin: 8px 4px;
      border-radius: 1px;
    }
  `);

  let toastTimer = null;
  function toast(msg) {
    let el = document.querySelector(".scx-toast");
    if (!el) {
      el = document.createElement("div");
      el.className = "scx-toast";
      document.body.appendChild(el);
    }
    el.textContent = msg;
    el.style.display = "block";
    clearTimeout(toastTimer);
    toastTimer = setTimeout(() => (el.style.display = "none"), 5200);
  }

  function isMyCreationsPage() {
    return location.pathname === "/my-creations/chatbots";
  }

  function ensureUi() {
    if (document.querySelector(".scx-btn")) return;

    const btn = document.createElement("button");
    btn.className = "scx-btn";
    btn.type = "button";
    btn.textContent = "⬇ Export";

    const menu = document.createElement("div");
    menu.className = "scx-menu";

    const bulkHtml = isMyCreationsPage() ? `
      <div class="scx-sep"></div>
      <div class="scx-menu-title" style="margin-top:10px;">Bulk export (My Creations)</div>
      <button data-action="bulk-both">
        Bulk export ZIP — JSON + PNG
        <small>Exports all bots on this page to one ZIP (json/ + png/ folders)</small>
      </button>
      <button data-action="bulk-json">
        Bulk export ZIP — JSON only
        <small>Faster and smaller</small>
      </button>
      <button data-action="bulk-png">
        Bulk export ZIP — PNG only
        <small>Slower; converts avatars and embeds metadata</small>
      </button>
    ` : "";

    menu.innerHTML = `
      <div class="scx-menu-title">Export options</div>

      <button data-action="json">
        Export .card.json
        <small>SC Card v1 JSON (persona→description)</small>
      </button>

      <button data-action="png">
        Export .card.png
        <small>Embeds Card v1 JSON into the avatar PNG (tEXt: chara)</small>
      </button>

      ${bulkHtml}

      <div class="scx-sep"></div>

      <button data-action="token" class="scx-danger">
        Set / Update Token
        <small>Fix 401 invalid/expired token</small>
      </button>

      <div class="scx-row">
        <button data-action="close">Close</button>
        <button data-action="help">Help</button>
      </div>
    `;

    function toggleMenu(show) {
      menu.style.display = show ? "block" : "none";
    }

    btn.addEventListener("click", () => {
      toggleMenu(menu.style.display !== "block");
    });

    // Close when clicking outside
    document.addEventListener("click", (e) => {
      const within = menu.contains(e.target) || btn.contains(e.target);
      if (!within) toggleMenu(false);
    });

    menu.addEventListener("click", async (e) => {
      const action = e.target?.closest("button")?.dataset?.action;
      if (!action) return;

      if (action === "close") {
        toggleMenu(false);
        return;
      }
      if (action === "help") {
        toast(
          "If export fails with 401:\n" +
          "1) DevTools → Network → find a successful prod.nd-api.com request\n" +
          "2) Copy the Authorization Bearer token\n" +
          "3) Click “Set / Update Token”\n\n" +
          "Bulk export:\n" +
          "- Open https://spicychat.ai/my-creations/chatbots\n" +
          "- Click Export → Bulk export ZIP"
        );
        return;
      }
      if (action === "token") {
        try {
          const token = (prompt("Paste access token (JWT). Saved locally.") || "").trim();
          if (!looksLikeJwt(token)) return toast("That doesn’t look like a JWT.\nNothing saved.");
          const guest = (prompt("Optional: guest_userid (UUID). Leave blank if unknown.") || "").trim();
          saveAuth(token, guest);
          toast("Saved ✅\nToken stored for exports.");
        } catch (err) {
          toast(`Failed to save token ❌\n${String(err?.message || err)}`);
        }
        return;
      }

      toggleMenu(false);

      try {
        toast("Auth check…");
        const auth = await ensureAuthInteractive();

        // BULK
        if (action.startsWith("bulk-")) {
          if (!isMyCreationsPage()) throw new Error("Bulk export only works on /my-creations/chatbots");
          if (typeof JSZip === "undefined") throw new Error("JSZip not loaded (@require failed).");

          const mode =
            action === "bulk-json" ? "json" :
            action === "bulk-png" ? "png" : "both";

          await bulkExportZip({ auth, mode });
          return;
        }

        // SINGLE (existing behavior)
        const id = detectCharacterId();
        if (!id) throw new Error("Couldn’t detect character id from URL. Open a /chat/<id> page.");

        toast(`Fetching definition…\n${id}`);
        const sc = await fetchScCharacter(id, auth);

        const card = scToCardV1(sc);
        const safeName = safeFileStem(card.name);

        if (action === "json") {
          const filename = `${safeName}.card.json`;
          downloadJson(card, filename);
          toast(`Exported ✅\n${filename}`);
          return;
        }

        if (action === "png") {
          const avatarUrl = resolveAvatarUrl(sc);
          if (!avatarUrl) throw new Error("No avatar_url on character.");

          toast("Fetching avatar…");
          const avatarBytes = await gmFetchArrayBuffer(avatarUrl);

          toast("Converting to PNG…");
          const pngBytes = await toPngArrayBuffer(avatarBytes);

          toast("Embedding metadata…");
          const embedded = embedCharaTextChunk(pngBytes, card);

          const filename = `${safeName}.card.png`;
          downloadPng(embedded, filename);
          toast(`Exported ✅\n${filename}`);
          return;
        }
      } catch (err) {
        toast(`Export failed ❌\n${String(err?.message || err)}`);
      }
    });

    document.body.appendChild(btn);
    document.body.appendChild(menu);

    const saved = readSavedAuth();
    toast(saved?.token ? "Exporter ready ✅\n(Using saved token)" : "Exporter ready ✅\n(Set token if you hit 401)");
  }

  // SPA navigation support
  let lastHref = location.href;
  setInterval(() => {
    if (location.href !== lastHref) {
      lastHref = location.href;
      ensureUi();
    }
  }, 800);

  if (document.readyState === "loading") {
    document.addEventListener("DOMContentLoaded", ensureUi);
  } else {
    ensureUi();
  }

  // -----------------------------
  // Bulk export
  // -----------------------------
  async function bulkExportZip({ auth, mode }) {
    toast("Waiting for bots to render…");
    await waitForMyCreationsBots(BULK.idWaitMs);

    toast("Collecting bot IDs…");
    const ids = collectMyCreationsBotIds();

    if (!ids.length) {
      throw new Error(
        "No bot IDs found on the page.\n" +
        "If the site renders bots without /chatbot/ URLs, we’ll need to hook the API response."
      );
    }

    const stamp = new Date().toISOString().replace(/[:]/g, "-").replace(/\..+/, "");
    const zipName = `spicychat-my-creations-${stamp}.zip`;

    toast(`Starting bulk export…\nFound ${ids.length} bots`);

    const zip = new JSZip();
    const folderJson = zip.folder("json");
    const folderPng  = zip.folder("png");
    const folderFail = zip.folder("failures");

    let ok = 0, fail = 0;

    const tasks = ids.map((id) => async () => {
      try {
        const sc = await fetchScCharacter(id, auth);
        const card = scToCardV1(sc);
        const safeName = safeFileStem(card.name) || safeFileStem(id);

        if (mode === "json" || mode === "both") {
          folderJson.file(`${safeName}.card.json`, JSON.stringify(card, null, 2));
        }

        if (mode === "png" || mode === "both") {
          const avatarUrl = resolveAvatarUrl(sc);
          if (!avatarUrl) throw new Error("No avatar_url on character.");
          const avatarBytes = await gmFetchArrayBuffer(avatarUrl);
          const pngBytes = await toPngArrayBuffer(avatarBytes);
          const embedded = embedCharaTextChunk(pngBytes, card);
          folderPng.file(`${safeName}.card.png`, embedded);
        }

        ok++;
      } catch (e) {
        fail++;
        folderFail.file(`${safeFileStem(id)}.txt`, String(e?.message || e));
      } finally {
        const done = ok + fail;
        toast(`Bulk exporting…\n${done}/${ids.length}\nOK: ${ok}  Failed: ${fail}`);
      }
    });

    await runWithConcurrency(tasks, BULK.concurrency);

    toast("Building ZIP…");
    const blob = await zip.generateAsync({
      type: "blob",
      compression: "DEFLATE",
      compressionOptions: { level: 6 }
    });

    downloadBlob(blob, zipName);
    toast(`Bulk export complete ✅\n${zipName}\nOK: ${ok}  Failed: ${fail}`);
  }

  async function waitForMyCreationsBots(timeoutMs = 12000) {
    const start = Date.now();

    // quick check
    if (collectMyCreationsBotIds().length) return;

    let done = false;
    const obs = new MutationObserver(() => {
      if (done) return;
      if (collectMyCreationsBotIds().length) {
        done = true;
        obs.disconnect();
      }
    });

    obs.observe(document.documentElement, { childList: true, subtree: true });

    while (!done && (Date.now() - start) < timeoutMs) {
      await sleep(350);
      if (collectMyCreationsBotIds().length) {
        done = true;
        break;
      }
    }

    obs.disconnect();
  }

  // Robust collector: tries attributes, scripts, __NEXT_DATA__, and full HTML.
  // Does NOT assume UUIDs (ids can be non-UUID).
  function collectMyCreationsBotIds() {
    const ids = new Set();

    const extractFromText = (text) => {
      if (!text) return;
      const re = /\/(chatbot|chat)\/([^\/?#"'<>\\\s]+)/ig;
      let m;
      while ((m = re.exec(text))) {
        const id = (m[2] || "").trim();
        if (id && id.length >= 8) ids.add(id);
      }
    };

    // 1) attributes + onclick strings
    const attrCandidates = ["href", "data-href", "data-url", "to"];
    const nodes = Array.from(document.querySelectorAll("*"));
    for (const el of nodes) {
      for (const a of attrCandidates) {
        const v = el.getAttribute?.(a);
        if (v && v.includes("/chat")) extractFromText(v);
      }
      const oc = el.getAttribute?.("onclick");
      if (oc && oc.includes("/chat")) extractFromText(oc);
    }

    // 2) scripts
    for (const s of document.querySelectorAll("script")) {
      const t = s.textContent || "";
      if (t.includes("/chat")) extractFromText(t);
    }

    // 3) __NEXT_DATA__ deep scan
    const next = document.querySelector("script#__NEXT_DATA__");
    if (next?.textContent) {
      extractFromText(next.textContent);
      try {
        const obj = JSON.parse(next.textContent);
        (function walk(x) {
          if (!x) return;
          if (typeof x === "string") {
            if (x.includes("/chat")) extractFromText(x);
            return;
          }
          if (Array.isArray(x)) return x.forEach(walk);
          if (typeof x === "object") Object.values(x).forEach(walk);
        })(obj);
      } catch {}
    }

    // 4) full HTML fallback
    const html = document.documentElement?.innerHTML || "";
    if (html.includes("/chat")) extractFromText(html);

    return Array.from(ids);
  }

  async function runWithConcurrency(taskFns, concurrency) {
    let i = 0;
    const workers = new Array(concurrency).fill(0).map(async () => {
      while (i < taskFns.length) {
        const fn = taskFns[i++];
        await fn();
      }
    });
    await Promise.all(workers);
  }

  function downloadBlob(blob, filename) {
    const url = URL.createObjectURL(blob);
    const a = document.createElement("a");
    a.href = url;
    a.download = filename;
    document.body.appendChild(a);
    a.click();
    a.remove();
    setTimeout(() => URL.revokeObjectURL(url), 30_000);
  }

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

  // -----------------------------
  // Helpers
  // -----------------------------
  function safeFileStem(name) {
    return (String(name || "character").trim() || "character")
      .replace(/[^\w\-]+/g, "_")
      .replace(/^_+|_+$/g, "")
      .slice(0, 80) || "character";
  }

  function detectCharacterId() {
    const m = location.pathname.match(/\/chat\/([^\/?#]+)/i);
    if (m && m[1]) return m[1];
    const uuid = location.pathname.match(/[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}/i);
    return uuid ? uuid[0] : "";
  }

  // -----------------------------
  // Auth
  // -----------------------------
  function looksLikeJwt(s) {
    return typeof s === "string" &&
      /^[A-Za-z0-9\-_]+\.[A-Za-z0-9\-_]+\.[A-Za-z0-9\-_]+$/.test(s.trim());
  }

  function readSavedAuth() {
    try {
      const raw = localStorage.getItem(AUTH_STORAGE_KEY);
      if (!raw) return null;
      const obj = JSON.parse(raw);
      return {
        token: typeof obj.token === "string" ? obj.token.trim() : "",
        guest: typeof obj.guest === "string" ? obj.guest.trim() : ""
      };
    } catch { return null; }
  }

  function saveAuth(token, guest) {
    localStorage.setItem(AUTH_STORAGE_KEY, JSON.stringify({ token, guest }));
  }

  function findFirstJwtInObject(obj) {
    if (!obj) return null;
    if (typeof obj === "string" && looksLikeJwt(obj)) return obj;

    if (Array.isArray(obj)) {
      for (const it of obj) {
        const hit = findFirstJwtInObject(it);
        if (hit) return hit;
      }
      return null;
    }

    if (typeof obj === "object") {
      for (const k of Object.keys(obj)) {
        const v = obj[k];
        if (typeof v === "string" && looksLikeJwt(v)) return v;
        if (v && typeof v === "object") {
          const hit = findFirstJwtInObject(v);
          if (hit) return hit;
        }
      }
    }
    return null;
  }

  function autoDiscoverToken() {
    const candidates = [];
    const scan = (store) => {
      for (let i = 0; i < store.length; i++) {
        const key = store.key(i);
        if (!key) continue;
        let val = "";
        try { val = store.getItem(key) || ""; } catch { continue; }
        if (!val) continue;

        if (looksLikeJwt(val)) candidates.push(val.trim());

        if ((val.startsWith("{") && val.endsWith("}")) || (val.startsWith("[") && val.endsWith("]"))) {
          try {
            const parsed = JSON.parse(val);
            const hit = findFirstJwtInObject(parsed);
            if (hit) candidates.push(hit.trim());
          } catch {}
        }
      }
    };
    scan(localStorage);
    scan(sessionStorage);
    candidates.sort((a, b) => b.length - a.length);
    return candidates[0] || "";
  }

  function autoDiscoverGuestUserId() {
    const uuidRe = /[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}/i;
    const keys = ["guest_userid","guestUserId","x-guest-userid","nd_guest_userid","spicychat_guest_userid"];
    for (const k of keys) {
      const v = (localStorage.getItem(k) || sessionStorage.getItem(k) || "").trim();
      const m = v.match(uuidRe);
      if (m) return m[0];
    }
    const scan = (store) => {
      for (let i = 0; i < store.length; i++) {
        const key = store.key(i);
        if (!key) continue;
        let val = "";
        try { val = store.getItem(key) || ""; } catch { continue; }
        const m = val.match(uuidRe);
        if (m) return m[0];
      }
      return "";
    };
    return scan(localStorage) || scan(sessionStorage) || "";
  }

  async function ensureAuthInteractive() {
    const saved = readSavedAuth();
    if (saved?.token && looksLikeJwt(saved.token)) return saved;

    const token = autoDiscoverToken();
    const guest = autoDiscoverGuestUserId();
    if (token && looksLikeJwt(token)) {
      saveAuth(token, guest || "");
      return { token, guest: guest || "" };
    }

    const pasted = prompt("Paste your access token (JWT). It will be saved locally.");
    const t = (pasted || "").trim();
    if (!looksLikeJwt(t)) throw new Error("Token not set or doesn’t look like a JWT.");
    const g = (prompt("Optional: guest_userid (UUID). Leave blank if unknown.") || "").trim();
    saveAuth(t, g);
    return { token: t, guest: g };
  }

  // -----------------------------
  // Fetch SC character
  // -----------------------------
  async function fetchScCharacter(id, auth) {
    const url = API_BASE + encodeURIComponent(id);

    const headers = {
      "Accept": "application/json",
      "Authorization": `Bearer ${auth.token}`,
      "x-app-id": "spicychat"
    };
    if (auth.guest) headers["x-guest-userid"] = auth.guest;

    const res = await fetch(url, { method: "GET", credentials: "include", headers });
    const text = await res.text().catch(() => "");

    if (!res.ok) {
      if (res.status === 401) localStorage.removeItem(AUTH_STORAGE_KEY);
      throw new Error(`API ${res.status}\n${text.slice(0, 500)}`);
    }

    let data = null;
    try { data = JSON.parse(text); } catch {}
    if (!data || typeof data !== "object") throw new Error("API returned non-JSON response.");

    return (data.data && typeof data.data === "object") ? data.data : data;
  }

  // -----------------------------
  // SC -> Card v1 mapping
  // persona -> description
  // -----------------------------
  function scToCardV1(sc) {
    const now = Date.now();
    return {
      name: String(sc?.name || "Unnamed").trim(),
      description: String(sc?.persona || "").trim(),
      title: String(sc?.title || "").trim(),
      scenario: String(sc?.scenario || "").trim(),
      first_mes: String(sc?.greeting || "").trim(),
      mes_example: String(sc?.dialogue || "").trim(),
      tags: Array.isArray(sc?.tags)
        ? sc.tags.map(t => String(t).trim()).filter(Boolean)
        : [],
      metadata: {
        version: 1,
        created: now,
        modified: now,
        source: {
          platform: "SpicyChat",
          id: String(sc?.id || "").trim(),
          creator_username: String(sc?.creator_username || "").trim()
        }
      }
    };
  }

  // -----------------------------
  // JSON download (single)
  // -----------------------------
  function downloadJson(obj, filename) {
    const jsonText = JSON.stringify(obj, null, 2);
    const dataUrl = "data:application/json;charset=utf-8," + encodeURIComponent(jsonText);

    if (typeof GM_download === "function") {
      GM_download({
        url: dataUrl,
        name: filename,
        saveAs: true,
        onerror: () => fallbackDownload(dataUrl, filename),
        ontimeout: () => fallbackDownload(dataUrl, filename)
      });
      return;
    }
    fallbackDownload(dataUrl, filename);
  }

  function fallbackDownload(url, filename) {
    const a = document.createElement("a");
    a.href = url;
    a.download = filename;
    document.body.appendChild(a);
    a.click();
    a.remove();
  }

  // -----------------------------
  // Avatar fetch + PNG conversion
  // -----------------------------
  function resolveAvatarUrl(sc) {
    const raw = String(sc?.avatar_url || "").trim();
    if (!raw) return "";
    if (raw.startsWith("http://") || raw.startsWith("https://")) return raw;
    if (raw.startsWith("/")) return "https://cdn.nd-api.com" + raw;
    return "https://cdn.nd-api.com/" + raw.replace(/^\/+/, "");
  }

  function gmFetchArrayBuffer(url) {
    return new Promise((resolve, reject) => {
      GM_xmlhttpRequest({
        method: "GET",
        url,
        responseType: "arraybuffer",
        onload: (res) => {
          if (res.status >= 200 && res.status < 300 && res.response) resolve(res.response);
          else reject(new Error(`Avatar fetch failed ${res.status}`));
        },
        onerror: () => reject(new Error("Avatar fetch network error"))
      });
    });
  }

  async function toPngArrayBuffer(imageArrayBuffer) {
    const blob = new Blob([imageArrayBuffer]);
    const bmp = await createImageBitmap(blob);

    const canvas = document.createElement("canvas");
    canvas.width = bmp.width;
    canvas.height = bmp.height;

    const ctx = canvas.getContext("2d");
    ctx.drawImage(bmp, 0, 0);

    const pngBlob = await new Promise((resolve) => canvas.toBlob(resolve, "image/png"));
    return await pngBlob.arrayBuffer();
  }

  // -----------------------------
  // PNG metadata embedding (tEXt "chara" = base64(json))
  // -----------------------------
  function crc32(buf) {
    let c = ~0;
    for (let i = 0; i < buf.length; i++) {
      c ^= buf[i];
      for (let k = 0; k < 8; k++) c = (c >>> 1) ^ (0xEDB88320 & -(c & 1));
    }
    return ~c >>> 0;
  }

  function u32be(n) {
    const b = new Uint8Array(4);
    b[0] = (n >>> 24) & 255;
    b[1] = (n >>> 16) & 255;
    b[2] = (n >>> 8) & 255;
    b[3] = n & 255;
    return b;
  }

  function concatU8(...parts) {
    const len = parts.reduce((a, p) => a + p.length, 0);
    const out = new Uint8Array(len);
    let off = 0;
    for (const p of parts) { out.set(p, off); off += p.length; }
    return out;
  }

  function makeChunk(type4, dataU8) {
    const type = new TextEncoder().encode(type4);
    const len = u32be(dataU8.length);
    const crc = u32be(crc32(concatU8(type, dataU8)));
    return concatU8(len, type, dataU8, crc);
  }

  function parsePngChunks(pngU8) {
    const sig = pngU8.slice(0, 8);
    let off = 8;
    const chunks = [];

    while (off + 8 <= pngU8.length) {
      const len = (pngU8[off] << 24) | (pngU8[off+1] << 16) | (pngU8[off+2] << 8) | pngU8[off+3];
      const type = new TextDecoder().decode(pngU8.slice(off+4, off+8));
      const dataStart = off + 8;
      const dataEnd = dataStart + len;
      const crcEnd = dataEnd + 4;

      chunks.push({ type, rawStart: off, rawEnd: crcEnd });
      off = crcEnd;
      if (type === "IEND") break;
    }
    return { signature: sig, chunks };
  }

  function b64Utf8(s) {
    return btoa(unescape(encodeURIComponent(s)));
  }

  function embedCharaTextChunk(pngArrayBuffer, cardJsonObj) {
    const pngU8 = new Uint8Array(pngArrayBuffer);
    const { signature, chunks } = parsePngChunks(pngU8);

    const jsonText = JSON.stringify(cardJsonObj);
    const b64 = b64Utf8(jsonText);

    // PNG tEXt payload: keyword\0text
    const payload = "chara\0" + b64;
    const textData = new TextEncoder().encode(payload);
    const textChunk = makeChunk("tEXt", textData);

    const outParts = [signature];
    for (const ch of chunks) {
      if (ch.type === "IEND") outParts.push(textChunk);
      outParts.push(pngU8.slice(ch.rawStart, ch.rawEnd));
    }
    return concatU8(...outParts).buffer;
  }

  function downloadPng(arrayBuffer, filename) {
    const blob = new Blob([arrayBuffer], { type: "image/png" });
    const url = URL.createObjectURL(blob);

    // Anchor download is the most reliable for blob binary in browsers
    const a = document.createElement("a");
    a.href = url;
    a.download = filename;
    document.body.appendChild(a);
    a.click();
    a.remove();

    setTimeout(() => URL.revokeObjectURL(url), 10_000);
  }
})();