🪅

Playwrightでサイトマップに記載されたページのスクリーンショットを保存する

2025/01/14に公開

E2EテストツールであるPlaywrightですが、スクリーンショット機能を持っています。今回は自動化の観点からPlaywrightの活用方法を検討するため、sitemap.xml に記載された全URLにアクセスしてスクリーンショットを保存するプログラムをつくってみました。

セットアップ

はじめに環境構築を行います。

  • Node.js
  • TypeScjript
  • Playwright
# ディレクトリを作成
mkdir test-playwright-snapshot && cd $_

# package.jsonを作成
npm init

# ライブラリをインストール (Playwrightはinitコマンドでインストール)
npm install @types/node typescript uuid 

# tsconfig.jsonを作成
npx tsc --init

# Playwrightの設定ファイルを作成、不要なサンプルコードを削除
npm init playwright@latest && rm -rf tests tests-examples

> test-playwright-snapshot@1.0.0 npx
> create-playwright

Getting started with writing end-to-end tests with Playwright:
Initializing project in '.'
✔ Where to put your end-to-end tests? · tests
✔ Add a GitHub Actions workflow? (y/N) · false
✔ Install Playwright browsers (can be done manually via 'npx playwright install')? (Y/n) · true

tsconfig.jsonpackage.json を編集して、ES Modulesとビルド周りの設定をします。ここではルートディレクトリ下の ./dest ディレクトリにビルド後のJavaScriptコードが格納される様に設定しています。

tsconfig.json
    "target": "ES2022" /* Set the JavaScript language version for emitted JavaScript and include compatible library declarations. */,
    "module": "ES2022" /* Specify what module code is generated. */,
    "moduleResolution": "node",                     /* Specify how TypeScript looks up a file from a given module specifier. */
    "outDir": "./dist" /* Specify an output folder for all emitted files. */,
package.json
@@ -1,12 +1,19 @@
 {
   "name": "test-playwright-snapshot",
   "version": "1.0.0",
-  "main": "index.js",
+  "main": "./dist/index.js",
+  "type": "module",
   "scripts": {
-    "test": "echo \"Error: no test specified\" && exit 1"
+    "build": "tsc",
+    "start": "node ./dist/index.js"
   },
   "keywords": [],
   "author": "",
   "license": "ISC",
-  "description": ""
+  "description": "",
+  "dependencies": {
+    "@types/node": "^22.10.5",
+    "typescript": "^5.7.3",
+    "uuid": "^11.0.5"
+  },
+  "devDependencies": {
+    "@playwright/test": "^1.49.1"
+  }
 }

ルートディレクトリにエントリポイントとなる index.ts を作成して、Playwrightが動作することを確認します。

index.ts
import { chromium } from "@playwright/test";

(async () => {
  const browser = await chromium.launch();
  const context = await browser.newContext();
  const page = await context.newPage();

  await page.goto("https://playwright.dev/");
  const title = await page.title();
  console.log(title);

  await browser.close();
})();
npm run build && npm run start

> test-playwright-snapshot@1.0.0 build
> tsc


> test-playwright-snapshot@1.0.0 start
> node ./dist/index.js

Playwright

コンソールにページタイトル Playwright が出力されていればOKです。

スクリーンショットを試してみる

環境構築ができたので、早速スクリーンショットAPIを使ってみます。

https://playwright.dev/docs/screenshots

ドキュメントを見ると、{ fullPage: true } オプションを指定することでページ全体のキャプチャを取得できることがわかります。バックグラウンドで適当なローカルサーバーを動かしたうえで、トップページのキャプチャを撮ってみましょう。

index.ts
  const baseUrl = "http://localhost:3000";
  await page.goto(baseUrl);
  await page.waitForURL(baseUrl);
  await page.screenshot({ path: "top.png", fullPage: true });
$ npm run build && npm run start && open top.png

サイトマップからページのスクリーンショットを保存する

