您需要先安装一个扩展,例如 篡改猴、Greasemonkey 或 暴力猴,之后才能安装此脚本。
您需要先安装一个扩展,例如 篡改猴 或 暴力猴,之后才能安装此脚本。
您需要先安装一个扩展,例如 篡改猴 或 暴力猴,之后才能安装此脚本。
您需要先安装一个扩展,例如 篡改猴 或 Userscripts ,之后才能安装此脚本。
您需要先安装一款用户脚本管理器扩展,例如 Tampermonkey,才能安装此脚本。
您需要先安装用户脚本管理器扩展后才能安装此脚本。
Extract character card with "T" key (WHILE IN CHAT PAGE) and save as .txt, .png, or .json (proxy required)
// ==UserScript== // @name JanitorAI Character Card Scraper // @version 1.5 // @description Extract character card with "T" key (WHILE IN CHAT PAGE) and save as .txt, .png, or .json (proxy required) // @match https://janitorai.com/* // @icon https://images.dwncdn.net/images/t_app-icon-l/p/46413ec0-e1d8-4eab-a0bc-67eadabb2604/3920235030/janitor-ai-logo // @grant none // @namespace https://sleazyfork.org/en/scripts/537206-janitorai-character-card-scraper // @run-at document-start // @license MIT // ==/UserScript== (() => { 'use strict'; /* ============================ == VARIABLES == ============================ */ let hasInitialized = false let viewActive = false let shouldInterceptNext = false let networkInterceptActive = false let exportFormat = null let chatData = null let currentTab = sessionStorage.getItem('lastActiveTab') || 'export' let useChatNameForName = localStorage.getItem('useChatNameForName') === 'true' || false; let animationTimeouts = []; let guiElement = null; const ANIMATION_DURATION = 150; // Animation duration for modal open/close in ms const TAB_ANIMATION_DURATION = 300; // Animation duration for tab switching in ms const TAB_BUTTON_DURATION = 250; // Animation duration for tab button effects const BUTTON_ANIMATION = 200; // Animation duration for format buttons const TOGGLE_ANIMATION = 350; // Animation duration for toggle switch const ACTIVE_TAB_COLOR = '#0080ff'; // Color for active tab indicator const INACTIVE_TAB_COLOR = 'transparent'; // Color for inactive tab indicator const BUTTON_COLOR = '#3a3a3a'; // Base color for buttons const BUTTON_HOVER_COLOR = '#4a4a4a'; // Hover color for buttons const BUTTON_ACTIVE_COLOR = '#0070dd'; // Active color for buttons when clicked const characterMetaCache = { id: null, creatorUrl: '', characterVersion: '', characterCardUrl: '', name: '', creatorNotes: '' }; /* ============================ == UTILITIES == ============================ */ function makeElement(tag, attrs = {}, styles = {}) { const el = document.createElement(tag); Object.entries(attrs).forEach(([key, value]) => el[key] = value); if (styles) { Object.entries(styles).forEach(([key, value]) => el.style[key] = value); } return el; } function saveFile(filename, blob) { const url = URL.createObjectURL(blob); const a = makeElement('a', { href: url, download: filename }); document.body.appendChild(a); a.click(); a.remove(); URL.revokeObjectURL(url); } function escapeRegExp(s) { return s.replace(/[.*+?^${}()|[\]\\]/g, '\\$&'); } function extractTagContent(sys, charName) { const escName = escapeRegExp(charName); const regex = new RegExp(`<\\s*${escName}\\s*>([\\s\\S]*?)<\\/\\s*${escName}\\s*>`, 'i'); const m = sys.match(regex); if (m && m[1] != null) return m[1].trim(); const openSub = `<${charName}`; const openIdx = sys.indexOf(openSub); if (openIdx < 0) return ''; const gtIdx = sys.indexOf('>', openIdx); if (gtIdx < 0) return ''; const closeOpenSub = `</${charName}`; const closeIdx = sys.indexOf(closeOpenSub, gtIdx + 1); if (closeIdx < 0) return ''; return sys.substring(gtIdx + 1, closeIdx).trim(); } /* ============================ == UI == ============================ */ function createUI() { if (guiElement && document.body.contains(guiElement)) { return; } animationTimeouts.forEach(timeoutId => clearTimeout(timeoutId)); animationTimeouts = []; viewActive = true; const gui = makeElement('div', { id: 'char-export-gui' }, { position: 'fixed', top: '50%', left: '50%', transform: 'translate(-50%, -50%) scale(0.95)', background: '#222', color: 'white', padding: '15px 20px 7px', borderRadius: '8px', boxShadow: '0 0 20px rgba(0,0,0,0.5)', zIndex: '10000', textAlign: 'center', width: '320px', overflow: 'hidden', display: 'flex', flexDirection: 'column', boxSizing: 'border-box', opacity: '0', transition: `opacity ${ANIMATION_DURATION}ms ease-out, transform ${ANIMATION_DURATION}ms ease-out` }); guiElement = gui; const tabContainer = makeElement('div', {}, { display: 'flex', justifyContent: 'center', marginBottom: '15px', borderBottom: '1px solid #444', paddingBottom: '8px', width: '100%' }); const tabsWrapper = makeElement('div', {}, { display: 'flex', justifyContent: 'center', width: '100%', maxWidth: '300px', margin: '0 auto' }); const createTabButton = (text, isActive) => { const button = makeElement('button', { textContent: text }, { background: 'transparent', border: 'none', color: '#fff', padding: '8px 20px', cursor: 'pointer', margin: '0 5px', fontWeight: 'bold', flex: '1', textAlign: 'center', position: 'relative', overflow: 'hidden', transition: `opacity ${TAB_BUTTON_DURATION}ms ease, transform ${TAB_BUTTON_DURATION}ms ease, color ${TAB_BUTTON_DURATION}ms ease` }); const indicator = makeElement('div', {}, { position: 'absolute', bottom: '0', left: '0', width: '100%', height: '2px', background: isActive ? ACTIVE_TAB_COLOR : INACTIVE_TAB_COLOR, transition: `transform ${TAB_BUTTON_DURATION}ms ease, background-color ${TAB_BUTTON_DURATION}ms ease` }); if (!isActive) { button.style.opacity = '0.7'; indicator.style.transform = 'scaleX(0.5)'; } button.appendChild(indicator); return { button, indicator }; }; const { button: exportTab, indicator: exportIndicator } = createTabButton('Export', true); const { button: settingsTab, indicator: settingsIndicator } = createTabButton('Settings', false); exportTab.onmouseover = () => { if (currentTab !== 'export') { exportTab.style.opacity = '1'; exportTab.style.transform = 'translateY(-2px)'; exportIndicator.style.transform = 'scaleX(0.8)'; } }; exportTab.onmouseout = () => { if (currentTab !== 'export') { exportTab.style.opacity = '0.7'; exportTab.style.transform = ''; exportIndicator.style.transform = 'scaleX(0.5)'; } }; settingsTab.onmouseover = () => { if (currentTab !== 'settings') { settingsTab.style.opacity = '1'; settingsTab.style.transform = 'translateY(-2px)'; settingsIndicator.style.transform = 'scaleX(0.8)'; } }; settingsTab.onmouseout = () => { if (currentTab !== 'settings') { settingsTab.style.opacity = '0.7'; settingsTab.style.transform = ''; settingsIndicator.style.transform = 'scaleX(0.5)'; } }; tabsWrapper.appendChild(exportTab); tabsWrapper.appendChild(settingsTab); tabContainer.appendChild(tabsWrapper); gui.appendChild(tabContainer); const exportContent = makeElement('div', { id: 'export-tab' }, { maxHeight: '60vh', overflowY: 'auto', padding: '0 5px 10px 0', display: 'flex', flexDirection: 'column', justifyContent: 'center' }); const title = makeElement('h2', { textContent: 'Export Character Card' }, { margin: '0 0 12px 0', fontSize: '18px', paddingTop: '5px' }); exportContent.appendChild(title); const buttonContainer = makeElement('div', {}, { display: 'flex', gap: '10px', justifyContent: 'center', marginBottom: '3px', marginTop: '8px' }); ['TXT', 'PNG', 'JSON'].forEach(format => { const type = format.toLowerCase(); const button = makeElement('button', { textContent: format }, { background: BUTTON_COLOR, border: 'none', color: 'white', padding: '10px 20px', borderRadius: '6px', cursor: 'pointer', fontWeight: 'bold', position: 'relative', overflow: 'hidden', flex: '1', transition: `all ${BUTTON_ANIMATION}ms ease`, boxShadow: '0 2px 4px rgba(0,0,0,0.1)', transform: 'translateY(0)' }); const shine = makeElement('div', {}, { position: 'absolute', top: '0', left: '0', width: '100%', height: '100%', background: 'linear-gradient(135deg, rgba(255,255,255,0.1) 0%, rgba(255,255,255,0) 60%)', transform: 'translateX(-100%)', transition: `transform ${BUTTON_ANIMATION * 1.5}ms ease-out`, pointerEvents: 'none' }); button.appendChild(shine); button.onmouseover = () => { button.style.background = BUTTON_HOVER_COLOR; button.style.transform = 'translateY(-2px)'; button.style.boxShadow = '0 4px 8px rgba(0,0,0,0.2)'; shine.style.transform = 'translateX(100%)'; }; button.onmouseout = () => { button.style.background = BUTTON_COLOR; button.style.transform = 'translateY(0)'; button.style.boxShadow = '0 2px 4px rgba(0,0,0,0.1)'; shine.style.transform = 'translateX(-100%)'; }; button.onmousedown = () => { button.style.transform = 'translateY(1px)'; button.style.boxShadow = '0 1px 2px rgba(0,0,0,0.2)'; button.style.background = BUTTON_ACTIVE_COLOR; }; button.onmouseup = () => { button.style.transform = 'translateY(-2px)'; button.style.boxShadow = '0 4px 8px rgba(0,0,0,0.2)'; button.style.background = BUTTON_HOVER_COLOR; }; button.onclick = (e) => { const rect = button.getBoundingClientRect(); const x = e.clientX - rect.left; const y = e.clientY - rect.top; const ripple = makeElement('div', {}, { position: 'absolute', borderRadius: '50%', backgroundColor: 'rgba(255,255,255,0.4)', width: '5px', height: '5px', transform: 'scale(1)', opacity: '1', animation: 'ripple 600ms linear', pointerEvents: 'none', top: `${y}px`, left: `${x}px`, marginLeft: '-2.5px', marginTop: '-2.5px' }); button.appendChild(ripple); exportFormat = type; closeV(); extraction(); setTimeout(() => ripple.remove(), 600); }; buttonContainer.appendChild(button); }); if (!document.getElementById('char-export-style')) { const style = document.createElement('style'); style.id = 'char-export-style'; style.textContent = ` @keyframes ripple { to { transform: scale(30); opacity: 0; } } `; document.head.appendChild(style); } exportContent.appendChild(buttonContainer); const contentWrapper = makeElement('div', { id: 'content-wrapper' }, { height: '103px', width: '100%', overflow: 'hidden', display: 'flex', flexDirection: 'column', justifyContent: 'center', position: 'relative' }); gui.appendChild(contentWrapper); const tabContentStyles = { height: '100%', width: '100%', overflowY: 'auto', overflowX: 'hidden', padding: '0', scrollbarWidth: 'none', msOverflowStyle: 'none', position: 'absolute', top: '0', left: '0', opacity: '1', transform: 'scale(1)', transition: `opacity ${TAB_ANIMATION_DURATION}ms ease, transform ${TAB_ANIMATION_DURATION}ms ease`, '&::-webkit-scrollbar': { width: '0', background: 'transparent' } }; Object.assign(exportContent.style, tabContentStyles); const scrollbarStyles = { ...tabContentStyles }; const settingsContent = makeElement('div', { id: 'settings-tab', style: 'display: none;' }, scrollbarStyles); contentWrapper.appendChild(exportContent); contentWrapper.appendChild(settingsContent); const settingsTitle = makeElement('h2', { textContent: 'Export Settings' }, { margin: '0 0 15px 0', fontSize: '18px', paddingTop: '5px' }); settingsContent.appendChild(settingsTitle); const toggleContainer = makeElement('div', {}, { display: 'flex', alignItems: 'center', marginBottom: '4px', marginTop: '5px', padding: '10px 10px 9px', background: '#2a2a2a', borderRadius: '8px', gap: '10px' }); const toggleWrapper = makeElement('div', { className: 'toggle-wrapper' }, { display: 'flex', alignItems: 'center', justifyContent: 'space-between', width: '100%', cursor: 'pointer' }); const toggleLabel = makeElement('span', { textContent: 'Use character\'s chat name', title: 'Uses chat name for the character name instead of label name.' }, { fontSize: '13px', color: '#fff', order: '2', textAlign: 'left', flex: '1', paddingLeft: '10px', wordBreak: 'break-word', lineHeight: '1.4' }); const toggle = makeElement('label', { className: 'switch' }, { position: 'relative', display: 'inline-block', width: '40px', height: '24px', order: '1', margin: '0', flexShrink: '0', borderRadius: '24px', boxShadow: '0 1px 3px rgba(0,0,0,0.2) inset', transition: `all ${TOGGLE_ANIMATION}ms ease` }); const slider = makeElement('span', { className: 'slider round' }, { position: 'absolute', cursor: 'pointer', top: '0', left: '0', right: '0', bottom: '0', backgroundColor: useChatNameForName ? ACTIVE_TAB_COLOR : '#ccc', transition: `background-color ${TOGGLE_ANIMATION}ms cubic-bezier(0.34, 1.56, 0.64, 1)`, borderRadius: '24px', overflow: 'hidden' }); const sliderShine = makeElement('div', {}, { position: 'absolute', top: '0', left: '0', width: '100%', height: '100%', background: 'linear-gradient(135deg, rgba(255,255,255,0.2) 0%, rgba(255,255,255,0) 50%)', opacity: '0.5', transition: `opacity ${TOGGLE_ANIMATION}ms ease` }); slider.appendChild(sliderShine); const sliderBefore = makeElement('span', { className: 'slider-before' }, { position: 'absolute', content: '""', height: '16px', width: '16px', left: '4px', bottom: '4px', backgroundColor: 'white', transition: `transform ${TOGGLE_ANIMATION}ms cubic-bezier(0.34, 1.56, 0.64, 1), box-shadow ${TOGGLE_ANIMATION}ms ease`, borderRadius: '50%', transform: useChatNameForName ? 'translateX(16px)' : 'translateX(0)', boxShadow: useChatNameForName ? '0 0 2px rgba(0,0,0,0.2), 0 0 5px rgba(0,128,255,0.3)' : '0 0 2px rgba(0,0,0,0.2)' }); const input = makeElement('input', { type: 'checkbox', checked: useChatNameForName }, { opacity: '0', width: '0', height: '0', position: 'absolute' }); input.addEventListener('change', (e) => { useChatNameForName = e.target.checked; localStorage.setItem('useChatNameForName', useChatNameForName); slider.style.backgroundColor = useChatNameForName ? ACTIVE_TAB_COLOR : '#ccc'; sliderBefore.style.transform = useChatNameForName ? 'translateX(16px)' : 'translateX(0)'; sliderBefore.style.boxShadow = useChatNameForName ? '0 0 2px rgba(0,0,0,0.2), 0 0 5px rgba(0,128,255,0.3)' : '0 0 2px rgba(0,0,0,0.2)'; if (useChatNameForName) { const pulse = makeElement('div', {}, { position: 'absolute', top: '0', left: '0', right: '0', bottom: '0', backgroundColor: ACTIVE_TAB_COLOR, borderRadius: '24px', opacity: '0.5', transform: 'scale(1.2)', pointerEvents: 'none', zIndex: '-1' }); toggle.appendChild(pulse); setTimeout(() => { pulse.style.opacity = '0'; pulse.style.transform = 'scale(1.5)'; pulse.style.transition = 'all 400ms ease-out'; }, 10); setTimeout(() => pulse.remove(), 400); } }); slider.style.backgroundColor = useChatNameForName ? '#007bff' : '#ccc'; slider.appendChild(sliderBefore); toggle.appendChild(input); toggle.appendChild(slider); toggleWrapper.addEventListener('click', (e) => { e.preventDefault(); input.checked = !input.checked; const event = new Event('change'); input.dispatchEvent(event); document.body.focus(); }); toggleWrapper.appendChild(toggleLabel); toggleWrapper.appendChild(toggle); toggleContainer.appendChild(toggleWrapper); settingsContent.appendChild(toggleContainer); const tabs = { export: { content: exportContent, tab: exportTab, active: true }, settings: { content: settingsContent, tab: settingsTab, active: false } }; function switchTab(tabKey) { animationTimeouts.forEach(timeoutId => clearTimeout(timeoutId)); animationTimeouts = []; Object.entries(tabs).forEach(([key, { content, tab }]) => { const isActive = key === tabKey; tab.style.opacity = isActive ? '1' : '0.7'; tab.style.transform = isActive ? 'translateY(-2px)' : ''; const indicator = tab.lastChild; if (indicator) { if (isActive) { indicator.style.background = ACTIVE_TAB_COLOR; indicator.style.transform = 'scaleX(1)'; } else { indicator.style.background = INACTIVE_TAB_COLOR; indicator.style.transform = 'scaleX(0.5)'; } } content.style.display = 'block'; if (isActive) { content.style.opacity = '0'; content.style.transform = 'scale(0.95)'; void content.offsetWidth; requestAnimationFrame(() => { content.style.opacity = '1'; content.style.transform = 'scale(1)'; }); } else { requestAnimationFrame(() => { content.style.opacity = '0'; content.style.transform = 'scale(0.95)'; }); const hideTimeout = setTimeout(() => { if (!tabs[key].active) { content.style.display = 'none'; } }, TAB_ANIMATION_DURATION); animationTimeouts.push(hideTimeout); } tabs[key].active = isActive; }); currentTab = tabKey; try { sessionStorage.setItem('lastActiveTab', tabKey); } catch (e) { console.warn('Failed to save tab state to sessionStorage', e); } } const handleTabClick = (e) => { const tabKey = e.target === exportTab ? 'export' : 'settings'; if (!tabs[tabKey].active) { switchTab(tabKey); } }; exportTab.onclick = handleTabClick; settingsTab.onclick = handleTabClick; Object.entries(tabs).forEach(([key, { content }]) => { const isActive = key === currentTab; content.style.display = isActive ? 'block' : 'none'; content.style.opacity = isActive ? '1' : '0'; content.style.transform = isActive ? 'scale(1)' : 'scale(0.95)'; }); switchTab(currentTab); document.body.appendChild(gui); void gui.offsetWidth; requestAnimationFrame(() => { gui.style.opacity = '1'; gui.style.transform = 'translate(-50%, -50%) scale(1)'; }); document.addEventListener('click', handleDialogOutsideClick); } function toggleUIState() { animationTimeouts.forEach(timeoutId => clearTimeout(timeoutId)); animationTimeouts = []; if (guiElement && document.body.contains(guiElement)) { if (viewActive) { guiElement.style.display = 'flex'; requestAnimationFrame(() => { guiElement.style.opacity = '1'; guiElement.style.transform = 'translate(-50%, -50%) scale(1)'; }); } else { requestAnimationFrame(() => { guiElement.style.opacity = '0'; guiElement.style.transform = 'translate(-50%, -50%) scale(0.95)'; }); const removeTimeout = setTimeout(() => { if (!viewActive && guiElement && document.body.contains(guiElement)) { document.body.removeChild(guiElement); document.removeEventListener('click', handleDialogOutsideClick); guiElement = null; } }, ANIMATION_DURATION); animationTimeouts.push(removeTimeout); } } else if (viewActive) { createUI(); } } function openV() { viewActive = true; toggleUIState(); } function closeV() { viewActive = false; toggleUIState(); } function handleDialogOutsideClick(e) { const gui = document.getElementById('char-export-gui'); if (gui && !gui.contains(e.target)) { closeV(); } } /* ============================ == INTERCEPTORS == ============================ */ function interceptNetwork() { if (networkInterceptActive) return; networkInterceptActive = true; const origXHR = XMLHttpRequest.prototype.open; XMLHttpRequest.prototype.open = function(method, url) { this.addEventListener('load', () => { if (url.includes('generateAlpha')) modifyResponse(this.responseText); if (url.includes('/hampter/chats/')) modifyChatResponse(this.responseText); }); return origXHR.apply(this, arguments); }; const origFetch = window.fetch; window.fetch = function(input, init) { const url = typeof input === 'string' ? input : input?.url; if (url && (url.includes('skibidi.com') || url.includes('proxy'))) { if (shouldInterceptNext && exportFormat) { setTimeout(() => modifyResponse('{}'), 300); return Promise.resolve(new Response('{}')); } return Promise.resolve(new Response(JSON.stringify({ error: 'Service unavailable' }))); } try { return origFetch.apply(this, arguments).then(res => { if (res.url?.includes('generateAlpha')) res.clone().text().then(modifyResponse); if (res.url?.includes('/hampter/chats/')) res.clone().text().then(modifyChatResponse); return res; }); } catch(e) { return Promise.resolve(new Response('{}')); } }; } function modifyResponse(text) { if (!shouldInterceptNext) return; shouldInterceptNext = false; try { const json = JSON.parse(text); const sys = json.messages.find(m => m.role === 'system')?.content || ''; let initMsg = ''; if (chatData?.chatMessages?.length) { const msgs = chatData.chatMessages; initMsg = msgs[msgs.length - 1].message; } const header = document.querySelector('p.chakra-text.css-1nj33dt'); let charName = chatData?.character?.chat_name || header?.textContent.match(/Chat with\s+(.*)$/)?.[1]?.trim() || 'char'; const charBlock = extractTagContent(sys, charName); const scen = extractTagContent(sys, 'scenario'); const rawExs = extractTagContent(sys, 'example_dialogs'); const exs = rawExs.replace(/^\s*Example conversations between[^:]*:\s*/, ''); const userName = document.documentElement.innerHTML.match(/\\"name\\":\\"([^\\"]+)\\"/)?.[1] || ''; switch (exportFormat) { case 'txt': { saveAsTxt(charBlock, scen, initMsg, exs, charName, userName); break; } case 'png': { saveAsPng(charName, charBlock, scen, initMsg, exs, userName); break; } case 'json': { saveAsJson(charName, charBlock, scen, initMsg, exs, userName); break; } } exportFormat = null; } catch (err) { console.error('Error processing response:', err); } } function modifyChatResponse(text) { try { if (!text || typeof text !== 'string' || !text.trim()) return; const data = JSON.parse(text); if (data && data.character) { chatData = data; } } catch (err) { // ignore parsing errors } } /* ============================ == CORE LOGIC == ============================ */ async function getCharacterMeta() { const charId = chatData?.character?.id; if (!charId) return { creatorUrl: '', characterVersion: '', characterCardUrl: '', name: '', creatorNotes: '' }; if (characterMetaCache.id === charId && characterMetaCache.useChatNameForName === useChatNameForName) { return { creatorUrl: characterMetaCache.creatorUrl, characterVersion: characterMetaCache.characterVersion, characterCardUrl: characterMetaCache.characterCardUrl, name: characterMetaCache.name, creatorNotes: characterMetaCache.creatorNotes }; } let creatorUrl = '', characterCardUrl = `https://janitorai.com/characters/${charId}`, characterVersion = characterCardUrl, name = chatData?.character?.name?.trim() || '', creatorNotes = chatData?.character?.description?.trim() || ''; try { const response = await fetch(characterCardUrl); const html = await response.text(); const doc = new DOMParser().parseFromString(html, 'text/html'); const link = doc.querySelector('a.chakra-link.css-15sl5jl'); if (link) { const href = link.getAttribute('href'); if (href) creatorUrl = `https://janitorai.com${href}`; } } catch (err) { console.error('Error fetching creator URL:', err); } if (chatData?.character?.chat_name && !useChatNameForName) { characterVersion += `\nChat Name: ${chatData.character.chat_name.trim()}`; } characterMetaCache.id = charId; characterMetaCache.creatorUrl = creatorUrl; characterMetaCache.characterVersion = characterVersion; characterMetaCache.characterCardUrl = characterCardUrl; characterMetaCache.name = name; characterMetaCache.creatorNotes = creatorNotes; characterMetaCache.useChatNameForName = useChatNameForName; return { creatorUrl, characterVersion, characterCardUrl, name, creatorNotes }; } async function buildTemplate(charBlock, scen, initMsg, exs) { const sections = []; const { creatorUrl, characterCardUrl } = await getCharacterMeta(); const realName = chatData.character.name.trim(); sections.push(`==== Name ====\n${realName}`); const chatName = (chatData.character.chat_name || realName).trim(); sections.push(`==== Chat Name ====\n${chatName}`); if (charBlock) sections.push(`==== Description ====\n${charBlock.trim()}`); if (scen) sections.push(`==== Scenario ====\n${scen.trim()}`); if (initMsg) sections.push(`==== Initial Message ====\n${initMsg.trim()}`); if (exs) sections.push(`==== Example Dialogs ====\n${exs.trim()}`); sections.push(`==== Character Card ====\n${characterCardUrl}`); sections.push(`==== Creator ====\n${creatorUrl}`); return sections.join('\n\n'); } function tokenizeNames(text, charName, userName) { const parts = text.split('\n\n'); const [cRx,uRx] = [charName,userName].map(n=>n?escapeRegExp(n):''); for (let i = 0, l = parts.length; i < l; ++i) { if (!/^==== (Name|Chat Name|Initial Message|Character Card|Creator) ====/.test(parts[i])) { parts[i] = parts[i] .replace(new RegExp(`(?<!\\w)${cRx}(?!\\w)`,'g'),'{{char}}') .replace(new RegExp(`(?<!\\w)${uRx}(?!\\w)`,'g'),'{{user}}'); } } return parts.join('\n\n'); } function tokenizeField(text, charName, userName) { if (!text || !charName) return text; const [charRegex, userRegex] = [charName, userName].map(n => n ? escapeRegExp(n.replace(/'$/, '')) : ''); let result = text; if (charRegex) { result = result .replace(new RegExp(`(?<!\\w)${charRegex}'s(?!\\w)`, 'g'), "{{char}}'s") .replace(new RegExp(`(?<!\\w)${charRegex}'(?!\\w)`, 'g'), "{{char}}'") .replace(new RegExp(`(?<![\\w'])${charRegex}(?![\\w'])`, 'g'), "{{char}}"); } if (userRegex) { result = result .replace(new RegExp(`(?<!\\w)${userRegex}'s(?!\\w)`, 'g'), "{{user}}'s") .replace(new RegExp(`(?<!\\w)${userRegex}'(?!\\w)`, 'g'), "{{user}}'") .replace(new RegExp(`(?<![\\w'])${userRegex}(?![\\w'])`, 'g'), "{{user}}"); } return result; } function extraction() { if (!exportFormat) return; shouldInterceptNext = true; interceptNetwork(); callApi(); } function callApi() { try { const textarea = document.querySelector('textarea'); if (!textarea) return; Object.getOwnPropertyDescriptor(HTMLTextAreaElement.prototype, 'value') .set.call(textarea, 'extract-char'); textarea.dispatchEvent(new Event('input', { bubbles: true })); ['keydown', 'keyup'].forEach(type => textarea.dispatchEvent(new KeyboardEvent(type, { key: 'Enter', code: 'Enter', bubbles: true })) ); } catch (err) { // ignore errors } } /* ============================ == CHARA CARD V2 == ============================ */ async function buildCharaCardV2(charName, charBlock, scen, initMsg, exs, userName) { const { creatorUrl, characterVersion, name, creatorNotes } = await getCharacterMeta(); const tokenizedDesc = tokenizeField(charBlock, charName, userName); const tokenizedScen = tokenizeField(scen, charName, userName); const tokenizedExs = tokenizeField(exs, charName, userName); let displayName = name; let versionText = characterVersion; if (useChatNameForName && chatData?.character?.chat_name) { displayName = chatData.character.chat_name.trim(); versionText = `${characterVersion}\nName: ${name}`; } return { spec: "chara_card_v2", spec_version: "2.0", data: { name: displayName, description: tokenizedDesc.trim(), personality: "", scenario: tokenizedScen.trim(), first_mes: initMsg.trim(), mes_example: tokenizedExs.trim(), creator_notes: creatorNotes, system_prompt: "", post_history_instructions: "", alternate_greetings: [], character_book: null, tags: [], creator: creatorUrl, character_version: versionText, extensions: {} } }; } /* ============================ == EXPORTERS == ============================ */ async function saveAsTxt(charBlock, scen, initMsg, exs, charName, userName) { const template = await buildTemplate(charBlock, scen, initMsg, exs); const tokenized = tokenizeNames(template, charName, userName); const rawName = chatData.character.name || chatData.character.chat_name || 'card'; const fileName = rawName.trim() || 'card'; saveFile( `${fileName}.txt`, new Blob([tokenized], { type: 'text/plain' }) ); } async function saveAsJson(charName, charBlock, scen, initMsg, exs, userName) { const jsonData = await buildCharaCardV2(charName, charBlock, scen, initMsg, exs, userName); const rawName = chatData.character.name || chatData.character.chat_name || 'card'; const fileName = rawName.trim() || 'card'; saveFile( `${fileName}.json`, new Blob([JSON.stringify(jsonData, null, 2)], { type: 'application/json' }) ); } async function saveAsPng(charName, charBlock, scen, initMsg, exs, userName) { try { const avatarImg = document.querySelector('img.chakra-image.css-i9mtpv'); if (!avatarImg) { alert('Character avatar not found'); return; } const cardData = await buildCharaCardV2(charName, charBlock, scen, initMsg, exs, userName); const avatarResponse = await fetch(avatarImg.src); const avatarBlob = await avatarResponse.blob(); const img = new Image(); img.onload = () => { const canvas = document.createElement('canvas'); canvas.width = img.width; canvas.height = img.height; const ctx = canvas.getContext('2d'); ctx.drawImage(img, 0, 0); canvas.toBlob(async (blob) => { try { const arrayBuffer = await blob.arrayBuffer(); const pngData = new Uint8Array(arrayBuffer); const jsonString = JSON.stringify(cardData); const base64Data = btoa(unescape(encodeURIComponent(jsonString))); const keyword = "chara"; const keywordBytes = new TextEncoder().encode(keyword); const nullByte = new Uint8Array([0]); const textBytes = new TextEncoder().encode(base64Data); const chunkType = new Uint8Array([116, 69, 88, 116]); // "tEXt" in ASCII const dataLength = keywordBytes.length + nullByte.length + textBytes.length; const lengthBytes = new Uint8Array(4); lengthBytes[0] = (dataLength >>> 24) & 0xFF; lengthBytes[1] = (dataLength >>> 16) & 0xFF; lengthBytes[2] = (dataLength >>> 8) & 0xFF; lengthBytes[3] = dataLength & 0xFF; const crcData = new Uint8Array(chunkType.length + keywordBytes.length + nullByte.length + textBytes.length); crcData.set(chunkType, 0); crcData.set(keywordBytes, chunkType.length); crcData.set(nullByte, chunkType.length + keywordBytes.length); crcData.set(textBytes, chunkType.length + keywordBytes.length + nullByte.length); const crc = computeCrc32(crcData, 0, crcData.length); const crcBytes = new Uint8Array(4); crcBytes[0] = (crc >>> 24) & 0xFF; crcBytes[1] = (crc >>> 16) & 0xFF; crcBytes[2] = (crc >>> 8) & 0xFF; crcBytes[3] = crc & 0xFF; let pos = 8; // Skip PNG signature while (pos < pngData.length - 12) { const length = pngData[pos] << 24 | pngData[pos + 1] << 16 | pngData[pos + 2] << 8 | pngData[pos + 3]; const type = String.fromCharCode( pngData[pos + 4], pngData[pos + 5], pngData[pos + 6], pngData[pos + 7] ); if (type === 'IEND') break; pos += 12 + length; // 4 (length) + 4 (type) + length + 4 (CRC) } const finalSize = pngData.length + lengthBytes.length + chunkType.length + dataLength + crcBytes.length; const finalPNG = new Uint8Array(finalSize); finalPNG.set(pngData.subarray(0, pos)); let writePos = pos; finalPNG.set(lengthBytes, writePos); writePos += lengthBytes.length; finalPNG.set(chunkType, writePos); writePos += chunkType.length; finalPNG.set(keywordBytes, writePos); writePos += keywordBytes.length; finalPNG.set(nullByte, writePos); writePos += nullByte.length; finalPNG.set(textBytes, writePos); writePos += textBytes.length; finalPNG.set(crcBytes, writePos); writePos += crcBytes.length; finalPNG.set(pngData.subarray(pos), writePos); const rawName = chatData.character.name || chatData.character.chat_name || 'card'; const fileName = rawName.trim() || 'card'; saveFile( `${fileName}.png`, new Blob([finalPNG], { type: 'image/png' }) ); console.log("Character card created successfully!"); } catch (err) { console.error('Error creating PNG:', err); alert('Failed to create PNG: ' + err.message); } }, 'image/png'); }; img.src = URL.createObjectURL(avatarBlob); } catch (err) { console.error('Error creating PNG:', err); alert('Failed to create PNG: ' + err.message); } } function computeCrc32(data, start, length) { let crc = 0xFFFFFFFF; for (let i = 0; i < length; i++) { const byte = data[start + i]; crc = (crc >>> 8) ^ crc32Table[(crc ^ byte) & 0xFF]; } return ~crc >>> 0; // Invert and cast to unsigned 32-bit } const crc32Table = (() => { const table = new Uint32Array(256); for (let i = 0; i < 256; i++) { let crc = i; for (let j = 0; j < 8; j++) { crc = (crc & 1) ? 0xEDB88320 ^ (crc >>> 1) : crc >>> 1; } table[i] = crc; } return table; })(); /* ============================ == ROUTING == ============================ */ function inChats() { const isInChat = /^\/chats\/\d+/.test(window.location.pathname); return isInChat; } function initialize() { if (hasInitialized || !inChats()) return; hasInitialized = true; shouldInterceptNext = false; networkInterceptActive = false; exportFormat = null; chatData = null; document.removeEventListener('keydown', handleKeyDown); document.addEventListener('keydown', handleKeyDown); interceptNetwork(); } function handleKeyDown(e) { if (!inChats()) return; if (e.key.toLowerCase() !== 't' || e.ctrlKey || e.metaKey || e.altKey || e.shiftKey) return; if (['INPUT', 'TEXTAREA'].includes(document.activeElement.tagName) || document.activeElement.isContentEditable) return; const proxyAllowed = chatData?.character?.allow_proxy; if (!proxyAllowed) { if (chatData?.character != null) { alert('Proxy disabled — extraction aborted.'); } return; } viewActive = !viewActive; toggleUIState(); } function cleanup() { hasInitialized = false; const gui = document.getElementById('char-export-gui'); if (gui) gui.remove(); document.removeEventListener('keydown', handleKeyDown); viewActive = false; animationTimeouts.forEach(timeoutId => clearTimeout(timeoutId)); animationTimeouts = []; } function handleRoute() { if (inChats()) { initialize(); } else { cleanup(); } } /* ============================ == ENTRYPOINT == ============================ */ window.addEventListener('load', () => { handleRoute(); }, { once: true }); window.addEventListener('popstate', () => { handleRoute(); }); ['pushState', 'replaceState'].forEach(fn => { const orig = history[fn]; history[fn] = function(...args) { const res = orig.apply(this, args); setTimeout(handleRoute, 50); return res; }; }); })();