您需要先安装一个扩展,例如 篡改猴、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 2.4 // @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 applyCharToken = localStorage.getItem('applyCharToken') !== 'false'; let animationTimeouts = []; let guiElement = null; sessionStorage.removeItem('char_export_scroll'); sessionStorage.removeItem('char_settings_scroll'); 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 TOOLTIP_SLIDE_FROM_RIGHT = true; // true = slide towards right (default). Set false for slide-left variant. const TOOLTIP_SLIDE_OFFSET = 10; // px the tooltip travels during slide animation const blankMeta = { creatorUrl: '', characterVersion: '', characterCardUrl: '', name: '', creatorNotes: '', personality: '', scenario: '', firstMessage: '', exampleDialogs: '', definitionExposed: false }; const characterMetaCache = { id: null, useChatNameForName: false, ...blankMeta }; interceptNetwork(); /* ============================ == 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; } /** * Extract creator profile URL from fetched character page document. * Looks for the profile anchor used across pages. * @param {Document} doc – HTML document parsed from character page. * @returns {string} Absolute URL or empty string. */ function getCreatorUrlFromDoc(doc) { const link = doc.querySelector('a.chakra-link.css-15sl5jl'); if (link) { const href = link.getAttribute('href'); if (href) return `https://janitorai.com${href}`; } return ''; } function saveFile(filename, blob) { const url = URL.createObjectURL(blob); const a = makeElement('a', { href: url, download: filename }); a.addEventListener('click', e => { e.stopPropagation(); }); document.body.appendChild(a); a.click(); a.remove(); URL.revokeObjectURL(url); } function getCharacterCardUrl(id) { return `https://janitorai.com/characters/${id}`; } function escapeRegExp(s) { return s.replace(/[.*+?^${}()|[\]\\]/g, '\\$&'); } function extractTagContent(sys, charName) { if (!charName || !sys) return ''; const variants = []; const trimmed = charName.trim(); variants.push(trimmed); const collapsed = trimmed.replace(/\s+/g, ' '); if (collapsed !== trimmed) variants.push(collapsed); if (trimmed.includes(' ')) variants.push(trimmed.replace(/\s+/g, '_')); for (const name of variants) { const escName = escapeRegExp(name); const regex = new RegExp(`<${escName}(?:\\s[^>]*)?\\s*>([\\s\\S]*?)<\\/${escName}\\s*>`, 'i'); const m = sys.match(regex); if (m && m[1] != null) { return m[1].trim(); } const openTagRx = new RegExp(`<${escName}(?:\\s[^>]*)?\\s*>`, 'i'); const closeTagRx = new RegExp(`<\\/${escName}\\s*>`, 'i'); const openMatch = openTagRx.exec(sys); const closeMatch = closeTagRx.exec(sys); if (openMatch && closeMatch && closeMatch.index > openMatch.index) { const start = openMatch.index + openMatch[0].length; const end = closeMatch.index; return sys.substring(start, end).trim(); } try { const parser = new DOMParser(); const doc = parser.parseFromString(`<root>${sys}</root>`, 'application/xml'); const elems = doc.getElementsByTagName(name); if (elems.length) { return elems[0].textContent.trim(); } } catch (_) {} } return ''; } function stripWatermark(text) { if (!text) return ''; const lines = text.split(/\r?\n/); const filtered = lines.filter(l => { const t = l.trim(); return !(/^created/i.test(t) && /janitorai\.com"?$/i.test(t)); }); return filtered.join('\n').trim(); } // === Early Chat Data Prefetch === function findChatId() { // Try URL first const direct = window.location.href.match(/\/chats\/(\d+)/); if (direct) return direct[1]; // Fallback: parse window._storeState_ script tag const scripts = document.querySelectorAll('script'); for (const s of scripts) { const txt = s.textContent; if (!txt || !txt.includes('window._storeState_')) continue; const m = txt.match(/JSON\.parse\("([\s\S]*?)"\)/); if (m && m[1]) { try { const decoded = JSON.parse(`"${m[1]}"`); // unescape const obj = JSON.parse(decoded); let cid = null; const walk = o => { if (!o || typeof o !== 'object' || cid) return; if (Object.prototype.hasOwnProperty.call(o, 'chatId') && typeof o.chatId === 'number') { cid = o.chatId; return; } for (const k in o) walk(o[k]); }; walk(obj); if (cid) return cid; } catch (_) {} } } return null; } function getAuthHeader() { // Look for any sb-auth-auth-token.* cookie variants const matches = document.cookie.match(/sb-auth-auth-token\.[^=]+=([^;]+)/g); if (matches && matches.length) { for (const seg of matches) { const rawVal = seg.substring(seg.indexOf('=') + 1); const hdr = extractBearer(rawVal); if (hdr) return hdr; } } return null; function extractBearer(val) { let raw = decodeURIComponent(val); if (raw.startsWith('base64-')) raw = raw.slice(7); // Raw JWT if (/^[A-Za-z0-9-_]+\.[A-Za-z0-9-_]+\.[A-Za-z0-9-_]+$/.test(raw)) { console.log('[getAuthHeader] using raw JWT'); return `Bearer ${raw}`; } // Try base64 JSON with access_token field try { const json = JSON.parse(atob(raw)); if (json && json.access_token) { console.log('[getAuthHeader] using access_token from JSON'); return `Bearer ${json.access_token}`; } } catch (err) { /* ignore */ } return null; } // Extract Bearer token from sb-auth-auth-token cookie (value is URL-encoded base64-JSON) const m = document.cookie.match(/sb-auth-auth-token\.\d+=([^;]+)/); if (!m) return null; let raw = decodeURIComponent(m[1]); if (raw.startsWith('base64-')) raw = raw.slice(7); try { const json = JSON.parse(atob(raw)); if (json.access_token) return `Bearer ${json.access_token}`; } catch (err) { // Fallback: some builds store the token directly, detect JWT shape (three dot-separated parts) if (/^[A-Za-z0-9-_]+\.[A-Za-z0-9-_]+\.[A-Za-z0-9-_]+$/.test(raw)) { return `Bearer ${raw}`; } } return null; } function getAppVersion() { // Extract version from any script src query param like '?v=2025-06-24.<hash>' const scr = [...document.querySelectorAll('script[src]')].find(s => /\bv=([\w.-]+)/.test(s.src)); if (scr) { const m = scr.src.match(/\bv=([\w.-]+)/); if (m) return m[1]; } // Fallback to hard-coded value seen in Headers (may need update if site version changes) return '2025-06-24.81a918c33'; } async function prefetchChatData() { if (chatData) return; const chatId = findChatId(); if (!chatId) return; const endpoint = `https://janitorai.com/hampter/chats/${chatId}`; const appVer = getAppVersion(); const auth = getAuthHeader(); console.log('[prefetchChatData] auth:', auth); const baseHeaders = { 'x-app-version': appVer, 'accept': 'application/json, text/plain, */*' }; if (auth) baseHeaders['Authorization'] = auth; console.log('[prefetchChatData] request headers', baseHeaders); // First try with cookies + headers try { let res = await fetch(endpoint, { method: 'GET', credentials: 'include', headers: baseHeaders }); if (res.status === 401) { // Retry with Authorization header if available const auth = getAuthHeader(); if (auth) { res = await fetch(endpoint, { method: 'GET', credentials: 'include', headers: { ...baseHeaders, 'Authorization': auth } }); } } if (res.ok) { const json = await res.json(); if (json && json.character) { chatData = json; console.log('[prefetchChatData] chatData pre-fetched'); } } } catch (err) { console.warn('[prefetchChatData] failed:', err); } } function tokenizeNames(text, charName, userName) { if (!text) return text; 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])) { if (applyCharToken) { if (cRx) parts[i] = parts[i].replace(new RegExp(`(?<!\\w)${cRx}(?!\\w)`, 'g'), '{{char}}'); if (uRx) parts[i] = parts[i].replace(new RegExp(`(?<!\\w)${uRx}(?!\\w)`, 'g'), '{{user}}'); } else if (charName) { parts[i] = parts[i].replace(/\{\{char\}\}/gi, charName); } } } return parts.join('\n\n'); } function tokenizeField(text, charName, userName) { if (!text) return text; const esc = n => escapeRegExp(n); const rules = []; if (applyCharToken && charName) { rules.push([new RegExp(`\\b${esc(charName)}('s)?\\b`, 'gi'), (_, sfx) => `{{char}}${sfx||''}`]); } if (userName) { rules.push([new RegExp(`\\b${esc(userName)}('s)?\\b`, 'gi'), (_, sfx) => `{{user}}${sfx||''}`]); } let out = rules.reduce((t, [rx, repl]) => t.replace(rx, repl), text); if (!applyCharToken && charName) { out = out.replace(/\{\{char\}\}/gi, charName); } return out; } /* ============================ == 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 unselectStyle = document.createElement('style'); unselectStyle.textContent = `#char-export-gui, #char-export-gui * { user-select: none; -webkit-user-select: none; -moz-user-select: none; -ms-user-select: none; } #char-export-gui { backdrop-filter: blur(6px); background: rgba(34,34,34,0.92); border: none; border-radius: 8px; } #char-export-overlay { position: fixed; top: 0; left: 0; width: 100%; height: 100%; background: rgba(0,0,0,0.5); z-index: 9998; } #char-export-gui #char-export-tooltip { font-size: 12px; background: #222; color: #fff; padding: 6px 10px; border-radius: 4px; pointer-events: none; } #char-export-gui button { background: #333; color: #fff; border: none; border-radius: 4px; padding: 8px 12px; transition: background 200ms ease, transform 200ms ease; } #char-export-gui button:hover:not(:disabled) { background: #444; transform: translateY(-1px); } #char-export-gui button:disabled { opacity: 0.6; cursor: not-allowed; } #char-export-gui button:focus { outline: none; }`; document.head.appendChild(unselectStyle); 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); /* ========= Dynamic Tooltip ========= */ (() => { let tEl; const show = (target) => { const msg = target.getAttribute('data-tooltip'); if (!msg) return; if (!tEl) { tEl = document.createElement('div'); tEl.id = 'char-export-tooltip'; tEl.id = 'char-export-tooltip'; tEl.style.cssText = 'position:fixed;padding:6px 10px;font-size:12px;background:#222;color:#fff;border-radius:4px;white-space:nowrap;pointer-events:none;box-shadow:0 4px 12px rgba(0,0,0,0.4);opacity:0;transition:opacity 200ms ease,transform 200ms ease;z-index:10002;'; const offset = TOOLTIP_SLIDE_FROM_RIGHT ? -TOOLTIP_SLIDE_OFFSET : TOOLTIP_SLIDE_OFFSET; tEl.style.transform = `translateX(${offset}px)`; document.body.appendChild(tEl); } tEl.textContent = msg; const guiRect = gui.getBoundingClientRect(); const tgtRect = target.getBoundingClientRect(); tEl.style.top = (tgtRect.top + tgtRect.height / 2 - tEl.offsetHeight / 2) + 'px'; tEl.style.left = (guiRect.right + 10) + 'px'; requestAnimationFrame(() => { tEl.style.opacity = '1'; tEl.style.transform = 'translateX(0)'; }); }; const hide = () => { if (!tEl) return; tEl.style.opacity = '0'; const offsetHide = TOOLTIP_SLIDE_FROM_RIGHT ? -TOOLTIP_SLIDE_OFFSET : TOOLTIP_SLIDE_OFFSET; tEl.style.transform = `translateX(${offsetHide}px)`; }; gui.addEventListener('mouseover', e => { const tgt = e.target.closest('[data-tooltip]'); if (tgt) show(tgt); }); gui.addEventListener('mouseout', e => { const tgt = e.target.closest('[data-tooltip]'); if (tgt) hide(); }); window.addEventListener('keydown', ev => { hide(); if ((ev.key === 't' || ev.key === 'T') && tEl) { tEl.style.opacity = '0'; const offsetHide = TOOLTIP_SLIDE_FROM_RIGHT ? -TOOLTIP_SLIDE_OFFSET : TOOLTIP_SLIDE_OFFSET; tEl.style.transform = `translateX(${offsetHide}px)`; } }); })(); 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; pointer-events: none; } } `; document.head.appendChild(style); } if (!document.getElementById('char-export-tooltip-style')) { const tooltipStyle = document.createElement('style'); tooltipStyle.id = 'char-export-tooltip-style'; tooltipStyle.textContent = ` [data-tooltip] { position: relative; } [data-tooltip]::before { content: ''; position: absolute; left: calc(100% + 4px); top: 50%; transform: translateY(-50%) scaleY(0.5); border: solid transparent; border-width: 6px 6px 6px 0; border-left-color: #333; opacity: 0; pointer-events: none; transition: opacity 150ms ease; z-index: 10001; } [data-tooltip]::after { content: attr(data-tooltip); position: absolute; left: calc(100% + 10px); top: 50%; transform: translateY(-50%) scale(0.8); background: #333; color: #fff; padding: 6px 10px; border-radius: 4px; white-space: nowrap; opacity: 0; pointer-events: none; transition: opacity 150ms ease, transform 150ms ease; z-index: 10001; } [data-tooltip]:hover::before, [data-tooltip]:hover::after { opacity: 1; transform: translateY(-50%) scale(1); } .toggle-wrapper { border-radius: 8px; transition: background 0.2s ease, box-shadow 0.2s ease; } .toggle-wrapper.active { background: transparent; box-shadow: none; } `; document.head.appendChild(tooltipStyle); if (!document.getElementById('char-export-scrollbar-style')) { const scrollStyle = document.createElement('style'); scrollStyle.id = 'char-export-scrollbar-style'; scrollStyle.textContent = ` #content-wrapper { overflow: visible; } #settings-tab { overflow-y: auto; scrollbar-width: none; -ms-overflow-style: none; } #settings-tab::-webkit-scrollbar { width: 0; height: 0; } `; document.head.appendChild(scrollStyle); if (!document.getElementById('char-export-tooltip-override-style')) { const overrideStyle = document.createElement('style'); overrideStyle.id = 'char-export-tooltip-override-style'; overrideStyle.textContent = ` [data-tooltip]::before { transform: translateY(-50%) translateX(-6px); transition: transform 200ms ease, opacity 200ms ease; } [data-tooltip]::after { transform: translateY(-50%) translateX(-10px); transition: transform 200ms ease, opacity 200ms ease; } [data-tooltip]:hover::before { transform: translateY(-50%) translateX(0); opacity:1; } [data-tooltip]:hover::after { transform: translateY(-50%) translateX(0); opacity:1; } [data-tooltip]::before,[data-tooltip]::after{display:none !important;} @keyframes toggle-pulse { 0% { transform: scale(1); } 50% { transform: scale(1.05); } 100% { transform: scale(1); } } .toggle-wrapper.pulse { animation: toggle-pulse 300ms ease; } .switch .slider-before { transition: transform 200ms ease, box-shadow 200ms ease; } .toggle-wrapper.active .slider-before { box-shadow: 0 0 2px rgba(0,0,0,0.2), 0 0 8px rgba(0,128,255,0.5); } .toggle-wrapper.active .slider { background-color: #0080ff; } `; document.head.appendChild(overrideStyle); } } } exportContent.appendChild(buttonContainer); const contentWrapper = makeElement('div', { id: 'content-wrapper' }, { height: '103px', width: '100%', overflow: 'visible', display: 'flex', flexDirection: 'column', justifyContent: 'center', position: 'relative' }); gui.appendChild(contentWrapper); const tabContentStyles = { height: '100%', width: '100%', overflowY: 'auto', overflowX: 'hidden', padding: '0', position: 'absolute', top: '0', left: '0', opacity: '1', transform: 'scale(1)', transition: `opacity ${TAB_ANIMATION_DURATION}ms ease, transform ${TAB_ANIMATION_DURATION}ms ease`, }; Object.assign(exportContent.style, tabContentStyles); exportContent.style.overflowY = 'hidden'; exportContent.style.overflowX = 'hidden'; const settingsContent = makeElement('div', { id: 'settings-tab', style: 'display: none;' }, tabContentStyles); contentWrapper.appendChild(exportContent); contentWrapper.appendChild(settingsContent); const savedExportScroll = parseInt(sessionStorage.getItem('char_export_scroll') || '0', 10); exportContent.scrollTop = savedExportScroll; const savedSettingsScroll = parseInt(sessionStorage.getItem('char_settings_scroll') || '0', 10); settingsContent.scrollTop = savedSettingsScroll; settingsContent.addEventListener('scroll', () => sessionStorage.setItem('char_settings_scroll', settingsContent.scrollTop)); requestAnimationFrame(() => { exportContent.scrollTop = savedExportScroll; settingsContent.scrollTop = savedSettingsScroll; }); 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', }, { 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)'; toggleWrapper.classList.toggle('active', useChatNameForName); toggleWrapper.classList.add('pulse'); setTimeout(() => toggleWrapper.classList.remove('pulse'), 300); 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); toggleLabel.setAttribute('data-tooltip', 'Uses chat name for the character name instead of label name.'); toggleWrapper.appendChild(toggle); toggleContainer.appendChild(toggleWrapper); settingsContent.appendChild(toggleContainer); const toggleContainerChar = makeElement('div', {}, { display: 'flex', alignItems: 'center', marginBottom: '4px', marginTop: '5px', padding: '10px 10px 9px', background: '#2a2a2a', borderRadius: '8px', gap: '10px' }); const toggleWrapperChar = makeElement('div', { className: 'toggle-wrapper' }, { display: 'flex', alignItems: 'center', justifyContent: 'space-between', width: '100%', cursor: 'pointer' }); const toggleLabelChar = makeElement('span', { textContent: 'Apply {{char}} tokenization', }, { fontSize: '13px', color: '#fff', order: '2', textAlign: 'left', flex: '1', paddingLeft: '10px', wordBreak: 'break-word', lineHeight: '1.4', }); const toggleChar = 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 sliderChar = makeElement('span', { className: 'slider round' }, { position: 'absolute', cursor: 'pointer', top: '0', left: '0', right: '0', bottom: '0', backgroundColor: applyCharToken ? ACTIVE_TAB_COLOR : '#ccc', transition: `background-color ${TOGGLE_ANIMATION}ms cubic-bezier(0.34, 1.56, 0.64, 1)`, borderRadius: '24px', overflow: 'hidden' }); const sliderShineChar = 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` }); sliderChar.appendChild(sliderShineChar); const sliderBeforeChar = 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: applyCharToken ? 'translateX(16px)' : 'translateX(0)', boxShadow: applyCharToken ? '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 inputChar = makeElement('input', { type: 'checkbox', checked: applyCharToken }, { opacity: '0', width: '0', height: '0', position: 'absolute' }); inputChar.addEventListener('change', (e) => { applyCharToken = e.target.checked; localStorage.setItem('applyCharToken', applyCharToken); toggleWrapperChar.classList.toggle('active', applyCharToken); toggleWrapperChar.classList.add('pulse'); setTimeout(() => toggleWrapperChar.classList.remove('pulse'), 300); sliderChar.style.backgroundColor = applyCharToken ? ACTIVE_TAB_COLOR : '#ccc'; sliderBeforeChar.style.transform = applyCharToken ? 'translateX(16px)' : 'translateX(0)'; sliderBeforeChar.style.boxShadow = applyCharToken ? '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 (applyCharToken) { const pulseChar = 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' }); toggleChar.appendChild(pulseChar); setTimeout(() => { pulseChar.style.opacity = '0'; pulseChar.style.transform = 'scale(1.5)'; pulseChar.style.transition = 'all 400ms ease-out'; }, 10); setTimeout(() => pulseChar.remove(), 400); } }); sliderChar.appendChild(sliderBeforeChar); toggleChar.appendChild(inputChar); toggleChar.appendChild(sliderChar); toggleWrapperChar.addEventListener('click', (e) => { e.preventDefault(); inputChar.checked = !inputChar.checked; const event = new Event('change'); inputChar.dispatchEvent(event); document.body.focus(); }); toggleWrapperChar.appendChild(toggleLabelChar); toggleLabelChar.setAttribute('data-tooltip', 'Toggle replacement of character names with {{char}} placeholder.'); toggleWrapperChar.appendChild(toggleChar); toggleContainerChar.appendChild(toggleWrapperChar); settingsContent.appendChild(toggleContainerChar); 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'; content.style.pointerEvents = isActive ? 'auto' : 'none'; 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 tt = document.getElementById('char-export-tooltip'); if (tt) { tt.style.opacity = '0'; const offsetHide = TOOLTIP_SLIDE_FROM_RIGHT ? -TOOLTIP_SLIDE_OFFSET : TOOLTIP_SLIDE_OFFSET; tt.style.transform = `translateX(${offsetHide}px)`; } 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 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'); const headerName = header?.textContent.match(/Chat with\s+(.*)$/)?.[1]?.trim(); const fullName = (chatData?.character?.chat_name || chatData?.character?.name || '').trim(); const nameFirst = (chatData?.character?.name || '').trim().split(/\s+/)[0]; const charName = fullName || nameFirst || headerName || 'char'; let charBlock = extractTagContent(sys, fullName); if (!charBlock) { charBlock = extractTagContent(sys, nameFirst || 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() { // ---------- BEGIN Method 1 helpers ---------- const findCharacter = (obj) => { if (!obj || typeof obj !== 'object') return null; if ('showdefinition' in obj && 'name' in obj && 'id' in obj) return obj; for (const key of Object.keys(obj)) { const res = findCharacter(obj[key]); if (res) return res; } return null; }; const extractFromJson = (json) => { if (!json) return null; const charObj = findCharacter(json); if (!charObj) { console.log('[getCharacterMeta] Method 1: no character object found'); return null; } console.log('[getCharacterMeta] Method 1: character object located, showdefinition=', charObj.showdefinition); const { name: rawName = '', chat_name: chatName = '', description: creatorNotesRaw = '', creator = {}, personality: personalityRaw = '', scenario: scenarioRaw = '', first_message: firstMsgRaw = '', example_dialogs: exDialogsRaw = '', showdefinition = false, id = '' } = charObj; if (!showdefinition) return null; const name = (chatName && !useChatNameForName ? rawName : (chatName || rawName)).trim(); let characterVersion = getCharacterCardUrl(id); if (chatName && !useChatNameForName) { characterVersion += `\nChat Name: ${chatName.trim()}`; } return { characterVersion, characterCardUrl: getCharacterCardUrl(id), name, creatorNotes: creatorNotesRaw, personality: stripWatermark(personalityRaw), scenario: stripWatermark(scenarioRaw), firstMessage: stripWatermark(firstMsgRaw), exampleDialogs: stripWatermark(exDialogsRaw), definitionExposed: true }; }; // ---------- END Method 1 helpers ---------- const charId = chatData?.character?.id; if (!charId) return { creatorUrl: '', characterVersion: '', characterCardUrl: '', name: '', creatorNotes: '', personality: '', scenario: '', firstMessage: '', exampleDialogs: '', definitionExposed: false }; if (characterMetaCache.id === charId && characterMetaCache.useChatNameForName === useChatNameForName) { return { ...characterMetaCache }; } const characterCardUrl = getCharacterCardUrl(charId); let meta = { ...blankMeta, characterVersion: characterCardUrl, characterCardUrl }; try { const response = await fetch(characterCardUrl); const html = await response.text(); const doc = new DOMParser().parseFromString(html, 'text/html'); // ---------- BEGIN Method 1 execution ---------- let metaFromJson = null; try { console.log('[getCharacterMeta] Method 1: scanning <script> tags'); const scripts = Array.from(doc.querySelectorAll('script')); for (const s of scripts) { const txt = s.textContent || ''; if (!txt.includes('window.mbxM.push(JSON.parse(')) continue; const m = txt.match(/JSON\.parse\(\s*("([\s\S]*?)")\s*\)/); if (!m || !m[1]) continue; let innerStr; try { innerStr = JSON.parse(m[1]); } catch (_) { continue; } let obj; try { obj = typeof innerStr === 'string' ? JSON.parse(innerStr) : innerStr; } catch (_) { continue; } metaFromJson = extractFromJson(obj); if (metaFromJson) break; } } catch (parseErr) { console.error('[getCharacterMeta] JSON parse error (Method 1):', parseErr); } // ---------- END Method 1 execution ---------- Object.assign(meta, metaFromJson || {}, { creatorUrl: getCreatorUrlFromDoc(doc), creatorNotes: chatData?.character?.description || '' }); } catch (_) {} Object.assign(characterMetaCache, { id: charId, useChatNameForName, ...meta }); return meta; } async function buildTemplate(charBlock, scen, initMsg, exs) { const sections = []; const { creatorUrl, characterCardUrl, creatorNotes, } = 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}`); if (creatorNotes) sections.push(`==== Creator Notes ====\n${creatorNotes}`); return sections.join('\n\n'); } 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' }) ); } function tokenizeField(text, charName, userName) { if (!text) return text; const esc = n => escapeRegExp(n); const rules = []; if (applyCharToken && charName) { rules.push([new RegExp(`\\b${esc(charName)}('s)?\\b`, 'gi'), (_, sfx) => `{{char}}${sfx||''}`]); } if (userName) { rules.push([new RegExp(`\\b${esc(userName)}('s)?\\b`, 'gi'), (_, sfx) => `{{user}}${sfx||''}`]); } let out = rules.reduce((t, [rx, repl]) => t.replace(rx, repl), text); if (!applyCharToken && charName) { out = out.replace(/\{\{char\}\}/gi, charName); } return out; } /* ============================ == INTERCEPTORS == ============================ */ function extraction() { if (!exportFormat) return; if (!document.querySelector('span.css-yhlqn1') && !document.querySelector('span.css-154nobl')) return; (async () => { const meta = await getCharacterMeta(); // Method 1 if (meta.definitionExposed) { const charName = meta.name; const userName = document.documentElement.innerHTML.match(/\\"name\\":\\"([^\\"]+)\\"/)?.[1] || ''; switch (exportFormat) { case 'txt': saveAsTxt(meta.personality, meta.scenario, meta.firstMessage, meta.exampleDialogs, charName, userName); break; case 'png': saveAsPng(charName, meta.personality, meta.scenario, meta.firstMessage, meta.exampleDialogs, userName); break; case 'json': saveAsJson(charName, meta.personality, meta.scenario, meta.firstMessage, meta.exampleDialogs, userName); break; } exportFormat = null; 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 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; // Attempt to prefetch chat data before UI makes its own request (disabled) // prefetchChatData(); 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; if (!chatData.character.allow_proxy) { 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; }; }); handleRoute(); })();