☰

JanitorAI Lorebook Exporter

Export JanitorAI lorebooks as SillyTavern World Info JSON.

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

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

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

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

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

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

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

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

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

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

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

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

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

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

このスクリプトの質問や評価の投稿はこちら、通報はこちらへお寄せください
// ==UserScript==
// @name         JanitorAI Lorebook Exporter
// @namespace    https://sleazyfork.org/en/scripts/600889-janitorai-lorebook-exporter
// @version      0.2.2
// @description  Export JanitorAI lorebooks as SillyTavern World Info JSON.
// @match        https://janitorai.com/*
// @icon         https://ella.janitorai.com/hotlink-ok/favicon-32x32.png
// @grant        none
// @noframes
// @run-at       document-idle
// @license      MIT
// ==/UserScript==

(function () {
  "use strict";

  const API = "/hampter";

  /* ------------------------------------------------------------------ */
  /* Conversion                                                          */
  /* ------------------------------------------------------------------ */

  // JanitorAI stores lorebook content as a JSON array, or an object keyed "0","1",...
  function parseEntries(raw) {
    const data = typeof raw === "string" ? JSON.parse(raw) : raw;
    if (Array.isArray(data)) return data.filter((e) => e && typeof e === "object");
    if (data && typeof data === "object") {
      const src = data.entries && typeof data.entries === "object" ? data.entries : data;
      return Object.keys(src)
        .sort((a, b) => (parseInt(a, 10) - parseInt(b, 10)) || a.localeCompare(b))
        .map((k) => src[k])
        .filter((e) => e && typeof e === "object");
    }
    return [];
  }

  function toList(v) {
    if (Array.isArray(v)) return v.map((s) => String(s).trim()).filter(Boolean);
    if (typeof v === "string") return v.split(",").map((s) => s.trim()).filter(Boolean);
    return [];
  }

  // "Jessie's Room" -> "Jessies_Room". Falls back to the first key, then "Entry_N".
  function tagName(e, keys, i) {
    const raw = e.name || e.comment || e.title || keys[0] || "";
    let tag = String(raw)
      .normalize("NFKD")
      .replace(/[\u0300-\u036f]/g, "")
      .replace(/['’`"]/g, "")
      .replace(/[^A-Za-z0-9_.-]+/g, "_")
      .replace(/^_+|_+$/g, "");
    if (!tag) tag = `Entry_${i + 1}`;
    if (!/^[A-Za-z_]/.test(tag)) tag = "_" + tag;
    return tag;
  }

  function wrapInTag(content, tag) {
    const body = String(content ?? "").trim();
    return `<${tag}>\n${body}\n</${tag}>`;
  }

  // SillyTavern-side validators reject floats/strings for numeric fields, so always emit integers.
  function toInt(v, fallback, min = -Infinity, max = Infinity) {
    const n = Math.round(Number(v));
    if (v === "" || v === null || v === undefined || !Number.isFinite(n)) return fallback;
    return Math.min(max, Math.max(min, n));
  }

  function convertEntry(e, i) {
    const keys = toList(e.key ?? e.keys ?? e.keywords ?? e.keysRaw);
    const secondary = toList(e.keysecondary ?? e.secondaryKeys ?? e.secondary_keys ?? e.keysecondaryRaw);
    const group = e.inclusionGroup ?? e.group ?? e.inclusionGroupRaw ?? "";
    const probability = toInt(e.probability, 100, 0, 100);
    const order = toInt(e.insertion_order ?? e.order, 100 * (i + 1));
    const tag = tagName(e, keys, i);
    return {
      uid: i,
      key: keys,
      keysecondary: secondary,
      comment: e.name || e.comment || e.title || tag,
      content: wrapInTag(e.content, tag),
      constant: !!e.constant,
      vectorized: false,
      selective: secondary.length > 0,
      selectiveLogic: [0, 1, 2, 3].includes(Number(e.selectiveLogic)) ? Number(e.selectiveLogic) : 0,
      addMemo: true,
      order,
      position: e.placementPosition === "before" ? 0 : 1,
      disable: e.enabled === false,
      excludeRecursion: false,
      preventRecursion: false,
      delayUntilRecursion: false,
      probability,
      useProbability: probability < 100,
      depth: 4,
      group: Array.isArray(group) ? group.join(", ") : String(group || ""),
      groupOverride: false,
      groupWeight: toInt(e.groupWeight, 100, 0),
      scanDepth: null,
      caseSensitive: e.case_sensitive ?? null,
      matchWholeWords: e.matchWholeWords ?? null,
      useGroupScoring: null,
      automationId: "",
      role: null,
      sticky: 0,
      cooldown: 0,
      delay: toInt(e.minMessages, 0, 0),
      displayIndex: i,
    };
  }

  function convertLorebook(raw, name) {
    const entries = {};
    parseEntries(raw).forEach((e, i) => {
      entries[String(i)] = convertEntry(e, i);
    });
    return { name: name || "JanitorAI Lorebook", entries };
  }

  /* ------------------------------------------------------------------ */
  /* API                                                                 */
  /* ------------------------------------------------------------------ */

  async function api(path) {
    const res = await fetch(API + path, { credentials: "include" });
    if (!res.ok) {
      const err = new Error(`${res.status} ${path}`);
      err.status = res.status;
      throw err;
    }
    const text = await res.text();
    return text ? JSON.parse(text) : null;
  }

  function currentCharacterId() {
    const m = location.pathname.match(/\/characters\/([0-9a-f-]{36})/i);
    return m ? m[1] : null;
  }

  function currentScriptId() {
    const m = location.pathname.match(/\/scripts\/([0-9a-f-]{36})/i);
    return m ? m[1] : null;
  }

  // Lorebooks attached to a character. character.scripts is not always populated for
  // anonymous viewers, so also scan the creator's public scripts for ones that list the character.
  async function findCharacterLorebooks(characterId) {
    const found = new Map();
    const character = await api(`/characters/${characterId}`);
    for (const s of character.scripts || []) {
      if (s && s.type === "lorebook") found.set(s.id, s);
    }
    if (character.creator_id) {
      for (let page = 1; page <= 20; page++) {
        let list;
        try {
          list = await api(`/script/user/${character.creator_id}?page=${page}`);
        } catch (_) {
          break;
        }
        const scripts = list?.scripts || [];
        for (const s of scripts) {
          if (s.type === "lorebook" && (s.characters || []).some((c) => c.id === characterId)) {
            found.set(s.id, { ...(found.get(s.id) || {}), ...s });
          }
        }
        if (scripts.length === 0 || page * scripts.length >= (list.total ?? 0)) break;
      }
    }
    return { character, scripts: [...found.values()] };
  }

  // Returns { raw, source } or throws if the code isn't readable.
  async function fetchLorebookCode(scriptId) {
    const meta = await api(`/script/${scriptId}`);
    if (meta.script && meta.script.trim()) return { meta, raw: meta.script };
    for (const type of ["published", "draft"]) {
      try {
        const c = await api(`/script/${scriptId}/content?type=${type}`);
        const raw = typeof c === "string" ? c : c?.content;
        if (raw && String(raw).trim()) return { meta, raw };
      } catch (_) {
        /* try next */
      }
    }
    const err = new Error("code-private");
    err.meta = meta;
    throw err;
  }

  /* ------------------------------------------------------------------ */
  /* UI                                                                  */
  /* ------------------------------------------------------------------ */

  function download(filename, obj) {
    const blob = new Blob([JSON.stringify(obj, null, 2)], { type: "application/json" });
    const a = document.createElement("a");
    a.href = URL.createObjectURL(blob);
    a.download = filename;
    document.body.appendChild(a);
    a.click();
    a.remove();
    setTimeout(() => URL.revokeObjectURL(a.href), 5000);
  }

  const safeName = (s) => String(s || "lorebook").replace(/[^\w\-. ]+/g, "_").trim() || "lorebook";

  async function exportScript(scriptId, log) {
    try {
      const { meta, raw } = await fetchLorebookCode(scriptId);
      const book = convertLorebook(raw, meta.title);
      const n = Object.keys(book.entries).length;
      download(`${safeName(meta.title)}.worldinfo.json`, book);
      log(`✔ "${meta.title}": ${n} entries exported.`);
    } catch (e) {
      if (e.message === "code-private") {
        log(
          `✖ "${e.meta?.title}" has private code (creator disabled code sharing), so the entries are not available. ` +
            "Only the creator can export it."
        );
      } else {
        log(`✖ ${scriptId}: ${e.message}`);
      }
    }
  }

  async function run(log) {
    const characterId = currentCharacterId();
    const scriptId = currentScriptId();
    if (scriptId) return exportScript(scriptId, log);
    if (!characterId) return log("Open a character page or a lorebook (/scripts/…) page first.");
    log("Looking for lorebooks…");
    const { character, scripts } = await findCharacterLorebooks(characterId);
    if (!scripts.length) {
      return log(
        `No lorebook found for "${character.name}". It may have none, or be attached to an account-only/unlisted script.`
      );
    }
    log(`Found ${scripts.length} lorebook(s).`);
    for (const s of scripts) await exportScript(s.id, log);
  }

  function buildUI() {
    if (document.getElementById("jlx-root")) return;
    const root = document.createElement("div");
    root.id = "jlx-root";
    root.style.cssText =
      "position:fixed;right:16px;bottom:16px;z-index:99999;font:13px system-ui,sans-serif;color:#eee;";
    root.innerHTML = `
      <div id="jlx-panel" style="display:none;width:320px;margin-bottom:8px;padding:12px;border-radius:10px;background:#1f1f24;border:1px solid #444;box-shadow:0 4px 16px #0008">
        <div style="font-weight:600;margin-bottom:8px">Lorebook → SillyTavern</div>
        <button id="jlx-run" style="width:100%;padding:6px;margin-bottom:6px;cursor:pointer">Export lorebook(s) for this page</button>
        <label style="display:block;margin-bottom:6px">Or convert a JanitorAI lorebook JSON file:
          <input id="jlx-file" type="file" accept=".json,application/json" style="width:100%;margin-top:4px"></label>
        <pre id="jlx-log" style="margin:0;max-height:160px;overflow:auto;white-space:pre-wrap;font-size:12px"></pre>
      </div>
      <button id="jlx-toggle" title="Lorebook exporter" style="float:right;width:40px;height:40px;border-radius:50%;border:1px solid #555;background:#2b2b33;color:#eee;font-size:18px;cursor:pointer">📖</button>`;
    document.body.appendChild(root);

    const panel = root.querySelector("#jlx-panel");
    const logEl = root.querySelector("#jlx-log");
    const log = (m) => {
      logEl.textContent += m + "\n";
      logEl.scrollTop = logEl.scrollHeight;
    };
    root.querySelector("#jlx-toggle").onclick = () => {
      panel.style.display = panel.style.display === "none" ? "block" : "none";
    };
    root.querySelector("#jlx-run").onclick = () => {
      logEl.textContent = "";
      run(log).catch((e) => log("✖ " + e.message));
    };
    root.querySelector("#jlx-file").onchange = async (ev) => {
      const f = ev.target.files[0];
      if (!f) return;
      logEl.textContent = "";
      try {
        const name = f.name.replace(/\.json$/i, "");
        const book = convertLorebook(await f.text(), name);
        download(`${safeName(name)}.worldinfo.json`, book);
        log(`✔ ${Object.keys(book.entries).length} entries converted.`);
      } catch (e) {
        log("✖ " + e.message);
      }
      ev.target.value = "";
    };
  }

  /* ------------------------------------------------------------------ */
  /* Inline download buttons                                             */
  /* ------------------------------------------------------------------ */

  const BTN_ATTR = "data-jlx-btn";

  function makeInlineButton(label, onClick) {
    const b = document.createElement("button");
    b.type = "button";
    b.setAttribute(BTN_ATTR, "1");
    b.textContent = label;
    b.title = "Download as SillyTavern World Info (JSON)";
    b.style.cssText =
      "margin:0 6px;padding:4px 10px;border-radius:8px;border:1px solid #666;background:#2b2b33;color:#eee;font:600 12px system-ui,sans-serif;cursor:pointer;white-space:nowrap;";
    b.addEventListener("click", async (ev) => {
      ev.preventDefault();
      ev.stopPropagation();
      const old = b.textContent;
      b.disabled = true;
      b.textContent = "…";
      await onClick((m) => {
        if (m.startsWith("✖")) alert(m);
        console.log("[lorebook-exporter]", m);
      });
      b.disabled = false;
      b.textContent = old;
    });
    return b;
  }

  // /scripts/<id>: put the button next to the clone button.
  function injectScriptPageButton() {
    const id = currentScriptId();
    if (!id || document.querySelector(`[${BTN_ATTR}="page"]`)) return;
    const clone = [...document.querySelectorAll("button")].find((b) =>
      /clon/i.test((b.getAttribute("aria-label") || "") + " " + (b.title || ""))
    );
    if (!clone || !clone.parentElement) return;
    const row = clone.closest('[class*="heroActions"]') || clone.parentElement;
    let anchor = clone;
    while (anchor.parentElement && anchor.parentElement !== row) anchor = anchor.parentElement;
    const b = makeInlineButton("⬇ SillyTavern", (log) => exportScript(id, log));
    b.setAttribute(BTN_ATTR, "page");
    row.insertBefore(b, anchor);
  }

  // Character page: lorebook cards in the scripts section (matched to scripts by title).
  let cardScripts = { characterId: null, scripts: null, loading: false };

  async function injectCardButtons() {
    const characterId = currentCharacterId();
    if (!characterId) return;
    const cards = [...document.querySelectorAll('[class*="scriptsList"] > *')].filter(
      (c) => !c.querySelector(`[${BTN_ATTR}]`)
    );
    if (!cards.length) return;
    if (cardScripts.characterId !== characterId) cardScripts = { characterId, scripts: null, loading: false };
    if (!cardScripts.scripts) {
      if (cardScripts.loading) return;
      cardScripts.loading = true;
      try {
        cardScripts.scripts = (await findCharacterLorebooks(characterId)).scripts;
      } catch (_) {
        cardScripts.scripts = [];
      }
      cardScripts.loading = false;
    }
    for (const card of cards) {
      const title = card.querySelector('[class*="cardTitle"]')?.textContent.trim();
      const script = cardScripts.scripts.find((s) => (s.title || "").trim() === title);
      if (!script || card.querySelector(`[${BTN_ATTR}]`)) continue;
      const b = makeInlineButton("⬇ ST", (log) => exportScript(script.id, log));
      (card.querySelector('[class*="typeBadge"]')?.parentElement || card).appendChild(b);
    }
  }

  function refreshInline() {
    try {
      injectScriptPageButton();
      injectCardButtons();
    } catch (e) {
      console.warn("[lorebook-exporter]", e);
    }
  }

  if (typeof document !== "undefined" && typeof window !== "undefined" && !window.__JLX_TEST__) {
    buildUI();
    refreshInline();
    let timer = null;
    new MutationObserver(() => {
      clearTimeout(timer);
      timer = setTimeout(refreshInline, 300);
    }).observe(document.body, { childList: true, subtree: true });
  }
  if (typeof globalThis !== "undefined") globalThis.__JLX__ = { convertLorebook, parseEntries };
})();