Sleazy Fork is available in English.

JanitorAI Character Card Scraper

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

Tính đến 07-06-2025. Xem phiên bản mới nhất.

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