使您的木偶脚本更具防弹性的7个提示
#javascript #puppeteer #node #chrome

Puppeteer开箱即用,带有许多出色的功能,并使开始自动化网络工作流程变得简单。实际上,您可能会在几分钟之内将所有内容启动并在本地运行。由于其相当简单的API,您可以使用相对较少的代码行动自动化的任何事情。

但是,像其他任何软件一样,魔鬼也在细节中。由于Puppeteer(和剧作家)都非常异步,因此有很多机会出错。如果您正在汇总数据或无API的自动化站点,那么网络的不断变化的性质很容易出现以破坏乐趣。明确的是:木偶up偶会出错,就像对他们正确的行为一样容易。很多时候,更容易ð¢

我对Puppeteer甚至Web自动化都不陌生,过去还写了自己的无头网络驱动程序。自从我10年前开始编程以来,这是我最长的热情。话虽如此,今天,我想介绍每个开发人员应该做的事情,以使他们的木偶脚本更加优雅。它不会解决您的所有问题,但是它会给您足够的情境意识,以使问题更容易诊断和解决。这样,让我们​​跳入!

更改您的页面。

Puppeteer将在页面中30秒后默认超时。Goto调用当指定的“加载”事件发生时。即使有一些很棒的加载事件,您也可以关注(例如NetworkIdle2事件),我们通常建议您在知道自己在做什么时使用domcontentloaded选项。特别是如果您追求特定的页面元素或网络电话要等待:

// DON'T do this:
// This will wait for network requests to be idle for 2 seconds before proceeding.
await page.goto('https://www.example.com', { waitUntil: 'networkidle2' });
await page.waitForSelector('h1');

// Do this instead:
// This will navigate to the page and *immediately* begin waiting for the h1 selector
// once the page's initial HTML is returned. Often much quicker.
await page.goto('https://www.example.com', { waitUntil: 'domcontentloaded' });
await page.waitForSelector('h1');

这本质上只是优化您的木偶代码以尽可能快地运行,而不是让慢速网络请求进入。通常,我们(browserless.io)会看到网络请求不响应,这会导致您的整个脚本中断,因为goto永远不会在NetworkIdle中成功完成。

您什么时候不应该使用?好吧,您唯一不应该真正不应该处理未知的时间。如果您不敏锐地了解页面布局,网络调用或DOM选择器,那么在页面已准备就绪时,执行NetworkIdle0或networkIdle2的功能与您所能达到的好处。

记录失败的网络调用

很多次,我们看到这是一个简单的网络调用,使整个工作流程失败。这可能是在本地运行puppeteer与云服务器上的puppeteer之间的区别,网站正在下降,甚至在其他地方进行网络问题。尽管互联网通常可以正常工作,但它仍然依赖于在线和运行的物理设备,但情况并非总是如此。

您可以做的最好的事情就是简单地记录失败的请求,然后尝试继续:

// DON'T do this: there's nothing here catching _any_ network errors!
await page.goto('https://www.example.com', { waitUntil: 'domcontentloaded' });
await page.waitForSelector('h1');

// Do this instead:
page.on('response', (res) => {
  if (!res.ok()) {
    console.error(`Non-200 response from this request: [${req.status()}] "${req.url()}"`);
  }
});

await page.goto('https://www.example.com', { waitUntil: 'domcontentloaded' });
await page.waitForSelector('h1');

当我们使用它时,我们不妨连接某种页面错误处理程序,并为这些日志/警告提供实用程序。

// Drop in your favorite logging technology here. We're using console.error to illustrate.
const logWarning = (message) => console.error(message);

// Page errors here might be deal-breakers
page.on('pageerror', (err) => {
  logWarning(`Page error emitted: "${err.message}"`);
});

page.on('response', (res) => {
  if (!res.ok()) {
    logWarning(`Non-200 response from this request: [${req.status()}] "${req.url()}"`);
  }
});

await page.goto('https://www.example.com', { waitUntil: 'domcontentloaded' });
await page.waitForSelector('h1');

监视浏览器过程

