Not a member of Pastebin yet?
Sign Up,
it unlocks many cool features!
- // ==UserScript==
- // @name JanitorAI Character Card Scraper
- // @version 1.1
- // @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
- // @run-at document-start
- // @license MIT
- // @namespace https://greasyfork.org/en/scripts/537206-janitorai-character-card-scraper
- // @downloadURL https://update.sleazyfork.org/scripts/537206/JanitorAI%20Character%20Card%20Scraper.user.js
- // @updateURL https://update.sleazyfork.org/scripts/537206/JanitorAI%20Character%20Card%20Scraper.meta.js
- // ==/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, '\\$&');
- }
- /* ============================
- == 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 = sys.match(new RegExp(`<${charName}>([\\s\\S]*?)<\\/${charName}>`, 'i'))?.[1]?.trim() || '';
- const scen = sys.match(/<scenario>([\s\S]*?)<\/scenario>/i)?.[1]?.trim() || '';
- const exs = sys.match(/<example_dialogs>([\s\S]*?)<\/example_dialogs>/i)?.[1]?.trim() || '';
- const userName = [...document.querySelectorAll('div.css-16u3s6f')]
- .map(e => e.textContent.trim())
- .find(n => n && n !== charName) || '';
- 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) {
- 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) 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;
- 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;
- };
- });
- })();
Add Comment
Please, Sign In to add comment