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