JanitorAI Full Export

Export your whole JanitorAI account: every chat, the character cards, and every lorebook that is reachable, as one JSON file.

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

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

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

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

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

이 스크립트를 설치하려면 유저 스크립트 관리자 확장 프로그램이 필요합니다.

(이미 유저 스크립트 관리자가 설치되어 있습니다. 설치를 진행합니다!)

이 스타일을 설치하려면 Stylus와 같은 확장 프로그램이 필요합니다.

이 스타일을 설치하려면 Stylus와 같은 확장 프로그램이 필요합니다.

이 스타일을 설치하려면 Stylus와 같은 확장 프로그램이 필요합니다.

이 스타일을 설치하려면 유저 스타일 관리자 확장 프로그램이 필요합니다.

이 스타일을 설치하려면 유저 스타일 관리자 확장 프로그램이 필요합니다.

이 스타일을 설치하려면 유저 스타일 관리자 확장 프로그램이 필요합니다.

(이미 유저 스타일 관리자가 설치되어 있습니다. 설치를 진행합니다!)

// ==UserScript==
// @name         JanitorAI Full Export
// @namespace    https://unorouter.com/
// @version      1.2.0
// @description  Export your whole JanitorAI account: every chat, the character cards, and every lorebook that is reachable, as one JSON file.
// @author       unorouter
// @match        https://janitorai.com/*
// @match        https://www.janitorai.com/*
// @icon         https://www.google.com/s2/favicons?sz=64&domain=janitorai.com
// @grant        none
// @license      MIT
// ==/UserScript==

// Export your whole JanitorAI account from your own logged-in session: every
// chat, the character cards behind them, and every lorebook that is reachable.
//
// It runs in YOUR browser because that is the only place the data exists for
// you: janitorai answers 403 to a datacenter address, and a private card or
// lorebook is only ever served to the session that owns the chat.
//
// WHAT IT CANNOT DO, and no tool can:
//   - a card whose creator turned allow_proxy OFF and hid the definition. Both
//     flags closed means the text is never assembled anywhere the browser can
//     see it.
// A private LOREBOOK is recoverable despite answering 404 on its own endpoint,
// because JanitorAI still resolves it into the prompt it assembles for a
// generation. Anything genuinely unreachable is listed at the end rather than
// silently exported as an empty card.

