472 lines
23 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

/*
* 聚胜算「平台原始账单[新]」每日账单导出(统一脚本,用 --platform 选平台)
*
* --platform=toutiao 头条放心购 → 资金流水 (导入 import-douyin-authoritative → 抖音账户)
* --platform=jd 京东商城 → 京东日账单 (导入 import-jd-daily → 京东账单 + 京喜账单,按店铺名拆分)
* --platform=jd-wallet 京东商城 → 京东钱包 (导入 import-jd-wallet → 京东网银钱包账户)
*
* 用法:
* node export-daily.mjs --platform=toutiao # 本月月初 → 昨日
* node export-daily.mjs --platform=jd --through=2026-07-31
* node export-daily.mjs --platform=jd --start=2026-07-01 --through=2026-07-06 # 分段导出
*
* 输出:CSV 文件(默认落 runtime/private/erp-exports/),并打印笔数/收入/支出核对数。
* 凭据来源:runtime/private/敏感信息.md(被 .gitignore 忽略)。
*/
import fs from 'node:fs';
import path from 'node:path';
import { fileURLToPath } from 'node:url';
import { loginErp, dismissDialogs, realClick } from './erp-session.mjs';
import { evaluate, sleep, goto, shot } from './cdp.mjs';
const BASE = path.dirname(fileURLToPath(import.meta.url));
/** 平台配置:页签、子页签、导出文件名前缀。 */
const PLATFORMS = {
toutiao: { label: '头条放心购', subTab: '资金流水', filePrefix: '头条放心购_资金流水', exportMainName: '头条放心购头条资金流水' },
jd: { label: '京东商城', subTab: '京东日账单', filePrefix: '京东日账单', exportMainName: '京东商城京东日账单' },
'jd-wallet': { label: '京东商城', subTab: '京东钱包', filePrefix: '京东钱包', exportMainName: '京东商城京东钱包' },
};
const arg = (name, fallback = null) => {
const hit = process.argv.find((a) => a.startsWith(`--${name}=`));
return hit ? hit.split('=').slice(1).join('=') : fallback;
};
const fmt = (d) => `${d.getFullYear()}-${String(d.getMonth() + 1).padStart(2, '0')}-${String(d.getDate()).padStart(2, '0')}`;
const today = new Date();
const yesterday = new Date(today.getTime() - 86400000);
const through = arg('through', fmt(yesterday));
// 归期以截止日所在月份为准:每月 1 日自动导出上一个整月(月初→上月末),其余日期导出本月月初→昨日。
const monthStart = arg('start', arg('month', through.slice(0, 7)) + '-01');
const OUT_DIR = arg('out', BASE + '\\erp-exports');
const BILL_URL = 'https://ss.erp321.com/process/platform-bill-new';
const platformKey = arg('platform', 'toutiao');
const platform = PLATFORMS[platformKey];
if (!platform) {
throw new Error('未知平台:' + platformKey + '(可选 toutiao / jd / jd-wallet)');
}
fs.mkdirSync(OUT_DIR, { recursive: true });
const log = (...a) => console.log(new Date().toISOString().slice(11, 19), ...a);
const conditions = [
{ SerachFldName: 'ab.create_time', serachColName: '结算时间', value: monthStart, operatorSymbol: 'GreaterThanOrEqual' },
{ SerachFldName: 'ab.create_time', serachColName: '结算时间', value: through, operatorSymbol: 'LessThan' },
{ SerachFldName: 'ab.channel', value: 'TouTiaoFXGZJ', serachColName: '渠道', operatorSymbol: 'In' },
];
async function clickPoint(cdp, sid, x, y) {
const pt = { x: Math.round(x), y: Math.round(y) };
await cdp.send('Input.dispatchMouseEvent', { type: 'mouseMoved', ...pt }, sid);
await cdp.send('Input.dispatchMouseEvent', { type: 'mousePressed', ...pt, button: 'left', clickCount: 1 }, sid);
await cdp.send('Input.dispatchMouseEvent', { type: 'mouseReleased', ...pt, button: 'left', clickCount: 1 }, sid);
}
async function closeAuthModal(cdp, sid, rounds = 3) {
for (let i = 0; i < rounds; i++) {
const box = await evaluate(cdp, sid, `(() => {
const vis = (el) => (el.offsetWidth || el.offsetHeight) && getComputedStyle(el).display !== 'none';
const m = [...document.querySelectorAll('.ant-modal.auth-expired-dialog')].filter(vis)[0];
if (!m) return null;
const b = m.querySelector('.ant-modal-close');
if (!b) return null;
const r = b.getBoundingClientRect();
return { x: r.left + r.width / 2, y: r.top + r.height / 2 };
})()`);
if (!box) return;
await clickPoint(cdp, sid, box.x, box.y);
await sleep(1000);
}
// 无头 Edge 下这个弹窗的关闭动画有时会卡在 leave 状态:X 已经点过,但弹窗和遮罩一直可见,
// 挡住日期面板与查询/导出按钮,看起来像「日历打不开」。先补发动画结束事件让 antd 正常卸载
// (无头下 CSS 动画不触发 animationend,rc-motion 就一直停在 leave);仍残留再退化为直接隐藏,
// 避免留下「隐藏但仍打开」的弹窗继续持有焦点/遮罩。只动「店铺授权过期提醒」这一棵。
const settle = await evaluate(cdp, sid, `(() => {
const vis = (el) => (el.offsetWidth || el.offsetHeight) && getComputedStyle(el).display !== 'none';
const dialog = [...document.querySelectorAll('.ant-modal.auth-expired-dialog')].filter(vis)[0];
if (!dialog) return null;
const root = dialog.closest('.ant-modal-root') || dialog.parentElement;
const mask = root ? root.querySelector('.ant-modal-mask') : null;
for (const el of [dialog, mask].filter(Boolean)) {
el.dispatchEvent(new Event('animationend', { bubbles: true }));
el.dispatchEvent(new Event('transitionend', { bubbles: true }));
}
return true;
})()`);
if (!settle) return;
await sleep(1200);
const hidden = await evaluate(cdp, sid, `(() => {
const vis = (el) => (el.offsetWidth || el.offsetHeight) && getComputedStyle(el).display !== 'none';
const dialog = [...document.querySelectorAll('.ant-modal.auth-expired-dialog')].filter(vis)[0];
if (!dialog) return 0;
let n = 0;
for (const el of [dialog, dialog.closest('.ant-modal-wrap'), dialog.closest('.ant-modal-root')]) {
if (el) { el.style.display = 'none'; n++; }
}
if (getComputedStyle(document.body).overflow === 'hidden') document.body.style.overflow = '';
return n;
})()`);
if (hidden) log('授权过期弹窗卡在关闭动画,已隐藏遮挡元素 ' + hidden + ' 个');
}
const { cdp, sessionId, state, close } = await loginErp({ headless: true });
if (!/epaas/.test(state.url)) {
await goto(cdp, sessionId, 'https://ww.erp321.com/epaas');
await sleep(4000);
}
await dismissDialogs(cdp, sessionId);
await realClick(cdp, sessionId, '胜算');
await sleep(7000);
// 「胜算」新标签页有时要十几秒才注册到 CDP,第一次点击也可能没生效(2026-10-13 09:30 就因为
// targetInfos 里还没有 ss.erp321.com,直接读 ss.targetId 报 "Cannot read properties of undefined")。
// 所以这里轮询等待,中途补点一次「胜算」,并在超过 30 秒仍未出现时给出明确错误。
let ss = null;
for (let attempt = 1; attempt <= 15 && !ss; attempt++) {
const targets = await cdp.send('Target.getTargets');
ss = targets.targetInfos.find((t) => /ss\.erp321\.com/.test(t.url));
if (ss) break;
if (attempt === 1) log(' 等待胜算标签页出现');
if (attempt === 8) {
log(' 胜算标签页仍未出现,重新点击「胜算」');
await realClick(cdp, sessionId, '胜算');
}
await sleep(2000);
}
if (!ss) throw new Error('未找到胜算标签页(ss.erp321.com)');
const { sessionId: sid } = await cdp.send('Target.attachToTarget', { targetId: ss.targetId, flatten: true });
await cdp.send('Page.enable', {}, sid);
await cdp.send('Runtime.enable', {}, sid);
// 打开账单页并激活平台页签(建立模块上下文)
await goto(cdp, sid, BILL_URL);
await sleep(6000);
await closeAuthModal(cdp, sid);
await evaluate(cdp, sid, `(() => { const norm=(s)=>(s||'').replace(/\\s+/g,''); const el=[...document.querySelectorAll('.ant-tabs-tab-btn, [role=tab]')].find(e=>norm(e.innerText)===${JSON.stringify(platform.label)}); el && el.click(); })()`);
await sleep(4500);
await closeAuthModal(cdp, sid);
await evaluate(cdp, sid, `(() => { const norm=(s)=>(s||'').replace(/\\s+/g,''); const el=[...document.querySelectorAll('.sub-tab-item')].find(e=>norm(e.innerText)===${JSON.stringify(platform.subTab)}); el && el.click(); })()`);
await sleep(2500);
await closeAuthModal(cdp, sid);
log(`结算时间范围:${monthStart} → ${through}`);
// 监听查询/导出接口
await cdp.send('Network.enable', {}, sid);
const seen = { list: [], export: [] };
cdp.on((msg) => {
if (msg.sessionId !== sid) return;
if (msg.method === 'Network.requestWillBeSent') {
const url = msg.params.request.url;
if (/GetBillRecordsList/.test(url) && msg.params.request.method === 'POST') seen.list.push(msg.params.requestId);
if (/AdaptiveExport\/ExportBillRecordsList/.test(url) && msg.params.request.method === 'POST') seen.export.push(msg.params.requestId);
}
});
// 1) 设置结算时间 = 月初 → 昨日(日历面板)
log('设置结算时间');
// 页签切换后筛选区偶尔还没渲染出来,先等「开始日期」输入框出现,避免直接取坐标时报 undefined
for (let wait = 0; wait < 20; wait++) {
const ready = await evaluate(cdp, sid, `!![...document.querySelectorAll('input')].find(x => x.placeholder === '开始日期' && (x.offsetWidth || x.offsetHeight))`);
if (ready) break;
if (wait === 0) log(' 等待筛选区渲染');
await sleep(1000);
}
// 日期面板有时只渲染在屏幕外(left/top = -9999)或尚未定位:这时坐标点击会点空,但面板其实已经可选。
// 所以选日期时视口内照旧按坐标点,落在视口外就退回 DOM click(rc-picker 单元格的 onClick 一样生效)。
const IN_VIEW_JS = `(el) => { const r = el.getBoundingClientRect(); return r.width > 0 && r.height > 0 && r.left >= 0 && r.top >= 0 && r.left < innerWidth && r.top < innerHeight; }`;
// 「店铺授权过期提醒」会反复弹出遮挡,先关弹窗再点日期框,并逐次校验面板是否真的打开
let calendarOpen = false;
for (let attempt = 1; attempt <= 8 && !calendarOpen; attempt++) {
await closeAuthModal(cdp, sid, 2);
const sBox = await evaluate(cdp, sid, `(() => { const i = [...document.querySelectorAll('input')].find(x => x.placeholder === '开始日期' && (x.offsetWidth||x.offsetHeight)); if (!i) return null; const r = i.getBoundingClientRect(); return { x: r.left + 6, y: r.top + r.height/2 }; })()`);
if (!sBox) { log(' 筛选区还没出来,稍后重试'); await sleep(2000); continue; }
await clickPoint(cdp, sid, sBox.x, sBox.y);
await sleep(1200);
// 等日期面板渲染出来(最多约 4 秒)
let cells = 0;
for (let wait = 0; wait < 8 && cells === 0; wait++) {
cells = await evaluate(cdp, sid, `document.querySelectorAll('.ant-picker-dropdown td[title]').length`);
if (cells === 0) await sleep(500);
}
calendarOpen = cells > 0;
if (!calendarOpen) log(` 日历未打开,第 ${attempt} 次重试`);
}
if (!calendarOpen) {
await shot(cdp, sid, OUT_DIR + '\\date-panel-failed.png');
throw new Error('日历面板未能打开,已截图:' + OUT_DIR + '\\date-panel-failed.png');
}
async function pickDate(title) {
for (let i = 0; i < 30; i++) {
const hit = await evaluate(cdp, sid, `(() => {
const inView = ${IN_VIEW_JS};
const c = [...document.querySelectorAll('.ant-picker-dropdown td[title]')]
.filter(td => (td.offsetWidth || td.offsetHeight) && td.getAttribute('title') === ${JSON.stringify(title)})[0];
if (!c) return null;
if (inView(c)) { const r = c.getBoundingClientRect(); return { x: r.left + r.width/2, y: r.top + r.height/2 }; }
c.click();
return 'js-click';
})()`);
if (hit === 'js-click') {
await sleep(1500);
return true;
}
if (hit) {
await clickPoint(cdp, sid, hit.x, hit.y);
await sleep(1500);
return true;
}
// 目标月份不在当前面板上:先把日历往前翻(左面板的上一月按钮)
const moved = await evaluate(cdp, sid, `(() => {
const prev = [...document.querySelectorAll('.ant-picker-header-prev-btn')].filter(el => el.offsetWidth || el.offsetHeight)[0];
if (!prev) return false;
prev.click();
return true;
})()`);
if (moved) {
await sleep(900);
continue;
}
// 面板没开或已切走:重新打开日期面板
const sBox = await evaluate(cdp, sid, `(() => { const i = [...document.querySelectorAll('input')].find(x => x.placeholder === '开始日期' && (x.offsetWidth||x.offsetHeight)); if (!i) return null; const r = i.getBoundingClientRect(); return { x: r.left + 6, y: r.top + r.height/2 }; })()`);
if (sBox) await clickPoint(cdp, sid, sBox.x, sBox.y);
await sleep(1800);
}
return false;
}
for (const title of [monthStart, through]) {
const ok = await pickDate(title);
if (!ok) throw new Error('日历里找不到日期:' + title);
}
const picked = await evaluate(cdp, sid, `[...document.querySelectorAll('input')].filter(i=>i.placeholder==='开始日期'||i.placeholder==='结束日期').map(i=>i.value).slice(0,2)`);
log('日期框:', JSON.stringify(picked));
if (picked[0] !== monthStart || picked[1] !== through) throw new Error('日期设置失败:' + JSON.stringify(picked));
const buttonPoint = (text) => `(() => {
const norm = (s) => (s || '').replace(/\\s+/g, '');
const b = [...document.querySelectorAll('button')].filter(x => norm(x.innerText) === ${JSON.stringify(text)} && (x.offsetWidth || x.offsetHeight)).pop();
if (!b) return null;
const r = b.getBoundingClientRect();
return { x: r.left + r.width / 2, y: r.top + r.height / 2 };
})()`;
// 2) 查询(同时建立页面模块上下文)——弹窗会周期性遮挡,失败就重试
let countText = null;
for (let attempt = 1; attempt <= 4 && !countText; attempt++) {
await closeAuthModal(cdp, sid, 2);
const before = seen.list.length;
const qb = await evaluate(cdp, sid, buttonPoint('查询'));
if (!qb) { log(`第${attempt}次:找不到查询按钮`); await sleep(2000); continue; }
await clickPoint(cdp, sid, qb.x, qb.y);
await sleep(8000);
countText = await evaluate(cdp, sid, `((document.body.innerText || '').replace(/\\s+/g,' ').match(/共(\\d+)条/) || [])[1] || null`);
log(`查询第${attempt}次:笔数=${countText},新请求=${seen.list.length - before}`);
if (!countText) await sleep(2000);
}
if (!countText) {
// 胜算该区间还没有账单(如当月账单尚未拉取)时按“无数据”结束,不算失败。
const noData = await evaluate(cdp, sid, `((document.body.innerText || '').includes('暂无数据'))`);
if (noData) {
log('该区间胜算暂无数据,跳过导出:' + monthStart + ' ~ ' + through);
fs.writeFileSync(path.join(OUT_DIR, 'last-export.json'), JSON.stringify({
platform: platformKey, platformLabel: platform.label, empty: true,
monthStart, through, pageCount: 0,
}, null, 2));
close();
process.exit(0);
}
throw new Error('查询未返回数据');
}
log('页面笔数:', countText);
/**
* 异步导出兜底:导出量超过胜算同步上限时,接口返回 { url:null, taskId:N, msg:'超出同步导出允许最大数据量。' },
* 文件由「异步导出下载管理」异步产出。这里轮询该列表,取本次任务的全部切片直链并逐一下载。
* 列表接口:POST https://pf.erp321.com/WebApi/PF/NightPlan/GetNightList?uvalue=pfcweb_process.platform-bill-new
* 任务行 :type='BillRecordsListExport'、status='Success'、data(JSON串).mainName、data.adaptiveExportInfo(JSON串).splitTimes[].filePath[].fileUrl
*/
async function fetchAsyncExportFiles(mainName) {
const listBody = JSON.stringify({
uid: '', coid: '', ip: '',
page: { currentPage: 1, pageSize: 25 },
data: { versionModel: 1201, cbShopSite: '', useOldTaskTable: false },
ssProjectType: 'SS', ssClientType: 'Web',
});
const listJs = `(async () => {
const r = await fetch('https://pf.erp321.com/WebApi/PF/NightPlan/GetNightList?uvalue=pfcweb_process.platform-bill-new', {
method: 'POST', credentials: 'include',
headers: { 'Content-Type': 'application/json;charset=UTF-8' },
body: ${JSON.stringify(listBody)},
});
const j = await r.json();
return (j.data || []).map((x) => {
let mainName = null, lineCount = null, files = [];
try {
const d = JSON.parse(x.data);
mainName = d.mainName;
const info = JSON.parse(d.adaptiveExportInfo);
lineCount = info.lineCount;
for (const s of info.splitTimes || []) {
for (const f of s.filePath || []) {
if (f.fileUrl) files.push({ url: f.fileUrl, rows: s.rowCount, begin: s.beginTime, end: s.endTime });
}
}
} catch (e) {}
return { type: x.type, status: x.status, created: x.created, mainName, lineCount, files };
});
})()`;
// 只认本次点击之后创建的同名任务(列表保留 3 天,避免取到历史任务留下的旧文件);容差 90 分钟以吸收服务器与本地时钟偏差。
const cutoff = new Date(Date.now() + 8 * 3600000 - 90 * 60000).toISOString().slice(0, 19).replace('T', ' ');
for (let round = 1; round <= 40; round++) {
await sleep(15000);
let rows = [];
try {
rows = (await evaluate(cdp, sid, listJs)) || [];
} catch (err) {
log(` 异步导出:第 ${round} 次读取下载中心失败(${String(err).slice(0, 140)})`);
continue;
}
const mine = rows
.filter((x) => x.type === 'BillRecordsListExport' && x.mainName === mainName && String(x.created || '') >= cutoff)
.sort((a, b) => String(a.created).localeCompare(String(b.created)));
if (!mine.length) {
log(` 异步导出:第 ${round} 次未找到「${mainName}」本次任务(${cutoff} 之后),继续等待`);
continue;
}
const done = mine.filter((x) => x.status === 'Success');
if (!done.length) {
log(` 异步导出:任务未完成(${mine.map((x) => x.status).join('/')}),继续等待`);
continue;
}
const seenUrl = new Set();
const parts = [];
for (const row of done) {
for (const f of row.files) {
if (seenUrl.has(f.url)) continue;
seenUrl.add(f.url);
parts.push(f);
}
}
parts.sort((a, b) => String(a.begin || '').localeCompare(String(b.begin || '')));
if (!parts.length) {
log(' 异步导出:任务已完成但暂无可下载文件,继续等待');
continue;
}
const files = [];
const partRows = [];
for (let i = 0; i < parts.length; i++) {
const res = await fetch(parts[i].url);
if (!res.ok) throw new Error('异步分片下载失败 HTTP ' + res.status + ':' + parts[i].url);
const buf = Buffer.from(await res.arrayBuffer());
const isZip = buf[0] === 0x50 && buf[1] === 0x4b;
const out = path.join(OUT_DIR, `${platform.filePrefix}_${monthStart}_${through}_part${i + 1}.${isZip ? 'xlsx' : 'csv'}`);
fs.writeFileSync(out, buf);
files.push(out);
partRows.push(parts[i].rows == null ? null : Number(parts[i].rows));
log(` 异步导出:分片 ${i + 1}/${parts.length} 已下载 ${path.basename(out)}(${parts[i].rows} 行 / ${parts[i].begin} ~ ${parts[i].end})`);
}
const lineCount = done[done.length - 1].lineCount;
const rowsSum = partRows.reduce((a, b) => a + (b || 0), 0);
log(`异步导出完成:分片 ${files.length} 个,分片行数合计=${rowsSum},任务总行数=${lineCount == null ? '未知' : lineCount}`);
return { files, partRows, lineCount };
}
throw new Error('异步导出在等待窗口内未完成(下载中心:' + mainName + ')');
}
// 3) 导出——同样带重试
let exportRes = null;
let asyncTaskId = null;
let asyncMsg = null;
for (let attempt = 1; attempt <= 4 && !exportRes && !asyncTaskId; attempt++) {
await closeAuthModal(cdp, sid, 2);
const eb = await evaluate(cdp, sid, buttonPoint('导出数据'));
if (!eb) { log(`第${attempt}次:找不到导出按钮`); await sleep(2000); continue; }
await clickPoint(cdp, sid, eb.x, eb.y);
await sleep(2500);
await closeAuthModal(cdp, sid, 1);
const confirmed = await evaluate(cdp, sid, `(() => {
const norm = (s) => (s || '').replace(/\\s+/g, '');
for (const m of [...document.querySelectorAll('.ant-modal')].filter(m => m.offsetWidth || m.offsetHeight)) {
const b = [...m.querySelectorAll('button')].find(x => norm(x.innerText) === '导出');
if (b) { b.click(); return 'ok'; }
}
return 'not-found';
})()`);
log(`导出第${attempt}次:确认框=${confirmed}`);
if (confirmed !== 'ok') { await sleep(2500); continue; }
await sleep(9000);
for (const id of seen.export.slice(-2)) {
try {
const body = await cdp.send('Network.getResponseBody', { requestId: id }, sid);
const parsed = JSON.parse(body.body);
if (parsed?.data?.url) { exportRes = parsed; break; }
if (parsed?.data?.taskId) { asyncTaskId = parsed.data.taskId; asyncMsg = parsed.data.msg; break; }
} catch {}
}
if (!exportRes && !asyncTaskId) await sleep(3000);
}
if (!exportRes && !asyncTaskId) {
for (const id of seen.export.slice(-3)) {
try {
const body = await cdp.send('Network.getResponseBody', { requestId: id }, sid);
log('导出响应原文:', String(body.body).slice(0, 300));
} catch {}
}
throw new Error('导出未成功或未取到下载地址');
}
// 3) 下载文件(同步导出直接给 XLSX 直链;超上限走异步下载中心,可能拆成多片)
const pageCount = countText ? Number(countText) : null;
let summary;
if (exportRes) {
log('导出接口返回:', JSON.stringify({ code: exportRes.code, ok: exportRes.data.isSuccess, url: String(exportRes.data.url).slice(0, 70) + '…' }));
const res = await fetch(exportRes.data.url);
if (!res.ok) throw new Error('下载失败 HTTP ' + res.status);
const buf = Buffer.from(await res.arrayBuffer());
const isZip = buf[0] === 0x50 && buf[1] === 0x4b;
const outFile = path.join(OUT_DIR, `${platform.filePrefix}_${monthStart}_${through}.${isZip ? 'xlsx' : 'csv'}`);
fs.writeFileSync(outFile, buf);
summary = {
platform: platformKey,
platformLabel: platform.label,
async: false,
file: outFile,
files: [outFile],
partCount: 1,
format: isZip ? 'xlsx' : 'csv',
bytes: buf.length,
pageCount,
monthStart,
through,
exportUrl: exportRes.data.url,
};
} else {
log('导出接口返回(异步):', JSON.stringify({ taskId: asyncTaskId, msg: asyncMsg }));
const asyncInfo = await fetchAsyncExportFiles(platform.exportMainName);
const rowsSum = asyncInfo.partRows.reduce((a, b) => a + (b || 0), 0);
if (pageCount && rowsSum && pageCount !== rowsSum) {
log(`警告:页面笔数 ${pageCount} 与异步分片行数合计 ${rowsSum} 不一致`);
}
summary = {
platform: platformKey,
platformLabel: platform.label,
async: true,
taskId: asyncTaskId,
file: asyncInfo.files[0],
files: asyncInfo.files,
partRows: asyncInfo.partRows,
partCount: asyncInfo.files.length,
lineCount: asyncInfo.lineCount,
format: 'xlsx',
pageCount,
monthStart,
through,
exportUrl: null,
};
}
log('导出完成:', JSON.stringify(summary, null, 1));
fs.writeFileSync(path.join(OUT_DIR, 'last-export.json'), JSON.stringify(summary, null, 2));
close();