Puppeteer等待页面完全加载

120

我正在尝试从网页创建PDF。

我正在处理的应用是单页面应用程序。

我尝试了很多选项和建议,都在https://github.com/GoogleChrome/puppeteer/issues/1412上。

但是它没有起作用。

    const browser = await puppeteer.launch({
    executablePath: 'C:\\Program Files (x86)\\Google\\Chrome\\Application\\chrome.exe',
    ignoreHTTPSErrors: true,
    headless: true,
    devtools: false,
    args: ['--no-sandbox', '--disable-setuid-sandbox']
});

const page = await browser.newPage();

await page.goto(fullUrl, {
    waitUntil: 'networkidle2'
});

await page.type('#username', 'scott');
await page.type('#password', 'tiger');

await page.click('#Login_Button');
await page.waitFor(2000);

await page.pdf({
    path: outputFileName,
    displayHeaderFooter: true,
    headerTemplate: '',
    footerTemplate: '',
    printBackground: true,
    format: 'A4'
});

我希望的是在页面完全加载后立即生成PDF报告。

我不想写任何类型的延迟,例如 await page.waitFor(2000);

我无法使用waitForSelector,因为页面上有图表和图形,这些图表和图形是在计算后渲染出来的。

将不胜感激。


我尝试了所有建议的解决方案。使用Node.js puppeteer没有任何作用。我转而使用Python脚本来加载HTML,等待几秒钟以加载外部元素/生成图表,然后生成PDF。 - W.M.
15个回答

131
你可以使用page.waitForNavigation()来等待新页面完全加载后再生成 PDF:
await page.goto(fullUrl, {
  waitUntil: 'networkidle0',
});

await page.type('#username', 'scott');
await page.type('#password', 'tiger');

await page.click('#Login_Button');

await page.waitForNavigation({
  waitUntil: 'networkidle0',
});

await page.pdf({
  path: outputFileName,
  displayHeaderFooter: true,
  headerTemplate: '',
  footerTemplate: '',
  printBackground: true,
  format: 'A4',
});
如果您希望在 PDF 中包含动态生成的某个元素,请考虑使用 page.waitForSelector(),以确保内容可见:
await page.waitForSelector('#example', {
  visible: true,
});

4
“networkidle0” 信号的文档在哪里? - Chilly Code
8
这里记录了'networkidle0'的文档,网址是https://github.com/GoogleChrome/puppeteer/blob/master/docs/api.md#pagegotourl-options - diegoubi
1
page.waitForSelector 应该在 page.goto 之后调用还是之前?你能回答我一个类似的问题吗?我在 https://stackoverflow.com/questions/58909236/pupeteer-script-does-not-wait-for-the-selector-to-get-loaded-and-i-get-a-blank-h 提出了一个问题。 - Amanda
3
使用networkidle0,相较于默认的load事件,有何优势?使用networkidle0更快吗? - Gary
我曾经遇到相同的问题。我使用的格式是“A4”。我的解决方案是不使用比例缩小(<1)选项。 - Bergi
显示剩余4条评论

120
有时候,networkidle事件并不能总是表明页面已经完全加载。页面上可能仍有一些JSscripts在修改内容。因此,观察浏览器对HTML源代码的修改似乎能够获得更好的结果。这是一个你可以使用的函数 -
const waitTillHTMLRendered = async (page, timeout = 30000) => {
  const checkDurationMsecs = 1000;
  const maxChecks = timeout / checkDurationMsecs;
  let lastHTMLSize = 0;
  let checkCounts = 1;
  let countStableSizeIterations = 0;
  const minStableSizeIterations = 3;

  while(checkCounts++ <= maxChecks){
    let html = await page.content();
    let currentHTMLSize = html.length; 

    let bodyHTMLSize = await page.evaluate(() => document.body.innerHTML.length);

    console.log('last: ', lastHTMLSize, ' <> curr: ', currentHTMLSize, " body html size: ", bodyHTMLSize);

    if(lastHTMLSize != 0 && currentHTMLSize == lastHTMLSize) 
      countStableSizeIterations++;
    else 
      countStableSizeIterations = 0; //reset the counter

    if(countStableSizeIterations >= minStableSizeIterations) {
      console.log("Page rendered fully..");
      break;
    }

    lastHTMLSize = currentHTMLSize;
    await page.waitForTimeout(checkDurationMsecs);
  }  
};

在处理页面内容之前,您可以在页面load / click函数调用后使用此功能。例如:

await page.goto(url, {'timeout': 10000, 'waitUntil':'load'});
await waitTillHTMLRendered(page)
const data = await page.content()

