JanitorAI Character Card Scraper

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

// ==UserScript==
// @name         JanitorAI Character Card Scraper
// @version      1.5
// @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 animationTimeouts      = [];
  let guiElement             = null;
  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 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) {
    const escName = escapeRegExp(charName);
    const regex = new RegExp(`<\\s*${escName}\\s*>([\\s\\S]*?)<\\/\\s*${escName}\\s*>`, 'i');
    const m = sys.match(regex);
    if (m && m[1] != null) return m[1].trim();
    const openSub = `<${charName}`;
    const openIdx = sys.indexOf(openSub);
    if (openIdx < 0) return '';
    const gtIdx = sys.indexOf('>', openIdx);
    if (gtIdx < 0) return '';
    const closeOpenSub = `</${charName}`;
    const closeIdx = sys.indexOf(closeOpenSub, gtIdx + 1);
    if (closeIdx < 0) return '';
    return sys.substring(gtIdx + 1, closeIdx).trim();
  }

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

    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;
          }
        }
      `;
      document.head.appendChild(style);
    }

    exportContent.appendChild(buttonContainer);

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

    const tabContentStyles = {
      height: '100%',
      width: '100%',
      overflowY: 'auto',
      overflowX: 'hidden',
      padding: '0',
      scrollbarWidth: 'none',
      msOverflowStyle: 'none',
      position: 'absolute',
      top: '0',
      left: '0',
      opacity: '1',
      transform: 'scale(1)',
      transition: `opacity ${TAB_ANIMATION_DURATION}ms ease, transform ${TAB_ANIMATION_DURATION}ms ease`,
      '&::-webkit-scrollbar': {
        width: '0',
        background: 'transparent'
      }
    };

    Object.assign(exportContent.style, tabContentStyles);

    const scrollbarStyles = { ...tabContentStyles };

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

    contentWrapper.appendChild(exportContent);
    contentWrapper.appendChild(settingsContent);

    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',
      title: 'Uses chat name for the character name instead of label 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)';

      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);
    toggleWrapper.appendChild(toggle);
    toggleContainer.appendChild(toggleWrapper);
    settingsContent.appendChild(toggleContainer);

    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';

        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 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');
      let charName = chatData?.character?.chat_name || header?.textContent.match(/Chat with\s+(.*)$/)?.[1]?.trim() || 'char';
      const charBlock = extractTagContent(sys, 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 || !charName) return text;

    const [charRegex, userRegex] = [charName, userName].map(n => n ? escapeRegExp(n.replace(/'$/, '')) : '');

    let result = text;

    if (charRegex) {
      result = result
        .replace(new RegExp(`(?<!\\w)${charRegex}'s(?!\\w)`, 'g'), "{{char}}'s")
        .replace(new RegExp(`(?<!\\w)${charRegex}'(?!\\w)`, 'g'), "{{char}}'")
        .replace(new RegExp(`(?<![\\w'])${charRegex}(?![\\w'])`, 'g'), "{{char}}");
    }

    if (userRegex) {
      result = result
        .replace(new RegExp(`(?<!\\w)${userRegex}'s(?!\\w)`, 'g'), "{{user}}'s")
        .replace(new RegExp(`(?<!\\w)${userRegex}'(?!\\w)`, 'g'), "{{user}}'")
        .replace(new RegExp(`(?<![\\w'])${userRegex}(?![\\w'])`, 'g'), "{{user}}");
    }

    return result;
  }

  function extraction() {
    if (!exportFormat) 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 = tokenizeNames(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.chakra-image.css-i9mtpv');
      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;

    const proxyAllowed = chatData?.character?.allow_proxy;
    if (!proxyAllowed) {
      if (chatData?.character != null) {
        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;
    };
  });
})();