根据您如何连接到浏览器,这里有一些东西可以帮助我们确保脚本具有弹性。第一件事是在断开事件上进行侦听器,因为它表明浏览器本身已经崩溃,或者我们已经断开了连接。这样做很简单:

browser.once('disconnected', () => logWarning(`Browser has closed or crashed and we've been disconnected!`));

现在将其与我们先前的代码段相结合以下:

const logWarning = (message) => console.error(message);

browser.once('disconnected', () => logWarning(`Browser has closed or crashed and we've been disconnected!`));

const page = await browser.newPage();

page.on('pageerror', (err) => {
  logWarning(`Page error emitted: "${err.message}"`);
});

page.on('response', (res) => {
  if (!res.ok()) {
    logWarning(`Non-200 response from this request: [${req.status()}] "${req.url()}"`);
  }
});

await page.goto('https://www.example.com', { waitUntil: 'domcontentloaded' });
await page.waitForSelector('h1');

中场休息

您可能已经注意到,我们已经开始添加很多听众和其他处理程序!从最佳实践的角度来看,它开始变得有些不利,并且在大多数情况下,所有这些代码本质上都是样板。换句话说:我们这里的所有代码几乎与PuppeTeer的自动化方面无关。因此,我们应该开始考虑制作一个将所有这些逻辑删除的高阶类,并让我们的面向业务的代码(正在执行实际自动化的代码)执行此操作。

让我们定义两个模块:一个用于处理事件和记录/监视的模块,第二个模块仅具有我们的业务逻辑并消耗了第一个模块。如果我们需要添加更多脚本,这也使我们更容易,因为我们将PuppeTeer的所有管理方面都提取为可重复使用的模块。我将在Typescript中说明这一点,因为它可以更好地与拥有更确定性的木偶体验的目标保持一致。

// puppeteer-helper.ts
import { Browser, Page } from 'puppeteer';

export class PuppeteerHelper {
  private page?: Page;

  static log(...messages: string[]) {
    // Use your logging library here
    console.warn(...messages);
  }

  private disconnectListener = () => {
    PuppeteerHelper.log(
      `Browser has closed or crashed and we've been disconnected!`
    );
  };

  constructor(private browser: Browser) {
    browser.once('disconnected', this.disconnectListener);
  }

  public close = async () => {
    this.browser.off('disconnect', this.disconnectListener);
    this.browser.close();
  }

  public newPage = async () => {
    const page = await this.browser.newPage();

    page.on('pageerror', (err) => {
      PuppeteerHelper.log(`Page error emitted: "${err.message}"`);
    });

    page.on('response', (res) => {
      if (!res.ok()) {
        PuppeteerHelper.log(`[${req.status()}]: "${req.url()}"`);
      }
    });

    // Capture the page so we can track stuff later
    this.page = page;

    return page;
  }
}

现在,以此为止,我们可以简单地将其轻松地整合到我们的简单脚本中:

import puppeteer from 'puppeteer';
import { PuppeteerHelper } from './puppeteer-helper.ts';

(async() => {
  const browser = new PuppeteerHelper(await puppeteer.launch());
  const page = await browser.newPage();

  await page.goto('https://www.example.com', { waitUntil: 'domcontentloaded' });
  await page.waitForSelector('h1');
  await browser.close();
})();

完美!现在我们已经有了一些管理层,让我们继续前进。

避免“文件://”请求

在这里有更多的安全预防措施,但是(理想情况下)我们确实希望避免使用Chrome,如果我们可以提供帮助的话。那,再加上大多数云提供商用来阅读托管元数据的一系列IP地址,我们也希望将其从Chrome远离Chrome。

我想重申我们永远不允许文件系统访问的重要性,因为 /etc /passwd之类的事情至关重要。除非您要有人接管您的云机,否则最好立即添加。

static: string[] urlDeny = [
  'file://',
  'http://169.254',
  'https://169.254',
  '169.254'
];

static: string[] ipDeny = [
  '169.254',
  '192.168',
  '0.0.0.0',
];

