JanitorAI Char Card Downloader

Downloads JanitorAI bot descriptions as PNG Character v2 cards.

// ==UserScript==
// @name         JanitorAI Char Card Downloader
// @namespace    https://greasyfork.org/
// @version      1.0
// @description  Downloads JanitorAI bot descriptions as PNG Character v2 cards.
// @author       Gemini, mostly
// @match        http*://*.janitorai.com/*
// @icon         https://www.google.com/s2/favicons?sz=64&domain=janitorai.com
// @grant        none
// @license MIT
// ==/UserScript==

(function() {
    'use strict';

    // --- Helper Functions ---

    /**
     * Encodes a UTF-8 string to Base64.
     * @param {string} str The UTF-8 string to encode.
     * @returns {string} The Base64 encoded string.
     */
    function utf8ToBase64(str) {
        const encoder = new TextEncoder();
        const uint8Array = encoder.encode(str);
        let binaryString = '';
        uint8Array.forEach(byte => {
            binaryString += String.fromCharCode(byte);
        });
        return btoa(binaryString);
    }

    /**
     * Generates a CRC32 lookup table.
     * @returns {Uint32Array} The CRC32 lookup table.
     */
    function makeCRCTable() {
        const table = new Uint32Array(256);
        const poly = 0xEDB88320; // Reversed polynomial for CRC32
        for (let n = 0; n < 256; n++) {
            let c = n;
            for (let k = 0; k < 8; k++) {
                if (c & 1) {
                    c = poly ^ (c >>> 1);
                } else {
                    c = c >>> 1;
                }
            }
            table[n] = c;
        }
        return table;
    }

    // Pre-compute CRC32 table for efficiency
    const crcTable = makeCRCTable();

    /**
     * Calculates CRC32 for a Uint8Array.
     * @param {Uint8Array} bytes The byte array to calculate CRC32 for.
     * @param {number} [start=0] The starting offset in the byte array.
     * @param {number} [length=bytes.length-start] The number of bytes to process.
     * @returns {number} The CRC32 checksum as an unsigned 32-bit integer.
     */
    function crc32(bytes, start = 0, length = bytes.length - start) {
        let crc = 0xFFFFFFFF;
        for (let i = 0; i < length; i++) {
            const byte = bytes[start + i];
            crc = (crc >>> 8) ^ crcTable[(crc ^ byte) & 0xFF];
        }
        return (crc ^ 0xFFFFFFFF) >>> 0; // Ensure unsigned 32-bit integer
    }


    // --- Main Download Function ---

    /**
     * Fetches a WebP image, converts it to PNG, adds a tEXt chunk with Base64 encoded text,
     * and downloads it.
     * @param {string} name The base name for the downloaded file (e.g., "myImage").
     * ".png" will be appended.
     * @param {string} url The URL of the WebP image.
     * @param {string} text The text to embed in the tEXt chunk (will be Base64 encoded).
     */
    async function downloadImageWithText(name, url, text) {
        try {
            // 1. Fetch WebP image
            const response = await fetch(url);
            if (!response.ok) {
                throw new Error(`HTTP error! Status: ${response.status} for URL: ${url}`);
            }
            const webpBlob = await response.blob();

            // 2. Transcode WebP to PNG using Canvas
            const imageBitmap = await createImageBitmap(webpBlob);
            const canvas = document.createElement('canvas');
            canvas.width = imageBitmap.width;
            canvas.height = imageBitmap.height;
            const ctx = canvas.getContext('2d');
            if (!ctx) {
                 throw new Error("Could not get 2D rendering context from canvas.");
            }
            ctx.drawImage(imageBitmap, 0, 0);
            if (typeof imageBitmap.close === 'function') {
                imageBitmap.close(); // Release memory if available
            }

            // Get initial PNG data as Uint8Array
            const base64PngDataUrl = canvas.toDataURL('image/png');
            const pngPrefix = "data:image/png;base64,";
            if (!base64PngDataUrl.startsWith(pngPrefix)) {
                throw new Error("Canvas did not return a valid PNG data URL.");
            }
            const base64Data = base64PngDataUrl.substring(pngPrefix.length);
            const binaryString = atob(base64Data); // Decode base64 to binary string
            const initialPngBytes = new Uint8Array(binaryString.length);
            for (let i = 0; i < binaryString.length; i++) {
                initialPngBytes[i] = binaryString.charCodeAt(i);
            }

            // 3. Prepare tEXt chunk
            const keyword = "chara"; // Or "Comment", "GreasemonkeyText", etc.
            const base64EncodedUserText = utf8ToBase64(text);

            const textEncoder = new TextEncoder();
            const keywordBytes = textEncoder.encode(keyword);
            const nullSeparatorByte = new Uint8Array([0]);
            const textContentBytes = textEncoder.encode(base64EncodedUserText); // Base64 is ASCII

            // Data for the tEXt chunk: Keyword + Null + Text
            const chunkData = new Uint8Array(
                keywordBytes.length + nullSeparatorByte.length + textContentBytes.length
            );
            chunkData.set(keywordBytes, 0);
            chunkData.set(nullSeparatorByte, keywordBytes.length);
            chunkData.set(textContentBytes, keywordBytes.length + nullSeparatorByte.length);

            // Chunk Type: "tEXt" (ASCII: 116, 69, 88, 116)
            const chunkTypeBytes = new Uint8Array([116, 69, 88, 116]);

            // Chunk Data Length (4 bytes, big-endian)
            const chunkDataLength = chunkData.length;
            const chunkLengthBytes = new Uint8Array(4);
            new DataView(chunkLengthBytes.buffer).setUint32(0, chunkDataLength, false); // false for big-endian

            // CRC (4 bytes, big-endian): calculated over Chunk Type + Chunk Data
            const bytesForCRC = new Uint8Array(chunkTypeBytes.length + chunkData.length);
            bytesForCRC.set(chunkTypeBytes, 0);
            bytesForCRC.set(chunkData, chunkTypeBytes.length);
            const crcValue = crc32(bytesForCRC);
            const crcBytes = new Uint8Array(4);
            new DataView(crcBytes.buffer).setUint32(0, crcValue, false);

            // Assemble the full tEXt chunk: Length + Type + Data + CRC
            const textChunkBytes = new Uint8Array(
                chunkLengthBytes.length +
                chunkTypeBytes.length +
                chunkData.length +
                crcBytes.length
            );
            textChunkBytes.set(chunkLengthBytes, 0);
            textChunkBytes.set(chunkTypeBytes, chunkLengthBytes.length);
            textChunkBytes.set(chunkData, chunkLengthBytes.length + chunkTypeBytes.length);
            textChunkBytes.set(crcBytes, chunkLengthBytes.length + chunkTypeBytes.length + chunkData.length);

            // 4. Inject tEXt chunk before IEND chunk
            // The IEND chunk is always the last chunk in a PNG file and is 12 bytes long.
            // (4 bytes for length 0, 4 bytes for "IEND" type, 4 bytes for CRC)
            const iendChunkPosition = initialPngBytes.length - 12;

            if (iendChunkPosition < 8) { // PNG signature is 8 bytes
                throw new Error("PNG data is too short to contain a valid IEND chunk.");
            }

            // Basic validation of IEND chunk's type 'IEND' for sanity check
            // ASCII: I=73, E=69, N=78, D=68
            // Position in initialPngBytes: iendChunkPosition + 4 to iendChunkPosition + 7 for type
            if (initialPngBytes[iendChunkPosition + 4] !== 73 ||
                initialPngBytes[iendChunkPosition + 5] !== 69 ||
                initialPngBytes[iendChunkPosition + 6] !== 78 ||
                initialPngBytes[iendChunkPosition + 7] !== 68) {
                console.warn("Could not reliably identify IEND chunk type at the expected position. The resulting PNG might have issues if the tEXt chunk is inserted incorrectly. This can happen if the canvas output is non-standard.");
            }

            const bytesBeforeIEND = initialPngBytes.subarray(0, iendChunkPosition);
            const iendChunk = initialPngBytes.subarray(iendChunkPosition); // The 12-byte IEND chunk

            // Concatenate: PNG data before IEND + tEXt chunk + IEND chunk
            const finalPngBytes = new Uint8Array(
                bytesBeforeIEND.length + textChunkBytes.length + iendChunk.length
            );
            finalPngBytes.set(bytesBeforeIEND, 0);
            finalPngBytes.set(textChunkBytes, bytesBeforeIEND.length);
            finalPngBytes.set(iendChunk, bytesBeforeIEND.length + textChunkBytes.length);

            // 5. Create Blob and Trigger Download
            const finalPngBlob = new Blob([finalPngBytes], { type: 'image/png' });
            const objectUrl = URL.createObjectURL(finalPngBlob);

            const a = document.createElement('a');
            a.style.display = 'none'; // Hide the anchor element
            a.href = objectUrl;
            a.download = `${name}.png`; // Append .png to the provided name
            document.body.appendChild(a);
            a.click();

            // Cleanup
            document.body.removeChild(a);
            URL.revokeObjectURL(objectUrl);
        } catch (error) {
            console.error(`Error in downloadImageWithText function for "${name}":`, error);
            // In a Greasemonkey script, you might want to use alert() or a custom notification
            alert(`Failed to download and process image "${name}": ${error.message}`);
        }
    }

    // Create the button
    const button = document.createElement('button');
    button.innerText = 'Download Card';
    button.style.position = 'fixed';
    button.style.top = '8px'; // Move to the top of the screen
    button.style.left = '50%'; // Center horizontally
    button.style.transform = 'translate(-50%, 0)'; // Adjust for horizontal centering
    button.style.padding = '5px 8px'; // Reduced vertical padding
    button.style.borderRadius = '6px';
    button.style.backgroundColor = 'rgba(128, 90, 213, 0.6)';
    button.style.color = 'var(--chakra-colors-whiteAlpha-800)';
    button.style.border = 'none';
    button.style.cursor = 'pointer';
    button.style.zIndex = '9999';
    button.style.fontFamily = 'var(--chakra-fonts-body)';
    button.style.lineHeight = '1';
    button.style.fontSize = '14px';

    function stripCopy(t) {
        return t.replaceAll(/^created by.*janitorai.com$/gm, '').trim();
    }

    var card;

    function setCard(jc) {
        card = {
      spec: 'chara_card_v2',
      spec_version: '2.0', // May 8th addition
      data: {
        name: jc.name,
        creator_notes: stripCopy(jc.description),
        description: stripCopy(jc.personality),
        scenario: stripCopy(jc.scenario),
        first_mes: stripCopy(jc.first_message),
        mes_example: stripCopy(jc.example_dialogs),

        // New fields start here
         /*
        system_prompt: string
        post_history_instructions: string
        alternate_greetings: Array<string>
        character_book?: CharacterBook
      */
        // May 8th additions
        tags: jc.tags.map(x=>x.slug).concat(jc.custom_tags || []),
        creator: jc.creator_name,
        character_version: jc.updated_at,
        extensions: jc,
          }
        };
        for (const f of ['description', 'personality', 'scenario', 'first_message', 'example_dialogs']) {
            delete jc[f];
        }
        button.style.display='block';
    }

    try {
        setCard(Object.values(JSON.parse(JSON.parse(document.evaluate('//script[contains(.,"_storeState_")]', document, null, XPathResult.FIRST_ORDERED_NODE_TYPE).singleNodeValue.text.match(/JSON.parse\((".*)\);/)[1]))).filter(x=>x.character)[0].character);
    } catch {}

    (function (open) {
      XMLHttpRequest.prototype.open = function () {
        this.addEventListener("readystatechange", () => {
          if (this.readyState == 4) {
              if (this.responseURL.match(/\/characters\//)) {
                  try {
                      setCard(JSON.parse(this.responseText));
                      // console.log("loaded card",  this.responseURL, this.responseText.slice(0, 15));
                  } catch{
                      button.style.display='none';
                  }
              }
          }
        }, false);
        return open.apply(this, arguments);
      };
    })(XMLHttpRequest.prototype.open);

    button.addEventListener('click', () => {
        downloadImageWithText(`janitorai - ${card.data.name}`, `https://ella.janitorai.com/bot-avatars/${card.data.extensions.avatar}`, JSON.stringify(card));
    });

    if (!card) {
        button.style.display='none';
    }

    document.body.prepend(button);
})();