(function () {
  "use strict";

  const runExport = async function (onProgress) {

    const log = (m) => console.log("%c" + m, "color:#6cf");
    const warn = (m) => console.warn("%c" + m, "color:#fc6");

    const cookie = (n) => {
      const m = document.cookie.match(
        new RegExp(
          "(?:^|; )" + n.replace(/[.*+?^${}()|[\]\\]/g, "\\$&") + "=([^;]*)",
        ),
      );
      return m ? decodeURIComponent(m[1]) : "";
    };

    // The session is a SPLIT supabase cookie; localStorage holds no token here.
    let token;
    try {
      const raw = (
        cookie("sb-auth-auth-token.0") + cookie("sb-auth-auth-token.1")
      ).replace("base64-", "");
      token = JSON.parse(atob(raw)).access_token;
    } catch {
      token = null;
    }
    if (!token) {
      console.error(
        "%cNot logged in to janitorai.com.",
        "color:#f66;font-weight:bold",
      );
      return;
    }

    const H = {
      accept: "application/json",
      "content-type": "application/json",
      authorization: "Bearer " + token,
    };
    const get = async (path) => {
      const r = await fetch(path, { headers: H, credentials: "include" });
      return r.ok ? r.json() : null;
    };

    // ---- every character you have chatted with, paginated -------------------
    const characters = [];
    for (let page = 1; ; page++) {
      const j = await get(`/hampter/chats/character-chats?page=${page}`);
      const batch = j?.characters ?? [];
      characters.push(...batch);
      if (!j?.hasMore || batch.length === 0) break;
    }
    log(
      `${characters.length} characters, ${characters[0] ? "" : "nothing to do"}`,
    );

    // ---- the assembled prompt, which is where hidden definitions live -------
    // open_ai_mode "proxy" makes JanitorAI build the prompt and hand it back to
    // its own caller instead of forwarding it, so one authenticated POST returns
    // what the card page will not show. The proxy target is never contacted, but
    // the userConfig must be COMPLETE or the server answers 502 while trying to
    // call a provider for real.
    const assembledPrompt = async (characterId, chatId) => {
      const chat = await get("/hampter/chats/" + chatId);
      if (!chat) return null;
      const res = await fetch("/generateAlpha", {
        method: "POST",
        headers: H,
        body: JSON.stringify({
          chat: {
            character_id: chat.chat?.character_id ?? characterId,
            id: chat.chat?.id ?? chatId,
            summary: chat.chat?.summary ?? "",
            user_id: chat.chat?.user_id,
          },
          chatMessages: chat.chatMessages ?? [],
          clientPlatform: "web",
          forcedPromptGenerationCacheRefetch: {
            character: false,
            chat: false,
            profile: false,
            script: false,
          },
          generateMode: "NEW",
          generateType: "CHAT",
          profile: chat.personas?.[0] ?? null,
          profiles: chat.personas ?? [],
          userConfig: {
            api: "openai",
            open_ai_mode: "proxy",
            open_ai_reverse_proxy: "https://example.invalid/v1/chat/completions",
            reverseProxyKey: "extract",
            openAiModel: "gpt-4",
            openAIKey: null,
            claudeApiKey: null,
            claudeModel: "",
            claude_jailbreak_prompt: "",
            open_ai_jailbreak_prompt: "",
            proxy_global_prompt: "",
            llm_prompt: "",
            bad_words: [],
            allow_mobile_nsfw: true,
            janitor_router_enabled: false,
            text_streaming: false,
            generation_settings: {
              context_length: 50000,
              enable_reasoning: false,
              enable_reasoning_chat: false,
              enable_router_temperature: false,
              enable_short_responses: false,
              max_new_token: 0,
              prefill_enabled: false,
              prefill_text: "",
              temperature: 1,
            },
          },
        }),
      });
      if (!res.ok) return null;
      const body = await res.json().catch(() => null);
      const system = (body?.messages ?? []).find((m) => m.role === "system");
      return system?.content ?? null;
    };

    // A private lorebook 404s on its own endpoint, but its text is still in the
    // assembled prompt, appended AFTER the tagged card blocks with no tag of its
    // own. That tail is the only copy the account can reach. Measured against a
    // real character: 7.3KB of lore in 13 blank-line separated blocks, which is
    // as close to the original entries as this gets. The KEYS are gone for good,
    // because the book was already resolved into flat text before it came back.
    const LORE_MARKER = "</example_dialogs>";
    const lorebookTail = (prompt) => {
      if (!prompt) return null;
      const at = prompt.lastIndexOf(LORE_MARKER);
      if (at < 0) return null;
      const tail = prompt.slice(at + LORE_MARKER.length).trim();
      return tail.length > 200 ? tail : null;
    };

    // Avatars are served as a bare filename off a fixed host, and the file has to
    // carry the BYTES rather than the URL: janitorai can delete or rotate an
    // avatar, and an export that only points at one degrades into a broken image.
    const avatarData = async (name) => {
      if (!name) return null;
      const url = /^https?:/.test(name)
        ? name
        : "https://ella.janitorai.com/bot-avatars/" + name;
      try {
        const r = await fetch(url);
        if (!r.ok) return null;
        const blob = await r.blob();
        // Skipped rather than inlined past a few MB: one oversized avatar would
        // bloat the export for every reader of the file.
        if (blob.size > 4 * 1024 * 1024) return null;
        const base64 = await new Promise((res) => {
          const fr = new FileReader();
          fr.onloadend = () => res(String(fr.result).split(",")[1] || null);
          fr.onerror = () => res(null);
          fr.readAsDataURL(blob);
        });
        return base64 ? { mimeType: blob.type || "image/png", base64 } : null;
      } catch {
        return null;
      }
    };

    const out = {};
    const skipped = [];
    let chatCount = 0;

    for (const [n, c] of characters.entries()) {
      log(`[${n + 1}/${characters.length}] ${c.name}`);
      if (onProgress) onProgress(n + 1, characters.length, c.name);
      const meta = (await get("/hampter/characters/" + c.character_id)) ?? {};

      // ---- chats and messages ---------------------------------------------
      // Paginated: this endpoint serves TEN chats per page and reports hasMore,
      // so reading the first response only would silently drop every chat past
      // the tenth for anyone who talks to one character a lot.
      const chatRows = [];
      for (let page = 1; ; page++) {
        const list = await get(
          `/hampter/chats/character/${c.character_id}/chats?page=${page}`,
        );
        const batch = list?.chats ?? [];
        chatRows.push(...batch);
        if (!list?.hasMore || batch.length === 0) break;
      }

      const chats = [];
      for (const row of chatRows) {
        const full = await get("/hampter/chats/" + row.id);
        const messages = (full?.chatMessages ?? [])
          // is_main:false is a swipe the user rejected. It is kept rather than
          // dropped so nothing is lost, but it is flagged, because importing one
          // as a normal turn rewrites the conversation: they are timestamped
          // when they were generated, which can be AFTER the turn that replaced
          // them.
          .map((m) => ({
            id: m.id,
            is_bot: m.is_bot,
            is_main: m.is_main !== false,
            message: m.message,
            created_at: m.created_at,
          }))
          .sort((a, b) => new Date(a.created_at) - new Date(b.created_at));
        if (messages.length === 0) continue;
        chats.push({
          chat_id: row.id,
          summary: row.summary,
          updated_at: row.updated_at,
          chat_count: messages.length,
          // The persona the user played in THIS chat, which is theirs and so is
          // always readable, unlike the character's own definition.
          persona: full?.personas?.[0]
            ? {
                name: full.personas[0].name,
                appearance: full.personas[0].appearance,
                pronouns: full.personas[0].pronouns,
              }
            : null,
          messages,
        });
        chatCount += messages.length;
      }

      // ---- the card, recovering a hidden definition where possible ---------
      const hiddenDefinition =
        !meta.personality && (meta.token_counts?.personality_tokens ?? 0) > 0;
      let prompt = null;
      // Fetched whenever the proxy is open, not only for a hidden definition: the
      // same call is what recovers a private lorebook below.
      if (meta.allow_proxy && chats[0]) {
        prompt = await assembledPrompt(c.character_id, chats[0].chat_id);
        if (!prompt && hiddenDefinition) {
          skipped.push({
            character: c.name,
            what: "definition",
            why: "proxy call failed",
          });
        }
      } else if (hiddenDefinition) {
        skipped.push({
          character: c.name,
          what: "definition",
          why: "hidden by the creator and allow_proxy is off",
        });
      }

      // ---- lorebooks, only the ones the author left readable ---------------
      const lorebooks = [];
      const privateBooks = [];
      for (const s of meta.scripts ?? []) {
        const book = await get("/hampter/script/" + s.id);
        if (!book?.script) {
          privateBooks.push(s.title || s.id);
          continue;
        }
        let entries = [];
        try {
          entries = JSON.parse(book.script);
        } catch {
          entries = [];
        }
        if (!Array.isArray(entries) || entries.length === 0) {
          // "advanced" books are a program that builds entries at chat time, so
          // there is nothing to export before it has run.
          skipped.push({
            character: c.name,
            what: "lorebook: " + (s.title || s.id),
            why: "script-based, has no static entries",
          });
          continue;
        }
        let settings = null;
        try {
          settings = JSON.parse(book.settings || "{}");
        } catch {
          settings = null;
        }
        lorebooks.push({
          id: s.id,
          title: book.title || s.title,
          entries,
          scan_depth: settings?.depth,
        });
      }

      // One recovered book for ALL private ones on this character: the prompt does
      // not say where a book ended and the next began, so splitting per source
      // would be invented structure rather than recovered structure.
      const tail = privateBooks.length ? lorebookTail(prompt) : null;
      if (tail) {
        const entries = tail
          .split(/\n{2,}/)
          .map((para) => para.trim())
          .filter((para) => para.length > 40)
          // Keyless and constant on purpose: the keys did not survive assembly,
          // and a keyless entry that is not always-on would never fire.
          .map((content) => ({ key: [], content, constant: true, enabled: true }));
        if (entries.length > 0) {
          lorebooks.push({
            id: "recovered-" + c.character_id,
            title: "Recovered lore (" + privateBooks.join(", ") + ")",
            recovered: true,
            entries,
          });
        }
      } else if (privateBooks.length) {
        for (const title of privateBooks) {
          skipped.push({
            character: c.name,
            what: "lorebook: " + title,
            why: meta.allow_proxy
              ? "private, and the prompt carried no lore to recover"
              : "private (404) and allow_proxy is off",
          });
        }
      }

      out[c.character_id] = {
        character_id: c.character_id,
        character_name: c.name,
        card: {
          name: meta.name || c.name,
          description: meta.description || "",
          personality: meta.personality || "",
          scenario: meta.scenario || "",
          first_mes: meta.first_message || "",
          mes_example: meta.example_dialogs || "",
          creator: meta.creator_name || meta.user_name || "",
          // Every greeting, not just the one this chat opened with.
          alternate_greetings: (meta.first_messages || []).slice(1),
          tags: []
            .concat(meta.tags || [], meta.custom_tags || [])
            .map((t) => (typeof t === "string" ? t : t?.name))
            .filter(Boolean),
          avatar: c.avatar || meta.avatar || "",
          avatar_data: await avatarData(c.avatar || meta.avatar),
          // Only kept when the definition was actually HIDDEN. The prompt is
          // fetched for every proxy-enabled character because it is also where a
          // private lorebook is recovered from, but storing it when the fields
          // came through normally duplicates them, and its lore half is already
          // in the recovered lorebook.
          assembled_prompt: hiddenDefinition ? prompt : null,
        },
        lorebooks,
        chats,
      };
    }

    const payload = {
      exported_at: new Date().toISOString(),
      characters: out,
      skipped,
    };
    const blob = new Blob([JSON.stringify(payload, null, 2)], {
      type: "application/json",
    });
    const a = document.createElement("a");
    a.href = URL.createObjectURL(blob);
    a.download = `janitorai-full-${new Date().toISOString().slice(0, 10)}.json`;
    a.click();
    URL.revokeObjectURL(a.href);

    const books = Object.values(out).reduce((n, c) => n + c.lorebooks.length, 0);
    log(
      `Done: ${characters.length} characters, ${chatCount} messages, ${books} lorebooks.`,
    );
    if (skipped.length) {
      warn(`${skipped.length} item(s) could not be exported:`);
      console.table(skipped);
    }
  };

  // A floating button rather than a console paste: the same code, but usable by
  // someone who has never opened devtools.
  const mount = () => {
    if (document.getElementById("jai-full-export-btn")) return;
    const btn = document.createElement("button");
    btn.id = "jai-full-export-btn";
    btn.textContent = "Export all";
    btn.style.cssText = [
      "position:fixed",
      "right:16px",
      "bottom:16px",
      "z-index:2147483647",
      "padding:10px 16px",
      "border-radius:10px",
      "border:1px solid rgba(255,255,255,.25)",
      "background:#d62896",
      "color:#fff",
      "font:600 14px system-ui,sans-serif",
      "cursor:pointer",
      "box-shadow:0 4px 14px rgba(0,0,0,.35)",
    ].join(";");
    btn.addEventListener("click", async () => {
      if (btn.disabled) return;
      btn.disabled = true;
      const label = btn.textContent;
      btn.textContent = "Exporting...";
      try {
        await runExport((done, total, name) => {
          btn.textContent = `${done}/${total} ${name}`.slice(0, 28);
        });
        btn.textContent = "Done";
      } catch (e) {
        console.error(e);
        btn.textContent = "Failed, see console";
      }
      setTimeout(() => {
        btn.textContent = label;
        btn.disabled = false;
      }, 4000);
    });
    document.body.appendChild(btn);
  };

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