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] || '', }; })()`); }