384 lines
13 KiB
JavaScript
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] || '',
|
|
};
|
|
})()`);
|
|
}
|