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