{"id":"5gmp5rtS3M","url":"https://pastebin.ca/5gmp5rtS3M","raw_url":"https://raw.anybin.ca/5gmp5rtS3M","visibility":"public","access":"public","created_at":1791018513148,"expires_at":1791623313148,"fetch_limit":null,"fetches_used":0,"reads_remaining":null,"size_bytes":30773,"syntax_hint":null,"title":null,"filename":null,"change_note":null,"cipher":null,"cipher_meta":null,"parent_id":null,"root_id":"5gmp5rtS3M","version":1,"owner_id":null,"recipient_id":null,"body":"// ==UserScript==\n// @name         Mufy 收藏夹角色检测器 (v14.4)\n// @namespace    clover\n// @version      14.4\n// @description  按作者UID快速收藏归夹 + 检测收藏夹角色状态 + 找出未归夹的卡 + 重名作者识别 + 新建收藏夹\n// @author       参考 willwefind/mufy-batch-export 的思路独立实现\n// @match        https://chat.mufy.ai/*\n// @grant        GM_addStyle\n// @run-at       document-end\n// ==/UserScript==\n\n/*\n * 本脚本的实现思路参考了以下开源项目：\n *   Mufy 批量导出聊天记录 (https://github.com/willwefind/mufy-batch-export)\n *   原作者: Ciel   许可证: AGPL-3.0-only\n *   本脚本代码为完全独立实现，特此致谢。\n */\n\n(function () {\n    'use strict';\n    console.log('[Mufy检测器] v14.4 已启动');\n\n    GM_addStyle(`\n        #mufy-btn{position:fixed;right:20px;bottom:100px;z-index:9999;padding:10px 18px;\n            background:#1a1a2e;color:#e9e4f5;border:1px solid #6c63ff;border-radius:30px;\n            font:bold 14px system-ui;cursor:grab;box-shadow:0 4px 15px rgba(0,0,0,.5);user-select:none}\n        #mufy-btn:hover{background:#2a2a4e}\n        #mufy-btn:active{cursor:grabbing}\n        .mufy-drag-handle{cursor:grab;user-select:none}\n        .mufy-drag-handle:active{cursor:grabbing}\n        #mufy-panel{position:fixed;right:20px;bottom:160px;z-index:10000;width:720px;\n            max-height:88vh;background:rgba(20,18,38,.97);backdrop-filter:blur(10px);\n            border:1px solid #4a4a7a;border-radius:16px;padding:16px 20px;color:#d4cee8;\n            font:13px system-ui;box-shadow:0 10px 40px rgba(0,0,0,.8);display:none;\n            flex-direction:column;gap:8px;overflow-y:auto;box-sizing:border-box}\n        #mufy-panel.show{display:flex}\n        #mufy-panel select,#mufy-panel input[type=text]{width:100%;padding:6px 8px;background:#0d0b1a;\n            color:#e9e4f5;border:1px solid #4a4a7a;border-radius:6px;font-size:13px;box-sizing:border-box}\n        .mufy-row{display:flex;flex-direction:column;gap:4px}\n        .mufy-row label{font-size:12px;color:#9c93bd}\n        #mufy-results{min-height:160px;max-height:300px;overflow-y:auto;background:#0d0b1a;border-radius:8px;\n            padding:6px 10px;font:12px/1.6 'Courier New',monospace;border:1px solid #2a2a4a;\n            white-space:pre-wrap;word-break:break-all;flex:0 0 auto}\n        #mufy-close{background:transparent;border:none;color:#9c93bd;font-size:18px;cursor:pointer;padding:0 4px}\n        #mufy-close:hover{color:#fff}\n        .mufy-stats{font-size:11px;color:#9c93bd;border-top:1px solid #2a2a4a;padding-top:6px;\n            display:flex;flex-wrap:wrap;gap:6px 12px}\n        .mufy-hint{font-size:11px;color:#6c63ff;margin-top:2px}\n        .mufy-mini{padding:6px 12px;border-radius:6px;border:none;font:bold 12px system-ui;\n            cursor:pointer;background:#3a3a5a;color:#fff}\n        .mufy-mini:hover{background:#5a5a8a}\n        .mufy-mini:disabled{opacity:.5;pointer-events:none}\n        .mufy-mini.warn{background:#8a3a3a}\n        .mufy-mini.warn:hover{background:#a54a4a}\n        .mufy-mini.primary{background:#6c63ff}\n        .mufy-mini.primary:hover{background:#8a82ff}\n        #mufy-pause{padding:8px 16px;border-radius:8px;border:none;font:bold 13px system-ui;\n            cursor:pointer;background:#feca57;color:#1a1a2e}\n        #mufy-pause:disabled{opacity:.4;pointer-events:none}\n        .mufy-chk{display:flex;align-items:center;gap:6px;font-size:12px;color:#d4cee8;cursor:pointer}\n        .mufy-chk input{width:auto;margin:0}\n        details.mufy-sec{border:1px solid #2a2a4a;border-radius:8px;padding:6px 10px;background:rgba(13,11,26,.5)}\n        details.mufy-sec > summary{cursor:pointer;font-size:12px;color:#d4cee8;padding:4px 0;user-select:none;list-style:none}\n        details.mufy-sec > summary::-webkit-details-marker{display:none}\n        details.mufy-sec > summary::before{content:'▶ ';color:#6c63ff;font-size:10px}\n        details.mufy-sec[open] > summary::before{content:'▼ '}\n        details.mufy-sec > div{margin-top:6px}\n        details#mufy-uid-sec > summary{cursor:pointer;font-size:13px;font-weight:600;color:#e9e4f5;\n            padding:2px 0;user-select:none;list-style:none;display:flex;align-items:center;gap:6px}\n        details#mufy-uid-sec > summary::-webkit-details-marker{display:none}\n        details#mufy-uid-sec > summary::before{content:'▶';color:#6c63ff;font-size:10px;margin-right:2px}\n        details#mufy-uid-sec[open] > summary::before{content:'▼'}\n        details#mufy-uid-sec > .uid-body{margin-top:8px}\n        #mufy-card-list{max-height:300px;overflow-y:auto;background:#0d0b1a;border:1px solid #2a2a4a;\n            border-radius:8px;padding:6px 8px}\n        #mufy-card-list label{display:flex;align-items:center;gap:6px;padding:3px 0;cursor:pointer;font-size:12px}\n        #mufy-card-list input[type=checkbox]{width:auto;margin:0;flex:0 0 auto}\n        #mufy-card-list .tag{font-size:10px;padding:1px 5px;border-radius:3px;flex:0 0 auto}\n        .tag.fav{background:#2d5e3a;color:#a8e6b8}\n        .tag.chat{background:#5e4a2d;color:#e6d4a8}\n        .tag.none{background:#2a2a4a;color:#888}\n        @media (max-width: 820px) {\n            #mufy-panel{left:0 !important;right:0 !important;width:100vw !important;\n                top:0 !important;bottom:0 !important;\n                height:100dvh !important;max-height:100dvh !important;\n                border-radius:0 !important;border:none !important;\n                padding:calc(14px + env(safe-area-inset-top, 0px)) 12px\n                        calc(14px + env(safe-area-inset-bottom, 0px)) 12px;\n                font-size:13px;box-sizing:border-box;\n                overscroll-behavior:contain;touch-action:pan-y}\n            #mufy-btn{bottom:calc(80px + env(safe-area-inset-bottom, 0px));\n                right:12px;padding:10px 16px;font-size:13px}\n            #mufy-panel input,#mufy-panel select,#mufy-panel textarea{\n                font-size:16px !important;padding:8px 10px !important;box-sizing:border-box}\n            #mufy-results{min-height:120px;max-height:38vh;font-size:12px;padding:6px 8px}\n            .mufy-mini{padding:8px 12px;font-size:13px;min-height:36px}\n            .mufy-chk{font-size:13px;padding:4px 0;gap:8px}\n            .mufy-chk input{width:18px;height:18px;flex:0 0 auto}\n            #mufy-card-list label{font-size:13px;padding:6px 0}\n            .mufy-drag-handle{cursor:default;touch-action:auto}\n            #mufy-card-list input[type=checkbox]{width:18px;height:18px;flex:0 0 auto}\n        }\n    `);\n\n    const token = () => localStorage.getItem('mufy-token') || '';\n    const sleep = (ms) => new Promise(r => setTimeout(r, ms));\n    const jitterSleep = (baseMs, jitterMs = 500) => sleep(baseMs + Math.floor(Math.random() * jitterMs));\n\n    async function refreshToken() {\n        const res = await fetch('https://chat.mufy.ai/api/users/refresh', {\n            method: 'POST',\n            headers: { Authorization: 'Bearer ' + token(), 'Content-Type': 'application/json' },\n            credentials: 'include', body: '{}'\n        });\n        if (!res.ok) throw new Error('登录态过期，请刷新 Mufy 页面');\n        const j = await res.json();\n        if (j.data && j.data.token) localStorage.setItem('mufy-token', j.data.token);\n    }\n    async function api(path) {\n        let res = await fetch('https://chat.mufy.ai' + path, {\n            headers: { Authorization: 'Bearer ' + token() }, credentials: 'include'\n        });\n        if (res.status === 401) {\n            await refreshToken();\n            res = await fetch('https://chat.mufy.ai' + path, {\n                headers: { Authorization: 'Bearer ' + token() }, credentials: 'include'\n            });\n        }\n        if (!res.ok) throw new Error('HTTP ' + res.status);\n        const json = await res.json();\n        if (json.code && json.code !== 200) throw new Error(json.reason || json.code);\n        return json.data;\n    }\n    async function apiWrite(path, method, body) {\n        let res = await fetch('https://chat.mufy.ai' + path, {\n            method, headers: { Authorization: 'Bearer ' + token(), 'Content-Type': 'application/json' },\n            credentials: 'include', body: JSON.stringify(body)\n        });\n        if (res.status === 401) {\n            await refreshToken();\n            res = await fetch('https://chat.mufy.ai' + path, {\n                method, headers: { Authorization: 'Bearer ' + token(), 'Content-Type': 'application/json' },\n                credentials: 'include', body: JSON.stringify(body)\n            });\n        }\n        if (!res.ok) throw new Error('HTTP ' + res.status);\n        const json = await res.json();\n        if (json.code && json.code !== 200) throw new Error(json.reason || json.code);\n        return json.data;\n    }\n    async function safeApiWrite(path, method, body, maxRetries = 2) {\n        for (let attempt = 0; attempt <= maxRetries; attempt++) {\n            try { return await apiWrite(path, method, body); }\n            catch (e) {\n                const isRateLimit = /429|Too Many Requests|频率/i.test(e.message);\n                if (isRateLimit && attempt < maxRetries) {\n                    const backoff = 7000 + Math.random() * 3000;\n                    console.warn(`[Mufy检测器] 触发频控，休眠 ${(backoff/1000).toFixed(1)} 秒…`);\n                    await sleep(backoff); continue;\n                }\n                throw e;\n            }\n        }\n    }\n\n    async function followCharacter(characterId) {\n        return await safeApiWrite('/api/characters/follow', 'POST', { characterId, isFavorite: true });\n    }\n\n    const charInfoCache = new Map();\n    const creatorInfoCache = new Map();\n\n    async function getCharInfo(characterId) {\n        if (charInfoCache.has(characterId)) return charInfoCache.get(characterId);\n        try {\n            const info = await api('/api/characters/get?id=' + encodeURIComponent(characterId));\n            const r = { ok: true, info };\n            charInfoCache.set(characterId, r);\n            return r;\n        } catch (e) {\n            const r = { ok: false, err: e.message || String(e) };\n            charInfoCache.set(characterId, r);\n            return r;\n        }\n    }\n    async function getCreatorInfo(creatorId) {\n        if (!creatorId) return { name: '', rounds: 0 };\n        if (creatorInfoCache.has(creatorId)) return creatorInfoCache.get(creatorId);\n        try {\n            const d = await api('/api/users/profiles?userId=' + encodeURIComponent(creatorId));\n            const r = {\n                name: (d && d.username) || '',\n                rounds: (d && typeof d.totalChatRounds === 'number') ? d.totalChatRounds : 0,\n            };\n            creatorInfoCache.set(creatorId, r);\n            return r;\n        } catch (e) {\n            const r = { name: '', rounds: 0 };\n            creatorInfoCache.set(creatorId, r);\n            return r;\n        }\n    }\n\n    async function getFolders() {\n        const out = []; let page = 1;\n        for (;;) {\n            const d = await api(`/api/favorites/list?pageNum=${page}&pageSize=100`);\n            const rows = (d && d.data) || [];\n            if (!rows.length) break;\n            out.push(...rows);\n            if (rows.length < 100) break;\n            page++; await jitterSleep(100, 50);\n        }\n        return out;\n    }\n    async function getFolderCharacters(folderId) {\n        const d = await api('/api/favorites/?id=' + encodeURIComponent(folderId));\n        return ((d && d.characters) || []).map(c => ({\n            characterId: c.characterId,\n            name: c.characterName || c.name || '',\n            lastSessionId: c.lastSessionId || '',\n        }));\n    }\n    const folderCharsCache = new Map();\n    async function getCachedFolderCharIds(folderId) {\n        if (folderCharsCache.has(folderId)) return folderCharsCache.get(folderId);\n        try {\n            const chars = await getFolderCharacters(folderId);\n            const set = new Set(chars.map(c => c.characterId));\n            folderCharsCache.set(folderId, set);\n            return set;\n        } catch { return new Set(); }\n    }\n\n    async function getAllFollowed() {\n        const out = []; let page = 1;\n        for (;;) {\n            const d = await api(`/api/characters/query_session?pageNum=${page}&pageSize=200&isFollowed=true`);\n            const rows = (d && d.data) || [];\n            if (!rows.length) break;\n            out.push(...rows);\n            if (d.hasNext === false) break;\n            page++; await jitterSleep(100, 50);\n        }\n        return out;\n    }\n    async function getRecent() {\n        const out = []; let page = 1;\n        for (;;) {\n            const d = await api(`/api/characters/query_session?pageNum=${page}&pageSize=200&isFollowed=false`);\n            const rows = (d && d.data) || [];\n            if (!rows.length) break;\n            out.push(...rows);\n            if (d.hasNext === false) break;\n            page++; await jitterSleep(100, 50);\n        }\n        return out;\n    }\n\n    let _followedIdSetCache = null;\n    let _chattedIdSetCache = null;\n    async function getFollowedIdSet(forceRefresh = false) {\n        if (_followedIdSetCache && !forceRefresh) return _followedIdSetCache;\n        const list = await getAllFollowed();\n        _followedIdSetCache = new Set(list.map(c => c.characterId));\n        return _followedIdSetCache;\n    }\n    async function getChattedIdSet(forceRefresh = false) {\n        if (_chattedIdSetCache && !forceRefresh) return _chattedIdSetCache;\n        const list = await getRecent();\n        _chattedIdSetCache = new Set(list.map(c => c.characterId));\n        return _chattedIdSetCache;\n    }\n\n    async function getCreatorCharacters(creatorId) {\n        const out = []; let page = 1;\n        for (;;) {\n            const d = await api(`/api/characters/list?creatorId=${encodeURIComponent(creatorId)}&pageNum=${page}&pageSize=200`);\n            let rows = [];\n            if (Array.isArray(d)) rows = d;\n            else if (d && Array.isArray(d.data)) rows = d.data;\n            else if (d && Array.isArray(d.list)) rows = d.list;\n            if (!rows.length) break;\n            out.push(...rows);\n            if (rows.length < 200) break;\n            page++; await jitterSleep(150, 100);\n        }\n        return out;\n    }\n\n    async function addCharsToFolder(folderId, ids, report) {\n        if (!ids.length) return { added: 0 };\n        let alreadyIn = new Set();\n        try { alreadyIn = await getCachedFolderCharIds(folderId); } catch {}\n        const toAdd = ids.filter(id => !alreadyIn.has(id));\n        if (report && alreadyIn.size) report(`   （夹里已有 ${alreadyIn.size} 张，过滤后待加 ${toAdd.length} 张）`);\n        if (!toAdd.length) { if (report) report('   所有卡都已在夹里。'); return { added: 0, skipped: ids.length }; }\n\n        const fid = /^\\d+$/.test(String(folderId)) ? Number(folderId) : folderId;\n        const BATCH = 20;\n        let added = 0;\n        for (let i = 0; i < toAdd.length; i += BATCH) {\n            const batch = toAdd.slice(i, i + BATCH);\n            try {\n                await safeApiWrite('/api/favorites/characters', 'PUT', { folderId: fid, addCharacterIds: batch });\n                added += batch.length;\n                if (report) report(`   ➕ 已加 ${Math.min(i + BATCH, toAdd.length)}/${toAdd.length}`);\n                await jitterSleep(500, 300);\n            } catch (e) {\n                if (report) report(`   ⚠️ 批量加失败（${e.message}），逐张重试…`);\n                for (const id of batch) {\n                    try {\n                        await apiWrite('/api/favorites/characters', 'PUT', { folderId: fid, addCharacterIds: [id] });\n                        added++;\n                        await jitterSleep(400, 200);\n                    } catch (e2) { if (report) report(`      ❌ 单张失败 ${id}：${e2.message}`); }\n                }\n            }\n        }\n        folderCharsCache.delete(String(folderId));\n        return { added };\n    }\n\n    // ═══════════ 检测 + 归夹 ═══════════\n    async function collectChars(scope, folderId, folderLabel, report) {\n        const groups = [];\n        if (scope === 'followed') {\n            report('📋 收集收藏的角色…');\n            let chars;\n            if (folderId) chars = await getFolderCharacters(folderId);\n            else chars = await getAllFollowed();\n            chars.forEach(c => c.group = 'followed');\n            groups.push({ key: 'followed', chars });\n            report(`   共 ${chars.length} 个。`);\n        } else if (scope === 'recent') {\n            report('📋 收集最近聊天（仅未收藏的）…');\n            const followedSet = new Set((await getAllFollowed()).map(c => c.characterId));\n            const recent = await getRecent();\n            const chars = recent.filter(c => !followedSet.has(c.characterId)).map(c => ({ ...c, group: 'recentOnly' }));\n            groups.push({ key: 'recentOnly', chars });\n            report(`   共 ${chars.length} 个。`);\n        } else if (scope === 'unorganized') {\n            report('📋 收集「收藏过但没归夹」的卡…');\n            const followed = await getAllFollowed();\n            report(`   我收藏了 ${followed.length} 张。`);\n            report('   读取所有收藏夹内容（遍历每个夹）…');\n            const folders = await getFolders();\n            const inFolders = new Set();\n            for (let i = 0; i < folders.length; i++) {\n                const f = folders[i];\n                try {\n                    const ids = await getCachedFolderCharIds(f.id);\n                    ids.forEach(id => inFolders.add(id));\n                } catch {}\n                if ((i + 1) % 10 === 0 || i + 1 === folders.length) {\n                    report(`     已读 ${i + 1}/${folders.length} 个夹…`);\n                }\n                await jitterSleep(80, 40);\n            }\n            report(`   所有夹里共 ${inFolders.size} 张卡。`);\n            const orphan = followed.filter(c => !inFolders.has(c.characterId));\n            orphan.forEach(c => c.group = 'followed');\n            groups.push({ key: 'followed', chars: orphan });\n            report(`   📥 未归夹：${orphan.length} 张。`);\n        } else {\n            report('📋 第一步：收藏的角色…');\n            let followed;\n            if (folderId) followed = await getFolderCharacters(folderId);\n            else followed = await getAllFollowed();\n            followed.forEach(c => c.group = 'followed');\n            groups.push({ key: 'followed', chars: followed });\n            report(`   收藏：${followed.length} 个。`);\n            report('📋 第二步：未收藏但聊过的…');\n            const followedSet = new Set(followed.map(c => c.characterId));\n            const recent = await getRecent();\n            const recentOnly = recent.filter(c => !followedSet.has(c.characterId)).map(c => ({ ...c, group: 'recentOnly' }));\n            groups.push({ key: 'recentOnly', chars: recentOnly });\n            report(`   未收藏但聊过：${recentOnly.length} 个。`);\n        }\n        return groups;\n    }\n\n    async function scanAuthors(groups, report) {\n        const allChars = groups.flatMap(g => g.chars);\n        const total = allChars.length;\n        const authorMap = new Map();\n        report(`\\n📡 预扫描作者（${total} 个角色）…`);\n        let done = 0;\n        for (const c of allChars) {\n            done++;\n            if (done % 20 === 0) report(`   ${done}/${total}…`);\n            const ci = await getCharInfo(c.characterId);\n            let creatorId = '', creatorName = '', creatorRounds = 0;\n            if (ci.ok && ci.info) {\n                creatorId = ci.info.creatorId || '';\n                if (creatorId) {\n                    const info = await getCreatorInfo(creatorId);\n                    creatorName = info.name;\n                    creatorRounds = info.rounds;\n                }\n            }\n            const key = creatorId || '__unknown__';\n            if (!authorMap.has(key)) {\n                authorMap.set(key, {\n                    creatorId,\n                    creatorName: creatorName || (creatorId ? creatorId : '（拿不到作者）'),\n                    creatorRounds,\n                    chars: [],\n                });\n            }\n            authorMap.get(key).chars.push(c);\n            await jitterSleep(50, 40);\n        }\n        report(`   完成：${authorMap.size} 个作者。`);\n        return authorMap;\n    }\n\n    async function checkOne(char) {\n        const id = char.characterId;\n        const name = char.name || id.slice(0, 8);\n        const lastSid = char.lastSessionId || '';\n        const r = { id, name, group: char.group, status: 'unknown', detail: '', creatorId: '', creatorName: '', creatorRounds: 0 };\n        const ci = await getCharInfo(id);\n        if (!ci.ok) {\n            const msg = ci.err;\n            if (/HTTP (404|500)|not found/i.test(msg)) { r.status='gone'; r.detail='卡已下架或被删除'; return r; }\n            if (/HTTP 503/.test(msg)) { r.status='locked'; r.detail='卡现在打不开（HTTP 503）'; return r; }\n            r.status='error'; r.detail='卡查询失败: '+msg; return r;\n        }\n        r.creatorId = ci.info.creatorId || '';\n        if (r.creatorId) {\n            const info = await getCreatorInfo(r.creatorId);\n            r.creatorName = info.name;\n            r.creatorRounds = info.rounds;\n        }\n        if (!lastSid) { r.status='never'; r.detail='卡在，从没聊过'; }\n        else { r.status='ok'; r.detail='卡在，有记录'; }\n        return r;\n    }\n\n    const STATUS_META = {\n        ok:     { icon: '✅', label: '正常（卡在 + 有记录）' },\n        never:  { icon: '📭', label: '卡在，从没聊过' },\n        gone:   { icon: '🔒', label: '卡已下架 / 被删除' },\n        locked: { icon: '🚫', label: '卡现在打不开（HTTP 503）' },\n        error:  { icon: '❓', label: '查询出错' },\n    };\n\n    function buildReport(rows, meta) {\n        const L = [];\n        L.push('Mufy 角色检测报告 v14.4');\n        L.push('时间：' + new Date().toLocaleString('zh-CN'));\n        L.push('范围：' + meta.scopeLabel);\n        if (meta.folderLabel) L.push('收藏夹：' + meta.folderLabel);\n        if (meta.authorFilter) L.push('作者筛选：' + meta.authorFilter);\n        L.push('角色总数：' + rows.length);\n        L.push('');\n        const sections = [\n            { key: 'followed',   title: '═══ ⭐ 收藏的角色 ═══' },\n            { key: 'recentOnly', title: '═══ ○ 没收藏但聊过的角色 ═══' },\n        ];\n        for (const sec of sections) {\n            const group = rows.filter(r => r.group === sec.key);\n            if (!group.length) continue;\n            L.push(sec.title + `（共 ${group.length} 个）`); L.push('');\n            for (const k of Object.keys(STATUS_META)) {\n                const list = group.filter(r => r.status === k);\n                if (!list.length) continue;\n                L.push(`── ${STATUS_META[k].icon} ${STATUS_META[k].label}（${list.length} 个）──`);\n                for (const r of list) {\n                    L.push(`  ${r.name}`);\n                    L.push(`      ID：${r.id}`);\n                    if (r.creatorName) L.push(`      作者：${r.creatorName}（作者总 ${r.creatorRounds} 轮）`);\n                    else if (r.creatorId) L.push(`      作者：${r.creatorId}`);\n                    L.push(`      状态：${r.detail}`);\n                }\n                L.push('');\n            }\n            L.push('');\n        }\n        L.push('═══ 统计 ═══');\n        for (const sec of sections) {\n            const grp = rows.filter(r => r.group === sec.key);\n            if (!grp.length) continue;\n            const label = sec.key === 'followed' ? '⭐ 收藏' : '○ 未收藏但聊过';\n            L.push(`${label}：${grp.length} 个`);\n            for (const k of Object.keys(STATUS_META)) {\n                const cnt = grp.filter(r => r.status === k).length;\n                if (cnt) L.push(`   ${STATUS_META[k].icon} ${STATUS_META[k].label}：${cnt}`);\n            }\n            L.push('');\n        }\n        const byCreator = new Map();\n        for (const r of rows) {\n            const key = r.creatorId || '__unknown__';\n            if (!byCreator.has(key)) byCreator.set(key, { name: r.creatorName || (r.creatorId || '（未知）'), rounds: r.creatorRounds || 0, cnt: 0 });\n            byCreator.get(key).cnt++;\n        }\n        if (byCreator.size) {\n            L.push('═══ 按作者（角色数降序）═══');\n            const sorted = [...byCreator.values()].sort((a, b) => b.cnt - a.cnt);\n            for (const { name, rounds, cnt } of sorted) L.push(`  ${name}（作者总 ${rounds} 轮） ${cnt} 个`);\n            L.push('');\n        }\n        return L.join('\\n');\n    }\n\n    function buildAuthorReport(rows) {\n        const byAuthor = new Map();\n        for (const r of rows) {\n            const key = r.creatorId || '__unknown__';\n            if (!byAuthor.has(key)) byAuthor.set(key, {\n                creatorId: r.creatorId || '',\n                name: r.creatorName || (r.creatorId || '（未知作者）'),\n                rounds: r.creatorRounds || 0, rows: [],\n            });\n            byAuthor.get(key).rows.push(r);\n        }\n        if (!byAuthor.size) return '';\n        const L = [];\n        L.push('Mufy 按作者分组清单 v14.4');\n        L.push('时间：' + new Date().toLocaleString('zh-CN'));\n        L.push('作者数：' + byAuthor.size + ' 角色总数：' + rows.length);\n        L.push('');\n        const sorted = [...byAuthor.values()].sort((a, b) => b.rows.length - a.rows.length);\n        for (const info of sorted) {\n            L.push(`═══ ✍️ ${info.name}（${info.rows.length} 个角色 · 作者总 ${info.rounds} 轮）═══`);\n            if (info.creatorId) L.push('   creatorId：' + info.creatorId);\n            for (const r of info.rows) {\n                const gm = r.group === 'followed' ? '⭐' : '○';\n                L.push(`  ${STATUS_META[r.status] ? STATUS_META[r.status].icon : '❓'} ${gm} 【${r.name}】`);\n                L.push(`      ID：${r.id}`);\n            }\n            L.push('');\n        }\n        return L.join('\\n');\n    }\n\n    function downloadText(text, filename) {\n        const blob = new Blob(['\\uFEFF' + text], { type: 'text/plain;charset=utf-8' });\n        const url = URL.createObjectURL(blob);\n        const a = document.createElement('a');\n        a.href = url; a.download = filename;\n        document.body.appendChild(a); a.click(); a.remove();\n        setTimeout(() => URL.revokeObjectURL(url), 4000);\n    }\n\n    async function buildOrganizePlan(fullAuthorMap, targetMap, report, followFirst) {\n        report('\\n🗂 正在生成归夹计划…');\n        const existingFolders = await getFolders();\n        const folderByName = new Map();\n        for (const f of existingFolders) folderByName.set(f.name, f);\n\n        const nameCount = new Map();\n        for (const [, info] of fullAuthorMap) {\n            if (!info.creatorId || !info.creatorName || info.creatorName.startsWith('（')) continue;\n            nameCount.set(info.creatorName, (nameCount.get(info.creatorName) || 0) + 1);\n        }\n        const dupNames = [...nameCount].filter(([, n]) => n > 1).map(([n]) => n);\n        if (dupNames.length) report(`  ⚠️ 发现 ${dupNames.length} 个重名作者：${dupNames.join('、')}`);\n\n        const fetchList = [];\n        for (const [, info] of targetMap) {\n            if (!info.creatorId || !info.creatorName || info.creatorName.startsWith('（')) continue;\n            const isDup = nameCount.get(info.creatorName) > 1;\n            const folderName = isDup ? `${info.creatorName}_${info.creatorId.slice(0, 6)}` : info.creatorName;\n            const ex = folderByName.get(folderName);\n            if (ex && !folderCharsCache.has(String(ex.id))) fetchList.push(ex.id);\n        }\n        if (fetchList.length > 0) {\n            report(`  ⚡ 快速加载 ${fetchList.length} 个已有夹的数据…`);\n            const BATCH = 5;\n            for (let i = 0; i < fetchList.length; i += BATCH) {\n                await Promise.all(fetchList.slice(i, i + BATCH).map(id => getCachedFolderCharIds(id)));\n                await sleep(60);\n            }\n        }\n\n        const plan = [];\n        for (const [, info] of targetMap) {\n            if (!info.creatorId || !info.creatorName || info.creatorName.startsWith('（')) continue;\n            const isDup = nameCount.get(info.creatorName) > 1;\n            const folderName = isDup ? `${info.creatorName}_${info.creatorId.slice(0, 6)}` : info.creatorName;\n            const existing = folderByName.get(folderName);\n            const alreadyIn = existing ? await getCachedFolderCharIds(existing.id) : new Set();\n            const followedChars = info.chars.filter(c => c.group === 'followed');\n            const recentOnlyChars = info.chars.filter(c => c.group === 'recentOnly');\n            const toAdd = followedChars.filter(c => !alreadyIn.has(c.characterId));\n            const toFollow = followFirst ? recentOnlyChars.filter(c => !alreadyIn.has(c.characterId)) : [];\n            const skippedRecent = followFirst ? [] : recentOnlyChars;\n\n            plan.push({\n                authorName: folderName, creatorId: info.creatorId,\n                totalChars: info.chars.length,\n                followedCount: followedChars.length, recentOnlyCount: recentOnlyChars.length,\n                existingFolderId: existing ? existing.id : null,\n                alreadyIn: alreadyIn.size,\n                toAdd, toFollow, skippedRecent,\n                allToAddIds: [...toAdd.map(c => c.characterId), ...toFollow.map(c => c.characterId)],\n            });\n        }\n        return plan;\n    }\n\n    function printPlan(plan, report, followFirst) {\n        report(''); report('════════ 📋 归夹计划（预览）════════'); report('');\n        let newFolders = 0, totalAdd = 0, totalFollow = 0, totalSkip = 0, skipAuthors = 0;\n        for (const p of plan) {\n            const addN = p.toAdd.length, followN = p.toFollow.length, skipN = p.skippedRecent.length;\n            if (!addN && !followN && !skipN) { skipAuthors++; report(`⏭  ${p.authorName} 无需操作`); continue; }\n            const act = p.existingFolderId ? '♻️ 复用' : '➕ 新建';\n            if (!p.existingFolderId) newFolders++;\n            totalAdd += addN; totalFollow += followN; totalSkip += skipN;\n            report(`${act} 夹「${p.authorN"}