494 lines
18 KiB
JavaScript
494 lines
18 KiB
JavaScript
import fs from 'node:fs';
|
||
import path from 'node:path';
|
||
import {
|
||
clickVisibleText,
|
||
clickVisibleSelector,
|
||
inspectTransactionPage,
|
||
launchAccountSession,
|
||
openTransactionDetail,
|
||
pageInfo,
|
||
} from './session.mjs';
|
||
import { accountLabel, loadAccounts, maskAccount } from './config.mjs';
|
||
import { shot, sleep } from '../erp-daily-bills/cdp.mjs';
|
||
|
||
const arg = (name, fallback = null) => {
|
||
const hit = process.argv.find((item) => item.startsWith(`--${name}=`));
|
||
return hit ? hit.slice(name.length + 3) : fallback;
|
||
};
|
||
const hasFlag = (name) => process.argv.includes(`--${name}`);
|
||
const formatDate = (date) => [
|
||
date.getFullYear(),
|
||
String(date.getMonth() + 1).padStart(2, '0'),
|
||
String(date.getDate()).padStart(2, '0'),
|
||
].join('-');
|
||
|
||
const targetDate = arg('date', formatDate(new Date(Date.now() - 86400000)));
|
||
if (!/^\d{4}-\d{2}-\d{2}$/.test(targetDate)) throw new Error('日期必须使用 YYYY-MM-DD 格式');
|
||
|
||
const outputRoot = path.resolve(arg('out', path.join(process.cwd(), 'runtime/private/wangshang-bank-exports', targetDate)));
|
||
const runId = new Date().toISOString().replace(/\D/g, '').slice(0, 14);
|
||
const requestedRunDirectory = arg('run-dir', '');
|
||
const runDirectory = requestedRunDirectory
|
||
? path.resolve(requestedRunDirectory)
|
||
: path.join(outputRoot, runId);
|
||
const accountRoot = path.join(runDirectory, 'accounts');
|
||
fs.mkdirSync(accountRoot, { recursive: true });
|
||
|
||
const manifestFile = path.join(runDirectory, 'manifest.json');
|
||
const stateFile = path.join(runDirectory, 'last-export.json');
|
||
const companyFilter = String(arg('company', '')).trim();
|
||
const limit = Math.max(1, Number(arg('limit', '999')) || 999);
|
||
const interactive = hasFlag('interactive');
|
||
const probe = hasFlag('probe');
|
||
const accounts = loadAccounts()
|
||
.filter((account) => !companyFilter || account.company.includes(companyFilter))
|
||
.slice(0, limit);
|
||
|
||
if (accounts.length === 0) throw new Error('没有匹配的网商银行账户');
|
||
|
||
function safeName(value, fallback) {
|
||
const name = String(value || '')
|
||
.replace(/[<>:"/\\|?*\u0000-\u001f]/g, '-')
|
||
.replace(/\s+/g, '')
|
||
.slice(0, 60);
|
||
return name || fallback;
|
||
}
|
||
|
||
function sanitize(value) {
|
||
return String(value ?? '')
|
||
.replace(/\b\d{10,30}\b/g, (digits) => `****${digits.slice(-4)}`)
|
||
.slice(0, 600);
|
||
}
|
||
|
||
function filesIn(directory) {
|
||
if (!fs.existsSync(directory)) return [];
|
||
return fs.readdirSync(directory)
|
||
.map((name) => path.join(directory, name))
|
||
.filter((file) => fs.statSync(file).isFile());
|
||
}
|
||
|
||
async function waitForDownload(directory, beforeFiles, timeoutMs = 90000) {
|
||
const deadline = Date.now() + timeoutMs;
|
||
while (Date.now() < deadline) {
|
||
const downloads = filesIn(directory)
|
||
.filter((file) => !beforeFiles.has(file) && !file.endsWith('.crdownload'))
|
||
.filter((file) => {
|
||
try {
|
||
return Date.now() - fs.statSync(file).mtimeMs > 900;
|
||
} catch {
|
||
return false;
|
||
}
|
||
});
|
||
if (downloads.length > 0) return downloads.sort(
|
||
(left, right) => fs.statSync(right).mtimeMs - fs.statSync(left).mtimeMs,
|
||
)[0];
|
||
await sleep(700);
|
||
}
|
||
return null;
|
||
}
|
||
|
||
async function setTargetDateAndQuery(session) {
|
||
let result;
|
||
let value;
|
||
for (let attempt = 0; attempt < 20; attempt++) {
|
||
result = await session.cdp.send('Runtime.evaluate', {
|
||
expression: `(() => {
|
||
const visible = (el) => !!(el.offsetWidth || el.offsetHeight);
|
||
const datePattern = /^\\d{4}-\\d{2}-\\d{2}$/;
|
||
const inputs = [...document.querySelectorAll('input')]
|
||
.filter((el) => visible(el) && !el.disabled && el.type !== 'hidden');
|
||
let dates = inputs.filter((el) => datePattern.test(el.value || ''));
|
||
if (dates.length < 2) {
|
||
dates = inputs.filter((el) =>
|
||
/日期|开始|结束|交易时间/.test([el.placeholder, el.getAttribute('aria-label')].join(' '))
|
||
);
|
||
}
|
||
if (dates.length < 2) return {
|
||
ok: false,
|
||
error: '未找到开始和结束日期输入框',
|
||
inputs: inputs.map((el) => ({
|
||
type: el.type,
|
||
value: (el.value || '').replace(/\\d{10,30}/g, (x) => '****' + x.slice(-4)),
|
||
placeholder: el.placeholder || '',
|
||
ariaLabel: el.getAttribute('aria-label') || '',
|
||
})),
|
||
};
|
||
return {
|
||
ok: true,
|
||
begin: dates[0].value,
|
||
end: dates[1].value,
|
||
points: dates.map((el) => {
|
||
const rect = el.getBoundingClientRect();
|
||
return { x: rect.x + rect.width / 2, y: rect.y + rect.height / 2 };
|
||
}),
|
||
};
|
||
})()`,
|
||
awaitPromise: true,
|
||
returnByValue: true,
|
||
}, session.sessionId);
|
||
if (result.exceptionDetails) throw new Error('设置网商银行日期失败');
|
||
value = result.result?.value;
|
||
if (value?.ok) break;
|
||
await sleep(700);
|
||
}
|
||
if (!value?.ok) throw new Error(value?.error || '设置网商银行查询条件失败');
|
||
|
||
const clickPoint = async (point) => {
|
||
await session.cdp.send('Input.dispatchMouseEvent', {
|
||
type: 'mousePressed',
|
||
x: point.x,
|
||
y: point.y,
|
||
button: 'left',
|
||
clickCount: 1,
|
||
}, session.sessionId);
|
||
await session.cdp.send('Input.dispatchMouseEvent', {
|
||
type: 'mouseReleased',
|
||
x: point.x,
|
||
y: point.y,
|
||
button: 'left',
|
||
clickCount: 1,
|
||
}, session.sessionId);
|
||
};
|
||
const targetMonthLabel = `${targetDate.slice(0, 4)}年${Number(targetDate.slice(5, 7))}月`;
|
||
const selectCalendarDate = async (inputIndex) => {
|
||
const points = await session.cdp.send('Runtime.evaluate', {
|
||
expression: `([...document.querySelectorAll('input')]
|
||
.filter((el) => (el.offsetWidth || el.offsetHeight)
|
||
&& /^\\d{4}-\\d{2}-\\d{2}$/.test(el.value || ''))
|
||
.map((el) => {
|
||
const rect = el.getBoundingClientRect();
|
||
return { x: rect.x + rect.width / 2, y: rect.y + rect.height / 2 };
|
||
}))`,
|
||
awaitPromise: true,
|
||
returnByValue: true,
|
||
}, session.sessionId).then((response) => response.result?.value || []);
|
||
const point = points[inputIndex];
|
||
if (!point) throw new Error('未找到网商银行日期输入框');
|
||
await clickPoint(point);
|
||
|
||
let header = '';
|
||
for (let attempt = 0; attempt < 8 && header === ''; attempt++) {
|
||
await sleep(350);
|
||
header = await session.cdp.send('Runtime.evaluate', {
|
||
expression: `([...document.querySelectorAll('.ant-picker-header-view')]
|
||
.find((el) => el.offsetWidth || el.offsetHeight)?.innerText || '')`,
|
||
awaitPromise: true,
|
||
returnByValue: true,
|
||
}, session.sessionId).then((response) => String(response.result?.value || '').trim());
|
||
}
|
||
if (header === '') {
|
||
await clickPoint(points[inputIndex]);
|
||
await sleep(700);
|
||
header = await session.cdp.send('Runtime.evaluate', {
|
||
expression: `([...document.querySelectorAll('.ant-picker-header-view')]
|
||
.find((el) => el.offsetWidth || el.offsetHeight)?.innerText || '')`,
|
||
awaitPromise: true,
|
||
returnByValue: true,
|
||
}, session.sessionId).then((response) => String(response.result?.value || '').trim());
|
||
}
|
||
if (header === '') throw new Error('网商银行日期面板未打开');
|
||
|
||
for (let attempt = 0; attempt < 8; attempt++) {
|
||
if (header.includes(targetMonthLabel)) break;
|
||
|
||
const match = /(\d{4})年\s*(\d{1,2})月/.exec(header);
|
||
if (!match) throw new Error('网商银行日期面板月份无法识别:' + header.slice(0, 80));
|
||
const shown = Number(match[1]) * 12 + Number(match[2]);
|
||
const target = Number(targetDate.slice(0, 4)) * 12 + Number(targetDate.slice(5, 7));
|
||
const selector = shown < target
|
||
? '.ant-picker-header-next-btn'
|
||
: '.ant-picker-header-prev-btn';
|
||
if (!await clickVisibleSelector(session.cdp, session.sessionId, selector)) {
|
||
throw new Error('网商银行日期面板无法切换月份');
|
||
}
|
||
await sleep(350);
|
||
header = await session.cdp.send('Runtime.evaluate', {
|
||
expression: `([...document.querySelectorAll('.ant-picker-header-view')]
|
||
.find((el) => el.offsetWidth || el.offsetHeight)?.innerText || '')`,
|
||
awaitPromise: true,
|
||
returnByValue: true,
|
||
}, session.sessionId).then((response) => String(response.result?.value || '').trim());
|
||
}
|
||
|
||
const cell = await session.cdp.send('Runtime.evaluate', {
|
||
expression: `(() => {
|
||
const el = [...document.querySelectorAll('td[title="${targetDate}"]')]
|
||
.find((cell) => (cell.offsetWidth || cell.offsetHeight)
|
||
&& /ant-picker-cell-in-view/.test(cell.className || ''));
|
||
const rect = el?.getBoundingClientRect();
|
||
return rect ? { x: rect.x + rect.width / 2, y: rect.y + rect.height / 2 } : null;
|
||
})()`,
|
||
awaitPromise: true,
|
||
returnByValue: true,
|
||
}, session.sessionId).then((response) => response.result?.value);
|
||
if (!cell) throw new Error(`网商银行日期面板找不到 ${targetDate}`);
|
||
await clickPoint(cell);
|
||
await sleep(600);
|
||
};
|
||
|
||
const currentDates = () => session.cdp.send('Runtime.evaluate', {
|
||
expression: `([...document.querySelectorAll('input')]
|
||
.filter((el) => /^\\d{4}-\\d{2}-\\d{2}$/.test(el.value || ''))
|
||
.map((el) => el.value))`,
|
||
awaitPromise: true,
|
||
returnByValue: true,
|
||
}, session.sessionId).then((response) => response.result?.value || []);
|
||
|
||
if (value.begin !== targetDate) await selectCalendarDate(0);
|
||
let dates = await currentDates();
|
||
if (dates[1] !== targetDate) await selectCalendarDate(1);
|
||
dates = await currentDates();
|
||
if (dates[0] !== targetDate || dates[1] !== targetDate) {
|
||
throw new Error('网商银行日期选择未生效');
|
||
}
|
||
if (!await clickVisibleText(session.cdp, session.sessionId, '查询')) {
|
||
throw new Error('未找到查询按钮');
|
||
}
|
||
await sleep(2500);
|
||
}
|
||
|
||
async function waitForQueryResult(session) {
|
||
const deadline = Date.now() + 60000;
|
||
while (Date.now() < deadline) {
|
||
const result = await session.cdp.send('Runtime.evaluate', {
|
||
expression: `(() => {
|
||
const text = document.body?.innerText || '';
|
||
const match = text.match(/共(?:计)?\\s*(\\d+)\\s*条/);
|
||
const money = (pattern) => {
|
||
const hit = text.match(pattern);
|
||
return hit ? Number(String(hit[1]).replace(/,/g, '')) : null;
|
||
};
|
||
return {
|
||
count: match ? Number(match[1]) : null,
|
||
countText: match?.[0] || '',
|
||
incomeTotal: money(/借方总额[((](?:收入|收)[))][::\\s]*[¥¥]?\\s*([\\d,.]+)/),
|
||
expenseTotal: money(/贷方总额[((](?:支出|支)[))][::\\s]*[¥¥]?\\s*([\\d,.]+)/),
|
||
empty: /暂无数据|没有找到符合条件|未查询到交易|未查询到相关内容/.test(text),
|
||
loading: /加载中|查询中/.test(text),
|
||
queryBusy: [...document.querySelectorAll('button')]
|
||
.some((el) => /^查询$/.test((el.innerText || '').replace(/\\s+/g, '')) && el.disabled),
|
||
dateValues: [...document.querySelectorAll('input')]
|
||
.filter((el) => /^\\d{4}-\\d{2}-\\d{2}$/.test(el.value || ''))
|
||
.map((el) => el.value)
|
||
};
|
||
})()`,
|
||
awaitPromise: true,
|
||
returnByValue: true,
|
||
}, session.sessionId);
|
||
const value = result.result?.value;
|
||
if (value && !value.loading && !value.queryBusy
|
||
&& value.dateValues.includes(targetDate)
|
||
&& (value.count !== null || value.empty)) {
|
||
return { ...value, count: value.count ?? 0 };
|
||
}
|
||
await sleep(900);
|
||
}
|
||
throw new Error('网商银行交易明细查询超时');
|
||
}
|
||
|
||
async function clickDownloadAll(session, expectedRows) {
|
||
if (await clickVisibleText(
|
||
session.cdp,
|
||
session.sessionId,
|
||
`下载所有${expectedRows}条明细`,
|
||
{ contains: true, selectors: 'button,[role="button"],a,span,div' },
|
||
)) {
|
||
return true;
|
||
}
|
||
|
||
return clickVisibleText(
|
||
session.cdp,
|
||
session.sessionId,
|
||
'下载所有',
|
||
{ contains: true, selectors: 'button,[role="button"],a,span,div' },
|
||
);
|
||
}
|
||
|
||
async function maybeOpenDownloadTask(session) {
|
||
return clickVisibleText(
|
||
session.cdp,
|
||
session.sessionId,
|
||
'下载任务',
|
||
{ selectors: 'button,[role="button"],a,span,div' },
|
||
);
|
||
}
|
||
|
||
async function confirmDownloadDialog(session) {
|
||
for (let attempt = 0; attempt < 20; attempt++) {
|
||
if (await clickVisibleText(
|
||
session.cdp,
|
||
session.sessionId,
|
||
'立即下载',
|
||
{ selectors: 'button,[role="button"],a,span,div' },
|
||
)) {
|
||
return true;
|
||
}
|
||
await sleep(400);
|
||
}
|
||
|
||
return false;
|
||
}
|
||
|
||
async function exportAccount(account, index) {
|
||
const accountDirectory = path.join(
|
||
accountRoot,
|
||
`${String(index + 1).padStart(2, '0')}-${account.bank_account.slice(-4)}`,
|
||
);
|
||
const downloadDirectory = path.join(accountDirectory, 'downloads');
|
||
fs.mkdirSync(downloadDirectory, { recursive: true });
|
||
|
||
const session = await launchAccountSession(account, { headless: !interactive });
|
||
try {
|
||
await session.login({ manualVerification: interactive });
|
||
const bank = await session.openBank();
|
||
await openTransactionDetail(bank.cdp, bank.sessionId, account);
|
||
await session.saveAuth();
|
||
let identity = null;
|
||
for (let attempt = 0; attempt < 20; attempt++) {
|
||
identity = await bank.cdp.send('Runtime.evaluate', {
|
||
expression: `(() => {
|
||
const text = document.body?.innerText || '';
|
||
const compact = text.replace(/\\s+/g, '');
|
||
const hit = text.match(/账户\\s*((?:\\d[\\s-]*){12,30})/);
|
||
return {
|
||
account: hit ? hit[1].replace(/\\D/g, '') : '',
|
||
companyPresent: compact.includes(${JSON.stringify(account.company.replace(/\s+/g, ''))}),
|
||
};
|
||
})()`,
|
||
awaitPromise: true,
|
||
returnByValue: true,
|
||
}, bank.sessionId).then((response) => response.result?.value || {});
|
||
if (identity.account) break;
|
||
await sleep(500);
|
||
}
|
||
if (!identity.companyPresent) {
|
||
throw new Error('网商银行页面公司名称与配置不一致');
|
||
}
|
||
if (!identity.account) {
|
||
throw new Error('无法读取网商银行页面账号');
|
||
}
|
||
if (identity.account !== account.bank_account) {
|
||
console.log(
|
||
`${accountLabel(account)} 账号配置与实际不一致,本次按页面账号 ****${identity.account.slice(-4)} 入账`,
|
||
);
|
||
}
|
||
const effectiveBankAccount = identity.account;
|
||
if (probe) {
|
||
const inspection = await inspectTransactionPage(bank.cdp, bank.sessionId);
|
||
const screenshot = path.join(accountDirectory, 'probe.png');
|
||
await shot(bank.cdp, bank.sessionId, screenshot);
|
||
fs.writeFileSync(
|
||
path.join(accountDirectory, 'probe.json'),
|
||
JSON.stringify({ ...inspection, screenshot }, null, 2),
|
||
'utf8',
|
||
);
|
||
console.log(JSON.stringify({
|
||
company: account.company,
|
||
account: maskAccount(effectiveBankAccount),
|
||
countText: inspection.countText,
|
||
inputs: inspection.inputs,
|
||
buttons: inspection.buttons,
|
||
screenshot,
|
||
}));
|
||
return { skip: true };
|
||
}
|
||
|
||
await session.setDownloadDir(downloadDirectory);
|
||
await setTargetDateAndQuery(bank);
|
||
const query = await waitForQueryResult(bank);
|
||
console.log(`${accountLabel(account)} ${targetDate} 页面记录 ${query.count} 笔`);
|
||
if (query.count === 0) {
|
||
return {
|
||
company: account.company,
|
||
account: effectiveBankAccount,
|
||
account_short: account.account_short,
|
||
expected_rows: 0,
|
||
expected_income: 0,
|
||
expected_expense: 0,
|
||
};
|
||
}
|
||
|
||
const beforeFiles = new Set(filesIn(downloadDirectory));
|
||
if (!await clickDownloadAll(bank, query.count)) {
|
||
throw new Error('未找到“下载所有明细”按钮');
|
||
}
|
||
await sleep(700);
|
||
await confirmDownloadDialog(bank);
|
||
let downloaded = await waitForDownload(downloadDirectory, beforeFiles, 60000);
|
||
if (!downloaded && await maybeOpenDownloadTask(bank)) {
|
||
await sleep(700);
|
||
await confirmDownloadDialog(bank);
|
||
downloaded = await waitForDownload(downloadDirectory, beforeFiles, 45000);
|
||
}
|
||
if (!downloaded) throw new Error('网商银行明细下载超时');
|
||
|
||
const downloadedName = safeName(
|
||
`${account.company}-${query.count}笔${path.extname(downloaded)}`,
|
||
`${maskAccount(account.bank_account)}${path.extname(downloaded)}`,
|
||
);
|
||
const finalFile = path.join(accountDirectory, downloadedName);
|
||
fs.copyFileSync(downloaded, finalFile);
|
||
const relative = path.relative(runDirectory, finalFile).replaceAll('\\', '/');
|
||
return {
|
||
company: account.company,
|
||
account: effectiveBankAccount,
|
||
account_short: account.account_short,
|
||
expected_rows: query.count,
|
||
expected_income: query.incomeTotal,
|
||
expected_expense: query.expenseTotal,
|
||
file: relative,
|
||
};
|
||
} finally {
|
||
await session.close();
|
||
}
|
||
}
|
||
|
||
const successes = [];
|
||
const failures = [];
|
||
try {
|
||
for (let index = 0; index < accounts.length; index++) {
|
||
const account = accounts[index];
|
||
let lastError = null;
|
||
for (let attempt = 1; attempt <= 2; attempt++) {
|
||
try {
|
||
console.log(`[${index + 1}/${accounts.length}] ${accountLabel(account)} 第 ${attempt} 次执行`);
|
||
const result = await exportAccount(account, index);
|
||
if (!result.skip) successes.push(result);
|
||
lastError = null;
|
||
break;
|
||
} catch (error) {
|
||
lastError = error;
|
||
console.error(`${accountLabel(account)} 失败:${sanitize(error?.message || error)}`);
|
||
if (attempt < 2) await sleep(3000);
|
||
}
|
||
}
|
||
if (lastError) {
|
||
failures.push({
|
||
company: account.company,
|
||
account_last4: account.bank_account.slice(-4),
|
||
error: sanitize(lastError?.message || lastError),
|
||
});
|
||
}
|
||
}
|
||
} finally {
|
||
if (!probe) {
|
||
fs.writeFileSync(manifestFile, JSON.stringify({
|
||
version: 1,
|
||
date: targetDate,
|
||
generated_at: new Date().toISOString(),
|
||
accounts: successes,
|
||
}, null, 2), 'utf8');
|
||
}
|
||
fs.writeFileSync(stateFile, JSON.stringify({
|
||
ok: failures.length === 0 && successes.length > 0,
|
||
date: targetDate,
|
||
accounts: successes.length,
|
||
failed: failures.length,
|
||
failures,
|
||
manifest: manifestFile,
|
||
cutoff_at: new Date().toISOString(),
|
||
}, null, 2), 'utf8');
|
||
}
|
||
|
||
if (!probe && failures.length > 0) process.exitCode = 1;
|