☰

JanitorAI BetterCharInfo

JanitorAI Script that will show more info on character cards.

Tendrás que instalar una extensión para tu navegador como Tampermonkey, Greasemonkey o Violentmonkey si quieres utilizar este script.

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

Tendrás que instalar una extensión como Tampermonkey o Violentmonkey para instalar este script.

Necesitarás instalar una extensión como Tampermonkey o Userscripts para instalar este script.

Tendrás que instalar una extensión como Tampermonkey antes de poder instalar este script.

Necesitarás instalar una extensión para administrar scripts de usuario si quieres instalar este script.

(Ya tengo un administrador de scripts de usuario, déjame instalarlo)

Tendrás que instalar una extensión como Stylus antes de poder instalar este script.

Tendrás que instalar una extensión como Stylus antes de poder instalar este script.

Tendrás que instalar una extensión como Stylus antes de poder instalar este script.

Para poder instalar esto tendrás que instalar primero una extensión de estilos de usuario.

Para poder instalar esto tendrás que instalar primero una extensión de estilos de usuario.

Para poder instalar esto tendrás que instalar primero una extensión de estilos de usuario.

(Ya tengo un administrador de estilos de usuario, déjame instalarlo)

// ==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();