JanitorAI Ultimate Toolkit

Universal backup, auto-import, Retention Index sorting for main & search pages with fixed Anti-Bot (>100 msgs/chat), and exact Tag Excluder.

スクリプトをインストールするには、Tampermonkey, GreasemonkeyViolentmonkey のような拡張機能のインストールが必要です。

You will need to install an extension such as Tampermonkey to install this script.

スクリプトをインストールするには、TampermonkeyViolentmonkey のような拡張機能のインストールが必要です。

スクリプトをインストールするには、TampermonkeyUserscripts のような拡張機能のインストールが必要です。

このスクリプトをインストールするには、Tampermonkeyなどの拡張機能をインストールする必要があります。

このスクリプトをインストールするには、ユーザースクリプト管理ツールの拡張機能をインストールする必要があります。

(ユーザースクリプト管理ツールは設定済みなのでインストール!)

このスタイルをインストールするには、Stylusなどの拡張機能をインストールする必要があります。

このスタイルをインストールするには、Stylus などの拡張機能をインストールする必要があります。

このスタイルをインストールするには、Stylus tなどの拡張機能をインストールする必要があります。

このスタイルをインストールするには、ユーザースタイル管理用の拡張機能をインストールする必要があります。

このスタイルをインストールするには、ユーザースタイル管理用の拡張機能をインストールする必要があります。

このスタイルをインストールするには、ユーザースタイル管理用の拡張機能をインストールする必要があります。

(ユーザースタイル管理ツールは設定済みなのでインストール!)

このスクリプトの質問や評価の投稿はこちら通報はこちらへお寄せください
// ==UserScript==
// @name         JanitorAI Ultimate Toolkit
// @namespace    http://tampermonkey.net/
// @version      3.3
// @description  Universal backup, auto-import, Retention Index sorting for main & search pages with fixed Anti-Bot (>100 msgs/chat), and exact Tag Excluder.
// @author       CoolGopnik
// @license      MIT
// @match        https://janitorai.com/*
// @match        https://*.janitorai.com/*
// @grant        GM_download
// @grant        GM_xmlhttpRequest
// ==/UserScript==

