Sleazy Fork is available in English.
JanitorAI Script that will show more info on character cards.
// ==UserScript==
// @name JanitorAI BetterCharInfo
// @namespace jai/zenithone/scripts
// @version 0.0.3.1
// @icon https://www.google.com/s2/favicons?sz=64&domain=janitorai.com
// @match https://janitorai.com/*
// @grant none
// @run-at document-start
// @author ZenithOne
// @description JanitorAI Script that will show more info on character cards.
// @license MIT
// ==/UserScript==
"use strict";
let data = {
fromApi: [],
cache: {},
isIntercepted: false,
};
/**
* Defers updateCards() to the next macrotask so React can finish its
* synchronous render/hydration before we touch the DOM. The guard on
* updateTimeout also debounces rapid-fire mutation events into a single call.
*
* - *deepseek/deepseek-v4-pro*
*/
let updateTimeout = null;
function scheduleUpdate() {
if (updateTimeout !== null) return;
updateTimeout = setTimeout(() => {
updateTimeout = null;
updateCards();
}, 0);
}
function log(msg, ...args) {
console.log(`[PCL] ${msg}`, ...args);
}
/**
* Fetching the charcter data that is shown when changing pages (or on load)
* is being done with xhr
*
* So, we are monkey patching this shit up.
*/
function overrideNetwork() {
log("Patching network APIs");
const originalOpen = XMLHttpRequest.prototype.open;
const originalSend = XMLHttpRequest.prototype.send;
XMLHttpRequest.prototype.open = function (method, url, ...args) {
this._method = method;
this._url = url;
return originalOpen.apply(this, [method, url, ...args]);
};
XMLHttpRequest.prototype.send = function (body) {
this.addEventListener("load", function () {
handleXhrLoad(this);
});
return originalSend.apply(this, arguments);
};
}
function handleXhrLoad(xhr) {
if (!xhr._url) return;
if (!xhr._url.includes("/hampter/characters")) return;
log("[XHR_INTERCEPT]", xhr._method, xhr._url);
// Just a checker if it went through here, to be toggled AFTER the info display is done
data.isIntercepted = true;
try {
/**
* Two types of return value.
*
* From the main page:
* - an object `{ data: [ { ... }, ... ], ... }`
*
* And from the similar characters section:
* - an array `[ { ... }, ... ]`
*
* No clue on why the fuck its different, but whatever.
*
* Good thing that `*.data` is kinda consistent, so we can check.
*/
let _data = JSON.parse(xhr.responseText);
data.fromApi = _data?.data ?? _data;
cacheCharData();
log("[XHR_DATA]", data);
} catch (e) {
console.warn("[PCL] XHR returned a non array or non object. URL:", xhr._url);
}
}
/**
* We are caching data from the char array since only new page views get fetched
* So opening page 1 triggers, opening page 2 triggers, and clicking back to page 1
* does not. This is just a band-aid fix.
*
* Not a permanent cache, as we are still prioritizing fresh data.
* It will constantly override the cache.
*/
function cacheCharData() {
/** @type {object[]} */
const fromApi = data.fromApi;
fromApi.forEach(obj => {
data.cache[obj.id] = obj;
});
}
function updateCards() {
/**
* Checks cache length instead of the one-shot `isIntercepted` flag.
* The flag was reset after the first run, so when React re-renders
* (wiping our DOM changes on hydration mismatch), subsequent mutation
* events would bail out and never re-apply. Cache survives re-renders.
*
* - *deepseek/deepseek-v4-pro*
*/
if (!Object.keys(data.cache).length) {
log("No cached data, doing nothing.");
return;
}
log("Updating cards.");
let characterCardElements = [
...document.querySelectorAll(".profile-character-card-wrapper")
];
characterCardElements = characterCardElements.filter(elem => {
return !elem.dataset.proxyChecked;
});
log("Elements after filter:", characterCardElements);
if (!characterCardElements.length) return;
for (let i = 0; i < characterCardElements.length; i++) {
linkDataToCard(characterCardElements[i]);
}
}
/**
* Overkill function, xd
*/
function timeAgo(query) {
const now = new Date();
const time = new Date(query);
const diffMs = now - time;
const diffSec = Math.floor(diffMs / 1000);
const diffMin = Math.floor(diffSec / 60);
const diffHr = Math.floor(diffMin / 60);
const diffDays = Math.floor(diffHr / 24);
const diffWeeks = Math.floor(diffDays / 7);
const diffMonths = Math.floor(diffWeeks / 4)
const diffYears = Math.floor(diffMonths / 12);
const fullDate = time.toLocaleDateString('en-US', {
month: 'short',
day: 'numeric',
year: "numeric",
});
const relativeObj = {
years: diffYears,
months: diffMonths,
weeks: diffWeeks,
days: diffDays,
hours: diffHr,
minutes: diffMin,
seconds: diffSec,
};
const smallestUnitNonZero = Object.keys(relativeObj).find(v => relativeObj[v] > 0);
let unitSuffix = smallestUnitNonZero.split("")[0];
let unitValue = relativeObj[smallestUnitNonZero];
if (smallestUnitNonZero === "months") { unitSuffix = "mo"; }
return {
relativeObj: relativeObj,
relativeFormatted: `${unitValue}${unitSuffix}`,
full: fullDate
}
}
function prettyNum(number) {
const magnitude = [
["b", 1_000_000_000],
["m", 1_000_000],
["k", 1_000],
];
const match = magnitude.find(([_, value]) => number >= value);
if (match) {
const [suffix, value] = match;
const numTrim = number / value;
let decimals = 2;
if (numTrim >= 100) { decimals = 0; }
else if (numTrim >= 10) { decimals = 1; }
return `${numTrim.toFixed(decimals)}${suffix}`;
}
return number;
}
function linkDataToCard(element) {
// i like to be safe, shut up
if (!element) {
console.warn("[PCL] No element passed to modify.");
return;
}
// Important elements to tweak
// Quite fragile, IMO, but there's no other way to do this shit. :(
const charNameSection = element.querySelector(".pp-cc-name.profile-character-card-name-box");
const chatCountRibbon = element.querySelector(".chakra-stack.pp-cc-chats.profile-character-card-chats-hstack");
const charTokensSection = element.querySelector(".chakra-text.pp-cc-tokens-count.profile-character-card-tokens-count");
const characterLink = element.querySelector(".profile-character-card-stack-link-component")?.href ?? "";
// https://janitorai.com/characters/<hash>_<some-stuff-idc-about>
const charLinkHash = characterLink.replace("https://janitorai.com/characters/", "").split("_")[0];
const characterData = data.cache[charLinkHash] ?? undefined;
if (!characterData) {
return;
}
const {
created_at, first_published_at, scheduled_published_at, updated_at,
stats, total_tokens, public_chat_count,
showdefinition, is_nsfw, is_proxy_enabled, is_public
} = characterData;
/**
* This is only relevant if the data is sourced from
* `/hampter/characters/<hash>?similar`
*
* As on every other places, `showdefinition` is NEVER `true`,
* but `is_proxy_enabled` works as expected
*
* The other places are:
* - `/hampter/characters` (front page, no character hash)
* - `/hampter/characters/v2/mine` (my characters page)
*
* For why it's done... no clue? It is an annoyance, really.
*
* Keeping it as it is for when the devs actually fix this one.
*/
let openLevel = 0;
openLevel = is_proxy_enabled ? 1 : openLevel;
openLevel = showdefinition ? 2 : openLevel;
const proxyEmoji = ["❌", "✅", "⭐"][openLevel];
const ratio = stats.message ? stats.message / stats.chat : 0;
const updateRelative = timeAgo(updated_at);
const publishRelative = timeAgo(first_published_at);
const createdAtRelative = timeAgo(created_at);
const isUpdateAndPublishedSame = (updateRelative.relativeFormatted === publishRelative.relativeFormatted);
// Character Name tweaks....
charNameSection.innerHTML = [
openLevel < 1 ? `<span>${proxyEmoji}</span>` : "",
charNameSection.innerHTML
].join("");
// Chat Count Ribbon tweaks...
chatCountRibbon.style.cssText = [
"display: flex",
"flex-direction: column",
"align-items: end",
].join(";");
chatCountRibbon.innerHTML = [
`<span>${prettyNum(stats.message)}📝</span>`,
`<span>${prettyNum(stats.chat)}💬</span>`,
`<span>${ratio.toFixed(1)}⚖️</span>`,
public_chat_count ? `<span>${prettyNum(public_chat_count)}📖</span>` : "",
`<hr style="color: white;">`,
is_public ? `<span>${publishRelative.relativeFormatted}🌐</span>` : "",
!isUpdateAndPublishedSame ? `<span>${updateRelative.relativeFormatted}🔧</span>` : "",
].join("");
// Token count section tweaks...
// In a block to not error out on janitorai.com/my_characters
if (charTokensSection) {
charTokensSection.innerHTML = [
prettyNum(total_tokens),
`${createdAtRelative.full}`,
].join(" ");
}
element.style.opacity = openLevel ? "100%" : "35%";
element.dataset.proxyChecked = true;
}
function getDataFromScript(scriptContent) {
if (!scriptContent) {
log("Empty scriptContent?");
return;
}
const rawJson = scriptContent
.trim()
.replace("window.mbxM.push(JSON.parse(", "")
.slice(0, -3); // removes the last "));"
const dataJson = JSON.parse(JSON.parse(rawJson)); // text -> string -> object
// somthing along the lines of: "SN--a:a:c-b--mainStore"
const mainStoreKey = Object.keys(dataJson).filter(key => key.includes("mainStore")).shift();
if (!mainStoreKey) {
log("Cannot find mainStoreKey on embedded script.");
return;
}
const characterData = dataJson?.[mainStoreKey]?.characters || [];
if (characterData.length < 1) {
log("Empty characterData array");
return;
}
log("mainStoreKey:", mainStoreKey, "characterData.length:", characterData.length);
data.fromApi = characterData;
cacheCharData();
data.isIntercepted = true;
/**
* Uses scheduleUpdate() instead of calling updateCards() directly
* so the DOM mutations land after React finishes hydrating.
*
* - *deepseek/deepseek-v4-pro*
*/
scheduleUpdate();
}
let scannedChars = [];
function charContainerObserver() {
const targetElement = document.querySelector("#root");
const callback = (mutations) => {
let willCallFunction = false;
mutations.forEach((mutation) => {
/**
* This targets both front page and the similar characters section,
* the latter on the character page.
*/
if (
mutation.target.classList.contains('characters-list-container-flex')
|| mutation.target.classList.contains('pp-cc-list-container')
) {
log("Detected target elems. Calling function.");
willCallFunction = true;
}
if (mutation.target.nodeName === "SCRIPT") {
const scriptContent = mutation.target.textContent;
if (scriptContent.startsWith("window.mbxM.push(JSON.parse(")) {
log("mbxM script detected from mutation, attempting...");
getDataFromScript(scriptContent);
}
}
});
if (willCallFunction) {
/**
* Delegates to scheduleUpdate() so DOM changes are deferred
* past React's current sync work (avoids hydration mismatch).\
*
* - *deepseek/deepseek-v4-pro*
*/
scheduleUpdate();
}
};
const observer = new MutationObserver(callback);
observer.observe(targetElement, {
childList: true,
subtree: true,
});
log("Observing changes on: ", targetElement);
}
function initialObserver() {
const docBody = document.body;
const callback = (mutations) => {
mutations.forEach(mutation => {
if (mutation.type !== "childList") {
return;
}
if (mutation.target.id !== "root") {
return;
}
log("#root found. Disconnecting initialObserver.");
charContainerObserver();
observer.disconnect();
});
}
const observer = new MutationObserver(callback);
observer.observe(docBody, {
childList: true,
subtree: true,
});
log("Observing document for #root.");
}
overrideNetwork();
initialObserver();