All pastes #5gmp5rtS3M Raw Edit

text paste

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