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

384 lines
13 KiB
JavaScript

import fs from 'node:fs';
import path from 'node:path';
import {
CDP,
evaluate,
goto,
launchEdge,
openPage,
setDownloadDir,
shot,
sleep,
} from '../erp-daily-bills/cdp.mjs';
import { accountLabel, profileDirectory } from './config.mjs';
import { restoreCookies, saveCookies } from './cookie-store.mjs';
const PORT = 9336;
const ALIPAY_HOME = 'https://b.alipay.com/page/home';
export async function launchAccountSession(account, { headless = true } = {}) {
const profileDir = profileDirectory(account);
fs.mkdirSync(profileDir, { recursive: true });
const { child, version } = await launchEdge({
port: PORT,
profileDir,
headless,
windowSize: '2048,1300',
forceDeviceScaleFactor: 1,
});
const cdp = await CDP.connect(version.webSocketDebuggerUrl);
await restoreCookies(cdp, account);
const { sessionId } = await openPage(cdp, 'about:blank');
return {
account,
cdp,
sessionId,
async close() {
try {
await Promise.race([
cdp.send('Browser.close'),
sleep(1500).then(() => {
throw new Error('浏览器关闭超时');
}),
]);
} catch {
// Browser.close may close the WebSocket before its reply is delivered.
}
cdp.close();
child.kill();
},
async login(options = {}) {
await loginAlipay(cdp, sessionId, account, options);
},
async openBank() {
return openEnterpriseBank(cdp, sessionId, account);
},
async setDownloadDir(directory) {
await setDownloadDir(cdp, directory);
},
async screenshot(file) {
return shot(cdp, sessionId, file);
},
async saveAuth() {
return saveCookies(cdp, account);
},
};
}
export async function pageInfo(cdp, sessionId) {
return evaluate(cdp, sessionId, `({
url: location.href,
title: document.title,
text: document.body?.innerText || '',
hasUser: !!document.querySelector('#J-input-user'),
hasPassword: !!document.querySelector('input[type="password"]'),
bodyReady: document.readyState === 'complete'
})`);
}
export async function clickVisibleText(
cdp,
sessionId,
text,
{ contains = false, selectors = 'a,button,li,[role="button"],span,div' } = {},
) {
const target = await evaluate(cdp, sessionId, `(() => {
const visible = (el) => !!(el.offsetWidth || el.offsetHeight);
const expected = ${JSON.stringify(String(text).replace(/\s+/g, ''))};
const contains = ${contains ? 'true' : 'false'};
const matches = [...document.querySelectorAll(${JSON.stringify(selectors)})]
.filter(visible)
.map((el) => ({
rect: el.getBoundingClientRect(),
text: (el.innerText || el.value || '').replace(/\\s+/g, ''),
}))
.filter((item) => contains ? item.text.includes(expected) : item.text === expected)
.filter((item) => item.rect.width > 0 && item.rect.height > 0)
.sort((left, right) => left.rect.width * left.rect.height - right.rect.width * right.rect.height);
const hit = matches[0]?.rect;
return hit ? { x: hit.x + hit.width / 2, y: hit.y + hit.height / 2 } : null;
})()`);
if (!target) return false;
await cdp.send('Input.dispatchMouseEvent', {
type: 'mousePressed',
x: target.x,
y: target.y,
button: 'left',
clickCount: 1,
}, sessionId);
await cdp.send('Input.dispatchMouseEvent', {
type: 'mouseReleased',
x: target.x,
y: target.y,
button: 'left',
clickCount: 1,
}, sessionId);
return true;
}
export async function clickVisibleSelector(cdp, sessionId, selector) {
const target = await evaluate(cdp, sessionId, `(() => {
const visible = (el) => !!(el.offsetWidth || el.offsetHeight);
const el = [...document.querySelectorAll(${JSON.stringify(selector)})].find(visible);
const rect = el?.getBoundingClientRect();
return rect ? { x: rect.x + rect.width / 2, y: rect.y + rect.height / 2 } : null;
})()`);
if (!target) return false;
await cdp.send('Input.dispatchMouseEvent', {
type: 'mousePressed',
x: target.x,
y: target.y,
button: 'left',
clickCount: 1,
}, sessionId);
await cdp.send('Input.dispatchMouseEvent', {
type: 'mouseReleased',
x: target.x,
y: target.y,
button: 'left',
clickCount: 1,
}, sessionId);
return true;
}
async function waitFor(check, description, timeoutMs = 60000) {
const deadline = Date.now() + timeoutMs;
while (Date.now() < deadline) {
const result = await check();
if (result) return result;
await sleep(800);
}
throw new Error(description + '超时');
}
export async function loginAlipay(cdp, sessionId, account, { manualVerification = false } = {}) {
await goto(cdp, sessionId, ALIPAY_HOME);
await sleep(1800);
let state = await pageInfo(cdp, sessionId);
if (!state.hasUser && !state.hasPassword && /b\.alipay\.com/.test(state.url)) {
return state;
}
const loginTab = await evaluate(cdp, sessionId, `(() => {
const visible = (el) => !!(el.offsetWidth || el.offsetHeight);
const target = [...document.querySelectorAll('a,button,li,[role="button"],span,div')]
.filter(visible)
.find((el) => (el.innerText || '').replace(/\\s+/g, '') === '账密登录');
const rect = target?.getBoundingClientRect();
return rect ? { x: rect.x + rect.width / 2, y: rect.y + rect.height / 2 } : null;
})()`);
if (loginTab) {
await cdp.send('Input.dispatchMouseEvent', {
type: 'mousePressed',
x: loginTab.x,
y: loginTab.y,
button: 'left',
clickCount: 1,
}, sessionId);
await cdp.send('Input.dispatchMouseEvent', {
type: 'mouseReleased',
x: loginTab.x,
y: loginTab.y,
button: 'left',
clickCount: 1,
}, sessionId);
}
await sleep(900);
const form = await evaluate(cdp, sessionId, `(() => {
const user = document.querySelector('#J-input-user');
const passwordBox = document.querySelector('#J-password');
const button = [...document.querySelectorAll('button,input[type="submit"],[role="button"],a')]
.find((el) => !el.disabled && (
/^登录$/.test((el.innerText || '').replace(/\\s+/g, ''))
|| /^登录$/.test(el.value || '')
|| el.id === 'J-login-simulate-btn'
));
const point = (el) => {
const rect = el?.getBoundingClientRect();
return rect ? { x: rect.x + rect.width / 2, y: rect.y + rect.height / 2 } : null;
};
const setter = Object.getOwnPropertyDescriptor(HTMLInputElement.prototype, 'value')?.set;
if (user) {
setter ? setter.call(user, '') : (user.value = '');
user.dispatchEvent(new Event('input', { bubbles: true }));
user.dispatchEvent(new Event('change', { bubbles: true }));
}
return user && passwordBox && button
? { user: point(user), passwordBox: point(passwordBox), submit: point(button) }
: null;
})()`);
if (!form) {
throw new Error(accountLabel(account) + ' 未找到支付宝账号密码登录表单');
}
const clickPoint = async (point) => {
await cdp.send('Input.dispatchMouseEvent', {
type: 'mousePressed',
x: point.x,
y: point.y,
button: 'left',
clickCount: 1,
}, sessionId);
await cdp.send('Input.dispatchMouseEvent', {
type: 'mouseReleased',
x: point.x,
y: point.y,
button: 'left',
clickCount: 1,
}, sessionId);
};
await clickPoint(form.user);
await cdp.send('Input.insertText', { text: account.alipay_account }, sessionId);
await sleep(1800);
const refreshedForm = await evaluate(cdp, sessionId, `(() => {
const point = (selector) => {
const el = document.querySelector(selector);
const rect = el?.getBoundingClientRect();
return rect ? { x: rect.x + rect.width / 2, y: rect.y + rect.height / 2 } : null;
};
return {
passwordBox: point('#J-password'),
submit: point('#J-login-simulate-btn'),
};
})()`);
if (refreshedForm?.passwordBox && refreshedForm?.submit) {
await clickPoint(refreshedForm.passwordBox);
await cdp.send('Input.insertText', { text: account.alipay_password }, sessionId);
await sleep(400);
await clickPoint(refreshedForm.submit);
}
let manualPromptShown = false;
state = await waitFor(async () => {
const current = await pageInfo(cdp, sessionId);
if (current.url.includes('b.alipay.com') && !current.hasUser && !current.hasPassword) {
return current;
}
if (/请在下图依次点击|短信验证|扫码验证|身份验证|安全验证|请完成验证码|请完成图片验证|请拖动滑块/.test(current.text)) {
if (manualVerification) {
if (!manualPromptShown) {
console.log(`请在打开的 Edge 窗口中完成 ${accountLabel(account)} 的安全验证,最多等待 5 分钟。`);
manualPromptShown = true;
}
return null;
}
throw new Error(accountLabel(account) + ' 需要人工完成支付宝安全验证,请先运行 open-login.mjs');
}
return null;
}, accountLabel(account) + ' 支付宝登录', manualVerification ? 300000 : 45000);
await waitFor(async () => {
const current = await pageInfo(cdp, sessionId);
return /企业网银|网商银行企业账户/.test(current.text) ? current : null;
}, accountLabel(account) + ' 支付宝企业工作台', 30000);
await sleep(2500);
return state;
}
export async function openEnterpriseBank(cdp, sessionId, account) {
const beforeTargets = new Set(
(await cdp.send('Target.getTargets')).targetInfos.map((target) => target.targetId),
);
let clicked = await clickVisibleText(cdp, sessionId, '企业网银', {
selectors: 'a,button,[role="button"],span,div',
});
if (!clicked) {
for (let attempt = 0; attempt < 20 && !clicked; attempt++) {
if (attempt % 4 === 0) {
await clickVisibleText(cdp, sessionId, '网商银行企业账户', {
contains: true,
selectors: 'a,button,[role="button"],li,span,div',
});
}
await sleep(500);
clicked = await clickVisibleText(cdp, sessionId, '企业网银', {
selectors: 'a,button,[role="button"],span,div',
});
}
if (!clicked) throw new Error(accountLabel(account) + ' 未找到“企业网银”入口');
}
return waitFor(async () => {
const current = await pageInfo(cdp, sessionId);
if (/b\.mybank\.cn/.test(current.url)) {
return { cdp, sessionId, targetId: null };
}
const targets = (await cdp.send('Target.getTargets')).targetInfos;
const target = targets.find((item) =>
item.type === 'page'
&& !beforeTargets.has(item.targetId)
&& /b\.mybank\.cn/.test(item.url || '')
);
if (!target) return null;
const attached = await cdp.send('Target.attachToTarget', {
targetId: target.targetId,
flatten: true,
});
await cdp.send('Page.enable', {}, attached.sessionId);
await cdp.send('Runtime.enable', {}, attached.sessionId);
return { cdp, sessionId: attached.sessionId, targetId: target.targetId };
}, accountLabel(account) + ' 进入企业网银', 60000);
}
export async function openTransactionDetail(cdp, sessionId, account) {
await waitFor(async () => {
const state = await pageInfo(cdp, sessionId);
return /账务查询/.test(state.text) ? state : null;
}, accountLabel(account) + ' 加载企业网银首页', 45000);
await sleep(1800);
let clicked = await clickVisibleText(cdp, sessionId, '交易明细查询');
if (!clicked) {
for (let attempt = 0; attempt < 20 && !clicked; attempt++) {
if (attempt % 4 === 0) {
await clickVisibleText(cdp, sessionId, '账务查询');
}
await sleep(500);
clicked = await clickVisibleText(cdp, sessionId, '交易明细查询');
}
}
if (!clicked) throw new Error(accountLabel(account) + ' 未找到“交易明细查询”入口');
await waitFor(async () => {
const state = await pageInfo(cdp, sessionId);
return /交易明细/.test(state.text) && /查询/.test(state.text) ? state : null;
}, accountLabel(account) + ' 加载交易明细页', 45000);
}
export async function inspectTransactionPage(cdp, sessionId) {
return evaluate(cdp, sessionId, `(() => {
const visible = (el) => !!(el.offsetWidth || el.offsetHeight);
const text = (el) => (el.innerText || el.value || '').replace(/\\s+/g, ' ').trim();
return {
url: location.href,
title: document.title,
inputs: [...document.querySelectorAll('input')].filter(visible).map((el, index) => ({
index,
type: el.type || '',
value: el.value || '',
placeholder: el.placeholder || '',
ariaLabel: el.getAttribute('aria-label') || '',
disabled: !!el.disabled,
className: String(el.className || '').slice(0, 120),
})),
buttons: [...document.querySelectorAll('button,[role="button"],a')]
.filter(visible)
.map(text)
.filter(Boolean)
.slice(0, 80),
countText: (document.body?.innerText || '').match(/共\\s*\\d+\\s*条/)?.[0] || '',
};
})()`);
}