17
我不确定为什么这个回答没有得到更多“喜欢”。实际上,很多时候我们只需要确保 JavaScript 在页面完成操作后再进行抓取。网络事件无法实现这一点,如果您有动态生成的内容,则并不总是能够可靠地执行“waitForSelector/visible:true”操作。 - Jason
谢谢@roberto - 顺便说一下,我刚刚更新了答案,你可以使用'load'事件而不是'networkidle2'。我认为这样会更加优化。我已经在生产环境中测试过了,可以确认它也很好用! - Anand Mahajan
我尝试将 checkDurationMsecs 设置为200毫秒,但是 bodyHTMLSize 仍在不断变化,并且给出了巨大的数字。我同时使用了electron和rect,非常奇怪。 - Ambroise Rabier
好的,我发现那个难以捕捉的 bug 真是太荒谬了。如果你有幸捕捉到那个 100k 长的 HTML 页面,你会发现其中有像 CodeMirror 这样的 CSS 类,这很可能是 https://codemirror.net/ 的代码库,这意味着... document.body.innerHTML 也会捕捉到开发者控制台!为了进行端到端测试,只需删除 mainWindow.webContents.openDevTools(); 即可。希望不会再有任何坏的惊喜了。 - Ambroise Rabier
这是使用Puppeteer和PagedJS将大型HTML文件渲染为PDF的答案。由于pagedJS polyfill仍在网络流量停止后处理内容,因此pdf请求在没有渲染所有内容的情况下启动。谢谢。 - Mike Smith
这是处理动态渲染页面时唯一的方法。网络空闲事件在这方面不可靠。 - undefined

54

在某些情况下,对我来说最好的解决方案是:

await page.goto(url, { waitUntil: 'domcontentloaded' });

你可以尝试一些其他选项:

await page.goto(url, { waitUntil: 'load' });
await page.goto(url, { waitUntil: 'domcontentloaded' });
await page.goto(url, { waitUntil: 'networkidle0' });
await page.goto(url, { waitUntil: 'networkidle2' });

您可以在 Puppeteer 文档中查看此内容:https://pptr.dev/#?product=Puppeteer&version=v11.0.0&show=api-pagewaitfornavigationoptions


3
这并不确保任何加载的脚本已经执行完毕。因此,HTML 可能仍在渲染过程中,而这会继续进行。 - AbuZubair
3
对于那些被这些选项搞糊涂的人来说,domcontentloaded 是第一个触发的,所以通常在你想在任何外部资源加载之前继续执行脚本时使用它。通常,这是因为你不需要它们的数据。loadnetworkidle2networkidle0 提供了不同程度的等待资源的方式,但它们都不能提供“页面已加载”的确切保证(因为这因网站而异,因此在一般情况下是不确定的)。 - ggorlen
这个能用在 page.click 上吗? - FreelanceConsultant
domcontentloaded - 对我也有效。谢谢! - Mr Special

39

我总喜欢等待选择器,因为它们中的许多是页面已完全加载的很好的指示器:

await page.waitForSelector('#blue-button');

你真是个天才,这个解决方案非常明显,特别是当你在等待特定元素时,而且一旦我没有猜错自己,谢谢! - Arch4Arts
@Arch4Arts 你应该创建自己的点击函数,它可以为你等待并点击。 - Nicolás A.

11

在最新的 Puppeteer 版本中,networkidle2 对我有效:

await page.goto(url, { waitUntil: 'networkidle2' });

10

在 Promise.all 中包装 page.clickpage.waitForNavigation

  await Promise.all([
    page.click('#submit_button'),
    page.waitForNavigation({ waitUntil: 'networkidle0' })
  ]);

2
page.waitForNavigation({ waitUntil: 'networkidle0' })page.waitForNetworkIdle() 是相同的吗? - milos

6

当我在使用离屏渲染器时,我遇到了与networkidle相同的问题。我需要一个基于WebGL的引擎来完成渲染,然后才能进行截图。对我起作用的是使用page.waitForFunction()方法。在我的案例中,使用如下:

await page.goto(url);
await page.waitForFunction("renderingCompleted === true")
const imageBuffer = await page.screenshot({});

在渲染代码中,当完成任务时,我只是将renderingCompleted变量设置为true。如果您无法访问页面代码,则可以使用其他现有的标识符。

5
到目前为止,还没有提到一个关键的事实:不可能编写一个适用于每个页面的"waitUntilPageLoaded"函数。如果可能的话,Puppeteer肯定会提供它。
这样的函数不能依赖于超时,因为总有一些页面加载所需的时间比超时更长。如果您扩展了超时时间以减少失败率,则在处理快速页面时,会引入不必要的延迟。超时通常是不好的解决方案,不符合 Puppeteer 的事件驱动模型。
如果响应涉及长时间运行的DOM更新并需要超过500毫秒才能触发渲染,则等待空闲网络请求可能并不总是有效。
等待 DOM 停止变化可能会错过缓慢的网络请求、长时间延迟的JS触发器或正在进行的DOM操作,除非特别处理,否则可能导致监听器永远无法处理。
当然,还有用户交互:需要单击并关闭验证码、提示以及cookie/订阅模态框,才能使页面处于合理状态,以进行全屏截图(例如)。
由于每个页面都具有不同的任意 JS 行为,因此典型的方法是编写事件驱动逻辑,该逻辑适用于特定页面。精确地做出指定的假设比拼凑各种黑客技巧,试图解决每个边缘情况要好得多。
如果你的用例是编写适用于每个页面的加载事件,我的建议是使用这里描述的工具的某些组合,以最平衡地满足您的需要(速度与精度、开发时间/代码复杂性与精度等)。为所有内容使用故障保护措施,而不是盲目地假设所有页面都会遵守您的假设。认真思考您真正需要尝试处理哪些网页。准备妥协并接受您可以容忍的一定程度的失败。
以下是您可以混合和匹配的策略,以等待加载以满足您的需求:

