Playwrightで特定のDom要素の変化を検知する

# TL;DR

```js
// 該当Webpageで対象Dom要素の変化を監視
await page.locator('[id=chat-container]').evaluate(element => {
  const observer = new MutationObserver(mutationsList => {
    if (mutationsList[0]?.type !== 'childList') return;
    // 該当変化をConsoleに出力
    console.log(`@@NewMessage@@ ${mutationsList[0].addedNodes[0].textContent}`);
  });
  observer.observe(element, { childList: true, subtree: true });
});

// PlaywrightでConsoleの出力を監視
page.on('console', msg => {
  if (msg.type() === 'log') {
    const text = msg.text();
    if (text.startsWith('@@NewMessage@@')) {
      const message = text.replace('@@NewMessage@@', '').trim();
      console.log(`Received message: ${message}`);
    }
  }
});
```

# はじめに
[Playwright](https://playwright.dev/)はMicrosoft社が開発したOpen sourceのブラウザ自動化ツールです。E2E テストをはじめ、いろんな便利な使い方があります。

Playwrightはいくつかの[EventListener](https://playwright.dev/docs/api/class-page#events)を提供していますが、特定のDom要素の変化を監視するListenerはありません。

# 方法
Playwrightが提供しているListenerの中、[ConsoleMessage](https://playwright.dev/docs/api/class-consolemessage#console-message-args)は比較的簡単に発火できて、Dialogのようなページの動きを停止させる副作用もありません。

そのConsoleMessage Eventを活用して、Dom要素変化のListenerを作成します。

## 下準備
デモンストレーションのために、Google Geminiで定期にメッセージを送信するダミーのチャットアプリを作りました。

一枚のHTMLですが、長いので折りたたみます。

<details><summary>Auto-Chat.html</summary>

```html
<!DOCTYPE html>
<html lang="ja">
<head>
  <meta charset="UTF-8">
  <meta name="viewport" content="width=device-width, initial-scale=1.0">
  <title>Auto Chat Demo</title>
  <style>
    body {
      font-family: 'Segoe UI', Tahoma, Geneva, Verdana, sans-serif;
      margin: 20px;
      background-color: #f0f0f0;
      display: flex;
      justify-content: center;
      align-items: center;
      min-height: 100vh;
    }

    #chat-container {
      width: 90%;
      max-width: 600px;
      height: 80vh;
      border: 1px solid #ddd;
      border-radius: 10px;
      background-color: #fff;
      box-shadow: 0 2px 10px rgba(0, 0, 0, 0.1);
      display: flex;
      flex-direction: column;
      overflow: hidden;
    }

    #messages {
      flex-grow: 1;
      overflow-y: auto;
      padding: 15px;
      display: flex;
      flex-direction: column;
    }

    .message {
      clear: both;
      margin-bottom: 10px;
      padding: 10px 15px;
      border-radius: 20px;
      max-width: 80%;
      word-wrap: break-word;
      position: relative;
    }

    .sent {
      background-color: #DCF8C6;
      color: #333;
      align-self: flex-end;
      border-bottom-right-radius: 5px;
    }

    .received {
      background-color: #E1F5FE;
      color: #333;
      align-self: flex-start;
      border-bottom-left-radius: 5px;
    }

    .message .username {
      font-size: 0.9em;
      font-weight: bold;
      color: #555;
      margin-right: 5px;
    }

    .message .timestamp {
      font-size: 0.7em;
      color: #888;
      bottom: 3px;
      right: 8px;
    }

    .received .timestamp {
      left: 10px;
    }

    #message-form {
      padding: 10px 15px;
      border-top: 1px solid #eee;
      display: flex;
    }

    #message-form label {
      margin-right: 10px;
      font-size: 0.9em;
      color: #555;
    }

    #message-form input[type="range"] {
      flex-grow: 1;
      margin-right: 10px;
    }
  </style>
</head>
<body>
  <div id="chat-container">
    <div id="messages"></div>
    <form id="message-form">
      <label for="send-speed">Send Speed: ( <span id="current-speed">12</span> per 60s )</label>
      <input type="range" name="send-speed" id="send-speed" min="1" max="20" value="12" step="1" onchange="sendSpeedChanged(this.value)">
    </form>
  </div>

  <script>
    const messagesDiv = document.getElementById('messages');

    const dealy = (ms) => new Promise(resolve => setTimeout(resolve, ms));

    let sendSpeed = parseInt(document.getElementById('send-speed').value, 10);
    function sendSpeedChanged(speed) {
      const currentSpeed = parseInt(speed, 10);
      document.getElementById('current-speed').textContent = currentSpeed;
      sendSpeed = currentSpeed;
    }

    (async () => {
      let timeout = 10000;
      let randValue = 0; 
      while (true) {
        timeout = 60000 / sendSpeed; // 60秒を送信速度で割る
        randValue = Math.floor(Math.random() * 100) + 1 // 1から100のランダムな値を生成

        if (randValue % 2 === 0) { // 偶数なら送信メッセージ
          let timestamp = new Date().toLocaleTimeString();
          appendMessage('User', 'Hello, this is a test message!', timestamp, true); // trueは送信メッセージを示す
        } else { // 奇数なら受信メッセージ
          let timestamp = new Date().toLocaleTimeString();
          appendMessage('Server', 'This is a response message!', timestamp, false); // falseは受信メッセージを示す
        }

        await dealy(timeout); // 指定された時間待機
      }
    })();

    function appendMessage(username, text, timestamp, isSent) {
      const messageElement = document.createElement('div');
      messageElement.classList.add('message');
      messageElement.classList.add(isSent ? 'sent' : 'received');
      messageElement.innerHTML = `
        <span class="username">
          <span class="name">${username}</span>
          <span class="timestamp">${timestamp}</span>
        </span>
        <span class="content">${text}</span>`;
      messagesDiv.appendChild(messageElement);
      messagesDiv.scrollTop = messagesDiv.scrollHeight; // スクロールを一番下へ
    }
  </script>
</body>
</html>
```

</details>

## 目的ページでDom要素の監視
Playwrightは`page.evaluate()`でページ画面上にJSコードの実行ができるので、そこで[MutationObserver](https://developer.mozilla.org/ja/docs/Web/API/MutationObserver)を使って、目標のDom要素を監視し、その変化を特定のフォーマットのメッセージでConsoleに出力します。

![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/87438/ab795883-9f2f-4c72-b415-125d09857f15.png)

ページの構造を見ると、チャットメッセージのコンテナは`div#messages`で、チャットメッセージはその配下の`div.message`です。

これが分かれば、早速作成します。

```playwright.js
import { chromium } from 'playwright';

const browser = await chromium.launch({ headless: false });
const context = await browser.newContext({ viewport: { width: 1024, height: 600 } });
const page = await context.newPage();

await page.goto('http://127.0.0.1:13579/Auto-Chat.html');

const chatContainer = page.locator('#messages');
await chatContainer.scrollIntoViewIfNeeded();

// メッセージコンテナの変化を監視
await chatContainer.evaluate(element => {
  const observer = new MutationObserver(mutationsList => {
    mutationsList.forEach(mutation => {
      if (mutation.type === 'childList') {
        mutation.addedNodes.forEach(node => {
          const res = {
            username: node.querySelector('.username .name')?.textContent,
            content: node.querySelector('.content')?.textContent,
            timestamp: node.querySelector('.timestamp')?.textContent,
          }
          console.log(`@@NewMessage@@ ${JSON.stringify(res)}`); // 情報をJSON形式でコンソールに出力
        });
      }
    });
  });
  observer.observe(element, { childList: true, /* subtree: true */}); // 孫要素も監視する場合はsubtree: trueを有効にする
});
```

## PlaywrightでConsoleMessageの監視

下記のロジックをなるべく `page.goto()` でページ移動する前に追加した方がいいです。
または`page.on('load', pg => {})`で包むようにしてください。

```js
// コンソールEventを監視
page.on('console', msg => {
  if (msg.type() === 'log') {
    const text = msg.text();
    if (text.startsWith('@@NewMessage@@')) {
      const res = JSON.parse(text.replace('@@NewMessage@@', '').trim());
      console.log(`Received message: ${res.username}: ${res.content} at ${res.timestamp}`);
    }
  }
});
```

# 結果

![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/87438/f3744aae-bbae-4f32-aa82-6164037dbc32.png)

```sh
$ node playwright.mjs
Received message: User: Hello, this is a test message! at 14:45:50
Received message: Server: This is a response message! at 14:45:55
Received message: User: Hello, this is a test message! at 14:46:00
Received message: User: Hello, this is a test message! at 14:46:05
Received message: Server: This is a response message! at 14:46:10
Received message: Server: This is a response message! at 14:46:15
Received message: User: Hello, this is a test message! at 14:46:20
Received message: Server: This is a response message! at 14:46:25
Received message: User: Hello, this is a test message! at 14:46:30
```


# 余談
監視したいページの[セキュリティポリシー](https://developer.mozilla.org/en-US/docs/Web/HTTP/Reference/Headers/Content-Security-Policy/trusted-types)により、MutationObserverでDom要素の変化こそ監視できたが、中身は空のnodeListの可能性もあります。

その時はEventだけ発火させ、Eventを検知した際Playwirghtの方で要素の中身を取得するようにしてください。

ただし、Playwrightで一気に大量なアクセスが発生し、エラーになる可能性があるので、[lodash](https://lodash.com/docs/4.17.15) の `debounce()` や `throttle()` でアクセス制限するといいです。


# 参考
- [\[Question\] Can I watch for changes in a specific element? · Issue #4051 · microsoft/playwright](https://github.com/microsoft/playwright/issues/4051)
- [MutationObserver について理解を深める](https://zenn.dev/dotdotdot/articles/94683f6dad45aa)
- [ConsoleMessage | Playwright](https://playwright.dev/docs/api/class-consolemessage#console-message-args)
- [lodash の debounce や throttle で簡単に負荷対策 #JavaScript - Qiita](https://qiita.com/waterada/items/986660d31bc107dbd91c)