This seems like a case of the new Promise
antipattern. Recent Node versions provide a promisified setTimeout
and setInterval
that lets you avoid callbacks.
For example, with setTimeout
:
const {setTimeout} = require("node:timers/promises");
const getScreenshots = async (
browser,
url,
ms,
frames
): Promise<string[]> => {
const page = await browser.newPage();
await page.setViewport({width: 1280, height: 800});
await page.goto(url, {waitUntil: "networkidle0"});
const screenshots = [];
for (let i = 0; i < frames; i++) {
const screenshot = await page.screenshot({
captureBeyondViewport: true,
fullPage: true,
encoding: "base64",
});
screenshots.push(screenshot);
await setTimeout(ms);
}
return screenshots;
};
With setInterval
:
const {setInterval} = require("node:timers/promises");
const getScreenshots = async (
browser,
url,
ms,
frames
): Promise<string[]> => {
const page = await browser.newPage();
await page.setViewport({width: 1280, height: 800});
await page.goto(url, {waitUntil: "networkidle0"});
const screenshots = [];
for await (const startTime of setInterval(ms)) {
const screenshot = await page.screenshot({
captureBeyondViewport: true,
fullPage: true,
encoding: "base64",
});
screenshots.push(screenshot);
if (screenshots.length >= frames) {
return screenshots;
}
}
};
The calling code is the same, with browser.close()
uncommented.
Note that any solution with setTimeout
or setInterval
will drift over time. Taking screenshots is a complex, non-instantaneous subprocess call anyway, so I imagine it'll be diminishing returns to attempt this, but you could try a tight requestAnimationFrame
loop with a performance.now()
call and drift correction.
In addition to new Promise
, other red flags are using async
on a function that doesn't have an await
in it, using async
on a new Promise
and making a setInterval
callback async
. Even if you don't have a newer Node version or don't have utils.promisify
(such as in the browser), it's better to bury the promisification in a one-off function and keep your mainline code callback-free:
const sleep = ms => new Promise(resolve => setTimeout(resolve, ms));
Other minor suggestions:
- It's a bit strange that you create an array of screenshots, then throw them all away except for the last one (you can more idiomatically access the last element of an array with
.at(-1)
).
- When you have more than a few arguments, as in
getScreenshots(browser, url, 42, 24)
, the recommended approach is to switch to a configuration object, like getScreenshots(browser, url, {ms: 42, frames: 24})
to keep the code readable.
- I generally prefer my Puppeteer helper functions to accept a
page
rather than a whole browser. This allows for maximum reusability because the callee isn't forced to create a new page. The caller can set whichever settings and URL on the page ahead of the screenshot call rather than passing them in as parameters.
Here's a complete, runnable example with the above suggestions applied:
const fs = require("node:fs/promises");
const puppeteer = require("puppeteer");
const {setInterval} = require("timers/promises");
const getScreenshots = async (page, opts = {ms: 1000, frames: 10}) => {
const screenshots = [];
for await (const startTime of setInterval(opts.ms)) {
const screenshot = await page.screenshot({
captureBeyondViewport: true,
fullPage: true,
encoding: "base64",
});
screenshots.push(screenshot);
if (screenshots.length >= opts.frames) {
return screenshots;
}
}
};
// Driver code for testing:
const html = `<!DOCTYPE html>
<html>
<body>
<h1></h1>
<script>
let i = 0;
setInterval(() => {
document.querySelector("h1").textContent = ++i;
}, 10);
</script>
</body>
</html>
`;
let browser;
(async () => {
browser = await puppeteer.launch();
const [page] = await browser.pages();
await page.setContent(html);
const screenshots = await getScreenshots(page, {ms: 100, frames: 10});
console.log(screenshots.length); // => 10
const gallery = `<!DOCTYPE html><html><body>
${screenshots.map(e => `
<img alt="test screenshot" src="data:image/png;base64,${e}">
`)}
</body></html>`;
await fs.writeFile("test.html", gallery);
})()
.catch(err => console.error(err))
.finally(() => browser?.close());
Open test.html
in your browser to see 10 screenshots at different intervals.
Note how removing newPage
from the function gives the caller the ability to shoot screenshots on a setContent
rather than a goto
.