☰

JanitorAI BetterCharInfo

JanitorAI Script that will show more info on character cards.

スクリプトをインストールするには、Tampermonkey, Greasemonkey や Violentmonkey のような拡張機能のインストールが必要です。

スクリプトをインストールするには、 Tampermonkey や Violentmonkey のような拡張機能のインストールが必要です。

スクリプトをインストールするには、Tampermonkey や Violentmonkey のような拡張機能のインストールが必要です。

スクリプトをインストールするには、Tampermonkey や Userscripts のような拡張機能のインストールが必要です。

このスクリプトをインストールするには、Tampermonkeyなどの拡張機能をインストールする必要があります。

このスクリプトをインストールするには、ユーザースクリプト管理ツールの拡張機能をインストールする必要があります。

(ユーザースクリプト管理ツールは設定済みなのでインストール!)

このスタイルをインストールするには、Stylusなどの拡張機能をインストールする必要があります。

このスタイルをインストールするには、Stylus などの拡張機能をインストールする必要があります。

このスタイルをインストールするには、Stylus tなどの拡張機能をインストールする必要があります。

このスタイルをインストールするには、ユーザースタイル管理用の拡張機能をインストールする必要があります。

このスタイルをインストールするには、ユーザースタイル管理用の拡張機能をインストールする必要があります。

このスタイルをインストールするには、ユーザースタイル管理用の拡張機能をインストールする必要があります。

(ユーザースタイル管理ツールは設定済みなのでインストール!)

このスクリプトの質問や評価の投稿はこちら、通報はこちらへお寄せください
// ==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();