// ==UserScript==
// @name Mufy 批量导出聊天记录
// @namespace https://github.com/willwefind/mufy-batch-export
// @version 1.46.0
// @description 一键把某个角色(或多个角色)的所有存档对话批量导出:打包成 ZIP、合并成一份 Markdown,或直接做成 EPUB 电子书;也能把整个「人设面具」库、和你自己创建的角色卡导出来
// @author Ciel
// @license AGPL-3.0-only
// @homepageURL https://github.com/willwefind/mufy-batch-export
// @supportURL https://github.com/willwefind/mufy-batch-export/issues
// @downloadURL https://raw.githubusercontent.com/willwefind/mufy-batch-export/main/mufy-batch-export.user.js
// @updateURL https://raw.githubusercontent.com/willwefind/mufy-batch-export/main/mufy-batch-export.user.js
// @match https://chat.mufy.ai/*
// @grant none
// @run-at document-idle
// ==/UserScript==
/*
* 两种用法:
* 1) 装了 Tampermonkey:把本文件拖进去 / 新建脚本粘贴保存,刷新 mufy 的聊天页面,
* 左下角会出现「⬇ 批量导出」按钮。
* 2) 不想装插件:在 mufy 的聊天页面按 F12 打开控制台,把本文件整段粘进去回车,
* 同样会出现那个按钮(关掉标签页就没了,下次再粘一遍)。
*
* 只读你自己的账号数据,不发任何东西出去;导出文件直接走浏览器下载。
*
* 🔞 面向成年用户。请勿向未成年人传播本工具或站点地址。
*/
(function () {
'use strict';
if (window.__mufyExporter) { window.__mufyExporter.open(); return; }
// 面板标题上显示的版本号。
// 🔴 2026-08-10 栽过一次:只改了上面的 @version,**面板里那个数字是手写的、没跟着改** ——
// 用户装好了新版,面板却还写着旧版号,于是所有人(包括我)都以为"更新没生效",
// 去查缓存、查装了两份、查扩展缓存,全查错了方向。**用户看到的版本号才是他的事实。**
// 现在只留这一处,并且 make-public.py 会校验它和 @version 一致,不一致直接构建失败。
const VERSION = '1.46.0';
// ---------- 基础工具 ----------
const ORIGIN = location.origin;
const sleep = (ms) => new Promise((r) => setTimeout(r, ms));
const token = () => localStorage.getItem('mufy-token') || '';
const short = (p) => p.split('?')[0];
// ---------- 登录态 ----------
// ⚠️ mufy 的 token 只活 15 分钟。一次完整导出可能跑好几分钟,
// 所以每次请求前都确认一遍,快到期就提前换新的。
function jwtExpMs(t) {
try {
const p = JSON.parse(atob(t.split('.')[1].replace(/-/g, '+').replace(/_/g, '/')));
return (p.exp || 0) * 1000;
} catch (e) {
return 0;
}
}
async function refreshToken() {
const res = await fetch(ORIGIN + '/api/users/refresh', {
method: 'POST',
headers: { Authorization: 'Bearer ' + token(), 'Content-Type': 'application/json' },
credentials: 'include',
});
const fail = '续不上登录态了,请刷新一下 mufy 页面(必要时重新登录)再试。';
if (!res.ok) throw new Error(fail + '(HTTP ' + res.status + ')');
const j = await res.json();
const t = j && j.data && j.data.token;
if (!t) throw new Error(fail);
localStorage.setItem('mufy-token', t); // 写回去,页面本身也跟着用新的
return t;
}
// 剩余不足 2 分钟就提前换
async function ensureToken() {
const t = token();
const exp = jwtExpMs(t);
if (!t || !exp || exp - Date.now() < 120000) return await refreshToken();
return t;
}
// 401 → 换 token 重来;5xx / 429 / 断网 → 退避重试(真遇到过 503)
// 传了 body 就走 POST(面具接口只认 POST,聊天那几个接口都是 GET)
async function api(path, body) {
const MAX = 4;
let last = '';
for (let i = 0; i < MAX; i++) {
const t = await ensureToken();
let res;
try {
const init = { headers: { Authorization: 'Bearer ' + t }, credentials: 'include' };
if (body !== undefined) {
init.method = 'POST';
init.headers['Content-Type'] = 'application/json';
init.body = JSON.stringify(body);
}
res = await fetch(ORIGIN + path, init);
} catch (e) {
last = '网络错误 ' + e.message;
await sleep(900 * (i + 1));
continue;
}
if (res.status === 401) {
last = 'HTTP 401';
await refreshToken(); // 换不到就直接抛,让她去刷新页面
await sleep(300);
continue;
}
if (res.status === 429 || res.status >= 500) {
last = 'HTTP ' + res.status;
await sleep(1200 * (i + 1));
continue;
}
if (res.status === 403) {
// 403 不是「没登录」(那是 401),是「认得你但这次不让过」。
// 实际见过的两类:①站点前面的防护(Cloudflare 之类)把请求当成机器人——
// 连太快、开了加速器/代理、网络环境异常都可能触发;②这个资源你的账号确实没权限。
throw new Error(
short(path) + ' → HTTP 403(被拒绝)。常见原因:① 站点的防护把请求当成了机器人' +
'(关掉加速器/VPN、换个网络、等几分钟再试);② 这个角色或资源你的账号没有权限看。'
);
}
if (!res.ok) throw new Error(short(path) + ' → HTTP ' + res.status);
const json = await res.json();
if (json.code && json.code !== 200) throw new Error(short(path) + ' → ' + (json.reason || json.code));
return json.data;
}
throw new Error(short(path) + ' → 重试 ' + MAX + ' 次仍失败(' + last + ')');
}
const qs = (k) => new URLSearchParams(location.search).get(k);
// 截断但别把 emoji 从中间劈开。JS 的字符串是 UTF-16,emoji 占两个码元(代理对),
// slice 正好切在中间会留下半个字符,后面 encodeURI 直接抛 "URI malformed"。
// (实测被这个咬过:207 个角色里有 2 个因此整个导出失败。)
function cut(s, n) {
let out = String(s).slice(0, n);
if (out.length && /[\uD800-\uDBFF]/.test(out[out.length - 1])) out = out.slice(0, -1);
return out;
}
// 目录页里的链接。万一还是撞上落单代理,别让一个文件名毁掉整个角色的导出。
function linkTarget(name) {
try {
return encodeURI(name);
} catch (e) {
return encodeURI(String(name).replace(/[\uD800-\uDFFF]/g, ''));
}
}
// Windows 文件名安全化
// 🔴🔴 「文件名是 A 角色、内容却是 B 角色」这个 bug v1.20 修过一次,但只修了**一次运行之内**:
// usedStems 是 per-run 的,一关面板就没了;而 EPUB 的文件名为了书架好看
// **连时间戳都不带**(ZIP 带)。于是两个「洗完之后同名」的角色**分两次单独导**时,
// 第二本会被浏览器存成「xxx (1).epub」——谁拿到正名取决于下载先后,名实又对不上了。
// (用户在 v1.38 上仍然报到这个症状。)
// 所以把「这个文件名归哪个 characterId」记在 localStorage 里,跨次也认得出来。
const NAME_OWNER_KEY = 'mufyx-name-owner';
const NAME_OWNER_MAX = 2000; // 别让它无限长
function nameOwners() {
try { return JSON.parse(localStorage.getItem(NAME_OWNER_KEY) || '{}') || {}; } catch (e) { return {}; }
}
function rememberName(safe, id) {
if (!safe || !id) return;
try {
const m = nameOwners();
if (m[safe] === id) return;
m[safe] = id;
const keys = Object.keys(m);
if (keys.length > NAME_OWNER_MAX) for (const k of keys.slice(0, keys.length - NAME_OWNER_MAX)) delete m[k];
localStorage.setItem(NAME_OWNER_KEY, JSON.stringify(m));
} catch (e) {}
}
function safeName(s) {
return cut(String(s || '未命名').replace(/[\\/:*?"<>|\r\n\t]/g, '_').replace(/\s+/g, ' ').trim(), 60);
}
function stamp(d) {
const t = d instanceof Date && !isNaN(d) ? d : new Date();
const p = (n) => String(n).padStart(2, '0');
return `${t.getFullYear()}${p(t.getMonth() + 1)}${p(t.getDate())}-${p(t.getHours())}${p(t.getMinutes())}`;
}
const mb = (n) => (n / 1048576).toFixed(2) + ' MB';
// 面板日志的出口。downloadBlob 在很深的地方被调用,拿不到 run() 里的 report,
// 所以留一个模块级的出口,run() 开跑时把它接上。
let logSink = null;
const say = (m) => { if (logSink) logSink(m); };
// ⚠️ 这里原来是「下载完 8 秒就 URL.revokeObjectURL()」。8 秒定得偏激进——
// FileSaver.js 这类库普遍留 40 秒以上,理由是浏览器把 blob 写进磁盘要时间,
// URL 提前注销有可能让还没写完的下载失败。
// ⚠️ **这是隐患,不是已证实的病因**:有用户反馈「脚本说完成了、下载列表里却没有文件」,
// 这条是嫌疑之一(另两个嫌疑:浏览器的自动下载策略静默拦截、下载目录失效),
// 截至改动时还没拿到能定案的现场信息。别把它当成已经查实的根因去引用。
//
// 现在不再定时注销:生成过的文件留在面板上可以手动再存一次。
// 手动点是**用户手势**,浏览器基本不拦;自动触发的下载才会被
// 「是否允许下载多个文件」那条策略掐掉,而且掐得没有任何提示。
const RECENT_MAX = 8;
const RECENT_MAX_BYTES = 64 * 1024 * 1024; // 兜底也不能把她的内存吃光
const recentFiles = [];
// ⚠️ 连 blob 本身一起留着不额外占内存:createObjectURL 本来就把它钉住了,
// revoke 之前都不会被回收。留个引用才能走「📤 分享」那条路(Web Share 要 File,不认 URL)。
function rememberFile(name, url, size, blob) {
recentFiles.push({ name, url, size, blob });
let total = recentFiles.reduce((n, f) => n + f.size, 0);
// 至少留一个,其余按「条数」和「总字节」两个上限一起淘汰
while (recentFiles.length > 1 && (recentFiles.length > RECENT_MAX || total > RECENT_MAX_BYTES)) {
const old = recentFiles.shift();
URL.revokeObjectURL(old.url);
total -= old.size;
}
renderRecent();
}
// 这个文件能不能走系统分享面板。
// 🔴 必须**逐个文件**探,不能只探一次浏览器:Chrome 对可分享的文件类型有白名单,
// 同一台机器上 .md 能分享、.zip 未必能。探不过就不画那个按钮 —— 宁可没有,
// 也别摆一个点了没反应的按钮(这一整批修的就是"点了没反应")。
function shareableFile(f) {
if (!f.blob || !navigator.share || !navigator.canShare) return null;
try {
const file = new File([f.blob], f.name, { type: f.blob.type || 'application/octet-stream' });
return navigator.canShare({ files: [file] }) ? file : null;
} catch (e) {
return null;
}
}
// 🔴 2026-08-12 用户报:自动下载没出现,**点「⬇ 手动保存」也一点反应都没有**。
// README 里原本写着「手动点属于用户操作,浏览器基本不会拦」—— 这位用户就是反例,
// 那句话已经收回。已知能让 `` 整个哑掉的情况:
// · 微信 / QQ / 小红书这类 App 内置浏览器(WebView)根本不给下载;
// · 迅雷 / IDM 之类下载器扩展把点击抢走,而它们接不住 blob: 链接;
// · 这个站的下载权限被设成了「阻止」。
// 所以同一份文件给三条出路,被拦的是哪个动作就换一个:
// ⬇ 存 —— 老路(``)
// 📤 分享 —— 系统分享面板(手机上最管用:能直接存进「文件」或发给自己)
// ↗ 打开 —— 新标签页,不带 download 属性,绕开"下载"这个动作本身
function renderRecent() {
const box = panel && panel.querySelector('#mufyx-recent');
if (!box) return;
box.textContent = '';
if (!recentFiles.length) return;
const tip = document.createElement('div');
tip.className = 'rt';
tip.textContent = `下载列表里没有?点下面这些手动保存(留最近 ${recentFiles.length} 个):`;
box.appendChild(tip);
// 分享失败时的说明落在这里,别只写进日志(日志可能已经滚很远了)
const note = document.createElement('div');
note.className = 'ns';
for (const f of [...recentFiles].reverse()) {
const row = document.createElement('div');
row.className = 'fr';
const a = document.createElement('a');
a.className = 'dl';
a.href = f.url;
a.download = f.name;
a.textContent = `⬇ ${f.name} ${mb(f.size)}`;
row.appendChild(a);
const file = shareableFile(f);
if (file) {
const b = document.createElement('button');
b.className = 'alt';
b.textContent = '📤 分享';
b.onclick = () => {
note.textContent = '';
// 点击本身就是用户手势,同步调用才算数
navigator.share({ files: [file] }).catch((e) => {
if (e && e.name === 'AbortError') return; // 自己点了取消,不是错
note.textContent = `分享没成功:${(e && e.message) || e}。试试左边的 ⬇ 或右边的 ↗。`;
});
};
row.appendChild(b);
}
const o = document.createElement('a');
o.className = 'alt';
o.href = f.url;
o.target = '_blank';
o.rel = 'noopener';
o.textContent = '↗ 打开';
row.appendChild(o);
box.appendChild(row);
}
const hint = document.createElement('div');
hint.className = 'rt hint';
hint.textContent =
'「↗ 打开」是最后的退路:它在新标签页里打开这个文件,存下来的文件名可能变成一串乱码,自己改回来就行。'
+ ' 上面这些都点了没反应=这个浏览器不给下载(在微信/QQ 里打开的网页最常见,'
+ '或者装了迅雷/IDM 这类下载器扩展)——换成手机自带的浏览器、或者用电脑重导一次最稳。';
box.appendChild(hint);
box.appendChild(note);
}
// ---------- 别让屏幕熄 ----------
// 手机上导一批角色要跑很久,中途息屏 / 切后台,系统就会把这个标签页回收掉,
// 导出当场断(有安卓用户反馈「息屏几次之后直接被踢出去,下载好的也没了」)。
// Wake Lock 能挡住「屏幕自动熄灭」,但**挡不住用户手动锁屏或切走**——
// 那两种情况下锁会被系统自动释放,所以回到前台时要重新申请。
let wakeLock = null;
async function acquireWakeLock(report) {
if (!navigator.wakeLock) {
if (report) report(' (这个浏览器不支持「保持屏幕常亮」,导出期间请别让屏幕熄。)');
return;
}
try {
wakeLock = await navigator.wakeLock.request('screen');
wakeLock.addEventListener('release', () => { wakeLock = null; });
if (report) report(' 已请求保持屏幕常亮,导出期间别切到别的 App。');
} catch (e) {
// 页面不可见时申请会抛,不是致命问题,别打断导出
if (report) report(` (没能保持屏幕常亮:${e.message}。请别让屏幕熄。)`);
}
}
function releaseWakeLock() {
try { if (wakeLock) wakeLock.release(); } catch (e) {}
wakeLock = null;
}
// 回到前台就补回来(息屏/切走的时候系统已经把它释放了)
const onVisibleReacquire = () => {
if (document.visibilityState === 'visible' && !wakeLock) acquireWakeLock(null);
};
let downloadCount = 0; // 这一轮到底真的产出了几个文件
// 这一轮有没有撞上「卡取不到」的角色 —— 结尾那句话要跟着改,
// 否则会把人往「是不是会员过期了」那个错误方向带(真发生过)。
let sawCardMissing = false;
function downloadBlob(filename, blob) {
downloadCount += 1;
const url = URL.createObjectURL(blob);
// 先把文件名和大小记进日志再点 —— 以后有人反馈「说完成了却没文件」,
// 一看日志就知道是「压根没生成」还是「生成了但浏览器没收下」。
say(` ⬇ ${filename} ${mb(blob.size)}`);
const a = document.createElement('a');
a.href = url;
a.download = filename;
document.body.appendChild(a);
a.click();
a.remove();
rememberFile(filename, url, blob.size, blob);
// 🔴 2026-08-12 安卓用户报:「前几个角色都弹了下载,后面这几个就不弹了,
// 下载列表里只有最早那两个。」—— 这是浏览器的「自动下载多个文件」策略,
// 第一个是用户点「开始导出」带出来的、算手势,从第二个起就归它管了,
// **而且拦得没有任何提示**。所以话要在这个当口说(事后说没用)。
if (downloadCount === 2) {
say(' ⚠️ 从第二个文件起,浏览器可能会问「是否允许下载多个文件」——**一定要点允许**。');
say(' 如果它没问、而下载列表停在前一两个:就是被静默拦了(安卓上最常见)。');
say(' 去浏览器的「网站设置 → 自动下载」把这个站设成允许,再导一次;');
say(' 或者用面板底下那排「⬇ 手动保存」把已经做好的补存下来。');
}
}
// 安卓(和 Windows 上一些老编辑器)看 .md 时会**自己猜编码**,猜成 GBK 中文就全是乱码。
// 猜得准不准跟文件内容有关,所以会出现「同一批导出,有的正常有的乱码」。
// 加个 UTF-8 BOM 就是明着告诉它别猜。
// ⚠️ **只给 .md 加**:JSON 带 BOM 会让 `JSON.parse` / Python 的 `json.loads` 直接报错
// (`make-epub.py` 和网页阅读器读的就是 `_原始数据.json`),EPUB 里的 XHTML 也不要。
const BOM = '\uFEFF'; // 写成转义:源码里别放不可见字符,会被编辑器/脚本悄悄吃掉 // 写成转义,别在源码里放不可见字符——会被编辑器/脚本悄悄吃掉
const wantsBom = (name) => /\.md$/i.test(name || '');
const withBom = (name, text) =>
wantsBom(name) && !String(text).startsWith(BOM) ? BOM + text : text;
const download = (filename, text) =>
downloadBlob(filename, new Blob([withBom(filename, text)], { type: 'text/plain;charset=utf-8' }));
// ---------- ZIP(不依赖任何外部库) ----------
// 压缩用浏览器原生 CompressionStream('deflate-raw');没有就退回不压缩存储。
const CRC_TABLE = (() => {
const t = new Uint32Array(256);
for (let i = 0; i < 256; i++) {
let c = i;
for (let k = 0; k < 8; k++) c = c & 1 ? 0xedb88320 ^ (c >>> 1) : c >>> 1;
t[i] = c >>> 0;
}
return t;
})();
function crc32(buf) {
let c = 0xffffffff;
for (let i = 0; i < buf.length; i++) c = CRC_TABLE[(c ^ buf[i]) & 0xff] ^ (c >>> 8);
return (c ^ 0xffffffff) >>> 0;
}
async function deflateRaw(bytes) {
if (typeof CompressionStream === 'undefined') return null;
try {
const stream = new Blob([bytes]).stream().pipeThrough(new CompressionStream('deflate-raw'));
return new Uint8Array(await new Response(stream).arrayBuffer());
} catch (e) {
return null;
}
}
// DOS 时间戳,好让资源管理器里按日期排得出来
function dosDT(d) {
const t = d instanceof Date && !isNaN(d) && d.getFullYear() >= 1980 ? d : new Date(1980, 0, 1);
return {
date: ((t.getFullYear() - 1980) << 9) | ((t.getMonth() + 1) << 5) | t.getDate(),
time: (t.getHours() << 11) | (t.getMinutes() << 5) | Math.floor(t.getSeconds() / 2),
};
}
// files: [{ name, text | bytes, date, store }]
// bytes = 已经是 Uint8Array 的二进制(封面 PNG 走这条)
// store = 强制不压缩。EPUB 的 mimetype 必须是第一个条目且不压缩,否则很多阅读器不认。
// ⚠️ 这里的建议以前是写死的三句,结果对两种人是错的(2026-08-10 用户报上来的):
// · 导 **EPUB** 的人——EPUB 本身就是个 zip,走的也是这个函数,
// 可它**根本不产出 JSON**,"取消勾选附带 JSON"对他毫无作用;
// · **已经填过分批**的人——还劝他"填成 20"是让他往回走。
// 所以现在传 opts 进来,看人下菜。
async function makeZip(files, onProgress, mime, opts) {
// 体量太大就先打个招呼。有用户在 Safari 上导一个 352 段存档的角色时遇到:
// 日志显示导出完毕,**页面却自己刷新了一下**,然后什么文件都没有。
// 那不是刷新,是标签页被系统回收后重载了——页面一没,blob 跟着没,
// 连「⬇ 手动保存」都救不回来。所以这话必须在开跑前说,事后说没用。
const bulk = files.reduce((n, f) => n + (f.text ? f.text.length : 0), 0);
if (bulk > 8e6) {
const o = opts || {};
say(` ⚠️ 这一包很大(约 ${(bulk / 1e6).toFixed(1)}M 字)。手机浏览器可能扛不住。`);
say(' 症状是「显示导出完毕、页面自己刷新了一下、然后没有文件」=标签页被系统回收了。');
// 分批切的是**段数**,切不开"单段特别长"。段数已经到底时再劝人调小是耍人。
if (o.chunk) {
say(` 你已经填了「每包最多 ${o.chunk} 段」。还能再调小就调(比如 ${Math.max(1, Math.floor(o.chunk / 2))});`);
say(' ⚠️ 但如果这个角色本来就只有一两段,那就已经到底了 ——');
say(' **分批切的是「段数」,切不开「一整段特别长」这种**,再填小也没用。');
} else {
say(' 建议:先把面板上的「每包最多多少段对话」填成 20 再导一次(这是最有效的一招)。');
}
if (o.json && o.shape !== 'epub') {
say(' 还可以取消勾选「附带 JSON 完整备份」(那份通常占一半以上)。');
} else if (o.shape === 'epub') {
// 别让人去关一个在这条路上根本不存在的开关
say(' (你导的是电子书,本来就不带 JSON,去关那个勾选没有用。)');
}
say(' 以上都到头了就**用电脑导这个角色** —— 桌面浏览器的内存额度宽得多,这是最稳的。');
}
const enc = new TextEncoder();
const parts = [];
const central = [];
let offset = 0;
for (let i = 0; i < files.length; i++) {
const f = files[i];
const nameBytes = enc.encode(f.name);
// withBom 只对 .md 生效;.json 和 EPUB 内部的 xhtml/opf/ncx 一律不加
const raw = f.bytes ? f.bytes : enc.encode(withBom(f.name, f.text));
// 🔴 编码完立刻把源数据放掉。几百段对话的 Markdown 加上那份完整 JSON 会一直
// 压在堆里,和压缩产物同时存在 —— 峰值能把标签页顶崩。
// 有用户在 Safari 上导一个 352 段存档的角色时遇到过:日志显示导出完毕,
// **页面却自己刷新了一下**,然后什么文件都没有。那不是刷新,
// 是系统把这个标签页杀掉后重载了;页面一重载,blob 跟着没,
// 连 v1.14 那个「手动保存」兜底都救不回来。
// 调用方在 makeZip 之后不再读 text/bytes(emit / emitMasks / emitCards 都是先建后打包)。
f.text = null;
f.bytes = null;
const crc = crc32(raw);
const { date, time } = dosDT(f.date);
let method = 0;
let body = raw;
if (!f.store) {
const packed = await deflateRaw(raw);
if (packed && packed.length < raw.length) { method = 8; body = packed; }
}
const lh = new Uint8Array(30 + nameBytes.length);
const dv = new DataView(lh.buffer);
dv.setUint32(0, 0x04034b50, true);
dv.setUint16(4, 20, true);
dv.setUint16(6, 0x0800, true); // 文件名按 UTF-8 解,中文名才不会乱码
dv.setUint16(8, method, true);
dv.setUint16(10, time, true);
dv.setUint16(12, date, true);
dv.setUint32(14, crc, true);
dv.setUint32(18, body.length, true);
dv.setUint32(22, raw.length, true);
dv.setUint16(26, nameBytes.length, true);
dv.setUint16(28, 0, true);
lh.set(nameBytes, 30);
parts.push(lh, body);
central.push({ nameBytes, crc, csize: body.length, usize: raw.length, method, date, time, offset });
offset += lh.length + body.length;
if (onProgress && i % 5 === 0) onProgress(i + 1, files.length);
}
let cdSize = 0;
const cd = [];
for (const c of central) {
const h = new Uint8Array(46 + c.nameBytes.length);
const dv = new DataView(h.buffer);
dv.setUint32(0, 0x02014b50, true);
dv.setUint16(4, 20, true);
dv.setUint16(6, 20, true);
dv.setUint16(8, 0x0800, true);
dv.setUint16(10, c.method, true);
dv.setUint16(12, c.time, true);
dv.setUint16(14, c.date, true);
dv.setUint32(16, c.crc, true);
dv.setUint32(20, c.csize, true);
dv.setUint32(24, c.usize, true);
dv.setUint16(28, c.nameBytes.length, true);
dv.setUint32(42, c.offset, true);
h.set(c.nameBytes, 46);
cd.push(h);
cdSize += h.length;
}
const eocd = new Uint8Array(22);
const dv = new DataView(eocd.buffer);
dv.setUint32(0, 0x06054b50, true);
dv.setUint16(8, central.length, true);
dv.setUint16(10, central.length, true);
dv.setUint32(12, cdSize, true);
dv.setUint32(16, offset, true);
return new Blob([...parts, ...cd, eocd], { type: mime || 'application/zip' });
}
// ---------- 内容处理 ----------
// content 是 [{type:'text', text:'...'}] 这样的数组
function contentToText(content) {
if (typeof content === 'string') return content;
if (!Array.isArray(content)) return content == null ? '' : JSON.stringify(content);
return content
.map((part) => {
if (part == null) return '';
if (typeof part === 'string') return part;
if (part.type === 'text' || typeof part.text === 'string') return part.text || '';
if (part.url) return ``;
return JSON.stringify(part);
})
.join('\n');
}
// 可选清洗:去掉 与状态栏 HTML,只留正文
function tidy(text) {
return String(text)
.replace(/[\s\S]*?<\/think>/g, '')
.replace(//g, '')
.replace(/