JanitorAI Character Card Scraper

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

От 18.06.2025. Виж последната версия.

// ==UserScript==
// @name         JanitorAI Character Card Scraper
// @version      1.6
// @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[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;

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