92451 67c85678e6 feat: 接入网商银行每日动账明细 (#831)
Co-authored-by: 92451 <924518135@qq.com>
Co-committed-by: 92451 <924518135@qq.com>
2026-11-02 20:58:42 +08:00

494 lines
18 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.

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;