PAYATHON 2026

Puppeteer 抓取 CanopyTax 时出现 Navigation timeout

支付阿杰

结论

CanopyTax 是单页应用,#/billing/payments/collected 是前端路由。用户登录后,页面可能仍在轮询、发送埋点请求或维持长连接,导致 networkidle2 一直达不到要求,最终在 60 秒后触发 Navigation timeout。

不要用 networkidle2 判断页面是否可用。更可靠的做法是:

  1. 确认登录和 OTP 提交已经完成;
  2. 使用 domcontentloaded,或者直接切换 hash 路由;
  3. 等待支付页面上稳定且唯一的 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,再等待支付页面特有的选择器出现。

备注:内容仅供参考。