サイトマップをもとにサイト内のページにアクセスして、スクリーンショットを保存していきます。手順としては以下のとおりです。

  1. http://localhost:3000/sitemap.xml のレスポンスから <loc> タグで囲まれたURLを取り出す
  2. URLの配列でループさせて、スクリーンショットの保存処理を回す
index.ts
import { chromium, expect, Response } from "@playwright/test";
import path from "path";
import { v4 as uuidv4 } from "uuid";

const sitemapUrl = "http://localhost:3000/sitemap.xml";

(async () => {
  const browser = await chromium.launch();
  const context = await browser.newContext();
  const page = await context.newPage();

  await page.goto(sitemapUrl);
  const html = await page.content();
  // URLの一覧を取得する
  const urls =
    html
      .match(/<loc>(.*?)<\/loc>/g)
      ?.map((regex) => regex.replace(/<\/?loc>/g, "")) || [];

  // URLの配列でループさせる
  for (const url of urls) {
    const page = await context.newPage();
    await page.goto(url);

    // 画像がすべて表示されるのを待つ
    const lazyImages = await (
      await page.locator('img[loading="lazy"]:visible')
    ).all();
    for (const image of lazyImages) {
      await image.scrollIntoViewIfNeeded({ timeout: 0 });
      await expect(image).not.toHaveJSProperty("naturalWidth", 0);
    }

    // ファイル名が被らない様にuuidで生成した名前でスクリーンショットを保存する
    const fullPath = path.join(process.pid.toString(), `${uuidv4()}.png`);
    await page.screenshot({ path: fullPath, fullPage: true, timeout: 0 });
    console.log(`${fullPath} saved`);
  }

  await browser.close();
})();

注意点として、高負荷によるタイムアウトを意識する必要がありました。for ループで回している部分に Promise を使いたくなるのですが、逐次処理でなければメモリリーク起因?のタイムアウトが発生してしまいます。

適切に page インスタンスの生成・廃棄ができればよいのでしょうが、うまくプログラミングできなかったのでこの形になりました。

index.ts
  // タイムアウトになる書き方 (Promise.allで非同期に実行)
  await Promise.all(
    urls.map(async (url) => {
      const page = await context.newPage();
      await page.goto(url);

      const lazyImages = await (
        await page.locator('img[loading="lazy"]:visible')
      ).all();
      for (const image of lazyImages) {
        await image.scrollIntoViewIfNeeded({ timeout: 0 });
        await expect(image).not.toHaveJSProperty("naturalWidth", 0);
      }

      const fullPath = path.join(process.pid.toString(), `${uuidv4()}.png`);
      await page.screenshot({ path: fullPath, fullPage: true, timeout: 0 });
      console.log(`${fullPath} saved`);
    })
  );
node:internal/process/promises:288
            triggerUncaughtException(err, true /* fromPromise */);
            ^

page.goto: Timeout 30000ms exceeded.
Call log:
  - navigating to "http://localhost:3000/example", waiting until "load"

    at /Users/yuki/src/test-playwright-snapshot/dist/index.js:16:20
    at /Users/yuki/src/test-playwright-snapshot/async file:/Users/yuki/src/test-playwright-snapshot/dist/index.js:14:5 {
  name: 'TimeoutError'
}

Node.js v18.20.4

また、Playwrightの非同期関数に対して執拗に { timeout: 0 }) を付与していますが、これがないとタイムアウトで落ちたり・落ちなかったりします。

最後に

Playwrightのスクリーンショット機能を試してみました。サイト全体のキャプチャを取得する目的で使用する場合、動的なコンテンツ (特に表示領域に入ったらアニメーションが発火する類のもの) は意図した結果になりにくく、サイトやページごとに調整が必要可能性が高いです。
自動化全般に言えることですが100%のクオリティにするのは難しいので、8割くらいの作業を簡略化する方向で考えてみたいと思います。

https://github.com/yuki-yamamura/test-playwright-snapshot

株式会社FLAT テックブログ

Discussion