JanitorAI Character Card Scraper

Extract character card with "T" key (WHILE IN CHAT PAGE) and save as .txt, .png, or .json (proxy required)

2025-06-23 기준 버전입니다. 최신 버전을 확인하세요.

// ==UserScript==
// @name         JanitorAI Character Card Scraper
// @version      2.2
// @description  Extract character card with "T" key (WHILE IN CHAT PAGE) and save as .txt, .png, or .json (proxy required)
// @match        https://janitorai.com/*
// @icon         https://images.dwncdn.net/images/t_app-icon-l/p/46413ec0-e1d8-4eab-a0bc-67eadabb2604/3920235030/janitor-ai-logo
// @grant        none
// @namespace    https://sleazyfork.org/en/scripts/537206-janitorai-character-card-scraper
// @run-at       document-start
// @license      MIT
// ==/UserScript==

(() => {
    'use strict';

    /* ============================
       ==      VARIABLES        ==
       ============================ */
    let hasInitialized = false
    let viewActive = false
    let shouldInterceptNext = false
    let networkInterceptActive = false
    let exportFormat = null
    let chatData = null
    let currentTab = sessionStorage.getItem('lastActiveTab') || 'export'
    let useChatNameForName = localStorage.getItem('useChatNameForName') === 'true' || false;
    let applyCharToken = localStorage.getItem('applyCharToken') !== 'false';
    let animationTimeouts = [];
    let guiElement = null;
    sessionStorage.removeItem('char_export_scroll');
    sessionStorage.removeItem('char_settings_scroll');
    const ANIMATION_DURATION = 150; // Animation duration for modal open/close in ms
    const TAB_ANIMATION_DURATION = 300; // Animation duration for tab switching in ms
    const TAB_BUTTON_DURATION = 250; // Animation duration for tab button effects
    const BUTTON_ANIMATION = 200; // Animation duration for format buttons
    const TOGGLE_ANIMATION = 350; // Animation duration for toggle switch
    const ACTIVE_TAB_COLOR = '#0080ff'; // Color for active tab indicator
    const INACTIVE_TAB_COLOR = 'transparent'; // Color for inactive tab indicator
    const BUTTON_COLOR = '#3a3a3a'; // Base color for buttons
    const BUTTON_HOVER_COLOR = '#4a4a4a'; // Hover color for buttons
    const BUTTON_ACTIVE_COLOR = '#0070dd'; // Active color for buttons when clicked
    const TOOLTIP_SLIDE_FROM_RIGHT = true; // true = slide towards right (default). Set false for slide-left variant.
    const TOOLTIP_SLIDE_OFFSET = 10; // px the tooltip travels during slide animation
    const characterMetaCache = {
        id: null,
        creatorUrl: '',
        characterVersion: '',
        characterCardUrl: '',
        name: '',
        creatorNotes: ''
    };

    /* ============================
       ==      UTILITIES        ==
       ============================ */
    function makeElement(tag, attrs = {}, styles = {}) {
        const el = document.createElement(tag);
        Object.entries(attrs).forEach(([key, value]) => el[key] = value);

        if (styles) {
            Object.entries(styles).forEach(([key, value]) => el.style[key] = value);
        }
        return el;
    }

    function saveFile(filename, blob) {
        const url = URL.createObjectURL(blob);
        const a = makeElement('a', {
            href: url,
            download: filename
        });
        document.body.appendChild(a);
        a.click();
        a.remove();
        URL.revokeObjectURL(url);
    }

    function escapeRegExp(s) {
        return s.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
    }

    function extractTagContent(sys, charName) {
        if (!charName || !sys) return '';
        const variants = [];
        const trimmed = charName.trim();
        variants.push(trimmed);
        const collapsed = trimmed.replace(/\s+/g, ' ');
        if (collapsed !== trimmed) variants.push(collapsed);
        if (trimmed.includes(' ')) variants.push(trimmed.replace(/\s+/g, '_'));

        for (const name of variants) {
            const escName = escapeRegExp(name);
            const regex = new RegExp(`<${escName}(?:\\s[^>]*)?\\s*>([\\s\\S]*?)<\\/${escName}\\s*>`, 'i');
            const m = sys.match(regex);
            if (m && m[1] != null) {
                return m[1].trim();
            }
            const openTagRx = new RegExp(`<${escName}(?:\\s[^>]*)?\\s*>`, 'i');
            const closeTagRx = new RegExp(`<\\/${escName}\\s*>`, 'i');
            const openMatch = openTagRx.exec(sys);
            const closeMatch = closeTagRx.exec(sys);
            if (openMatch && closeMatch && closeMatch.index > openMatch.index) {
                const start = openMatch.index + openMatch[0].length;
                const end = closeMatch.index;
                return sys.substring(start, end).trim();
            }
            try {
                const parser = new DOMParser();
                const doc = parser.parseFromString(`<root>${sys}</root>`, 'application/xml');
                const elems = doc.getElementsByTagName(name);
                if (elems.length) {
                    return elems[0].textContent.trim();
                }
            } catch (_) {}
        }
        return '';
    }

    /* ============================
       ==          UI           ==
       ============================ */
    function createUI() {
        if (guiElement && document.body.contains(guiElement)) {
            return;
        }

        animationTimeouts.forEach(timeoutId => clearTimeout(timeoutId));
        animationTimeouts = [];

        viewActive = true;

        const gui = makeElement('div', {
            id: 'char-export-gui'
        }, {
            position: 'fixed',
            top: '50%',
            left: '50%',
            transform: 'translate(-50%, -50%) scale(0.95)',
            background: '#222',
            color: 'white',
            padding: '15px 20px 7px',
            borderRadius: '8px',
            boxShadow: '0 0 20px rgba(0,0,0,0.5)',
            zIndex: '10000',
            textAlign: 'center',
            width: '320px',
            overflow: 'hidden',
            display: 'flex',
            flexDirection: 'column',
            boxSizing: 'border-box',
            opacity: '0',
            transition: `opacity ${ANIMATION_DURATION}ms ease-out, transform ${ANIMATION_DURATION}ms ease-out`
        });

        guiElement = gui;
        const unselectStyle = document.createElement('style');
        unselectStyle.textContent = `#char-export-gui, #char-export-gui * {
  user-select: none; -webkit-user-select: none; -moz-user-select: none; -ms-user-select: none;
}
#char-export-gui {
  backdrop-filter: blur(6px);
  background: rgba(34,34,34,0.92);
  border: none;
  border-radius: 8px;
}
#char-export-overlay {
  position: fixed; top: 0; left: 0; width: 100%; height: 100%;
  background: rgba(0,0,0,0.5); z-index: 9998;
}
#char-export-gui #char-export-tooltip {
  font-size: 12px; background: #222; color: #fff;
  padding: 6px 10px; border-radius: 4px; pointer-events: none;
}
#char-export-gui button {
  background: #333; color: #fff; border: none;
  border-radius: 4px; padding: 8px 12px;
  transition: background 200ms ease, transform 200ms ease;
}
#char-export-gui button:hover:not(:disabled) {
  background: #444; transform: translateY(-1px);
}
#char-export-gui button:disabled {
  opacity: 0.6; cursor: not-allowed;
}
#char-export-gui button:focus {
  outline: none;
}`;
        document.head.appendChild(unselectStyle);

        const tabContainer = makeElement('div', {}, {
            display: 'flex',
            justifyContent: 'center',
            marginBottom: '15px',
            borderBottom: '1px solid #444',
            paddingBottom: '8px',
            width: '100%'
        });

        const tabsWrapper = makeElement('div', {}, {
            display: 'flex',
            justifyContent: 'center',
            width: '100%',
            maxWidth: '300px',
            margin: '0 auto'
        });

        const createTabButton = (text, isActive) => {
            const button = makeElement('button', {
                textContent: text
            }, {
                background: 'transparent',
                border: 'none',
                color: '#fff',
                padding: '8px 20px',
                cursor: 'pointer',
                margin: '0 5px',
                fontWeight: 'bold',
                flex: '1',
                textAlign: 'center',
                position: 'relative',
                overflow: 'hidden',
                transition: `opacity ${TAB_BUTTON_DURATION}ms ease, transform ${TAB_BUTTON_DURATION}ms ease, color ${TAB_BUTTON_DURATION}ms ease`
            });

            const indicator = makeElement('div', {}, {
                position: 'absolute',
                bottom: '0',
                left: '0',
                width: '100%',
                height: '2px',
                background: isActive ? ACTIVE_TAB_COLOR : INACTIVE_TAB_COLOR,
                transition: `transform ${TAB_BUTTON_DURATION}ms ease, background-color ${TAB_BUTTON_DURATION}ms ease`
            });

            if (!isActive) {
                button.style.opacity = '0.7';
                indicator.style.transform = 'scaleX(0.5)';
            }

            button.appendChild(indicator);
            return {
                button,
                indicator
            };
        };

        const {
            button: exportTab,
            indicator: exportIndicator
        } = createTabButton('Export', true);
        const {
            button: settingsTab,
            indicator: settingsIndicator
        } = createTabButton('Settings', false);

        exportTab.onmouseover = () => {
            if (currentTab !== 'export') {
                exportTab.style.opacity = '1';
                exportTab.style.transform = 'translateY(-2px)';
                exportIndicator.style.transform = 'scaleX(0.8)';
            }
        };
        exportTab.onmouseout = () => {
            if (currentTab !== 'export') {
                exportTab.style.opacity = '0.7';
                exportTab.style.transform = '';
                exportIndicator.style.transform = 'scaleX(0.5)';
            }
        };

        settingsTab.onmouseover = () => {
            if (currentTab !== 'settings') {
                settingsTab.style.opacity = '1';
                settingsTab.style.transform = 'translateY(-2px)';
                settingsIndicator.style.transform = 'scaleX(0.8)';
            }
        };
        settingsTab.onmouseout = () => {
            if (currentTab !== 'settings') {
                settingsTab.style.opacity = '0.7';
                settingsTab.style.transform = '';
                settingsIndicator.style.transform = 'scaleX(0.5)';
            }
        };

        tabsWrapper.appendChild(exportTab);
        tabsWrapper.appendChild(settingsTab);
        tabContainer.appendChild(tabsWrapper);
        gui.appendChild(tabContainer);
        /* ========= Dynamic Tooltip ========= */
        (() => {
            let tEl;
            const show = (target) => {
                const msg = target.getAttribute('data-tooltip');
                if (!msg) return;
                if (!tEl) {
                    tEl = document.createElement('div');
                    tEl.id = 'char-export-tooltip';
                    tEl.id = 'char-export-tooltip';
                    tEl.style.cssText = 'position:fixed;padding:6px 10px;font-size:12px;background:#222;color:#fff;border-radius:4px;white-space:nowrap;pointer-events:none;box-shadow:0 4px 12px rgba(0,0,0,0.4);opacity:0;transition:opacity 200ms ease,transform 200ms ease;z-index:10002;';
                    const offset = TOOLTIP_SLIDE_FROM_RIGHT ? -TOOLTIP_SLIDE_OFFSET : TOOLTIP_SLIDE_OFFSET;
                    tEl.style.transform = `translateX(${offset}px)`;
                    document.body.appendChild(tEl);
                }
                tEl.textContent = msg;
                const guiRect = gui.getBoundingClientRect();
                const tgtRect = target.getBoundingClientRect();
                tEl.style.top = (tgtRect.top + tgtRect.height / 2 - tEl.offsetHeight / 2) + 'px';
                tEl.style.left = (guiRect.right + 10) + 'px';
                requestAnimationFrame(() => {
                    tEl.style.opacity = '1';
                    tEl.style.transform = 'translateX(0)';
                });
            };
            const hide = () => {
                if (!tEl) return;
                tEl.style.opacity = '0';
                const offsetHide = TOOLTIP_SLIDE_FROM_RIGHT ? -TOOLTIP_SLIDE_OFFSET : TOOLTIP_SLIDE_OFFSET;
                tEl.style.transform = `translateX(${offsetHide}px)`;
            };
            gui.addEventListener('mouseover', e => {
                const tgt = e.target.closest('[data-tooltip]');
                if (tgt) show(tgt);
            });
            gui.addEventListener('mouseout', e => {
                const tgt = e.target.closest('[data-tooltip]');
                if (tgt) hide();
            });
            window.addEventListener('keydown', ev => {
                hide();
                if ((ev.key === 't' || ev.key === 'T') && tEl) {
                    tEl.style.opacity = '0';
                    const offsetHide = TOOLTIP_SLIDE_FROM_RIGHT ? -TOOLTIP_SLIDE_OFFSET : TOOLTIP_SLIDE_OFFSET;
                    tEl.style.transform = `translateX(${offsetHide}px)`;
                }
            });
        })();

        const exportContent = makeElement('div', {
            id: 'export-tab'
        }, {
            maxHeight: '60vh',
            overflowY: 'auto',
            padding: '0 5px 10px 0',
            display: 'flex',
            flexDirection: 'column',
            justifyContent: 'center'
        });

        const title = makeElement('h2', {
            textContent: 'Export Character Card'
        }, {
            margin: '0 0 12px 0',
            fontSize: '18px',
            paddingTop: '5px'
        });
        exportContent.appendChild(title);

        const buttonContainer = makeElement('div', {}, {
            display: 'flex',
            gap: '10px',
            justifyContent: 'center',
            marginBottom: '3px',
            marginTop: '8px'
        });

        ['TXT', 'PNG', 'JSON'].forEach(format => {
            const type = format.toLowerCase();

            const button = makeElement('button', {
                textContent: format
            }, {
                background: BUTTON_COLOR,
                border: 'none',
                color: 'white',
                padding: '10px 20px',
                borderRadius: '6px',
                cursor: 'pointer',
                fontWeight: 'bold',
                position: 'relative',
                overflow: 'hidden',
                flex: '1',
                transition: `all ${BUTTON_ANIMATION}ms ease`,
                boxShadow: '0 2px 4px rgba(0,0,0,0.1)',
                transform: 'translateY(0)'
            });

            const shine = makeElement('div', {}, {
                position: 'absolute',
                top: '0',
                left: '0',
                width: '100%',
                height: '100%',
                background: 'linear-gradient(135deg, rgba(255,255,255,0.1) 0%, rgba(255,255,255,0) 60%)',
                transform: 'translateX(-100%)',
                transition: `transform ${BUTTON_ANIMATION * 1.5}ms ease-out`,
                pointerEvents: 'none'
            });
            button.appendChild(shine);

            button.onmouseover = () => {
                button.style.background = BUTTON_HOVER_COLOR;
                button.style.transform = 'translateY(-2px)';
                button.style.boxShadow = '0 4px 8px rgba(0,0,0,0.2)';
                shine.style.transform = 'translateX(100%)';
            };

            button.onmouseout = () => {
                button.style.background = BUTTON_COLOR;
                button.style.transform = 'translateY(0)';
                button.style.boxShadow = '0 2px 4px rgba(0,0,0,0.1)';
                shine.style.transform = 'translateX(-100%)';
            };

            button.onmousedown = () => {
                button.style.transform = 'translateY(1px)';
                button.style.boxShadow = '0 1px 2px rgba(0,0,0,0.2)';
                button.style.background = BUTTON_ACTIVE_COLOR;
            };

            button.onmouseup = () => {
                button.style.transform = 'translateY(-2px)';
                button.style.boxShadow = '0 4px 8px rgba(0,0,0,0.2)';
                button.style.background = BUTTON_HOVER_COLOR;
            };

            button.onclick = (e) => {
                const rect = button.getBoundingClientRect();
                const x = e.clientX - rect.left;
                const y = e.clientY - rect.top;

                const ripple = makeElement('div', {}, {
                    position: 'absolute',
                    borderRadius: '50%',
                    backgroundColor: 'rgba(255,255,255,0.4)',
                    width: '5px',
                    height: '5px',
                    transform: 'scale(1)',
                    opacity: '1',
                    animation: 'ripple 600ms linear',
                    pointerEvents: 'none',
                    top: `${y}px`,
                    left: `${x}px`,
                    marginLeft: '-2.5px',
                    marginTop: '-2.5px'
                });

                button.appendChild(ripple);

                exportFormat = type;
                closeV();
                extraction();

                setTimeout(() => ripple.remove(), 600);
            };

            buttonContainer.appendChild(button);
        });


        if (!document.getElementById('char-export-style')) {
            const style = document.createElement('style');
            style.id = 'char-export-style';
            style.textContent = `
          @keyframes ripple {
            to {
              transform: scale(30);
              opacity: 0; pointer-events: none;
            }
          }
        `;
            document.head.appendChild(style);
        }
        if (!document.getElementById('char-export-tooltip-style')) {
            const tooltipStyle = document.createElement('style');
            tooltipStyle.id = 'char-export-tooltip-style';
            tooltipStyle.textContent = `
[data-tooltip] {
  position: relative;
}
[data-tooltip]::before {
  content: '';
  position: absolute;
  left: calc(100% + 4px);
  top: 50%;
  transform: translateY(-50%) scaleY(0.5);
  border: solid transparent;
  border-width: 6px 6px 6px 0;
  border-left-color: #333;
  opacity: 0; pointer-events: none;
  transition: opacity 150ms ease;
  z-index: 10001;
}
[data-tooltip]::after {
  content: attr(data-tooltip);
  position: absolute;
  left: calc(100% + 10px);
  top: 50%;
  transform: translateY(-50%) scale(0.8);
  background: #333;
  color: #fff;
  padding: 6px 10px;
  border-radius: 4px;
  white-space: nowrap;
  opacity: 0; pointer-events: none;
  transition: opacity 150ms ease, transform 150ms ease;
  z-index: 10001;
}
[data-tooltip]:hover::before,
[data-tooltip]:hover::after {
  opacity: 1;
  transform: translateY(-50%) scale(1);
}

.toggle-wrapper {
  border-radius: 8px;
  transition: background 0.2s ease, box-shadow 0.2s ease;
}
.toggle-wrapper.active {
  background: transparent;
  box-shadow: none;
}
            `;
            document.head.appendChild(tooltipStyle);
            if (!document.getElementById('char-export-scrollbar-style')) {
                const scrollStyle = document.createElement('style');
                scrollStyle.id = 'char-export-scrollbar-style';
                scrollStyle.textContent = `
#content-wrapper { overflow: visible; }
#settings-tab { overflow-y: auto; scrollbar-width: none; -ms-overflow-style: none; }
#settings-tab::-webkit-scrollbar { width: 0; height: 0; }







            `;
                document.head.appendChild(scrollStyle);
                if (!document.getElementById('char-export-tooltip-override-style')) {
                    const overrideStyle = document.createElement('style');
                    overrideStyle.id = 'char-export-tooltip-override-style';
                    overrideStyle.textContent = `
[data-tooltip]::before { transform: translateY(-50%) translateX(-6px); transition: transform 200ms ease, opacity 200ms ease; }
[data-tooltip]::after { transform: translateY(-50%) translateX(-10px); transition: transform 200ms ease, opacity 200ms ease; }
[data-tooltip]:hover::before { transform: translateY(-50%) translateX(0); opacity:1; }
[data-tooltip]:hover::after { transform: translateY(-50%) translateX(0); opacity:1; }

[data-tooltip]::before,[data-tooltip]::after{display:none !important;}
@keyframes toggle-pulse { 0% { transform: scale(1); } 50% { transform: scale(1.05); } 100% { transform: scale(1); } }
.toggle-wrapper.pulse { animation: toggle-pulse 300ms ease; }

.switch .slider-before { transition: transform 200ms ease, box-shadow 200ms ease; }
.toggle-wrapper.active .slider-before { box-shadow: 0 0 2px rgba(0,0,0,0.2), 0 0 8px rgba(0,128,255,0.5); }
.toggle-wrapper.active .slider { background-color: #0080ff; }
            `;
                    document.head.appendChild(overrideStyle);
                }
            }
        }

        exportContent.appendChild(buttonContainer);

        const contentWrapper = makeElement('div', {
            id: 'content-wrapper'
        }, {
            height: '103px',
            width: '100%',
            overflow: 'visible',
            display: 'flex',
            flexDirection: 'column',
            justifyContent: 'center',
            position: 'relative'
        });
        gui.appendChild(contentWrapper);

        const tabContentStyles = {
            height: '100%',
            width: '100%',
            overflowY: 'auto',
            overflowX: 'hidden',
            padding: '0',


            position: 'absolute',
            top: '0',
            left: '0',
            opacity: '1',
            transform: 'scale(1)',
            transition: `opacity ${TAB_ANIMATION_DURATION}ms ease, transform ${TAB_ANIMATION_DURATION}ms ease`,
        };

        Object.assign(exportContent.style, tabContentStyles);
        exportContent.style.overflowY = 'hidden';
        exportContent.style.overflowX = 'hidden';



        const settingsContent = makeElement('div', {
            id: 'settings-tab',
            style: 'display: none;'
        }, tabContentStyles);

        contentWrapper.appendChild(exportContent);
        contentWrapper.appendChild(settingsContent);
        const savedExportScroll = parseInt(sessionStorage.getItem('char_export_scroll') || '0', 10);
        exportContent.scrollTop = savedExportScroll;
        const savedSettingsScroll = parseInt(sessionStorage.getItem('char_settings_scroll') || '0', 10);
        settingsContent.scrollTop = savedSettingsScroll;
        settingsContent.addEventListener('scroll', () => sessionStorage.setItem('char_settings_scroll', settingsContent.scrollTop));


        requestAnimationFrame(() => {
            exportContent.scrollTop = savedExportScroll;
            settingsContent.scrollTop = savedSettingsScroll;
        });

        const settingsTitle = makeElement('h2', {
            textContent: 'Export Settings'
        }, {
            margin: '0 0 15px 0',
            fontSize: '18px',
            paddingTop: '5px'
        });
        settingsContent.appendChild(settingsTitle);

        const toggleContainer = makeElement('div', {}, {
            display: 'flex',
            alignItems: 'center',
            marginBottom: '4px',
            marginTop: '5px',
            padding: '10px 10px 9px',
            background: '#2a2a2a',
            borderRadius: '8px',
            gap: '10px'
        });

        const toggleWrapper = makeElement('div', {
            className: 'toggle-wrapper'
        }, {
            display: 'flex',
            alignItems: 'center',
            justifyContent: 'space-between',
            width: '100%',
            cursor: 'pointer'
        });

        const toggleLabel = makeElement('span', {
            textContent: 'Use character\'s chat name',

        }, {
            fontSize: '13px',
            color: '#fff',
            order: '2',
            textAlign: 'left',
            flex: '1',
            paddingLeft: '10px',
            wordBreak: 'break-word',
            lineHeight: '1.4',




        });

        const toggle = makeElement('label', {
            className: 'switch'
        }, {
            position: 'relative',
            display: 'inline-block',
            width: '40px',
            height: '24px',
            order: '1',
            margin: '0',
            flexShrink: '0',
            borderRadius: '24px',
            boxShadow: '0 1px 3px rgba(0,0,0,0.2) inset',
            transition: `all ${TOGGLE_ANIMATION}ms ease`
        });

        const slider = makeElement('span', {
            className: 'slider round'
        }, {
            position: 'absolute',
            cursor: 'pointer',
            top: '0',
            left: '0',
            right: '0',
            bottom: '0',
            backgroundColor: useChatNameForName ? ACTIVE_TAB_COLOR : '#ccc',
            transition: `background-color ${TOGGLE_ANIMATION}ms cubic-bezier(0.34, 1.56, 0.64, 1)`,
            borderRadius: '24px',
            overflow: 'hidden'
        });

        const sliderShine = makeElement('div', {}, {
            position: 'absolute',
            top: '0',
            left: '0',
            width: '100%',
            height: '100%',
            background: 'linear-gradient(135deg, rgba(255,255,255,0.2) 0%, rgba(255,255,255,0) 50%)',
            opacity: '0.5',
            transition: `opacity ${TOGGLE_ANIMATION}ms ease`
        });
        slider.appendChild(sliderShine);

        const sliderBefore = makeElement('span', {
            className: 'slider-before'
        }, {
            position: 'absolute',
            content: '""',
            height: '16px',
            width: '16px',
            left: '4px',
            bottom: '4px',
            backgroundColor: 'white',
            transition: `transform ${TOGGLE_ANIMATION}ms cubic-bezier(0.34, 1.56, 0.64, 1), box-shadow ${TOGGLE_ANIMATION}ms ease`,
            borderRadius: '50%',
            transform: useChatNameForName ? 'translateX(16px)' : 'translateX(0)',
            boxShadow: useChatNameForName ?
                '0 0 2px rgba(0,0,0,0.2), 0 0 5px rgba(0,128,255,0.3)' : '0 0 2px rgba(0,0,0,0.2)'
        });

        const input = makeElement('input', {
            type: 'checkbox',
            checked: useChatNameForName
        }, {
            opacity: '0',
            width: '0',
            height: '0',
            position: 'absolute'
        });

        input.addEventListener('change', (e) => {
            useChatNameForName = e.target.checked;
            localStorage.setItem('useChatNameForName', useChatNameForName);

            slider.style.backgroundColor = useChatNameForName ? ACTIVE_TAB_COLOR : '#ccc';
            sliderBefore.style.transform = useChatNameForName ? 'translateX(16px)' : 'translateX(0)';

            sliderBefore.style.boxShadow = useChatNameForName ?
                '0 0 2px rgba(0,0,0,0.2), 0 0 5px rgba(0,128,255,0.3)' :
                '0 0 2px rgba(0,0,0,0.2)';
            toggleWrapper.classList.toggle('active', useChatNameForName);
            toggleWrapper.classList.add('pulse');
            setTimeout(() => toggleWrapper.classList.remove('pulse'), 300);

            if (useChatNameForName) {
                const pulse = makeElement('div', {}, {
                    position: 'absolute',
                    top: '0',
                    left: '0',
                    right: '0',
                    bottom: '0',
                    backgroundColor: ACTIVE_TAB_COLOR,
                    borderRadius: '24px',
                    opacity: '0.5',
                    transform: 'scale(1.2)',
                    pointerEvents: 'none',
                    zIndex: '-1'
                });

                toggle.appendChild(pulse);

                setTimeout(() => {
                    pulse.style.opacity = '0';
                    pulse.style.transform = 'scale(1.5)';
                    pulse.style.transition = 'all 400ms ease-out';
                }, 10);

                setTimeout(() => pulse.remove(), 400);
            }
        });

        slider.style.backgroundColor = useChatNameForName ? '#007bff' : '#ccc';
        slider.appendChild(sliderBefore);
        toggle.appendChild(input);
        toggle.appendChild(slider);

        toggleWrapper.addEventListener('click', (e) => {
            e.preventDefault();
            input.checked = !input.checked;
            const event = new Event('change');
            input.dispatchEvent(event);
            document.body.focus();
        });

        toggleWrapper.appendChild(toggleLabel);
        toggleLabel.setAttribute('data-tooltip', 'Uses chat name for the character name instead of label name.');
        toggleWrapper.appendChild(toggle);
        toggleContainer.appendChild(toggleWrapper);
        settingsContent.appendChild(toggleContainer);
        const toggleContainerChar = makeElement('div', {}, {
            display: 'flex',
            alignItems: 'center',
            marginBottom: '4px',
            marginTop: '5px',
            padding: '10px 10px 9px',
            background: '#2a2a2a',
            borderRadius: '8px',
            gap: '10px'
        });
        const toggleWrapperChar = makeElement('div', {
            className: 'toggle-wrapper'
        }, {
            display: 'flex',
            alignItems: 'center',
            justifyContent: 'space-between',
            width: '100%',
            cursor: 'pointer'
        });
        const toggleLabelChar = makeElement('span', {
            textContent: 'Apply {{char}} tokenization',

        }, {
            fontSize: '13px',
            color: '#fff',
            order: '2',
            textAlign: 'left',
            flex: '1',
            paddingLeft: '10px',
            wordBreak: 'break-word',
            lineHeight: '1.4',




        });
        const toggleChar = makeElement('label', {
            className: 'switch'
        }, {
            position: 'relative',
            display: 'inline-block',
            width: '40px',
            height: '24px',
            order: '1',
            margin: '0',
            flexShrink: '0',
            borderRadius: '24px',
            boxShadow: '0 1px 3px rgba(0,0,0,0.2) inset',
            transition: `all ${TOGGLE_ANIMATION}ms ease`
        });
        const sliderChar = makeElement('span', {
            className: 'slider round'
        }, {
            position: 'absolute',
            cursor: 'pointer',
            top: '0',
            left: '0',
            right: '0',
            bottom: '0',
            backgroundColor: applyCharToken ? ACTIVE_TAB_COLOR : '#ccc',
            transition: `background-color ${TOGGLE_ANIMATION}ms cubic-bezier(0.34, 1.56, 0.64, 1)`,
            borderRadius: '24px',
            overflow: 'hidden'
        });
        const sliderShineChar = makeElement('div', {}, {
            position: 'absolute',
            top: '0',
            left: '0',
            width: '100%',
            height: '100%',
            background: 'linear-gradient(135deg, rgba(255,255,255,0.2) 0%, rgba(255,255,255,0) 50%)',
            opacity: '0.5',
            transition: `opacity ${TOGGLE_ANIMATION}ms ease`
        });
        sliderChar.appendChild(sliderShineChar);
        const sliderBeforeChar = makeElement('span', {
            className: 'slider-before'
        }, {
            position: 'absolute',
            content: '""',
            height: '16px',
            width: '16px',
            left: '4px',
            bottom: '4px',
            backgroundColor: 'white',
            transition: `transform ${TOGGLE_ANIMATION}ms cubic-bezier(0.34, 1.56, 0.64, 1), box-shadow ${TOGGLE_ANIMATION}ms ease`,
            borderRadius: '50%',
            transform: applyCharToken ? 'translateX(16px)' : 'translateX(0)',
            boxShadow: applyCharToken ? '0 0 2px rgba(0,0,0,0.2), 0 0 5px rgba(0,128,255,0.3)' : '0 0 2px rgba(0,0,0,0.2)'
        });
        const inputChar = makeElement('input', {
            type: 'checkbox',
            checked: applyCharToken
        }, {
            opacity: '0',
            width: '0',
            height: '0',
            position: 'absolute'
        });
        inputChar.addEventListener('change', (e) => {
            applyCharToken = e.target.checked;
            localStorage.setItem('applyCharToken', applyCharToken);
            toggleWrapperChar.classList.toggle('active', applyCharToken);
            toggleWrapperChar.classList.add('pulse');
            setTimeout(() => toggleWrapperChar.classList.remove('pulse'), 300);
            sliderChar.style.backgroundColor = applyCharToken ? ACTIVE_TAB_COLOR : '#ccc';
            sliderBeforeChar.style.transform = applyCharToken ? 'translateX(16px)' : 'translateX(0)';
            sliderBeforeChar.style.boxShadow = applyCharToken ? '0 0 2px rgba(0,0,0,0.2), 0 0 5px rgba(0,128,255,0.3)' : '0 0 2px rgba(0,0,0,0.2)';
            if (applyCharToken) {
                const pulseChar = makeElement('div', {}, {
                    position: 'absolute',
                    top: '0',
                    left: '0',
                    right: '0',
                    bottom: '0',
                    backgroundColor: ACTIVE_TAB_COLOR,
                    borderRadius: '24px',
                    opacity: '0.5',
                    transform: 'scale(1.2)',
                    pointerEvents: 'none',
                    zIndex: '-1'
                });
                toggleChar.appendChild(pulseChar);
                setTimeout(() => {
                    pulseChar.style.opacity = '0';
                    pulseChar.style.transform = 'scale(1.5)';
                    pulseChar.style.transition = 'all 400ms ease-out';
                }, 10);
                setTimeout(() => pulseChar.remove(), 400);
            }
        });
        sliderChar.appendChild(sliderBeforeChar);
        toggleChar.appendChild(inputChar);
        toggleChar.appendChild(sliderChar);
        toggleWrapperChar.addEventListener('click', (e) => {
            e.preventDefault();
            inputChar.checked = !inputChar.checked;
            const event = new Event('change');
            inputChar.dispatchEvent(event);
            document.body.focus();
        });
        toggleWrapperChar.appendChild(toggleLabelChar);
        toggleLabelChar.setAttribute('data-tooltip', 'Toggle replacement of character names with {{char}} placeholder.');
        toggleWrapperChar.appendChild(toggleChar);
        toggleContainerChar.appendChild(toggleWrapperChar);
        settingsContent.appendChild(toggleContainerChar);

        const tabs = {
            export: {
                content: exportContent,
                tab: exportTab,
                active: true
            },
            settings: {
                content: settingsContent,
                tab: settingsTab,
                active: false
            }
        };

        function switchTab(tabKey) {
            animationTimeouts.forEach(timeoutId => clearTimeout(timeoutId));
            animationTimeouts = [];

            Object.entries(tabs).forEach(([key, {
                content,
                tab
            }]) => {
                const isActive = key === tabKey;

                tab.style.opacity = isActive ? '1' : '0.7';
                tab.style.transform = isActive ? 'translateY(-2px)' : '';

                const indicator = tab.lastChild;
                if (indicator) {
                    if (isActive) {
                        indicator.style.background = ACTIVE_TAB_COLOR;
                        indicator.style.transform = 'scaleX(1)';
                    } else {
                        indicator.style.background = INACTIVE_TAB_COLOR;
                        indicator.style.transform = 'scaleX(0.5)';
                    }
                }

                content.style.display = 'block';
                content.style.pointerEvents = isActive ? 'auto' : 'none';

                if (isActive) {
                    content.style.opacity = '0';
                    content.style.transform = 'scale(0.95)';

                    void content.offsetWidth;

                    requestAnimationFrame(() => {
                        content.style.opacity = '1';
                        content.style.transform = 'scale(1)';
                    });

                } else {
                    requestAnimationFrame(() => {
                        content.style.opacity = '0';
                        content.style.transform = 'scale(0.95)';
                    });

                    const hideTimeout = setTimeout(() => {
                        if (!tabs[key].active) {
                            content.style.display = 'none';
                        }
                    }, TAB_ANIMATION_DURATION);
                    animationTimeouts.push(hideTimeout);
                }

                tabs[key].active = isActive;
            });

            currentTab = tabKey;
            try {
                sessionStorage.setItem('lastActiveTab', tabKey);
            } catch (e) {
                console.warn('Failed to save tab state to sessionStorage', e);
            }
        }

        const handleTabClick = (e) => {
            const tt = document.getElementById('char-export-tooltip');
            if (tt) {
                tt.style.opacity = '0';
                const offsetHide = TOOLTIP_SLIDE_FROM_RIGHT ? -TOOLTIP_SLIDE_OFFSET : TOOLTIP_SLIDE_OFFSET;
                tt.style.transform = `translateX(${offsetHide}px)`;
            }
            const tabKey = e.target === exportTab ? 'export' : 'settings';
            if (!tabs[tabKey].active) {
                switchTab(tabKey);
            }
        };

        exportTab.onclick = handleTabClick;
        settingsTab.onclick = handleTabClick;

        Object.entries(tabs).forEach(([key, {
            content
        }]) => {
            const isActive = key === currentTab;

            content.style.display = isActive ? 'block' : 'none';
            content.style.opacity = isActive ? '1' : '0';
            content.style.transform = isActive ? 'scale(1)' : 'scale(0.95)';
        });

        switchTab(currentTab);
        document.body.appendChild(gui);

        void gui.offsetWidth;

        requestAnimationFrame(() => {
            gui.style.opacity = '1';
            gui.style.transform = 'translate(-50%, -50%) scale(1)';
        });

        document.addEventListener('click', handleDialogOutsideClick);
    }

    function toggleUIState() {
        animationTimeouts.forEach(timeoutId => clearTimeout(timeoutId));
        animationTimeouts = [];

        if (guiElement && document.body.contains(guiElement)) {
            if (viewActive) {
                guiElement.style.display = 'flex';

                requestAnimationFrame(() => {
                    guiElement.style.opacity = '1';
                    guiElement.style.transform = 'translate(-50%, -50%) scale(1)';
                });
            } else {
                requestAnimationFrame(() => {
                    guiElement.style.opacity = '0';
                    guiElement.style.transform = 'translate(-50%, -50%) scale(0.95)';
                });

                const removeTimeout = setTimeout(() => {
                    if (!viewActive && guiElement && document.body.contains(guiElement)) {
                        document.body.removeChild(guiElement);
                        document.removeEventListener('click', handleDialogOutsideClick);
                        guiElement = null;
                    }
                }, ANIMATION_DURATION);
                animationTimeouts.push(removeTimeout);
            }
        } else if (viewActive) {
            createUI();
        }
    }

    function openV() {
        viewActive = true;
        toggleUIState();
    }

    function closeV() {
        viewActive = false;
        toggleUIState();
    }

    function handleDialogOutsideClick(e) {
        const gui = document.getElementById('char-export-gui');
        if (gui && !gui.contains(e.target)) {
            closeV();
        }
    }

    /* ============================
       ==      INTERCEPTORS     ==
       ============================ */
    function interceptNetwork() {
        if (networkInterceptActive) return;
        networkInterceptActive = true;

        const origXHR = XMLHttpRequest.prototype.open;
        XMLHttpRequest.prototype.open = function(method, url) {
            this.addEventListener('load', () => {
                if (url.includes('generateAlpha')) modifyResponse(this.responseText);
                if (url.includes('/hampter/chats/')) modifyChatResponse(this.responseText);
            });
            return origXHR.apply(this, arguments);
        };

        const origFetch = window.fetch;
        window.fetch = function(input, init) {
            const url = typeof input === 'string' ? input : input?.url;

            if (url && (url.includes('skibidi.com') || url.includes('proxy'))) {
                if (shouldInterceptNext && exportFormat) {
                    setTimeout(() => modifyResponse('{}'), 300);
                    return Promise.resolve(new Response('{}'));
                }
                return Promise.resolve(new Response(JSON.stringify({
                    error: 'Service unavailable'
                })));
            }

            try {
                return origFetch.apply(this, arguments).then(res => {
                    if (res.url?.includes('generateAlpha')) res.clone().text().then(modifyResponse);
                    if (res.url?.includes('/hampter/chats/')) res.clone().text().then(modifyChatResponse);
                    return res;
                });
            } catch (e) {
                return Promise.resolve(new Response('{}'));
            }
        };
    }

    function modifyResponse(text) {
        if (!shouldInterceptNext) return;
        shouldInterceptNext = false;
        try {
            const json = JSON.parse(text);
            const sys = json.messages.find(m => m.role === 'system')?.content || '';
            let initMsg = '';
            if (chatData?.chatMessages?.length) {
                const msgs = chatData.chatMessages;
                initMsg = msgs[msgs.length - 1].message;
            }
            const header = document.querySelector('p.chakra-text.css-1nj33dt');
            const headerName = header?.textContent.match(/Chat with\s+(.*)$/)?.[1]?.trim();
            const fullName = (chatData?.character?.chat_name || chatData?.character?.name || '').trim();
            const nameFirst = (chatData?.character?.name || '').trim().split(/\s+/)[0];
            const charName = fullName || nameFirst || headerName || 'char';
            let charBlock = extractTagContent(sys, fullName);
            if (!charBlock) {
                charBlock = extractTagContent(sys, nameFirst || charName);
            }
            const scen = extractTagContent(sys, 'scenario');
            const rawExs = extractTagContent(sys, 'example_dialogs');
            const exs = rawExs.replace(/^\s*Example conversations between[^:]*:\s*/, '');
            const userName = document.documentElement.innerHTML.match(/\\"name\\":\\"([^\\"]+)\\"/)?.[1] || '';
            switch (exportFormat) {
                case 'txt': {
                    saveAsTxt(charBlock, scen, initMsg, exs, charName, userName);
                    break;
                }
                case 'png': {
                    saveAsPng(charName, charBlock, scen, initMsg, exs, userName);
                    break;
                }
                case 'json': {
                    saveAsJson(charName, charBlock, scen, initMsg, exs, userName);
                    break;
                }
            }
            exportFormat = null;
        } catch (err) {
            console.error('Error processing response:', err);
        }
    }

    function modifyChatResponse(text) {
        try {
            if (!text || typeof text !== 'string' || !text.trim()) return;

            const data = JSON.parse(text);
            if (data && data.character) {
                chatData = data;
            }
        } catch (err) {
            // ignore parsing errors
        }
    }

    /* ============================
       ==      CORE LOGIC       ==
       ============================ */
    async function getCharacterMeta() {
        const charId = chatData?.character?.id;
        if (!charId) return {
            creatorUrl: '',
            characterVersion: '',
            characterCardUrl: '',
            name: '',
            creatorNotes: ''
        };
        if (characterMetaCache.id === charId && characterMetaCache.useChatNameForName === useChatNameForName) {
            return {
                creatorUrl: characterMetaCache.creatorUrl,
                characterVersion: characterMetaCache.characterVersion,
                characterCardUrl: characterMetaCache.characterCardUrl,
                name: characterMetaCache.name,
                creatorNotes: characterMetaCache.creatorNotes
            };
        }
        let creatorUrl = '',
            characterCardUrl = `https://janitorai.com/characters/${charId}`,
            characterVersion = characterCardUrl,
            name = chatData?.character?.name?.trim() || '',
            creatorNotes = chatData?.character?.description?.trim() || '';
        try {
            const response = await fetch(characterCardUrl);
            const html = await response.text();
            const doc = new DOMParser().parseFromString(html, 'text/html');
            const link = doc.querySelector('a.chakra-link.css-15sl5jl');
            if (link) {
                const href = link.getAttribute('href');
                if (href) creatorUrl = `https://janitorai.com${href}`;
            }
        } catch (err) {
            console.error('Error fetching creator URL:', err);
        }
        if (chatData?.character?.chat_name && !useChatNameForName) {
            characterVersion += `\nChat Name: ${chatData.character.chat_name.trim()}`;
        }
        characterMetaCache.id = charId;
        characterMetaCache.creatorUrl = creatorUrl;
        characterMetaCache.characterVersion = characterVersion;
        characterMetaCache.characterCardUrl = characterCardUrl;
        characterMetaCache.name = name;
        characterMetaCache.creatorNotes = creatorNotes;
        characterMetaCache.useChatNameForName = useChatNameForName;
        return {
            creatorUrl,
            characterVersion,
            characterCardUrl,
            name,
            creatorNotes
        };
    }

    async function buildTemplate(charBlock, scen, initMsg, exs) {
        const sections = [];
        const {
            creatorUrl,
            characterCardUrl
        } = await getCharacterMeta();
        const realName = chatData.character.name.trim();
        sections.push(`==== Name ====\n${realName}`);
        const chatName = (chatData.character.chat_name || realName).trim();
        sections.push(`==== Chat Name ====\n${chatName}`);
        if (charBlock) sections.push(`==== Description ====\n${charBlock.trim()}`);
        if (scen) sections.push(`==== Scenario ====\n${scen.trim()}`);
        if (initMsg) sections.push(`==== Initial Message ====\n${initMsg.trim()}`);
        if (exs) sections.push(`==== Example Dialogs ====\n${exs.trim()}`);
        sections.push(`==== Character Card ====\n${characterCardUrl}`);
        sections.push(`==== Creator ====\n${creatorUrl}`);
        return sections.join('\n\n');
    }

    function tokenizeNames(text, charName, userName) {
        const parts = text.split('\n\n');
        const [cRx, uRx] = [charName, userName].map(n => n ? escapeRegExp(n) : '');
        for (let i = 0, l = parts.length; i < l; ++i) {
            if (!/^==== (Name|Chat Name|Initial Message|Character Card|Creator) ====/.test(parts[i])) {
                parts[i] = parts[i]
                    .replace(new RegExp(`(?<!\\w)${cRx}(?!\\w)`, 'g'), '{{char}}')
                    .replace(new RegExp(`(?<!\\w)${uRx}(?!\\w)`, 'g'), '{{user}}');
            }
        }
        return parts.join('\n\n');
    }

    function tokenizeField(text, charName, userName) {
        if (!text) return text;
        const esc = n => escapeRegExp(n);
        const rules = [];
        if (applyCharToken && charName) {
            rules.push([new RegExp(`\\b${esc(charName)}('s)?\\b`, 'gi'), (_, sfx) => `{{char}}${sfx||''}`]);
        }
        if (userName) {
            rules.push([new RegExp(`\\b${esc(userName)}('s)?\\b`, 'gi'), (_, sfx) => `{{user}}${sfx||''}`]);
        }
        return rules.reduce((t, [rx, repl]) => t.replace(rx, repl), text);
    }

    function extraction() {
        if (!exportFormat) return;
        if (!document.querySelector('span.css-yhlqn1') && !document.querySelector('span.css-154nobl')) return;
        if (!chatData?.character) {
            if (confirm('Chat data not loaded yet. Reload the page now?')) {
                window.location.reload();
            }
            return;
        }
        if (!chatData.character.allow_proxy) {
            alert('Proxy disabled — extraction aborted.');
            return;
        }
        shouldInterceptNext = true;
        interceptNetwork();
        callApi();
    }

    function callApi() {
        try {
            const textarea = document.querySelector('textarea');
            if (!textarea) return;

            Object.getOwnPropertyDescriptor(HTMLTextAreaElement.prototype, 'value')
                .set.call(textarea, 'extract-char');
            textarea.dispatchEvent(new Event('input', {
                bubbles: true
            }));

            ['keydown', 'keyup'].forEach(type =>
                textarea.dispatchEvent(new KeyboardEvent(type, {
                    key: 'Enter',
                    code: 'Enter',
                    bubbles: true
                }))
            );
        } catch (err) {
            // ignore errors
        }
    }

    /* ============================
       ==    CHARA CARD V2      ==
       ============================ */
    async function buildCharaCardV2(charName, charBlock, scen, initMsg, exs, userName) {
        const {
            creatorUrl,
            characterVersion,
            name,
            creatorNotes
        } = await getCharacterMeta();
        const tokenizedDesc = tokenizeField(charBlock, charName, userName);
        const tokenizedScen = tokenizeField(scen, charName, userName);
        const tokenizedExs = tokenizeField(exs, charName, userName);

        let displayName = name;
        let versionText = characterVersion;

        if (useChatNameForName && chatData?.character?.chat_name) {
            displayName = chatData.character.chat_name.trim();
            versionText = `${characterVersion}\nName: ${name}`;
        }

        return {
            spec: "chara_card_v2",
            spec_version: "2.0",
            data: {
                name: displayName,
                description: tokenizedDesc.trim(),
                personality: "",
                scenario: tokenizedScen.trim(),
                first_mes: initMsg.trim(),
                mes_example: tokenizedExs.trim(),
                creator_notes: creatorNotes,
                system_prompt: "",
                post_history_instructions: "",
                alternate_greetings: [],
                character_book: null,
                tags: [],
                creator: creatorUrl,
                character_version: versionText,
                extensions: {}
            }
        };
    }

    /* ============================
       ==       EXPORTERS       ==
       ============================ */
    async function saveAsTxt(charBlock, scen, initMsg, exs, charName, userName) {
        const template = await buildTemplate(charBlock, scen, initMsg, exs);
        const tokenized = tokenizeField(template, charName, userName);
        const rawName = chatData.character.name || chatData.character.chat_name || 'card';
        const fileName = rawName.trim() || 'card';
        saveFile(
            `${fileName}.txt`,
            new Blob([tokenized], {
                type: 'text/plain'
            })
        );
    }

    async function saveAsJson(charName, charBlock, scen, initMsg, exs, userName) {
        const jsonData = await buildCharaCardV2(charName, charBlock, scen, initMsg, exs, userName);

        const rawName = chatData.character.name || chatData.character.chat_name || 'card';
        const fileName = rawName.trim() || 'card';
        saveFile(
            `${fileName}.json`,
            new Blob([JSON.stringify(jsonData, null, 2)], {
                type: 'application/json'
            })
        );
    }

    async function saveAsPng(charName, charBlock, scen, initMsg, exs, userName) {
        try {
            const avatarImg = document.querySelector('img[src*="/bot-avatars/"]');
            if (!avatarImg) {
                alert('Character avatar not found.');
                return;
            }

            const cardData = await buildCharaCardV2(charName, charBlock, scen, initMsg, exs, userName);

            const avatarResponse = await fetch(avatarImg.src);
            const avatarBlob = await avatarResponse.blob();

            const img = new Image();
            img.onload = () => {
                const canvas = document.createElement('canvas');
                canvas.width = img.width;
                canvas.height = img.height;

                const ctx = canvas.getContext('2d');
                ctx.drawImage(img, 0, 0);

                canvas.toBlob(async (blob) => {
                    try {
                        const arrayBuffer = await blob.arrayBuffer();
                        const pngData = new Uint8Array(arrayBuffer);

                        const jsonString = JSON.stringify(cardData);

                        const base64Data = btoa(unescape(encodeURIComponent(jsonString)));

                        const keyword = "chara";
                        const keywordBytes = new TextEncoder().encode(keyword);
                        const nullByte = new Uint8Array([0]);
                        const textBytes = new TextEncoder().encode(base64Data);

                        const chunkType = new Uint8Array([116, 69, 88, 116]); // "tEXt" in ASCII
                        const dataLength = keywordBytes.length + nullByte.length + textBytes.length;

                        const lengthBytes = new Uint8Array(4);
                        lengthBytes[0] = (dataLength >>> 24) & 0xFF;
                        lengthBytes[1] = (dataLength >>> 16) & 0xFF;
                        lengthBytes[2] = (dataLength >>> 8) & 0xFF;
                        lengthBytes[3] = dataLength & 0xFF;

                        const crcData = new Uint8Array(chunkType.length + keywordBytes.length + nullByte.length + textBytes.length);
                        crcData.set(chunkType, 0);
                        crcData.set(keywordBytes, chunkType.length);
                        crcData.set(nullByte, chunkType.length + keywordBytes.length);
                        crcData.set(textBytes, chunkType.length + keywordBytes.length + nullByte.length);

                        const crc = computeCrc32(crcData, 0, crcData.length);
                        const crcBytes = new Uint8Array(4);
                        crcBytes[0] = (crc >>> 24) & 0xFF;
                        crcBytes[1] = (crc >>> 16) & 0xFF;
                        crcBytes[2] = (crc >>> 8) & 0xFF;
                        crcBytes[3] = crc & 0xFF;

                        let pos = 8; // Skip PNG signature
                        while (pos < pngData.length - 12) {
                            const length = pngData[pos] << 24 | pngData[pos + 1] << 16 |
                                pngData[pos + 2] << 8 | pngData[pos + 3];

                            const type = String.fromCharCode(
                                pngData[pos + 4], pngData[pos + 5],
                                pngData[pos + 6], pngData[pos + 7]
                            );

                            if (type === 'IEND') break;
                            pos += 12 + length; // 4 (length) + 4 (type) + length + 4 (CRC)
                        }

                        const finalSize = pngData.length + lengthBytes.length + chunkType.length + dataLength + crcBytes.length;
                        const finalPNG = new Uint8Array(finalSize);

                        finalPNG.set(pngData.subarray(0, pos));
                        let writePos = pos;

                        finalPNG.set(lengthBytes, writePos);
                        writePos += lengthBytes.length;
                        finalPNG.set(chunkType, writePos);
                        writePos += chunkType.length;
                        finalPNG.set(keywordBytes, writePos);
                        writePos += keywordBytes.length;
                        finalPNG.set(nullByte, writePos);
                        writePos += nullByte.length;
                        finalPNG.set(textBytes, writePos);
                        writePos += textBytes.length;
                        finalPNG.set(crcBytes, writePos);
                        writePos += crcBytes.length;

                        finalPNG.set(pngData.subarray(pos), writePos);

                        const rawName = chatData.character.name || chatData.character.chat_name || 'card';
                        const fileName = rawName.trim() || 'card';
                        saveFile(
                            `${fileName}.png`,
                            new Blob([finalPNG], {
                                type: 'image/png'
                            })
                        );

                        console.log("Character card created successfully!");
                    } catch (err) {
                        console.error('Error creating PNG:', err);
                        alert('Failed to create PNG: ' + err.message);
                    }
                }, 'image/png');
            };

            img.src = URL.createObjectURL(avatarBlob);
        } catch (err) {
            console.error('Error creating PNG:', err);
            alert('Failed to create PNG: ' + err.message);
        }
    }

    function computeCrc32(data, start, length) {
        let crc = 0xFFFFFFFF;

        for (let i = 0; i < length; i++) {
            const byte = data[start + i];
            crc = (crc >>> 8) ^ crc32Table[(crc ^ byte) & 0xFF];
        }

        return ~crc >>> 0; // Invert and cast to unsigned 32-bit
    }

    const crc32Table = (() => {
        const table = new Uint32Array(256);

        for (let i = 0; i < 256; i++) {
            let crc = i;
            for (let j = 0; j < 8; j++) {
                crc = (crc & 1) ? 0xEDB88320 ^ (crc >>> 1) : crc >>> 1;
            }
            table[i] = crc;
        }

        return table;
    })();

    /* ============================
       ==       ROUTING         ==
       ============================ */
    function inChats() {
        const isInChat = /^\/chats\/\d+/.test(window.location.pathname);
        return isInChat;
    }

    function initialize() {
        if (hasInitialized || !inChats()) return;
        hasInitialized = true;
        shouldInterceptNext = false;
        networkInterceptActive = false;
        exportFormat = null;
        chatData = null;

        document.removeEventListener('keydown', handleKeyDown);
        document.addEventListener('keydown', handleKeyDown);

        interceptNetwork();
    }

    function handleKeyDown(e) {
        if (!inChats()) return;
        if (e.key.toLowerCase() !== 't' || e.ctrlKey || e.metaKey || e.altKey || e.shiftKey) return;
        if (['INPUT', 'TEXTAREA'].includes(document.activeElement.tagName) || document.activeElement.isContentEditable) return;

        if (!document.querySelector('span.css-yhlqn1') && !document.querySelector('span.css-154nobl')) return;
        if (!chatData?.character) {
            if (confirm('Chat data not loaded yet. Reload the page now?')) {
                window.location.reload();
            }
            return;
        }
        if (!chatData.character.allow_proxy) {
            alert('Proxy disabled — extraction aborted.');
            return;
        }

        viewActive = !viewActive;
        toggleUIState();
    }

    function cleanup() {
        hasInitialized = false;
        const gui = document.getElementById('char-export-gui');
        if (gui) gui.remove();
        document.removeEventListener('keydown', handleKeyDown);
        viewActive = false;
        animationTimeouts.forEach(timeoutId => clearTimeout(timeoutId));
        animationTimeouts = [];
    }

    function handleRoute() {
        if (inChats()) {
            initialize();
        } else {
            cleanup();
        }
    }

    /* ============================
       ==      ENTRYPOINT       ==
       ============================ */
    window.addEventListener('load', () => {
        handleRoute();
    }, {
        once: true
    });
    window.addEventListener('popstate', () => {
        handleRoute();
    });

    ['pushState', 'replaceState'].forEach(fn => {
        const orig = history[fn];
        history[fn] = function(...args) {
            const res = orig.apply(this, args);
            setTimeout(handleRoute, 50);
            return res;
        };
    });
    handleRoute();
})();