Puppeteer 抓取 CanopyTax 时出现 Navigation timeout
结论
CanopyTax 是单页应用,#/billing/payments/collected 是前端路由。用户登录后,页面可能仍在轮询、发送埋点请求或维持长连接,导致 networkidle2 一直达不到要求,最终在 60 秒后触发 Navigation timeout。
不要用 networkidle2 判断页面是否可用。更可靠的做法是:
- 确认登录和 OTP 提交已经完成;
- 使用
domcontentloaded,或者直接切换 hash 路由; - 等待支付页面上稳定且唯一的 DOM 元素出现。
原因
networkidle2 要求页面在一段时间内最多保留两个网络连接。登录后的后台系统可能持续发起请求,因此这个条件未必能够满足。
代码中还有两个容易出现时序问题的地方:
await page.click(CTA_SELECTOR);
await page.waitForNavigation();
如果点击后马上发生导航,等到 waitForNavigation() 注册监听器时,导航事件可能已经结束。应该同时启动点击和等待:
await Promise.all([
page.waitForNavigation({ waitUntil: 'domcontentloaded' }),
page.click(CTA_SELECTOR),
]);
OTP 提交后的固定等待也有同样的问题:
await sleep(4000);
固定等待 4 秒并不能证明认证状态已经建立。网络较慢时可能不够,网络较快时又会白白浪费时间。应该改为等待登录成功后的 URL 或页面元素。
推荐实现
下面的选择器需要替换为 CanopyTax 当前页面中实际存在的元素。支付页选择器应尽量稳定,可以使用页面标题、数据表格或支付记录容器,不要依赖随机生成的 class。
const paymentUrl =
'https://app.canopytax.com/#/billing/payments/collected';
const browser = await puppeteer.launch({
args: ['--no-sandbox', '--disable-setuid-sandbox'],
ignoreHTTPSErrors: true,
});
const page = await browser.newPage();
await page.setViewport({ width: 1366, height: 768 });
await page.setUserAgent(userAgent);
page.setDefaultTimeout(60_000);
page.setDefaultNavigationTimeout(60_000);
await page.goto(websiteUrl, {
waitUntil: 'domcontentloaded',
});
await page.type(USERNAME_SELECTOR, C.username);
await page.type(PASSWORD_SELECTOR, C.password);
await Promise.all([
page.waitForNavigation({
waitUntil: 'domcontentloaded',
timeout: 60_000,
}),
page.click(CTA_SELECTOR),
]);
await page.waitForSelector(OTP_NEXT_SELECTOR, {
visible: true,
});
await page.click(OTP_NEXT_SELECTOR);
const otp = await this.getOtpFromEmail();
await page.waitForSelector(OTP_INPUT_SELECTOR, {
visible: true,
});
await page.type(OTP_INPUT_SELECTOR, otp);
await Promise.all([
// 如果 OTP 提交只触发 SPA 路由变化而不触发文档导航,
// 此处不要使用 waitForNavigation,改为等待登录后的元素。
page.waitForSelector(LOGGED_IN_SELECTOR, {
visible: true,
timeout: 60_000,
}),
page.click(OTP_NEXT_SELECTOR),
]);
console.log('OTP Submitted');
await page.goto(paymentUrl, {
waitUntil: 'domcontentloaded',
timeout: 60_000,
});
await page.waitForSelector(PAYMENTS_PAGE_SELECTOR, {
visible: true,
timeout: 60_000,
});
// 在这里抓取支付页面数据。
await browser.close();
LOGGED_IN_SELECTOR 应该是仅在登录完成后才会出现的元素,例如应用主导航。PAYMENTS_PAGE_SELECTOR 应该对应支付记录页面特有的元素。
SPA 路由的更稳妥处理方式
目标地址只改变 # 后面的前端路由,因此也可以直接修改 location.hash,不必把这次切换当作完整的文档导航:
await page.evaluate(() => {
window.location.hash = '#/billing/payments/collected';
});
await page.waitForFunction(
() => window.location.hash === '#/billing/payments/collected',
{ timeout: 30_000 }
);
await page.waitForSelector(PAYMENTS_PAGE_SELECTOR, {
visible: true,
timeout: 60_000,
});
如果应用中有支付页面的导航入口,优先点击这个入口通常更可靠,因为应用自身的路由和状态更新都会正常触发:
await page.click(PAYMENTS_NAV_SELECTOR);
await page.waitForFunction(
() => window.location.hash.includes('/billing/payments/collected'),
{ timeout: 30_000 }
);
await page.waitForSelector(PAYMENTS_PAGE_SELECTOR, {
visible: true,
timeout: 60_000,
});
如果 OTP 提交不会发生真正的导航
单页应用提交 OTP 时,可能只是调用接口并在当前文档中切换路由。这时 waitForNavigation() 也可能超时,应该改为等待接口响应和登录后的页面元素:
const [response] = await Promise.all([
page.waitForResponse(
response =>
response.url().includes('/auth/') &&
response.request().method() === 'POST',
{ timeout: 60_000 }
),
page.click(OTP_NEXT_SELECTOR),
]);
if (!response.ok()) {
throw new Error(
`OTP authentication failed: ${response.status()} ${response.url()}`
);
}
await page.waitForSelector(LOGGED_IN_SELECTOR, {
visible: true,
timeout: 60_000,
});
这里的 /auth/ 只是匹配示例,必须根据浏览器开发者工具中实际的 OTP 请求地址进行调整。
注意事项
- 单纯增加
timeout只会推迟报错,无法解决networkidle2始终不能满足的问题。 - 不要用
sleep(4000)或sleep(50000)判断页面状态。应该等待明确的 URL、响应或 DOM 元素。 click()和waitForNavigation()必须通过Promise.all()同时启动,以免错过导航事件。- 如果目标是 SPA 内部路由,应优先等待业务元素,而不是完整的文档导航。
- 如果仍然超时,可以监听
requestfailed、console和页面错误,检查是否有接口失败、认证过期或前端脚本异常:
page.on('requestfailed', request => {
console.error(
'Request failed:',
request.url(),
request.failure()?.errorText
);
});
page.on('pageerror', error => {
console.error('Page error:', error);
});
page.on('console', message => {
console.log('Browser console:', message.text());
});
核心修正是移除支付页上的 waitUntil: 'networkidle2',改用 domcontentloaded,再等待支付页面特有的选择器出现。
备注:内容仅供参考。
