☰

JanitorAI BetterCharInfo

JanitorAI Script that will show more info on character cards.

Du musst eine Erweiterung wie Tampermonkey, Greasemonkey oder Violentmonkey installieren, um dieses Skript zu installieren.

Um dieses Skript zu installieren, müssen Sie eine Erweiterung wie Tampermonkey oder Violentmonkey installieren.

Um dieses Skript zu installieren, müssen Sie eine Erweiterung wie Tampermonkey oder Violentmonkey installieren.

Um dieses Skript zu installieren, müssen Sie eine Erweiterung wie Tampermonkey oder Userscripts installieren.

Um dieses Skript zu installieren, müssen Sie eine Erweiterung wie Tampermonkey installieren.

Sie müssten eine Skript Manager Erweiterung installieren damit sie dieses Skript installieren können

(Ich habe schon ein Skript Manager, Lass mich es installieren!)

Um dieses Design zu installieren, müssen Sie eine Erweiterung wie Stylus installieren.

Um dieses Design zu installieren, müssen Sie eine Erweiterung wie Stylus installieren.

Um dieses Design zu installieren, müssen Sie eine Erweiterung wie Stylus installieren.

Um diesen Stil zu installieren, müssen Sie eine Erweiterung für den Benutzerstil Verwaltung installieren.

Um diesen Stil zu installieren, müssen Sie eine Erweiterung für den Benutzerstil Verwaltung installieren.

Um diesen Stil zu installieren, müssen Sie eine Erweiterung für den Benutzerstil Verwaltung installieren.

(Ich habe bereits einen Benutzerstil Verwaltung, ich möchte ihn installieren!)

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