Export JanitorAI lorebooks as SillyTavern World Info JSON.
// ==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 };
})();