// Later, let's use these and make sure they get reject right away:
public newPage = async () => {
  const page = await this.browser.newPage();

  page.on('request', (req) => {
    if (PuppeteerHelper.urlDeny.some((url) => req.url().startsWith(url))) {
      PuppeteerHelper.log(`Blocking request and exiting: "${req.url()}"`);
      this.close();
    }
  });

  page.on('response', (res) => {
    const responseUrl = res.url();
    const remoteAddressIP = res.remoteAddress().ip;

    if (!res.ok()) {
      PuppeteerHelper.log(`[${res.status()}]: "${res.url()}"`);
    }

    if (responseUrl && PuppeteerHelper.urlDeny.some((url) => responseUrl.startsWith(url))) {
      PuppeteerHelper.log(`Blocking request URL and exiting: "${responseUrl}"`);
      this.close();
    }

    if (remoteAddressIP && PuppeteerHelper.ipDeny.some((ip) => remoteAddressIP.startsWith(ip))) {
      PuppeteerHelper.log(`Blocking request IP and exiting: "${responseUrl}"`);
      this.close();
    }
  })

  page.on('pageerror', (err) => {
    PuppeteerHelper.log(`Page error emitted: "${err.message}"`);
  });

  this.page = page;

  return page;
};

在任何项目中,我们都可能需要一路走来的安全性,因此最好始终将任何技术的安全性视为永久性的工作。在这里,我们可以保证,如果由此产生的IP地址或URL敏感,并且我们正在与不良演员打交道,则事情会在网络回复之前被杀死。

当我们遵守安全性时,让我们再做一件事...

与您的应用程序分开的铬

这是通常在软件中分开关注点的常见最佳实践。一个很好的示例是确保您的应用程序代码在单独的硬件上运行,而不是您的数据库。这使程序员在如何扩展方面具有很大的灵活性,但还可以对数据库诸如防火墙和连接池之类的内容进行更严格的控制。由于我们正在处理黑盒(Chrome),因此我们还应在使用它时谨慎行事。

与其使用Puppeteer在本地启动Chrome,让我们在其他地方启动Chrome并连接到它。无浏览器使这一微不足道,因为它是Chrome的沙箱层,这意味着您可以以短暂的方式运行它,使其与应用程序代码和数据完全隔离。这与上面的网络助手相结合,使您对部署有更多信心。

我将把运行无浏览器的Docker方面作为您的练习(我们有很多资源),但是一旦您运行它(我们假设Localhost和port 3000),只需连接到它:

// DON'T launch locally!
const browser = new PuppeteerHelper(await puppeteer.launch());

// DO replace launch with connect:
const browser = new PuppeteerHelper(await puppeteer.connect({ browserWSEndpoint: 'ws://localhost:3000' }));

现在,我们已经将镀铬都远离代码库,让我们在失败的情况下做最后一件事。

拍照,持续更长的时间!

您可以做的最有用的事情之一就是在失败的情况下屏幕截图网页。结合网络记录,可以更轻松地为您提供有关正在发生的事情的线索。刮擦和自动化网络并不像我们想要的那样确定性,但是给自己的上下文和证据可以使体验变得更好。

为了做到这一点并保存在同一网络带宽上,让我们以50%的质量进行快速的JPEG图像。将尺寸设置为1024x768也应保持尺寸的问题,但是您可以做任何想做的事情。我们将调整我们的“关闭”方法以捕获最后一个屏幕截图,以便我们还可以跟踪随着时间的推移页面更改。如果您在凌晨2点通知您有问题,这将极大地帮助您,因为您会在页面上有一张照片,因为它应该是

public close() {
  this.browser.off('disconnect', this.disconnectListener);
  if (this.page) {
    // screenshot here is a base64-encoded string of the jpeg file.
    // We don't do anything here with it since this greatly depends on your tools and tooling.
    // We'll catch issues here and just return `null` in cases of total failure
    const screenshot = await this.page.screenshot({ type: 'jpeg', quality: 50, fullPage: true }).catch(() => null);
  }
  this.browser.close();
}

