Cpt_Haddock

Untitled

May 31st, 2025
19
0
Never
Not a member of Pastebin yet? Sign Up, it unlocks many cool features!
text 38.70 KB | None | 0 0
  1. // ==UserScript==
  2. // @name JanitorAI Character Card Scraper
  3. // @version 1.1
  4. // @description Extract character card with "T" key (WHILE IN CHAT PAGE) and save as .txt, .png, or .json (proxy required)
  5. // @match https://janitorai.com/*
  6. // @icon https://images.dwncdn.net/images/t_app-icon-l/p/46413ec0-e1d8-4eab-a0bc-67eadabb2604/3920235030/janitor-ai-logo
  7. // @grant none
  8. // @run-at document-start
  9. // @license MIT
  10. // @namespace https://greasyfork.org/en/scripts/537206-janitorai-character-card-scraper
  11. // @downloadURL https://update.sleazyfork.org/scripts/537206/JanitorAI%20Character%20Card%20Scraper.user.js
  12. // @updateURL https://update.sleazyfork.org/scripts/537206/JanitorAI%20Character%20Card%20Scraper.meta.js
  13. // ==/UserScript==
  14.  
  15. (() => {
  16. 'use strict';
  17.  
  18. /* ============================
  19. == VARIABLES ==
  20. ============================ */
  21. let hasInitialized = false
  22. let viewActive = false
  23. let shouldInterceptNext = false
  24. let networkInterceptActive = false
  25. let exportFormat = null
  26. let chatData = null
  27. let currentTab = sessionStorage.getItem('lastActiveTab') || 'export'
  28. let useChatNameForName = localStorage.getItem('useChatNameForName') === 'true' || false;
  29. let animationTimeouts = [];
  30. let guiElement = null;
  31. const ANIMATION_DURATION = 150; // Animation duration for modal open/close in ms
  32. const TAB_ANIMATION_DURATION = 300; // Animation duration for tab switching in ms
  33. const TAB_BUTTON_DURATION = 250; // Animation duration for tab button effects
  34. const BUTTON_ANIMATION = 200; // Animation duration for format buttons
  35. const TOGGLE_ANIMATION = 350; // Animation duration for toggle switch
  36. const ACTIVE_TAB_COLOR = '#0080ff'; // Color for active tab indicator
  37. const INACTIVE_TAB_COLOR = 'transparent'; // Color for inactive tab indicator
  38. const BUTTON_COLOR = '#3a3a3a'; // Base color for buttons
  39. const BUTTON_HOVER_COLOR = '#4a4a4a'; // Hover color for buttons
  40. const BUTTON_ACTIVE_COLOR = '#0070dd'; // Active color for buttons when clicked
  41. const characterMetaCache = { id: null, creatorUrl: '', characterVersion: '', characterCardUrl: '', name: '', creatorNotes: '' };
  42.  
  43. /* ============================
  44. == UTILITIES ==
  45. ============================ */
  46. function makeElement(tag, attrs = {}, styles = {}) {
  47. const el = document.createElement(tag);
  48. Object.entries(attrs).forEach(([key, value]) => el[key] = value);
  49.  
  50. if (styles) {
  51. Object.entries(styles).forEach(([key, value]) => el.style[key] = value);
  52. }
  53. return el;
  54. }
  55.  
  56. function saveFile(filename, blob) {
  57. const url = URL.createObjectURL(blob);
  58. const a = makeElement('a', { href: url, download: filename });
  59. document.body.appendChild(a);
  60. a.click();
  61. a.remove();
  62. URL.revokeObjectURL(url);
  63. }
  64.  
  65. function escapeRegExp(s) {
  66. return s.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
  67. }
  68.  
  69. /* ============================
  70. == UI ==
  71. ============================ */
  72. function createUI() {
  73. if (guiElement && document.body.contains(guiElement)) {
  74. return;
  75. }
  76.  
  77. animationTimeouts.forEach(timeoutId => clearTimeout(timeoutId));
  78. animationTimeouts = [];
  79.  
  80. viewActive = true;
  81.  
  82. const gui = makeElement('div', { id: 'char-export-gui' }, {
  83. position: 'fixed',
  84. top: '50%',
  85. left: '50%',
  86. transform: 'translate(-50%, -50%) scale(0.95)',
  87. background: '#222',
  88. color: 'white',
  89. padding: '15px 20px 7px',
  90. borderRadius: '8px',
  91. boxShadow: '0 0 20px rgba(0,0,0,0.5)',
  92. zIndex: '10000',
  93. textAlign: 'center',
  94. width: '320px',
  95. overflow: 'hidden',
  96. display: 'flex',
  97. flexDirection: 'column',
  98. boxSizing: 'border-box',
  99. opacity: '0',
  100. transition: `opacity ${ANIMATION_DURATION}ms ease-out, transform ${ANIMATION_DURATION}ms ease-out`
  101. });
  102.  
  103. guiElement = gui;
  104.  
  105. const tabContainer = makeElement('div', {}, {
  106. display: 'flex',
  107. justifyContent: 'center',
  108. marginBottom: '15px',
  109. borderBottom: '1px solid #444',
  110. paddingBottom: '8px',
  111. width: '100%'
  112. });
  113.  
  114. const tabsWrapper = makeElement('div', {}, {
  115. display: 'flex',
  116. justifyContent: 'center',
  117. width: '100%',
  118. maxWidth: '300px',
  119. margin: '0 auto'
  120. });
  121.  
  122. const createTabButton = (text, isActive) => {
  123. const button = makeElement('button', { textContent: text }, {
  124. background: 'transparent',
  125. border: 'none',
  126. color: '#fff',
  127. padding: '8px 20px',
  128. cursor: 'pointer',
  129. margin: '0 5px',
  130. fontWeight: 'bold',
  131. flex: '1',
  132. textAlign: 'center',
  133. position: 'relative',
  134. overflow: 'hidden',
  135. transition: `opacity ${TAB_BUTTON_DURATION}ms ease, transform ${TAB_BUTTON_DURATION}ms ease, color ${TAB_BUTTON_DURATION}ms ease`
  136. });
  137.  
  138. const indicator = makeElement('div', {}, {
  139. position: 'absolute',
  140. bottom: '0',
  141. left: '0',
  142. width: '100%',
  143. height: '2px',
  144. background: isActive ? ACTIVE_TAB_COLOR : INACTIVE_TAB_COLOR,
  145. transition: `transform ${TAB_BUTTON_DURATION}ms ease, background-color ${TAB_BUTTON_DURATION}ms ease`
  146. });
  147.  
  148. if (!isActive) {
  149. button.style.opacity = '0.7';
  150. indicator.style.transform = 'scaleX(0.5)';
  151. }
  152.  
  153. button.appendChild(indicator);
  154. return { button, indicator };
  155. };
  156.  
  157. const { button: exportTab, indicator: exportIndicator } = createTabButton('Export', true);
  158. const { button: settingsTab, indicator: settingsIndicator } = createTabButton('Settings', false);
  159.  
  160. exportTab.onmouseover = () => {
  161. if (currentTab !== 'export') {
  162. exportTab.style.opacity = '1';
  163. exportTab.style.transform = 'translateY(-2px)';
  164. exportIndicator.style.transform = 'scaleX(0.8)';
  165. }
  166. };
  167. exportTab.onmouseout = () => {
  168. if (currentTab !== 'export') {
  169. exportTab.style.opacity = '0.7';
  170. exportTab.style.transform = '';
  171. exportIndicator.style.transform = 'scaleX(0.5)';
  172. }
  173. };
  174.  
  175. settingsTab.onmouseover = () => {
  176. if (currentTab !== 'settings') {
  177. settingsTab.style.opacity = '1';
  178. settingsTab.style.transform = 'translateY(-2px)';
  179. settingsIndicator.style.transform = 'scaleX(0.8)';
  180. }
  181. };
  182. settingsTab.onmouseout = () => {
  183. if (currentTab !== 'settings') {
  184. settingsTab.style.opacity = '0.7';
  185. settingsTab.style.transform = '';
  186. settingsIndicator.style.transform = 'scaleX(0.5)';
  187. }
  188. };
  189.  
  190. tabsWrapper.appendChild(exportTab);
  191. tabsWrapper.appendChild(settingsTab);
  192. tabContainer.appendChild(tabsWrapper);
  193. gui.appendChild(tabContainer);
  194.  
  195. const exportContent = makeElement('div', { id: 'export-tab' }, {
  196. maxHeight: '60vh',
  197. overflowY: 'auto',
  198. padding: '0 5px 10px 0',
  199. display: 'flex',
  200. flexDirection: 'column',
  201. justifyContent: 'center'
  202. });
  203.  
  204. const title = makeElement('h2', { textContent: 'Export Character Card' }, {
  205. margin: '0 0 12px 0',
  206. fontSize: '18px',
  207. paddingTop: '5px'
  208. });
  209. exportContent.appendChild(title);
  210.  
  211. const buttonContainer = makeElement('div', {}, {
  212. display: 'flex',
  213. gap: '10px',
  214. justifyContent: 'center',
  215. marginBottom: '3px',
  216. marginTop: '8px'
  217. });
  218.  
  219. ['TXT', 'PNG', 'JSON'].forEach(format => {
  220. const type = format.toLowerCase();
  221.  
  222. const button = makeElement('button', { textContent: format }, {
  223. background: BUTTON_COLOR,
  224. border: 'none',
  225. color: 'white',
  226. padding: '10px 20px',
  227. borderRadius: '6px',
  228. cursor: 'pointer',
  229. fontWeight: 'bold',
  230. position: 'relative',
  231. overflow: 'hidden',
  232. flex: '1',
  233. transition: `all ${BUTTON_ANIMATION}ms ease`,
  234. boxShadow: '0 2px 4px rgba(0,0,0,0.1)',
  235. transform: 'translateY(0)'
  236. });
  237.  
  238. const shine = makeElement('div', {}, {
  239. position: 'absolute',
  240. top: '0',
  241. left: '0',
  242. width: '100%',
  243. height: '100%',
  244. background: 'linear-gradient(135deg, rgba(255,255,255,0.1) 0%, rgba(255,255,255,0) 60%)',
  245. transform: 'translateX(-100%)',
  246. transition: `transform ${BUTTON_ANIMATION * 1.5}ms ease-out`,
  247. pointerEvents: 'none'
  248. });
  249. button.appendChild(shine);
  250.  
  251. button.onmouseover = () => {
  252. button.style.background = BUTTON_HOVER_COLOR;
  253. button.style.transform = 'translateY(-2px)';
  254. button.style.boxShadow = '0 4px 8px rgba(0,0,0,0.2)';
  255. shine.style.transform = 'translateX(100%)';
  256. };
  257.  
  258. button.onmouseout = () => {
  259. button.style.background = BUTTON_COLOR;
  260. button.style.transform = 'translateY(0)';
  261. button.style.boxShadow = '0 2px 4px rgba(0,0,0,0.1)';
  262. shine.style.transform = 'translateX(-100%)';
  263. };
  264.  
  265. button.onmousedown = () => {
  266. button.style.transform = 'translateY(1px)';
  267. button.style.boxShadow = '0 1px 2px rgba(0,0,0,0.2)';
  268. button.style.background = BUTTON_ACTIVE_COLOR;
  269. };
  270.  
  271. button.onmouseup = () => {
  272. button.style.transform = 'translateY(-2px)';
  273. button.style.boxShadow = '0 4px 8px rgba(0,0,0,0.2)';
  274. button.style.background = BUTTON_HOVER_COLOR;
  275. };
  276.  
  277. button.onclick = (e) => {
  278. const rect = button.getBoundingClientRect();
  279. const x = e.clientX - rect.left;
  280. const y = e.clientY - rect.top;
  281.  
  282. const ripple = makeElement('div', {}, {
  283. position: 'absolute',
  284. borderRadius: '50%',
  285. backgroundColor: 'rgba(255,255,255,0.4)',
  286. width: '5px',
  287. height: '5px',
  288. transform: 'scale(1)',
  289. opacity: '1',
  290. animation: 'ripple 600ms linear',
  291. pointerEvents: 'none',
  292. top: `${y}px`,
  293. left: `${x}px`,
  294. marginLeft: '-2.5px',
  295. marginTop: '-2.5px'
  296. });
  297.  
  298. button.appendChild(ripple);
  299.  
  300. exportFormat = type;
  301. closeV();
  302. extraction();
  303.  
  304. setTimeout(() => ripple.remove(), 600);
  305. };
  306.  
  307. buttonContainer.appendChild(button);
  308. });
  309.  
  310.  
  311. if (!document.getElementById('char-export-style')) {
  312. const style = document.createElement('style');
  313. style.id = 'char-export-style';
  314. style.textContent = `
  315. @keyframes ripple {
  316. to {
  317. transform: scale(30);
  318. opacity: 0;
  319. }
  320. }
  321. `;
  322. document.head.appendChild(style);
  323. }
  324.  
  325. exportContent.appendChild(buttonContainer);
  326.  
  327. const contentWrapper = makeElement('div', { id: 'content-wrapper' }, {
  328. height: '103px',
  329. width: '100%',
  330. overflow: 'hidden',
  331. display: 'flex',
  332. flexDirection: 'column',
  333. justifyContent: 'center',
  334. position: 'relative'
  335. });
  336. gui.appendChild(contentWrapper);
  337.  
  338. const tabContentStyles = {
  339. height: '100%',
  340. width: '100%',
  341. overflowY: 'auto',
  342. overflowX: 'hidden',
  343. padding: '0',
  344. scrollbarWidth: 'none',
  345. msOverflowStyle: 'none',
  346. position: 'absolute',
  347. top: '0',
  348. left: '0',
  349. opacity: '1',
  350. transform: 'scale(1)',
  351. transition: `opacity ${TAB_ANIMATION_DURATION}ms ease, transform ${TAB_ANIMATION_DURATION}ms ease`,
  352. '&::-webkit-scrollbar': {
  353. width: '0',
  354. background: 'transparent'
  355. }
  356. };
  357.  
  358. Object.assign(exportContent.style, tabContentStyles);
  359.  
  360. const scrollbarStyles = { ...tabContentStyles };
  361.  
  362. const settingsContent = makeElement('div', { id: 'settings-tab', style: 'display: none;' }, scrollbarStyles);
  363.  
  364. contentWrapper.appendChild(exportContent);
  365. contentWrapper.appendChild(settingsContent);
  366.  
  367. const settingsTitle = makeElement('h2', { textContent: 'Export Settings' }, {
  368. margin: '0 0 15px 0',
  369. fontSize: '18px',
  370. paddingTop: '5px'
  371. });
  372. settingsContent.appendChild(settingsTitle);
  373.  
  374. const toggleContainer = makeElement('div', {}, {
  375. display: 'flex',
  376. alignItems: 'center',
  377. marginBottom: '4px',
  378. marginTop: '5px',
  379. padding: '10px 10px 9px',
  380. background: '#2a2a2a',
  381. borderRadius: '8px',
  382. gap: '10px'
  383. });
  384.  
  385. const toggleWrapper = makeElement('div', {
  386. className: 'toggle-wrapper'
  387. }, {
  388. display: 'flex',
  389. alignItems: 'center',
  390. justifyContent: 'space-between',
  391. width: '100%',
  392. cursor: 'pointer'
  393. });
  394.  
  395. const toggleLabel = makeElement('span', {
  396. textContent: 'Use character\'s chat name',
  397. title: 'Uses chat name for the character name instead of label name.'
  398. }, {
  399. fontSize: '13px',
  400. color: '#fff',
  401. order: '2',
  402. textAlign: 'left',
  403. flex: '1',
  404. paddingLeft: '10px',
  405. wordBreak: 'break-word',
  406. lineHeight: '1.4'
  407. });
  408.  
  409. const toggle = makeElement('label', { className: 'switch' }, {
  410. position: 'relative',
  411. display: 'inline-block',
  412. width: '40px',
  413. height: '24px',
  414. order: '1',
  415. margin: '0',
  416. flexShrink: '0',
  417. borderRadius: '24px',
  418. boxShadow: '0 1px 3px rgba(0,0,0,0.2) inset',
  419. transition: `all ${TOGGLE_ANIMATION}ms ease`
  420. });
  421.  
  422. const slider = makeElement('span', { className: 'slider round' }, {
  423. position: 'absolute',
  424. cursor: 'pointer',
  425. top: '0',
  426. left: '0',
  427. right: '0',
  428. bottom: '0',
  429. backgroundColor: useChatNameForName ? ACTIVE_TAB_COLOR : '#ccc',
  430. transition: `background-color ${TOGGLE_ANIMATION}ms cubic-bezier(0.34, 1.56, 0.64, 1)`,
  431. borderRadius: '24px',
  432. overflow: 'hidden'
  433. });
  434.  
  435. const sliderShine = makeElement('div', {}, {
  436. position: 'absolute',
  437. top: '0',
  438. left: '0',
  439. width: '100%',
  440. height: '100%',
  441. background: 'linear-gradient(135deg, rgba(255,255,255,0.2) 0%, rgba(255,255,255,0) 50%)',
  442. opacity: '0.5',
  443. transition: `opacity ${TOGGLE_ANIMATION}ms ease`
  444. });
  445. slider.appendChild(sliderShine);
  446.  
  447. const sliderBefore = makeElement('span', { className: 'slider-before' }, {
  448. position: 'absolute',
  449. content: '""',
  450. height: '16px',
  451. width: '16px',
  452. left: '4px',
  453. bottom: '4px',
  454. backgroundColor: 'white',
  455. transition: `transform ${TOGGLE_ANIMATION}ms cubic-bezier(0.34, 1.56, 0.64, 1), box-shadow ${TOGGLE_ANIMATION}ms ease`,
  456. borderRadius: '50%',
  457. transform: useChatNameForName ? 'translateX(16px)' : 'translateX(0)',
  458. boxShadow: useChatNameForName ?
  459. '0 0 2px rgba(0,0,0,0.2), 0 0 5px rgba(0,128,255,0.3)' :
  460. '0 0 2px rgba(0,0,0,0.2)'
  461. });
  462.  
  463. const input = makeElement('input', {
  464. type: 'checkbox',
  465. checked: useChatNameForName
  466. }, {
  467. opacity: '0',
  468. width: '0',
  469. height: '0',
  470. position: 'absolute'
  471. });
  472.  
  473. input.addEventListener('change', (e) => {
  474. useChatNameForName = e.target.checked;
  475. localStorage.setItem('useChatNameForName', useChatNameForName);
  476.  
  477. slider.style.backgroundColor = useChatNameForName ? ACTIVE_TAB_COLOR : '#ccc';
  478. sliderBefore.style.transform = useChatNameForName ? 'translateX(16px)' : 'translateX(0)';
  479.  
  480. sliderBefore.style.boxShadow = useChatNameForName ?
  481. '0 0 2px rgba(0,0,0,0.2), 0 0 5px rgba(0,128,255,0.3)' :
  482. '0 0 2px rgba(0,0,0,0.2)';
  483.  
  484. if (useChatNameForName) {
  485. const pulse = makeElement('div', {}, {
  486. position: 'absolute',
  487. top: '0',
  488. left: '0',
  489. right: '0',
  490. bottom: '0',
  491. backgroundColor: ACTIVE_TAB_COLOR,
  492. borderRadius: '24px',
  493. opacity: '0.5',
  494. transform: 'scale(1.2)',
  495. pointerEvents: 'none',
  496. zIndex: '-1'
  497. });
  498.  
  499. toggle.appendChild(pulse);
  500.  
  501. setTimeout(() => {
  502. pulse.style.opacity = '0';
  503. pulse.style.transform = 'scale(1.5)';
  504. pulse.style.transition = 'all 400ms ease-out';
  505. }, 10);
  506.  
  507. setTimeout(() => pulse.remove(), 400);
  508. }
  509. });
  510.  
  511. slider.style.backgroundColor = useChatNameForName ? '#007bff' : '#ccc';
  512. slider.appendChild(sliderBefore);
  513. toggle.appendChild(input);
  514. toggle.appendChild(slider);
  515.  
  516. toggleWrapper.addEventListener('click', (e) => {
  517. e.preventDefault();
  518. input.checked = !input.checked;
  519. const event = new Event('change');
  520. input.dispatchEvent(event);
  521. document.body.focus();
  522. });
  523.  
  524. toggleWrapper.appendChild(toggleLabel);
  525. toggleWrapper.appendChild(toggle);
  526. toggleContainer.appendChild(toggleWrapper);
  527. settingsContent.appendChild(toggleContainer);
  528.  
  529. const tabs = {
  530. export: {
  531. content: exportContent,
  532. tab: exportTab,
  533. active: true
  534. },
  535. settings: {
  536. content: settingsContent,
  537. tab: settingsTab,
  538. active: false
  539. }
  540. };
  541.  
  542. function switchTab(tabKey) {
  543. animationTimeouts.forEach(timeoutId => clearTimeout(timeoutId));
  544. animationTimeouts = [];
  545.  
  546. Object.entries(tabs).forEach(([key, { content, tab }]) => {
  547. const isActive = key === tabKey;
  548.  
  549. tab.style.opacity = isActive ? '1' : '0.7';
  550. tab.style.transform = isActive ? 'translateY(-2px)' : '';
  551.  
  552. const indicator = tab.lastChild;
  553. if (indicator) {
  554. if (isActive) {
  555. indicator.style.background = ACTIVE_TAB_COLOR;
  556. indicator.style.transform = 'scaleX(1)';
  557. } else {
  558. indicator.style.background = INACTIVE_TAB_COLOR;
  559. indicator.style.transform = 'scaleX(0.5)';
  560. }
  561. }
  562.  
  563. content.style.display = 'block';
  564.  
  565. if (isActive) {
  566. content.style.opacity = '0';
  567. content.style.transform = 'scale(0.95)';
  568.  
  569. void content.offsetWidth;
  570.  
  571. requestAnimationFrame(() => {
  572. content.style.opacity = '1';
  573. content.style.transform = 'scale(1)';
  574. });
  575.  
  576. } else {
  577. requestAnimationFrame(() => {
  578. content.style.opacity = '0';
  579. content.style.transform = 'scale(0.95)';
  580. });
  581.  
  582. const hideTimeout = setTimeout(() => {
  583. if (!tabs[key].active) {
  584. content.style.display = 'none';
  585. }
  586. }, TAB_ANIMATION_DURATION);
  587. animationTimeouts.push(hideTimeout);
  588. }
  589.  
  590. tabs[key].active = isActive;
  591. });
  592.  
  593. currentTab = tabKey;
  594. try {
  595. sessionStorage.setItem('lastActiveTab', tabKey);
  596. } catch (e) {
  597. console.warn('Failed to save tab state to sessionStorage', e);
  598. }
  599. }
  600.  
  601. const handleTabClick = (e) => {
  602. const tabKey = e.target === exportTab ? 'export' : 'settings';
  603. if (!tabs[tabKey].active) {
  604. switchTab(tabKey);
  605. }
  606. };
  607.  
  608. exportTab.onclick = handleTabClick;
  609. settingsTab.onclick = handleTabClick;
  610.  
  611. Object.entries(tabs).forEach(([key, { content }]) => {
  612. const isActive = key === currentTab;
  613.  
  614. content.style.display = isActive ? 'block' : 'none';
  615. content.style.opacity = isActive ? '1' : '0';
  616. content.style.transform = isActive ? 'scale(1)' : 'scale(0.95)';
  617. });
  618.  
  619. switchTab(currentTab);
  620. document.body.appendChild(gui);
  621.  
  622. void gui.offsetWidth;
  623.  
  624. requestAnimationFrame(() => {
  625. gui.style.opacity = '1';
  626. gui.style.transform = 'translate(-50%, -50%) scale(1)';
  627. });
  628.  
  629. document.addEventListener('click', handleDialogOutsideClick);
  630. }
  631.  
  632. function toggleUIState() {
  633. animationTimeouts.forEach(timeoutId => clearTimeout(timeoutId));
  634. animationTimeouts = [];
  635.  
  636. if (guiElement && document.body.contains(guiElement)) {
  637. if (viewActive) {
  638. guiElement.style.display = 'flex';
  639.  
  640. requestAnimationFrame(() => {
  641. guiElement.style.opacity = '1';
  642. guiElement.style.transform = 'translate(-50%, -50%) scale(1)';
  643. });
  644. } else {
  645. requestAnimationFrame(() => {
  646. guiElement.style.opacity = '0';
  647. guiElement.style.transform = 'translate(-50%, -50%) scale(0.95)';
  648. });
  649.  
  650. const removeTimeout = setTimeout(() => {
  651. if (!viewActive && guiElement && document.body.contains(guiElement)) {
  652. document.body.removeChild(guiElement);
  653. document.removeEventListener('click', handleDialogOutsideClick);
  654. guiElement = null;
  655. }
  656. }, ANIMATION_DURATION);
  657. animationTimeouts.push(removeTimeout);
  658. }
  659. } else if (viewActive) {
  660. createUI();
  661. }
  662. }
  663.  
  664. function openV() {
  665. viewActive = true;
  666. toggleUIState();
  667. }
  668.  
  669. function closeV() {
  670. viewActive = false;
  671. toggleUIState();
  672. }
  673.  
  674. function handleDialogOutsideClick(e) {
  675. const gui = document.getElementById('char-export-gui');
  676. if (gui && !gui.contains(e.target)) {
  677. closeV();
  678. }
  679. }
  680.  
  681. /* ============================
  682. == INTERCEPTORS ==
  683. ============================ */
  684. function interceptNetwork() {
  685. if (networkInterceptActive) return;
  686. networkInterceptActive = true;
  687.  
  688. const origXHR = XMLHttpRequest.prototype.open;
  689. XMLHttpRequest.prototype.open = function(method, url) {
  690. this.addEventListener('load', () => {
  691. if (url.includes('generateAlpha')) modifyResponse(this.responseText);
  692. if (url.includes('/hampter/chats/')) modifyChatResponse(this.responseText);
  693. });
  694. return origXHR.apply(this, arguments);
  695. };
  696.  
  697. const origFetch = window.fetch;
  698. window.fetch = function(input, init) {
  699. const url = typeof input === 'string' ? input : input?.url;
  700.  
  701. if (url && (url.includes('skibidi.com') || url.includes('proxy'))) {
  702. if (shouldInterceptNext && exportFormat) {
  703. setTimeout(() => modifyResponse('{}'), 300);
  704. return Promise.resolve(new Response('{}'));
  705. }
  706. return Promise.resolve(new Response(JSON.stringify({ error: 'Service unavailable' })));
  707. }
  708.  
  709. try {
  710. return origFetch.apply(this, arguments).then(res => {
  711. if (res.url?.includes('generateAlpha')) res.clone().text().then(modifyResponse);
  712. if (res.url?.includes('/hampter/chats/')) res.clone().text().then(modifyChatResponse);
  713. return res;
  714. });
  715. } catch(e) {
  716. return Promise.resolve(new Response('{}'));
  717. }
  718. };
  719. }
  720.  
  721. function modifyResponse(text) {
  722. if (!shouldInterceptNext) return;
  723. shouldInterceptNext = false;
  724. try {
  725. const json = JSON.parse(text);
  726. const sys = json.messages.find(m => m.role === 'system')?.content || '';
  727. let initMsg = '';
  728. if (chatData?.chatMessages?.length) {
  729. const msgs = chatData.chatMessages;
  730. initMsg = msgs[msgs.length - 1].message;
  731. }
  732. const header = document.querySelector('p.chakra-text.css-1nj33dt');
  733. let charName = chatData?.character?.chat_name || header?.textContent.match(/Chat with\s+(.*)$/)?.[1]?.trim() || 'char';
  734. const charBlock = sys.match(new RegExp(`<${charName}>([\\s\\S]*?)<\\/${charName}>`, 'i'))?.[1]?.trim() || '';
  735. const scen = sys.match(/<scenario>([\s\S]*?)<\/scenario>/i)?.[1]?.trim() || '';
  736. const exs = sys.match(/<example_dialogs>([\s\S]*?)<\/example_dialogs>/i)?.[1]?.trim() || '';
  737. const userName = [...document.querySelectorAll('div.css-16u3s6f')]
  738. .map(e => e.textContent.trim())
  739. .find(n => n && n !== charName) || '';
  740. switch (exportFormat) {
  741. case 'txt': {
  742. saveAsTxt(charBlock, scen, initMsg, exs, charName, userName);
  743. break;
  744. }
  745. case 'png': {
  746. saveAsPng(charName, charBlock, scen, initMsg, exs, userName);
  747. break;
  748. }
  749. case 'json': {
  750. saveAsJson(charName, charBlock, scen, initMsg, exs, userName);
  751. break;
  752. }
  753. }
  754. exportFormat = null;
  755. } catch (err) {
  756. console.error('Error processing response:', err);
  757. }
  758. }
  759.  
  760. function modifyChatResponse(text) {
  761. try {
  762. if (!text || typeof text !== 'string' || !text.trim()) return;
  763.  
  764. const data = JSON.parse(text);
  765. if (data && data.character) {
  766. chatData = data;
  767. }
  768. } catch (err) {
  769. // ignore parsing errors
  770. }
  771. }
  772.  
  773. /* ============================
  774. == CORE LOGIC ==
  775. ============================ */
  776. async function getCharacterMeta() {
  777. const charId = chatData?.character?.id;
  778. if (!charId) return { creatorUrl: '', characterVersion: '', characterCardUrl: '', name: '', creatorNotes: '' };
  779. if (characterMetaCache.id === charId) {
  780. return {
  781. creatorUrl: characterMetaCache.creatorUrl,
  782. characterVersion: characterMetaCache.characterVersion,
  783. characterCardUrl: characterMetaCache.characterCardUrl,
  784. name: characterMetaCache.name,
  785. creatorNotes: characterMetaCache.creatorNotes
  786. };
  787. }
  788. let creatorUrl = '',
  789. characterCardUrl = `https://janitorai.com/characters/${charId}`,
  790. characterVersion = characterCardUrl,
  791. name = chatData?.character?.name?.trim() || '',
  792. creatorNotes = chatData?.character?.description?.trim() || '';
  793. try {
  794. const response = await fetch(characterCardUrl);
  795. const html = await response.text();
  796. const doc = new DOMParser().parseFromString(html, 'text/html');
  797. const link = doc.querySelector('a.chakra-link.css-15sl5jl');
  798. if (link) {
  799. const href = link.getAttribute('href');
  800. if (href) creatorUrl = `https://janitorai.com${href}`;
  801. }
  802. } catch (err) { console.error('Error fetching creator URL:', err); }
  803. if (chatData?.character?.chat_name) characterVersion += `\nChat Name: ${chatData.character.chat_name.trim()}`;
  804. characterMetaCache.id = charId;
  805. characterMetaCache.creatorUrl = creatorUrl;
  806. characterMetaCache.characterVersion = characterVersion;
  807. characterMetaCache.characterCardUrl = characterCardUrl;
  808. characterMetaCache.name = name;
  809. characterMetaCache.creatorNotes = creatorNotes;
  810. return { creatorUrl, characterVersion, characterCardUrl, name, creatorNotes };
  811. }
  812.  
  813. async function buildTemplate(charBlock, scen, initMsg, exs) {
  814. const sections = [];
  815. const { creatorUrl, characterCardUrl } = await getCharacterMeta();
  816. const realName = chatData.character.name.trim();
  817. sections.push(`==== Name ====\n${realName}`);
  818. const chatName = (chatData.character.chat_name || realName).trim();
  819. sections.push(`==== Chat Name ====\n${chatName}`);
  820. if (charBlock) sections.push(`==== Description ====\n${charBlock.trim()}`);
  821. if (scen) sections.push(`==== Scenario ====\n${scen.trim()}`);
  822. if (initMsg) sections.push(`==== Initial Message ====\n${initMsg.trim()}`);
  823. if (exs) sections.push(`==== Example Dialogs ====\n ${exs.trim()}`);
  824. sections.push(`==== Character Card ====\n${characterCardUrl}`);
  825. sections.push(`==== Creator ====\n${creatorUrl}`);
  826. return sections.join('\n\n');
  827. }
  828.  
  829. function tokenizeNames(text, charName, userName) {
  830. const parts = text.split('\n\n');
  831. const [cRx,uRx] = [charName,userName].map(n=>n?escapeRegExp(n):'');
  832. for (let i = 0, l = parts.length; i < l; ++i) {
  833. if (!/^==== (Name|Chat Name|Initial Message|Character Card|Creator) ====/.test(parts[i])) {
  834. parts[i] = parts[i]
  835. .replace(new RegExp(`(?<!\\w)${cRx}(?!\\w)`,'g'),'{{char}}')
  836. .replace(new RegExp(`(?<!\\w)${uRx}(?!\\w)`,'g'),'{{user}}');
  837. }
  838. }
  839. return parts.join('\n\n');
  840. }
  841.  
  842. function tokenizeField(text, charName, userName) {
  843. if (!text || !charName) return text;
  844.  
  845. const [charRegex, userRegex] = [charName, userName].map(n => n ? escapeRegExp(n.replace(/'$/, '')) : '');
  846.  
  847. let result = text;
  848.  
  849. if (charRegex) {
  850. result = result
  851. .replace(new RegExp(`(?<!\\w)${charRegex}'s(?!\\w)`, 'g'), "{{char}}'s")
  852. .replace(new RegExp(`(?<!\\w)${charRegex}'(?!\\w)`, 'g'), "{{char}}'")
  853. .replace(new RegExp(`(?<![\\w'])${charRegex}(?![\\w'])`, 'g'), "{{char}}");
  854. }
  855.  
  856. if (userRegex) {
  857. result = result
  858. .replace(new RegExp(`(?<!\\w)${userRegex}'s(?!\\w)`, 'g'), "{{user}}'s")
  859. .replace(new RegExp(`(?<!\\w)${userRegex}'(?!\\w)`, 'g'), "{{user}}'")
  860. .replace(new RegExp(`(?<![\\w'])${userRegex}(?![\\w'])`, 'g'), "{{user}}");
  861. }
  862.  
  863. return result;
  864. }
  865.  
  866. function extraction() {
  867. if (!exportFormat) return;
  868. shouldInterceptNext = true;
  869. interceptNetwork();
  870. callApi();
  871. }
  872.  
  873. function callApi() {
  874. try {
  875. const textarea = document.querySelector('textarea');
  876. if (!textarea) return;
  877.  
  878. Object.getOwnPropertyDescriptor(HTMLTextAreaElement.prototype, 'value')
  879. .set.call(textarea, 'extract-char');
  880. textarea.dispatchEvent(new Event('input', { bubbles: true }));
  881.  
  882. ['keydown', 'keyup'].forEach(type =>
  883. textarea.dispatchEvent(new KeyboardEvent(type, { key: 'Enter', code: 'Enter', bubbles: true }))
  884. );
  885. } catch (err) {
  886. // ignore errors
  887. }
  888. }
  889.  
  890. /* ============================
  891. == CHARA CARD V2 ==
  892. ============================ */
  893. async function buildCharaCardV2(charName, charBlock, scen, initMsg, exs, userName) {
  894. const { creatorUrl, characterVersion, name, creatorNotes } = await getCharacterMeta();
  895. const tokenizedDesc = tokenizeField(charBlock, charName, userName);
  896. const tokenizedScen = tokenizeField(scen, charName, userName);
  897. const tokenizedExs = tokenizeField(exs, charName, userName);
  898.  
  899. let displayName = name;
  900. let versionText = characterVersion;
  901.  
  902. if (useChatNameForName && chatData?.character?.chat_name) {
  903. displayName = chatData.character.chat_name.trim();
  904. versionText = `${characterVersion}\nName: ${name}`;
  905. }
  906.  
  907. return {
  908. spec: "chara_card_v2",
  909. spec_version: "2.0",
  910. data: {
  911. name: displayName,
  912. description: tokenizedDesc.trim(),
  913. personality: "",
  914. scenario: tokenizedScen.trim(),
  915. first_mes: initMsg.trim(),
  916. mes_example: tokenizedExs.trim(),
  917. creator_notes: creatorNotes,
  918. system_prompt: "",
  919. post_history_instructions: "",
  920. alternate_greetings: [],
  921. character_book: null,
  922. tags: [],
  923. creator: creatorUrl,
  924. character_version: versionText,
  925. extensions: {}
  926. }
  927. };
  928. }
  929.  
  930. /* ============================
  931. == EXPORTERS ==
  932. ============================ */
  933. async function saveAsTxt(charBlock, scen, initMsg, exs, charName, userName) {
  934. const template = await buildTemplate(charBlock, scen, initMsg, exs);
  935. const tokenized = tokenizeNames(template, charName, userName);
  936. const rawName = chatData.character.name || chatData.character.chat_name || 'card';
  937. const fileName = rawName.trim() || 'card';
  938. saveFile(
  939. `${fileName}.txt`,
  940. new Blob([tokenized], { type: 'text/plain' })
  941. );
  942. }
  943.  
  944. async function saveAsJson(charName, charBlock, scen, initMsg, exs, userName) {
  945. const jsonData = await buildCharaCardV2(charName, charBlock, scen, initMsg, exs, userName);
  946.  
  947. const rawName = chatData.character.name || chatData.character.chat_name || 'card';
  948. const fileName = rawName.trim() || 'card';
  949. saveFile(
  950. `${fileName}.json`,
  951. new Blob([JSON.stringify(jsonData, null, 2)], { type: 'application/json' })
  952. );
  953. }
  954.  
  955. async function saveAsPng(charName, charBlock, scen, initMsg, exs, userName) {
  956. try {
  957. const avatarImg = document.querySelector('img.chakra-image.css-i9mtpv');
  958. if (!avatarImg) {
  959. alert('Character avatar not found');
  960. return;
  961. }
  962.  
  963. const cardData = await buildCharaCardV2(charName, charBlock, scen, initMsg, exs, userName);
  964.  
  965. const avatarResponse = await fetch(avatarImg.src);
  966. const avatarBlob = await avatarResponse.blob();
  967.  
  968. const img = new Image();
  969. img.onload = () => {
  970. const canvas = document.createElement('canvas');
  971. canvas.width = img.width;
  972. canvas.height = img.height;
  973.  
  974. const ctx = canvas.getContext('2d');
  975. ctx.drawImage(img, 0, 0);
  976.  
  977. canvas.toBlob(async (blob) => {
  978. try {
  979. const arrayBuffer = await blob.arrayBuffer();
  980. const pngData = new Uint8Array(arrayBuffer);
  981.  
  982. const jsonString = JSON.stringify(cardData);
  983.  
  984. const base64Data = btoa(unescape(encodeURIComponent(jsonString)));
  985.  
  986. const keyword = "chara";
  987. const keywordBytes = new TextEncoder().encode(keyword);
  988. const nullByte = new Uint8Array([0]);
  989. const textBytes = new TextEncoder().encode(base64Data);
  990.  
  991. const chunkType = new Uint8Array([116, 69, 88, 116]); // "tEXt" in ASCII
  992. const dataLength = keywordBytes.length + nullByte.length + textBytes.length;
  993.  
  994. const lengthBytes = new Uint8Array(4);
  995. lengthBytes[0] = (dataLength >>> 24) & 0xFF;
  996. lengthBytes[1] = (dataLength >>> 16) & 0xFF;
  997. lengthBytes[2] = (dataLength >>> 8) & 0xFF;
  998. lengthBytes[3] = dataLength & 0xFF;
  999.  
  1000. const crcData = new Uint8Array(chunkType.length + keywordBytes.length + nullByte.length + textBytes.length);
  1001. crcData.set(chunkType, 0);
  1002. crcData.set(keywordBytes, chunkType.length);
  1003. crcData.set(nullByte, chunkType.length + keywordBytes.length);
  1004. crcData.set(textBytes, chunkType.length + keywordBytes.length + nullByte.length);
  1005.  
  1006. const crc = computeCrc32(crcData, 0, crcData.length);
  1007. const crcBytes = new Uint8Array(4);
  1008. crcBytes[0] = (crc >>> 24) & 0xFF;
  1009. crcBytes[1] = (crc >>> 16) & 0xFF;
  1010. crcBytes[2] = (crc >>> 8) & 0xFF;
  1011. crcBytes[3] = crc & 0xFF;
  1012.  
  1013. let pos = 8; // Skip PNG signature
  1014. while (pos < pngData.length - 12) {
  1015. const length = pngData[pos] << 24 | pngData[pos + 1] << 16 |
  1016. pngData[pos + 2] << 8 | pngData[pos + 3];
  1017.  
  1018. const type = String.fromCharCode(
  1019. pngData[pos + 4], pngData[pos + 5],
  1020. pngData[pos + 6], pngData[pos + 7]
  1021. );
  1022.  
  1023. if (type === 'IEND') break;
  1024. pos += 12 + length; // 4 (length) + 4 (type) + length + 4 (CRC)
  1025. }
  1026.  
  1027. const finalSize = pngData.length + lengthBytes.length + chunkType.length + dataLength + crcBytes.length;
  1028. const finalPNG = new Uint8Array(finalSize);
  1029.  
  1030. finalPNG.set(pngData.subarray(0, pos));
  1031. let writePos = pos;
  1032.  
  1033. finalPNG.set(lengthBytes, writePos); writePos += lengthBytes.length;
  1034. finalPNG.set(chunkType, writePos); writePos += chunkType.length;
  1035. finalPNG.set(keywordBytes, writePos); writePos += keywordBytes.length;
  1036. finalPNG.set(nullByte, writePos); writePos += nullByte.length;
  1037. finalPNG.set(textBytes, writePos); writePos += textBytes.length;
  1038. finalPNG.set(crcBytes, writePos); writePos += crcBytes.length;
  1039.  
  1040. finalPNG.set(pngData.subarray(pos), writePos);
  1041.  
  1042. const rawName = chatData.character.name || chatData.character.chat_name || 'card';
  1043. const fileName = rawName.trim() || 'card';
  1044. saveFile(
  1045. `${fileName}.png`,
  1046. new Blob([finalPNG], { type: 'image/png' })
  1047. );
  1048.  
  1049. console.log("Character card created successfully!");
  1050. } catch (err) {
  1051. console.error('Error creating PNG:', err);
  1052. alert('Failed to create PNG: ' + err.message);
  1053. }
  1054. }, 'image/png');
  1055. };
  1056.  
  1057. img.src = URL.createObjectURL(avatarBlob);
  1058. } catch (err) {
  1059. console.error('Error creating PNG:', err);
  1060. alert('Failed to create PNG: ' + err.message);
  1061. }
  1062. }
  1063.  
  1064. function computeCrc32(data, start, length) {
  1065. let crc = 0xFFFFFFFF;
  1066.  
  1067. for (let i = 0; i < length; i++) {
  1068. const byte = data[start + i];
  1069. crc = (crc >>> 8) ^ crc32Table[(crc ^ byte) & 0xFF];
  1070. }
  1071.  
  1072. return ~crc >>> 0; // Invert and cast to unsigned 32-bit
  1073. }
  1074.  
  1075. const crc32Table = (() => {
  1076. const table = new Uint32Array(256);
  1077.  
  1078. for (let i = 0; i < 256; i++) {
  1079. let crc = i;
  1080. for (let j = 0; j < 8; j++) {
  1081. crc = (crc & 1) ? 0xEDB88320 ^ (crc >>> 1) : crc >>> 1;
  1082. }
  1083. table[i] = crc;
  1084. }
  1085.  
  1086. return table;
  1087. })();
  1088.  
  1089. /* ============================
  1090. == ROUTING ==
  1091. ============================ */
  1092. function inChats() {
  1093. const isInChat = /^\/chats\/\d+/.test(window.location.pathname);
  1094. return isInChat;
  1095. }
  1096.  
  1097. function initialize() {
  1098. if (hasInitialized || !inChats()) return;
  1099. hasInitialized = true;
  1100. shouldInterceptNext = false;
  1101. networkInterceptActive = false;
  1102. exportFormat = null;
  1103. chatData = null;
  1104.  
  1105. document.removeEventListener('keydown', handleKeyDown);
  1106. document.addEventListener('keydown', handleKeyDown);
  1107.  
  1108. interceptNetwork();
  1109. }
  1110.  
  1111. function handleKeyDown(e) {
  1112. if (!inChats()) return;
  1113. if (e.key.toLowerCase() !== 't' || e.ctrlKey || e.metaKey || e.altKey || e.shiftKey) return;
  1114. if (['INPUT', 'TEXTAREA'].includes(document.activeElement.tagName) || document.activeElement.isContentEditable) return;
  1115.  
  1116. const proxyAllowed = chatData?.character?.allow_proxy;
  1117. if (!proxyAllowed) {
  1118. if (chatData?.character != null) {
  1119. alert('Proxy disabled — extraction aborted.');
  1120. }
  1121. return;
  1122. }
  1123.  
  1124. viewActive = !viewActive;
  1125. toggleUIState();
  1126. }
  1127.  
  1128. function cleanup() {
  1129. hasInitialized = false;
  1130. const gui = document.getElementById('char-export-gui');
  1131. if (gui) gui.remove();
  1132. document.removeEventListener('keydown', handleKeyDown);
  1133. viewActive = false;
  1134. animationTimeouts.forEach(timeoutId => clearTimeout(timeoutId));
  1135. animationTimeouts = [];
  1136. }
  1137.  
  1138. function handleRoute() {
  1139. if (inChats()) {
  1140. initialize();
  1141. } else {
  1142. cleanup();
  1143. }
  1144. }
  1145.  
  1146. /* ============================
  1147. == ENTRYPOINT ==
  1148. ============================ */
  1149. window.addEventListener('load', () => {
  1150. handleRoute();
  1151. }, { once: true });
  1152. window.addEventListener('popstate', () => {
  1153. handleRoute();
  1154. });
  1155.  
  1156. ['pushState', 'replaceState'].forEach(fn => {
  1157. const orig = history[fn];
  1158. history[fn] = function(...args) {
  1159. const res = orig.apply(this, args);
  1160. setTimeout(handleRoute, 50);
  1161. return res;
  1162. };
  1163. });
  1164. })();
Add Comment
Please, Sign In to add comment