page.goto()page.waitForNavigation()默认使用load事件,该事件“在页面加载完成后触发,包括所有依赖资源,如样式表和图像”(MDN),但通常太保守了;没必要等待大量不需要的数据。通常情况下,数据可用而无需等待所有外部资源,因此domcontentloaded应该更快。请参见我发布的文章Avoiding Puppeteer Antipatterns进行进一步讨论。

另一方面,如果在load之后有JS触发的网络请求,则会丢失该数据。因此,使用networkidle2networkidle0,它们在活动网络请求数量为2或0后等待500毫秒。2版本的动机是一些站点保持正在进行的请求打开,这将导致networkidle0超时。

如果您正在等待可能具有有效负载的特定网络响应(或者,对于一般情况,实现自己的网络空闲监视器),请使用page.waitForResponse()page.waitForRequest()page.waitForNetworkIdle()page.on("request", ...)在这里也很有用。

如果您正在等待特定选择器可见,请使用page.waitForSelector()。如果您正在等待特定页面加载,请确定指示您要等待状态的选择器。通常情况下,对于专用于一个页面的脚本,无论是提取数据还是单击某些内容,这都是等待所需状态的主要工具。框架和阴影根会阻止此函数。

page.waitForFunction()可以让你等待任意的判断条件,例如,检查页面的HTML或特定列表是否达到一定长度。它还可以用于快速进入框架和shadow roots,等待依赖于嵌套状态的判断条件。此函数还适用于检测DOM变化。

最常用的工具是page.evaluate(),它将代码插入到浏览器中。您可以在此处放置几乎任何条件;大多数其他Puppeteer函数都是通用情况下的便利包装器,可以使用evaluate手动实现。


4

记住没有一种万能的方法可以处理所有页面加载的问题, 一种策略是监视DOM,直到它稳定下来(即未发生变化)超过n毫秒。这类似于网络空闲解决方案,但针对的是DOM而不是请求,因此涵盖了不同的加载行为子集。

通常,此代码将遵循page.waitForNavigation({waitUntil: "domcontentloaded"})page.goto(url, {waitUntil: "domcontentloaded"}),但您也可以与其一起等待,例如waitForNetworkIdle()使用Promise.all()Promise.race()

以下是一个简单的示例:

const puppeteer = require("puppeteer"); // ^14.3.0

const waitForDOMStable = (
  page,
  options={timeout: 30000, idleTime: 2000}
) =>
  page.evaluate(({timeout, idleTime}) =>
    new Promise((resolve, reject) => {
      setTimeout(() => {
        observer.disconnect();
        const msg = `timeout of ${timeout} ms ` +
          "exceeded waiting for DOM to stabilize";
        reject(Error(msg));
      }, timeout);
      const observer = new MutationObserver(() => {
        clearTimeout(timeoutId);
        timeoutId = setTimeout(finish, idleTime);
      });
      const config = {
        attributes: true,
        childList: true,
        subtree: true
      };
      observer.observe(document.body, config);
      const finish = () => {
        observer.disconnect();
        resolve();
      };
      let timeoutId = setTimeout(finish, idleTime);
    }),
    options
  )
;

const html = `<!DOCTYPE html><html lang="en"><head>
<title>test</title></head><body><h1></h1><script>
(async () => {
  for (let i = 0; i < 10; i++) {
    document.querySelector("h1").textContent += i + " ";
    await new Promise(r => setTimeout(r, 1000));
  }
})();
</script></body></html>`;

let browser;
(async () => {
  browser = await puppeteer.launch({headless: true});
  const [page] = await browser.pages();
  await page.setContent(html);
  await waitForDOMStable(page);
  console.log(await page.$eval("h1", el => el.textContent));
})()
  .catch(err => console.error(err))
  .finally(() => browser?.close())
;

对于DOM变化频繁的页面,如果超时时间小于空闲时间(idle),则超时会触发并拒绝Promise,遵循典型的Puppeteer回退机制。您可以设置更激进的总超时时间以适应您的需求,或者定制逻辑来忽略(或仅监视)特定子树。

4

3
waitFor已被弃用,将在未来版本中移除。请参阅https://github.com/puppeteer/puppeteer/issues/6214获取详细信息以及如何迁移您的代码。 - kenberkeley

网页内容由stack overflow 提供, 点击上面的
可以查看英文原文,
原文链接