为了生效,我们需要更新我们的业务级代码以捕获错误并尝试此屏幕截图。这也很简单:

import puppeteer from 'puppeteer';
import { PuppeteerHelper } from './puppeteer-helper.ts';

(async() => {
  const browser = new PuppeteerHelper(await puppeteer.connect({ browserWSEndpoint: 'ws://localhost:3000' }));

  try {
    const page = await browser.newPage();
    await page.goto('https://www.example.com', { waitUntil: 'domcontentloaded' });
    await page.waitForSelector('h1');
    await browser.close();
  } catch (err) {
    PuppeteerHelper.log(`Error running script: ${err.message}`);
  } finally {
    browser.close();
  }
})();

既然随着时间的流逝,我们可以开始更好地了解事物随着时间的变化,并对我们的自动化感到更加自信。将所有内容放在一起,这是我们编写的模块:

// puppeteer-helper.ts
import { Browser, Page } from 'puppeteer-core';

export class PuppeteerHelper {
  private page?: Page;

  constructor(private browser: Browser) {
    browser.once('disconnected', this.disconnectListener);
  }

  // Add some static url's and IPs to our class:
  static urlDeny: string[] = [
    'file://',
    'http://169.254',
    'https://169.254',
    '169.254'
  ];

  static ipDeny: string[] = [
    '169.254',
    '192.168',
    '0.0.0.0',
  ];

  static log(...messages: string[]) {
    // Use your logging library here
    console.warn(...messages);
  }

  private disconnectListener = () => {
    PuppeteerHelper.log(
      `Browser has closed or crashed and we've been disconnected!`
    );
  };

  public close = async () => {
    this.browser.off('disconnect', this.disconnectListener);
    if (this.page) {
      // screenshot here is a base64-encoded string of the jpeg file.
      // We don't do anything here with it since this greatly depends on your tools and tooling.
      // We also catch and return null here in case something fails
      const screenshot = await this.page.screenshot({ type: 'jpeg', quality: 50, fullPage: true }).catch(() => null);
    }
    this.browser.close();
  };

  public newPage = async () => {
    const page = await this.browser.newPage();

    page.on('request', (req) => {
      if (PuppeteerHelper.urlDeny.some((url) => req.url().startsWith(url))) {
        PuppeteerHelper.log(`Blocking request and exiting: "${req.url()}"`);
        this.close();
      }
    });

    page.on('response', (res) => {
      const responseUrl = res.url();
      const remoteAddressIP = res.remoteAddress().ip;

      if (responseUrl && PuppeteerHelper.urlDeny.some((url) => responseUrl.startsWith(url))) {
        PuppeteerHelper.log(`Blocking request URL and exiting: "${responseUrl}"`);
        this.close();
      }

      if (remoteAddressIP && PuppeteerHelper.ipDeny.some((ip) => remoteAddressIP.startsWith(ip))) {
        PuppeteerHelper.log(`Blocking request IP and exiting: "${responseUrl}"`);
        this.close();
      }
    })

    page.on('pageerror', (err) => {
      PuppeteerHelper.log(`Page error emitted: "${err.message}"`);
    });

    page.on('response', (res) => {
      if (!res.ok()) {
        PuppeteerHelper.log(`[${res.status()}]: "${res.url()}"`);
      }
    });

    this.page = page;

    return page;
  };
}

随意将其视为木偶媒体的管理代码的起点。您可以在这里做很多事情,例如随着时间甚至所有网络请求跟踪DOM更改。虽然这些绝对是建议,但我们完全取决于您的用例或不有趣的内容。

我们从这里去哪里?

这只是起点!在运行Puppeteer时,我们没有谈论您可能需要考虑的很多事情:

  • 我们如何确保我们不会运行太多流量?
  • 我们可以使我们的脚本更可靠吗?
  • 工具和其他要考虑的东西? 请务必在我们的博客上关注我们以获得最佳实践等等。如果您正在考虑运行自己的镀铬实例群,我们很想听听您的想法并提供一些帮助!