472 lines
23 KiB
JavaScript
472 lines
23 KiB
JavaScript
/*
|
||
* 聚胜算「平台原始账单[新]」每日账单导出(统一脚本,用 --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();
|