(function() {
    'use strict';

    const delay = ms => new Promise(resolve => setTimeout(resolve, ms));
    const retentionCache = new Map();

    // Helper: Parse human-readable numbers ("1.5m" -> 1500000, "43k" -> 43000)
    function parseFormattedNumber(str) {
        if (!str) return 0;
        str = String(str).trim().toLowerCase().replace(/,/g, '');
        let multiplier = 1;
        if (str.endsWith('m')) {
            multiplier = 1000000;
            str = str.slice(0, -1);
        } else if (str.endsWith('k')) {
            multiplier = 1000;
            str = str.slice(0, -1);
        } else if (str.endsWith('b')) {
            multiplier = 1000000000;
            str = str.slice(0, -1);
        }
        const num = parseFloat(str);
        return isNaN(num) ? 0 : num * multiplier;
    }

    // Helper: Safely normalize tag strings
    function normalizeTag(tag) {
        if (!tag || typeof tag !== 'string') return '';
        return tag
            .toLowerCase()
            .replace(/^#/, '')
            .replace(/[\u{1F600}-\u{1F64F}\u{1F300}-\u{1F5FF}\u{1F680}-\u{1F6FF}\u{1F700}-\u{1F77F}\u{1F780}-\u{1F7FF}\u{1F800}-\u{1F8FF}\u{1F900}-\u{1F9FF}\u{1FA00}-\u{1FA6F}\u{1FA70}-\u{1FAFF}\u{2600}-\u{26FF}\u{2700}-\u{27BF}]/gu, '')
            .trim();
    }

    // Check if current page is in blacklisted utility pages
    function isBlacklistedPage() {
        const path = window.location.pathname;
        const blacklistedPaths = [
            '/my_chats',
            '/my_personas',
            '/profile-settings',
            '/media-library',
            '/edit_character'
        ];
        return blacklistedPaths.some(p => path.startsWith(p));
    }

    // Check if current page is Lorebooks / Scripts
    function isLorebookPage() {
        const path = window.location.pathname;
        return path.startsWith('/scripts') || path.includes('/lorebooks') || !!document.querySelector('._scriptsFullscreenContainer_1s6vr_1');
    }

    // --- REAL-TIME TAG EXCLUDER / BLACKLIST ---
    function applyTagFilter() {
        if (isBlacklistedPage() || isLorebookPage()) return;

        const rawExcluded = localStorage.getItem('jn_excluded_tags') || '';
        if (!rawExcluded.trim()) {
            document.querySelectorAll('div[role="group"].profile-character-card-wrapper').forEach(card => {
                if (card.dataset.hiddenByTag === 'true') {
                    card.style.display = '';
                    delete card.dataset.hiddenByTag;
                }
            });
            return;
        }

        const excludedTags = rawExcluded
            .split(',')
            .map(t => normalizeTag(t))
            .filter(t => t.length > 0);

        if (excludedTags.length === 0) return;

        const cards = document.querySelectorAll('div[role="group"].profile-character-card-wrapper');

        cards.forEach(card => {
            const tagElements = card.querySelectorAll('.pp-cc-tags-item, [class*="pp-tag-"]');
            const cardTags = Array.from(tagElements)
                .map(el => normalizeTag(el.textContent))
                .filter(t => t.length > 0);

            const shouldExclude = excludedTags.some(exTag =>
                cardTags.some(cardTag => cardTag === exTag)
            );

            if (shouldExclude) {
                card.style.display = 'none';
                card.dataset.hiddenByTag = 'true';
            } else if (card.dataset.hiddenByTag === 'true') {
                card.style.display = '';
                delete card.dataset.hiddenByTag;
            }
        });
    }

    // Render badge on card UI
    function renderCardBadge(card, score, isBotted = false) {
        let badge = card.querySelector('.jn-retention-badge');
        if (!badge) {
            badge = document.createElement('div');
            badge.className = 'jn-retention-badge';
            badge.style.cssText = `
                position: absolute;
                top: 8px;
                right: 8px;
                color: #ffffff;
                font-size: 11px;
                font-weight: bold;
                padding: 3px 8px;
                border-radius: 6px;
                z-index: 10;
                box-shadow: 0 2px 6px rgba(0,0,0,0.5);
                backdrop-filter: blur(4px);
            `;
            card.style.position = 'relative';
            card.appendChild(badge);
        }

        if (isBotted) {
            badge.style.background = 'rgba(229, 62, 62, 0.95)';
            badge.textContent = `⚠️ Botted`;
        } else {
            badge.style.background = 'rgba(107, 70, 193, 0.92)';
            badge.textContent = `🔥 ${score.toFixed(2)}`;
        }
    }

    function createStyledButton(id, text, onClick) {
        const btn = document.createElement('button');
        btn.id = id;
        btn.textContent = text;
        btn.style.padding = '10px 16px';
        btn.style.backgroundColor = '#6b46c1';
        btn.style.color = '#ffffff';
        btn.style.border = 'none';
        btn.style.borderRadius = '8px';
        btn.style.cursor = 'pointer';
        btn.style.fontWeight = 'bold';
        btn.style.fontSize = '13px';
        btn.style.boxShadow = '0 4px 12px rgba(0, 0, 0, 0.4)';
        btn.style.transition = 'background-color 0.2s ease';

        btn.onmouseover = function() { if (!btn.disabled) btn.style.backgroundColor = '#553c9a'; };
        btn.onmouseout = function() { if (!btn.disabled) btn.style.backgroundColor = '#6b46c1'; };
        btn.onclick = onClick;

        return btn;
    }

    // Inject "Sort by Retention" tab
    function injectFilterBarTab() {
        const path = window.location.pathname;
        const shouldHideRetention = isBlacklistedPage() || isLorebookPage() || path.startsWith('/characters/') || path.startsWith('/create_character');

        const existingTab = document.getElementById('jn-retention-tab');
        if (shouldHideRetention) {
            if (existingTab) existingTab.remove();
            return;
        }

        if (existingTab) return;

        const targetContainer = document.querySelector('div[role="tablist"].jn-filter-bar') ||
                                document.querySelector('.css-hgbogc') ||
                                document.querySelector('div[role="tablist"]');

        if (!targetContainer) return;

        const retentionBtn = document.createElement('button');
        retentionBtn.id = 'jn-retention-tab';
        retentionBtn.type = 'button';
        retentionBtn.role = 'tab';
        retentionBtn.className = '_tab_1os0o_34 jn-filter-bar__tab';
        retentionBtn.style.cssText = `
            background-color: #805ad5;
            color: #ffffff;
            font-weight: bold;
            padding: 6px 12px;
            border: none;
            border-radius: 6px;
            cursor: pointer;
            font-size: 13px;
            display: inline-flex;
            align-items: center;
            gap: 4px;
            margin-left: 8px;
            box-shadow: 0 2px 6px rgba(0, 0, 0, 0.3);
            transition: background-color 0.2s ease;
        `;

        retentionBtn.innerHTML = `<span class="_icon_1os0o_88 jn-filter-bar__icon">🔥</span>Sort by Retention`;
        retentionBtn.onclick = runRetentionSort;

        targetContainer.appendChild(retentionBtn);
    }

    function injectUI() {
        injectFilterBarTab();
        applyTagFilter();

        const path = window.location.pathname;
        const isSingleCharacter = path.startsWith('/characters/');
        const isCreatePage = path.startsWith('/create_character');
        const isSearchPage = path.startsWith('/search');
        const isLorebook = isLorebookPage();
        const isBlacklisted = isBlacklistedPage();

        const existingContainer = document.getElementById('ai-backup-container');

        if (isBlacklisted) {
            if (existingContainer) existingContainer.remove();
            return;
        }

        if (existingContainer && document.body.contains(existingContainer)) return;

        const container = document.createElement('div');
        container.id = 'ai-backup-container';
        container.style.position = 'fixed';
        container.style.bottom = '20px';
        container.style.right = '20px';
        container.style.zIndex = '99999';
        container.style.display = 'flex';
        container.style.flexDirection = 'column';
        container.style.gap = '8px';

        const showTagFilterBox = !isSingleCharacter && !isCreatePage && !isLorebook;

        if (showTagFilterBox) {
            const tagFilterBox = document.createElement('div');
            tagFilterBox.style.cssText = `
                background: #1a202c;
                padding: 10px 14px;
                border-radius: 8px;
                box-shadow: 0 4px 12px rgba(0,0,0,0.5);
                display: flex;
                flex-direction: column;
                gap: 4px;
                border: 1px solid #4a5568;
            `;

            const savedTags = localStorage.getItem('jn_excluded_tags') || '';
            tagFilterBox.innerHTML = `
                <label style="font-size: 11px; font-weight: bold; color: #cbd5e1; font-family: sans-serif;">
                    🚫 Exclude Tags (comma-separated):
                </label>
                <input type="text" id="jn-tag-filter-input" value="${savedTags}" placeholder="e.g. Dead Dove, ntr, Male" style="
                    padding: 6px 10px;
                    background: #2d3748;
                    color: #ffffff;
                    border: 1px solid #4a5568;
                    border-radius: 6px;
                    font-size: 12px;
                    outline: none;
                ">
            `;
            container.appendChild(tagFilterBox);
        }

        if (isCreatePage) {
            const btnImport = createStyledButton('ai-btn-import', '📤 Import Character', openImportModal);
            container.appendChild(btnImport);
        } else if (isSingleCharacter) {
            const btn = createStyledButton('ai-btn-single-char', '📥 Save Character Data', runSingleCharacterBackup);
            container.appendChild(btn);
        } else if (isLorebook) {
            const btnActive = createStyledButton('ai-btn-single-lorebook', '📖 Export Active Lorebook', runSingleLorebookBackup);
            const btnBulk = createStyledButton('ai-btn-bulk-lorebook', '⚡ Bulk Export All Lorebooks', runBulkLorebookBackup);
            container.appendChild(btnActive);
            container.appendChild(btnBulk);
        } else if (!isSearchPage) {
            const btn = createStyledButton('ai-btn-bulk-bots', '⚡ Bulk Backup All Bots', runBulkBackup);
            container.appendChild(btn);
        }

        document.body.appendChild(container);

        const inputEl = document.getElementById('jn-tag-filter-input');
        if (inputEl) {
            inputEl.oninput = function() {
                localStorage.setItem('jn_excluded_tags', inputEl.value);
                applyTagFilter();
            };
        }
    }

    // Helper: Extract stats safely from Next.js JSON
    function extractStatsFromNextData(doc) {
        const nextDataEl = doc.getElementById('__NEXT_DATA__');
        if (!nextDataEl) return null;
        try {
            const json = JSON.parse(nextDataEl.textContent);
            const props = json?.props?.pageProps;

            let char = props?.character || props?.dehydratedState?.queries?.[0]?.state?.data;
            if (!char && props?.dehydratedState?.queries) {
                for (const q of props.dehydratedState.queries) {
                    if (q?.state?.data?.num_messages || q?.state?.data?.stats?.num_messages) {
                        char = q.state.data;
                        break;
                    }
                }
            }

            if (char) {
                const favs = char.stats?.num_favs ?? char.num_favs ?? char.favs_count ?? 0;
                const chats = char.stats?.num_chats ?? char.num_chats ?? char.chat_count ?? 0;
                const msgs = char.stats?.num_messages ?? char.num_messages ?? char.message_count ?? 0;
                return {
                    likes: parseFormattedNumber(favs),
                    chats: parseFormattedNumber(chats),
                    msgs: parseFormattedNumber(msgs)
                };
            }
        } catch (e) {
            console.warn('NextData JSON parse error:', e);
        }
        return null;
    }

    // --- ACCURATE RETENTION CALCULATION WITH SAFE FALLBACKS ---
    async function fetchBotScore(card, url) {
        if (retentionCache.has(url)) {
            const cached = retentionCache.get(url);
            renderCardBadge(card, cached.score, cached.isBotted);
            return { element: card, score: cached.score };
        }

        try {
            const response = await fetch(url);
            const htmlText = await response.text();

            const parser = new DOMParser();
            const doc = parser.parseFromString(htmlText, 'text/html');

            // 1. Guaranteed chats count from preview card
            const cardChatsEl = card.querySelector('.pp-cc-chats-count');
            const cardChatsCount = parseFormattedNumber(cardChatsEl ? cardChatsEl.textContent : '0');

            let likesCount = 0;
            let chatsCount = 0;
            let messagesCount = 0;

            // Strategy A: Next.js JSON Extraction
            const nextData = extractStatsFromNextData(doc);
            if (nextData && (nextData.chats > 0 || nextData.msgs > 0)) {
                likesCount = nextData.likes;
                chatsCount = nextData.chats;
                messagesCount = nextData.msgs;
            } else {
                // Strategy B: DOM Extraction
                const favContainer = doc.querySelector('div[class*="_container_138g0_"]') || doc.querySelector('button[title="favorite"]')?.parentElement;
                if (favContainer) {
                    likesCount = parseFormattedNumber(favContainer.textContent);
                }

                const ribbonContainer = doc.querySelector('.character-chat-messages-stat-ribbon-tag-hstack') || 
                                        doc.querySelector('[class*="character-chat-messages-stat-ribbon-tag-hstack"]');

                if (ribbonContainer) {
                    const statParagraphs = ribbonContainer.querySelectorAll('p');
                    const parsedNums = Array.from(statParagraphs).map(p => parseFormattedNumber(p.textContent)).filter(n => n > 0);

                    if (parsedNums.length >= 2) {
                        chatsCount = Math.min(parsedNums[0], parsedNums[1]);
                        messagesCount = Math.max(parsedNums[0], parsedNums[1]);
                    } else if (parsedNums.length === 1) {
                        messagesCount = parsedNums[0];
                    }
                }
            }

            // Fallback: If chats were not parsed from page, use preview card chats count
            if (chatsCount <= 0 && cardChatsCount > 0) {
                chatsCount = cardChatsCount;
            }

            // Absolute mathematical check: Messages MUST be >= Chats
            if (messagesCount > 0 && chatsCount > 0 && messagesCount < chatsCount) {
                const temp = messagesCount;
                messagesCount = chatsCount;
                chatsCount = temp;
            }

            // If we don't have BOTH valid numbers, we CANNOT determine retention safely
            if (chatsCount <= 0 || messagesCount <= 0) {
                const cacheVal = { score: 0, isBotted: false };
                retentionCache.set(url, cacheVal);
                renderCardBadge(card, 0, false);
                return { element: card, score: 0 };
            }

            const baseMsgsPerChat = messagesCount / chatsCount;

            // Anti-bot threshold (> 100 msgs/chat)
            if (baseMsgsPerChat > 100) {
                const cacheVal = { score: -1, isBotted: true };
                retentionCache.set(url, cacheVal);
                renderCardBadge(card, 0, true);
                return { element: card, score: -1 };
            }

            const likeRatio = likesCount / chatsCount;
            const retentionIndex = baseMsgsPerChat * (1 + likeRatio);

            const cacheVal = { score: retentionIndex, isBotted: false };
            retentionCache.set(url, cacheVal);
            renderCardBadge(card, retentionIndex, false);

            return { element: card, score: retentionIndex };
        } catch (err) {
            console.error('Error fetching bot details:', err);
            return { element: card, score: 0 };
        }
    }

    async function runRetentionSort() {
        const retentionBtn = document.getElementById('jn-retention-tab');
        const cards = Array.from(document.querySelectorAll('div[role="group"].profile-character-card-wrapper'));

        if (!cards || cards.length === 0) {
            alert('No character cards found on page.');
            return;
        }

        if (retentionBtn) {
            retentionBtn.disabled = true;
            retentionBtn.style.opacity = '0.7';
        }

        const listContainer = cards[0].parentElement;
        const BATCH_SIZE = 6;
        let completed = 0;
        const results = [];

        for (let i = 0; i < cards.length; i += BATCH_SIZE) {
            const batch = cards.slice(i, i + BATCH_SIZE);

            const batchPromises = batch.map(card => {
                const linkEl = card.querySelector('a.profile-character-card-stack-link-component');
                if (!linkEl || !linkEl.href) return Promise.resolve({ element: card, score: 0 });

                return fetchBotScore(card, linkEl.href).then(res => {
                    completed++;
                    if (retentionBtn) {
                        const pct = Math.round((completed / cards.length) * 100);
                        retentionBtn.innerHTML = `⏳ ${pct}% (${completed}/${cards.length})`;
                    }
                    return res;
                });
            });

            const batchResults = await Promise.all(batchPromises);
            results.push(...batchResults);

            await delay(50);
        }

        results.sort((a, b) => b.score - a.score);

        results.forEach(item => {
            listContainer.appendChild(item.element);
        });

        applyTagFilter();

        if (retentionBtn) {
            retentionBtn.disabled = false;
            retentionBtn.style.opacity = '1';
            retentionBtn.innerHTML = `✅ Sorted!`;
            setTimeout(() => {
                retentionBtn.innerHTML = `<span class="_icon_1os0o_88 jn-filter-bar__icon" style="margin-right: 4px;">🔥</span>Sort by Retention`;
            }, 2500);
        }
    }

    // Convert Rendered HTML Back to Markdown (*, **)
    function parseMarkdownContainer(container) {
        if (!container) return '';

        function convertNode(node) {
            if (node.nodeType === Node.TEXT_NODE) return node.textContent;
            if (node.nodeType !== Node.ELEMENT_NODE) return '';

            const tag = node.tagName.toLowerCase();
            const children = Array.from(node.childNodes).map(convertNode).join('');

            if (tag === 'strong' || tag === 'b') return children.trim() ? `**${children.trim()}**` : '';
            if (tag === 'em' || tag === 'i') return children.trim() ? `*${children.trim()}*` : '';
            if (tag === 'code') return children.trim() ? `\`${children.trim()}\`` : '';
            if (tag === 'a') {
                const href = node.getAttribute('href');
                return (href && children.trim()) ? `[${children.trim()}](${href})` : children;
            }
            if (tag === 'hr') return '\n---\n';
            if (tag === 'p' || tag === 'div') return children.trim() ? `${children}\n\n` : '\n';
            if (tag === 'br') return '\n';
            if (tag === 'h1') return `# ${children}\n\n`;
            if (tag === 'h2') return `## ${children}\n\n`;
            if (tag === 'h3') return `### ${children}\n\n`;
            if (tag === 'li') return `* ${children.trim()}\n`;

            return children;
        }

        let text = convertNode(container);
        return text.replace(/\n{3,}/g, '\n\n').trim();
    }

    // --- BACKUP & IMPORT FUNCTIONS ---
    async function runSingleCharacterBackup() {
        const btn = document.getElementById('ai-btn-single-char');
        if (btn) btn.disabled = true;

        await processAndSaveCharacter(document, window.location.href);

        if (btn) {
            btn.disabled = false;
            btn.textContent = '📥 Save Character Data';
        }
    }

    async function runBulkBackup() {
        const cards = document.querySelectorAll('div[role="group"].profile-character-card-wrapper');

        if (!cards || cards.length === 0) {
            alert('No character cards found on this page.');
            return;
        }

        const btn = document.getElementById('ai-btn-bulk-bots');
        if (!confirm(`Found ${cards.length} character cards. Start bulk downloading all data, bios, and avatars into folders?`)) {
            return;
        }

        if (btn) {
            btn.disabled = true;
            btn.style.backgroundColor = '#4a5568';
        }

        const characterUrls = [];
        cards.forEach(card => {
            const linkEl = card.querySelector('a.profile-character-card-stack-link-component');
            if (linkEl && linkEl.href) {
                characterUrls.push(linkEl.href);
            }
        });

        for (let i = 0; i < characterUrls.length; i++) {
            const url = characterUrls[i];
            if (btn) btn.textContent = `⏳ Downloading ${i + 1}/${characterUrls.length}...`;

            try {
                const response = await fetch(url);
                const htmlText = await response.text();

                const parser = new DOMParser();
                const doc = parser.parseFromString(htmlText, 'text/html');

                await processAndSaveCharacter(doc, url);
            } catch (err) {
                console.error(`Failed to backup character at ${url}:`, err);
            }

            await delay(1500);
        }

        if (btn) {
            btn.disabled = false;
            btn.style.backgroundColor = '#6b46c1';
            btn.textContent = '✅ Bulk Backup Complete!';
            setTimeout(() => {
                btn.textContent = '⚡ Bulk Backup All Bots';
            }, 3000);
        }
    }

    async function runSingleLorebookBackup() {
        const btn = document.getElementById('ai-btn-single-lorebook');
        if (btn) btn.disabled = true;

        const titleEl = document.querySelector('h2._title_1x34f_32') || document.querySelector('h2') || document.querySelector('[class*="_title_"]');
        const lorebookTitle = titleEl ? titleEl.textContent.trim() : 'Lorebook';
        const safeTitle = lorebookTitle.replace(/[^a-z0-9_-]/gi, '_');

        let cmContent = document.querySelector('.cm-content');

        if (!cmContent) {
            const jsonToggleBtn = document.querySelector('button[title="JSON Editor"]');
            if (jsonToggleBtn) {
                jsonToggleBtn.click();
                await delay(400);
                cmContent = document.querySelector('.cm-content');
            }
        }

        if (!cmContent) {
            alert('Could not find the JSON editor. Please switch to the "JSON Editor" tab on the page.');
            if (btn) btn.disabled = false;
            return;
        }

        const lines = Array.from(cmContent.querySelectorAll('.cm-line')).map(line => line.textContent);
        const rawJsonText = lines.join('\n').trim();

        if (!rawJsonText) {
            alert('The active Lorebook editor appears to be empty.');
            if (btn) btn.disabled = false;
            return;
        }

        let formattedJson = rawJsonText;
        try {
            const parsed = JSON.parse(rawJsonText);
            formattedJson = JSON.stringify(parsed, null, 2);
        } catch (e) {
            console.warn('Exporting raw text (JSON syntax warning).');
        }

        saveTextToFolder(formattedJson, `Lorebooks/${safeTitle}.json`, 'application/json');

        if (btn) {
            btn.disabled = false;
            btn.textContent = '✅ Lorebook Exported!';
            setTimeout(() => {
                btn.textContent = '📖 Export Active Lorebook';
            }, 3000);
        }
    }

    async function runBulkLorebookBackup() {
        const lorebookCards = document.querySelectorAll('div[class*="_card_"][class*="_clickable_"]');

        if (!lorebookCards || lorebookCards.length === 0) {
            alert('No lorebook scripts found in the list on this page.');
            return;
        }

        const btn = document.getElementById('ai-btn-bulk-lorebook');
        if (!confirm(`Found ${lorebookCards.length} lorebooks in the list. Export all of them as JSON files?`)) {
            return;
        }

        if (btn) {
            btn.disabled = true;
            btn.style.backgroundColor = '#4a5568';
        }

        for (let i = 0; i < lorebookCards.length; i++) {
            const card = lorebookCards[i];

            const titleEl = card.querySelector('[class*="_cardTitle_"]') || card.querySelector('h3');
            const lorebookTitle = titleEl ? titleEl.textContent.trim() : `Lorebook_${i + 1}`;
            const safeTitle = lorebookTitle.replace(/[^a-z0-9_-]/gi, '_');

            if (btn) btn.textContent = `⏳ Exporting ${i + 1}/${lorebookCards.length}: ${lorebookTitle}...`;

            card.click();
            await delay(600);

            let cmContent = document.querySelector('.cm-content');
            if (!cmContent) {
                const jsonToggleBtn = document.querySelector('button[title="JSON Editor"]');
                if (jsonToggleBtn) {
                    jsonToggleBtn.click();
                    await delay(400);
                    cmContent = document.querySelector('.cm-content');
                }
            }

            if (cmContent) {
                const lines = Array.from(cmContent.querySelectorAll('.cm-line')).map(line => line.textContent);
                const rawJsonText = lines.join('\n').trim();

                if (rawJsonText) {
                    let formattedJson = rawJsonText;
                    try {
                        const parsed = JSON.parse(rawJsonText);
                        formattedJson = JSON.stringify(parsed, null, 2);
                    } catch (e) {
                        console.warn(`Raw export for "${lorebookTitle}".`);
                    }

                    saveTextToFolder(formattedJson, `Lorebooks/${safeTitle}.json`, 'application/json');
                }
            }

            await delay(300);
        }

        if (btn) {
            btn.disabled = false;
            btn.style.backgroundColor = '#6b46c1';
            btn.textContent = '✅ All Lorebooks Exported!';
            setTimeout(() => {
                btn.textContent = '⚡ Bulk Export All Lorebooks';
            }, 3000);
        }
    }

    function openImportModal() {
        if (document.getElementById('ai-import-modal')) return;

        const modal = document.createElement('div');
        modal.id = 'ai-import-modal';
        modal.style.position = 'fixed';
        modal.style.top = '50%';
        modal.style.left = '50%';
        modal.style.transform = 'translate(-50%, -50%)';
        modal.style.zIndex = '100000';
        modal.style.backgroundColor = '#1a202c';
        modal.style.color = '#ffffff';
        modal.style.padding = '24px';
        modal.style.borderRadius = '12px';
        modal.style.boxShadow = '0 10px 25px rgba(0,0,0,0.8)';
        modal.style.width = '420px';
        modal.style.fontFamily = 'sans-serif';

        modal.innerHTML = `
            <h3 style="margin-top:0; font-size:18px; color:#a78bfa;">📤 Import Character Data</h3>
            <p style="font-size:12px; color:#cbd5e1; margin-bottom:16px;">Select exported files to auto-fill the creation form:</p>

            <label style="display:block; font-size:12px; margin-bottom:4px; font-weight:bold;">1. Character Info File (_info.txt):</label>
            <input type="file" id="ai-import-info-file" accept=".txt" style="margin-bottom:12px; width:100%; font-size:12px;">

            <label style="display:block; font-size:12px; margin-bottom:4px; font-weight:bold;">2. Character Bio File (_bio.txt / .html):</label>
            <input type="file" id="ai-import-bio-file" accept=".html,.txt" style="margin-bottom:12px; width:100%; font-size:12px;">

            <label style="display:block; font-size:12px; margin-bottom:4px; font-weight:bold;">3. Avatar Image (_avatar.webp / image):</label>
            <input type="file" id="ai-import-avatar-file" accept="image/*" style="margin-bottom:20px; width:100%; font-size:12px;">

            <div style="display:flex; justify-content:flex-end; gap:10px;">
                <button id="ai-import-cancel-btn" style="padding:8px 14px; background:#4a5568; color:#fff; border:none; border-radius:6px; cursor:pointer;">Cancel</button>
                <button id="ai-import-submit-btn" style="padding:8px 14px; background:#6b46c1; color:#fff; border:none; border-radius:6px; cursor:pointer; font-weight:bold;">Import & Fill Form</button>
            </div>
        `;

        document.body.appendChild(modal);

        document.getElementById('ai-import-cancel-btn').onclick = () => modal.remove();
        document.getElementById('ai-import-submit-btn').onclick = executeImportFormFill;
    }

    function setNativeInputValue(element, value) {
        if (!element) return;
        const valueSetter = Object.getOwnPropertyDescriptor(element, 'value')?.set;
        const prototype = Object.getPrototypeOf(element);
        const prototypeValueSetter = Object.getOwnPropertyDescriptor(prototype, 'value')?.set;

        if (prototypeValueSetter && valueSetter !== prototypeValueSetter) {
            prototypeValueSetter.call(element, value);
        } else if (valueSetter) {
            valueSetter.call(element, value);
        } else {
            element.value = value;
        }
        element.dispatchEvent(new Event('input', { bubbles: true }));
        element.dispatchEvent(new Event('change', { bubbles: true }));
        element.dispatchEvent(new Event('blur', { bubbles: true }));
    }

    async function executeImportFormFill() {
        const infoFile = document.getElementById('ai-import-info-file').files[0];
        const bioFile = document.getElementById('ai-import-bio-file').files[0];
        const avatarFile = document.getElementById('ai-import-avatar-file').files[0];

        if (!infoFile && !bioFile && !avatarFile) {
            alert('Please select at least one file to import.');
            return;
        }

        if (avatarFile) {
            const fileInput = document.querySelector('input[type="file"][accept*="image"]');
            if (fileInput) {
                const dataTransfer = new DataTransfer();
                dataTransfer.items.add(avatarFile);
                fileInput.files = dataTransfer.files;
                fileInput.dispatchEvent(new Event('change', { bubbles: true }));
            }
        }

        if (bioFile) {
            const bioText = await bioFile.text();
            const bioEditor = document.querySelector('div.tiptap.ProseMirror');
            if (bioEditor) {
                bioEditor.innerHTML = bioText;
                bioEditor.dispatchEvent(new Event('input', { bubbles: true }));
                bioEditor.dispatchEvent(new Event('blur', { bubbles: true }));
            }
        }

        if (infoFile) {
            const infoText = await infoFile.text();

            const nameMatch = infoText.match(/^CHARACTER:\s*(.+)$/m);
            if (nameMatch && nameMatch[1]) {
                const charName = nameMatch[1].trim();
                const nameInput = document.getElementById('name');
                setNativeInputValue(nameInput, charName);
            }

            const parseSection = (headerPatterns) => {
                const patternStr = Array.isArray(headerPatterns) ? headerPatterns.join('|') : headerPatterns;
                const regex = new RegExp(`---\\s*\\[\\s*(?:${patternStr}).*?\\]\\s*---\\n\\n([\\s\\S]*?)(?=\\n\\n---\\s*\\[|$)`, 'i');
                const match = infoText.match(regex);
                return match ? match[1].trim() : '';
            };

            const personality = parseSection(['Personality', 'Persona']);
            const scenario = parseSection(['Scenario', 'Setting']);
            const firstMessage = parseSection(['Initial Messages', 'Initial Message', 'First Message', 'First Messages']);
            const exampleDialogs = parseSection(['Example dialogs', 'Example dialog', 'Example Dialogues', 'Example']);

            if (personality) setNativeInputValue(document.getElementById('personality'), personality);
            if (scenario) setNativeInputValue(document.getElementById('scenario'), scenario);
            if (firstMessage) {
                const firstMsgInput = document.getElementById('first_messages.0') || document.querySelector('textarea[name="first_messages.0"]');
                setNativeInputValue(firstMsgInput, firstMessage);
            }
            if (exampleDialogs) setNativeInputValue(document.getElementById('example_dialogs'), exampleDialogs);
        }

        document.getElementById('ai-import-modal').remove();
        alert('✅ Character form filled successfully!');
    }

    async function processAndSaveCharacter(docElement, pageUrl) {
        const accordionItems = docElement.querySelectorAll('div[id^="info-"]');
        const avatarEl = docElement.querySelector('img.avatar-image') || docElement.querySelector('img[src*="/bot-avatars/"]');

        const titleEl = docElement.querySelector('h1') || docElement.querySelector('.pp-cc-name');
        let charName = titleEl ? titleEl.textContent.trim() : '';
        if (!charName && avatarEl && avatarEl.alt) {
            charName = avatarEl.alt.trim();
        }
        if (!charName) charName = 'Character';

        const safeFileName = charName.replace(/[^a-z0-9_-]/gi, '_');

        let fullTextContent = `=========================================\n`;
        fullTextContent += `CHARACTER: ${charName}\n`;
        fullTextContent += `URL: ${pageUrl}\n`;
        if (avatarEl && avatarEl.src) {
            fullTextContent += `Avatar URL: ${avatarEl.src}\n`;
        }
        fullTextContent += `=========================================\n\n`;

        if (accordionItems && accordionItems.length > 0) {
            accordionItems.forEach((item) => {
                const titleTextEl = item.querySelector('[class*="characterInfoAccordionTitleText"]');
                const sectionTitle = titleTextEl ? titleTextEl.textContent.trim() : item.id;

                const markdownContainer = item.querySelector('[class*="characterInfoMarkdownContainer"]');

                if (markdownContainer) {
                    const sectionText = parseMarkdownContainer(markdownContainer);
                    fullTextContent += `--- [ ${sectionTitle} ] ---\n\n`;
                    fullTextContent += `${sectionText}\n\n`;
                }
            });
        }

        saveTextToFolder(fullTextContent, `${safeFileName}/${safeFileName}_info.txt`, 'text/plain');

        let bioHtml = '';
        const creatorBox = docElement.querySelector('.character-card-creator-box') || docElement.querySelector('[class*="character-card-creator-box"]');

        if (creatorBox) {
            const paragraphParent = creatorBox.querySelector('p')?.parentElement;
            if (paragraphParent) {
                bioHtml = paragraphParent.innerHTML.trim();
            } else {
                bioHtml = creatorBox.innerHTML.trim();
            }
        }

        if (bioHtml) {
            saveTextToFolder(bioHtml, `${safeFileName}/${safeFileName}_bio.txt`, 'text/plain');
        }

        if (avatarEl && avatarEl.src) {
            const ext = avatarEl.src.split('?')[0].split('.').pop() || 'webp';
            GM_download({
                url: avatarEl.src,
                name: `${safeFileName}/${safeFileName}_avatar.${ext}`
            });
        }
    }

    function saveTextToFolder(textContent, relativeFilePath, mimeType = 'text/plain') {
        const blob = new Blob([textContent], { type: `${mimeType};charset=utf-8;` });
        const blobUrl = URL.createObjectURL(blob);
        GM_download({
            url: blobUrl,
            name: relativeFilePath,
            onload: () => URL.revokeObjectURL(blobUrl),
            onerror: () => URL.revokeObjectURL(blobUrl)
        });
    }

    // SPA Routing & DOM Mutation Monitor
    let lastUrl = location.href;
    setInterval(() => {
        try {
            if (location.href !== lastUrl) {
                lastUrl = location.href;
                const existingContainer = document.getElementById('ai-backup-container');
                if (existingContainer) existingContainer.remove();
                const retentionTab = document.getElementById('jn-retention-tab');
                if (retentionTab) retentionTab.remove();
            }
            injectUI();
            applyTagFilter();
        } catch (e) {
            console.error('JanitorAI Tool Error:', e);
        }
    }, 1000);
})();