メインコンテンツへスキップ
paste
bin
.ca
type · paste · share
⌘
K
ファミリー
bin ファミリー
pastebin.ca
ハブ
Share text and code with expiry and privacy controls.
imagebin.ca
Upload and share images with direct links.
filebin.ca
Drop a file and get a shareable link.
notebin.ca
Write Markdown notes with durable links.
turl.ca
Short, reputation-checked links.
attn.ca
Notifications and alerts for your services.
voicebin.ca
Record and share short voice clips.
dnsbin.ca
Inspect DNS and debug records.
ドキュメント
サインイン
?
← ペーストに戻る
›
編集 / フォーク
無題のペースト
#5gmp5rtS3M
public / public
新しいバージョン
匿名
作成日 7 days ago
失効まで 10 hours
30.1 KB
構文:
text
変更により、このペーストにリンクされた新しいペーストが作成されます — 元のペーストは変更されません。
新しいバージョン
変更により、このペーストにリンクされた新しいペーストが作成されます — 元のペーストは変更されません。
タイトル(任意)
ファイル名
構文
text
text
bash
c
cpp
css
diff
dockerfile
go
html
ini
java
javascript
json
kotlin
lua
makefile
markdown
nginx
php
python
ruby
rust
shellscript
sql
swift
toml
typescript
xml
yaml
可視性
パブリックフィード
アクセス
public
失効
7日
10分
1時間
1日
7日
30日
90日
カスタム…
カスタム失効
変更メモ
(任意)
このペーストはパブリックフィードに表示されます。リンクを知っている人だけに見せたい場合は可視性を変更してください。
新しいバージョンを作成
キャンセル
貼り付けるか入力…
// ==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