QR Send

スマホとPCのChrome間で、URLやテキストをQRコード1回のペアリングで送り合える自作Chrome拡張。アカウント不要・無料。

使ってみる →

【超初心者向け】Playwright + TypeScriptチュートリアル:環境構築からブラウザ自動操作まで一歩ずつ

Selenium・自動化

はじめに

このチュートリアルは、「プログラミングはほとんど触ったことがないけれど、ブラウザの操作を自動化してみたい」という方向けに書いています。

使うのは2つの道具です。

  • Playwright(プレイライト): ブラウザを自分の代わりに操作してくれるツールです。ページを開く、ボタンをクリックする、文字を読み取る、といった作業をプログラムから指示できます
  • TypeScript(タイプスクリプト): JavaScriptに「型」という仕組みを足した言語です。型があると、間違った使い方をしたときにエディタが先に教えてくれるので、初心者でもミスに気づきやすくなります

以前、Ruby言語とSeleniumというツールを使ったチュートリアル「ruby + selenium webdriverチュートリアル」を書きました。今回はその内容を、PlaywrightとTypeScriptというまったく別の組み合わせで、同じ章立てのままなぞり直したものです。Rubyの記事を読んだことがない方でも、この記事だけで最初から最後まで進められるようにしています。

途中でエラーが出ても大丈夫です。むしろエラーが出るのが普通なので、つまずきやすいポイントはできるだけ先回りして書いておきました。焦らず1つずつ進めていきましょう。

このチュートリアルの前提

このチュートリアルを進めるうえで、JavaScript・TypeScriptのごく基本的な部品だけは知っておくと理解がスムーズです。ただし、今の時点で完璧に分かっていなくてもまったく問題ありません。コードを実際に動かしながら、必要なところは都度説明していきます。

  • 変数: 値に名前をつけて保管しておく箱のようなものです。const name = "たろう"; のように書きます
  • 配列: 複数の値をひとまとめにしたリストです。const list = ["a", "b", "c"]; のように書きます
  • for文(このチュートリアルでは主にfor...of: 配列の中身を1つずつ順番に取り出して処理を繰り返す書き方です
  • 関数: 一連の処理をひとまとめにして、名前をつけて呼び出せるようにしたものです

「聞いたことはあるけど自信はない」くらいの理解で十分です。分からない言葉が出てきたら、その都度立ち止まって説明を読み返してもらえれば進められるように書いています。

環境構築

ここが一番地味で、そして一番つまずきやすいところです。1つずつ確認しながら進めましょう。

1. Node.jsをインストールする

Playwright(TypeScript版)はNode.jsという実行環境の上で動きます。まだ入れていない方は、公式サイトからインストーラーをダウンロードしてください。

  • https://nodejs.org/ にアクセスし、「LTS」と書かれている方(推奨版、の意味です)をダウンロードしてインストールします

インストールが終わったら、ターミナル(Macなら「ターミナル」アプリ、Windowsなら「コマンドプロンプト」や「PowerShell」)で次のコマンドを実行し、バージョンが表示されることを確認します。

node -v
npm -v

数字(例: v20.11.0 のような表示)が出てくれば成功です。「command not found」のようなエラーが出る場合は、インストールがうまくいっていないか、ターミナルの再起動が必要な場合があります。一度ターミナルを閉じて開き直してみてください。

2. プロジェクトを作る

作業用のフォルダを作り、その中に移動して、npmの初期化コマンドを実行します。npmはNode.jsに付属しているパッケージ管理ツールで、必要な部品(ライブラリ)のインストールやプロジェクトの設定を担当してくれます。

mkdir playwright-tutorial
cd playwright-tutorial
npm init -y

npm init -yを実行すると、そのフォルダにpackage.jsonというファイルが作られます。これは「このプロジェクトにはどんな部品が入っているか」を管理するための設定ファイルです。中身を細かく気にする必要は今のところありません。

3. Playwright・TypeScript実行用ツールを入れる

続けて、Playwright本体と、TypeScriptのコードをそのまま実行するためのtsxというツールをインストールします。

npm install -D playwright typescript tsx
npx playwright install chromium

それぞれ何をしているか、簡単に説明します。

  • npm install -D playwright typescript tsx: PlaywrightとTypeScript、そしてtsx(TypeScriptのファイルをコンパイルせずにそのまま実行してくれるツール)をインストールしています。-Dは「開発用の部品として入れる」という意味のオプションです
  • npx playwright install chromium: Playwrightが操作するブラウザ本体(今回はChromium、Google Chromeのベースになっているブラウザ)をダウンロードします。この1行を忘れると、後の手順で「ブラウザの実行ファイルが見つからない」というエラーになるので注意してください

ここでつまずいたら

  • npx playwright install chromiumの後に何十MBもダウンロードが始まります。回線が遅いと数分かかることがありますが、正常な動作です
  • 会社のPCなどでプロキシ設定が必要な環境では、ダウンロードが途中で止まることがあります。その場合は自宅のネットワークなど別の回線で一度試してみてください
  • Windowsで文字化けする場合は、ターミナルの文字コード設定をUTF-8にすると改善することが多いです

4. 最初の1ファイルを作って動かしてみる

環境構築が正しくできているか確認するために、hello.tsというファイルを作ります。

import { chromium } from "playwright";

const browser = await chromium.launch({ headless: false });
console.log("ブラウザが起動しました");
await browser.close();

補足: 通常のTypeScript/JavaScriptではawait(後述します)はasyncという関数の中でしか使えないのですが、tsxを使うとファイルの一番外側(トップレベル)でもawaitをそのまま書くことができます。このチュートリアルのコードはすべてこの書き方で統一しているので、asyncで囲む手間がありません。

保存したら、そのファイルがあるフォルダで次のコマンドを実行します。

npx tsx hello.ts

一瞬ブラウザのウィンドウが開いて、すぐに閉じたはずです。ターミナルに「ブラウザが起動しました」と表示されていれば、環境構築は成功です。ここまでできれば、あとは書くコードを少しずつ増やしていくだけです。

ここでつまずいたら

  • Cannot find module 'playwright'と出る場合: npm install -D playwright typescript tsxを実行したフォルダと、npx tsx hello.tsを実行しているフォルダが違う可能性があります。ls(Windowsはdir)でファイルの場所を確認してください
  • browserType.launch: Executable doesn't existと出る場合: npx playwright install chromiumが実行できていません。もう一度実行してください
  • 何も反応がなくフリーズしたように見える場合: 社内ネットワークなどでブラウザの起動がブロックされていることがあります。個人のネットワーク環境で試してみてください

チュートリアル1: ブラウザとページを起動する

ここからが本編です。まずはPlaywrightで実際にブラウザを起動し、目に見える形で動かしてみましょう。tutorial-1.tsというファイルを作ります。

import { chromium } from "playwright";

// headless: false にすると、ブラウザの画面が実際に表示された状態(headful)で起動する
const browser = await chromium.launch({ headless: false });

// 1つのタブに相当するページを新しく開く
const page = await browser.newPage();

// 3秒間待って、開いたブラウザを目で確認できるようにする
await page.waitForTimeout(3000);

// ブラウザを閉じる
await browser.close();
npx tsx tutorial-1.ts

真っ白なブラウザのウィンドウが3秒間ほど表示されて、自動的に閉じれば成功です。

ここで出てきた言葉を1つずつ確認しておきましょう。

  • chromium.launch({ headless: false }): 新しいブラウザのプロセスを起動します。headlessとは「画面を表示しない」という意味の設定で、これをfalseにすることで、あえて画面が見える状態(headful、ヘッドフルと呼びます)にしています。最初のうちは画面が見えたほうが「今何が起きているか」が分かって安心なので、このチュートリアルでは基本的にheadless: falseで進めます
  • browser.newPage(): ブラウザの中に新しいタブ(ページ)を1つ開きます。以降の操作は、このpageという変数を通して行います
  • browser.close(): 使い終わったブラウザを閉じます。これを書き忘れると、ブラウザのプロセスが起動したまま残り続けてしまうので、必ずセットで書くようにしてください

なぜheadless: falseで始めるのかというと、「プログラムが実際にブラウザを操作している様子」を自分の目で見られると、この後の内容がぐっと理解しやすくなるからです。仕組みに慣れてきたら、headless: true(または省略、これがデフォルトの動作です)にして画面を表示せずに高速に実行することもできます。

チュートリアル2: URLにアクセスして表示する

次は、開いたページで実際のWebサイトを表示してみましょう。tutorial-2.tsを作ります。

import { chromium } from "playwright";

const browser = await chromium.launch({ headless: false });
const page = await browser.newPage();

// 指定したURLにアクセスする
await page.goto("https://example.com");

await page.waitForTimeout(3000);
await browser.close();
npx tsx tutorial-2.ts

example.comという、Webの説明用に用意された簡単なページが表示されたはずです。

ここでpage.goto("https://example.com")の前についているawaitについて説明します。Playwrightの多くの操作(ページを開く、クリックする、など)は、実際にブラウザ側の処理が終わるまで少し時間がかかります。そこでPlaywrightはこうした操作の結果を「Promise(プロミス)」と呼ばれる、「あとで結果が返ってくる約束手形」のようなものとして返します。awaitをつけると、「この処理が終わるまで、次の行には進まずに待つ」という意味になります。

awaitを付け忘れるとどうなるか、少し体験してみましょう。

// 悪い例: awaitを付け忘れている
page.goto("https://example.com");
console.log("ページを開いた直後のタイトル:", await page.title());

このように書くと、page.gotoが実際にページの読み込みを終える前に次の行が実行されてしまい、意図した通りの結果にならないことがあります。「Playwrightの操作にはほぼ必ずawaitを付ける」と覚えておくと安全です。

ここでつまずいたら

  • 会社のプロキシ環境などでインターネットに出られない場合、page.gotoがタイムアウトすることがあります。その場合は後述する練習用サイト(ローカルファイル)を使う手順に進んでも問題ありません
  • net::ERR_NAME_NOT_RESOLVEDのようなエラーが出る場合は、ネットワーク接続そのものを確認してください

チュートリアル3: ロケータで要素にアクセスする

ここからは、ページの中の特定の部分(見出しやボタンなど)を指定して操作する方法を学びます。ページの中の「この要素」を指し示すためのオブジェクトを、Playwrightではロケータ(locator、直訳すると「位置を特定するもの」)と呼びます。ロケータは、いわばページの中の要素の「住所」のようなものだとイメージしてください。

example.comのページには、次のような要素があります。

  • <h1>タグで書かれた「Example Domain」という見出し
  • 説明文の段落
  • 「More information…」という文字のリンク

これらをロケータで指定してみましょう。tutorial-3.tsを作ります。

import { chromium } from "playwright";

const browser = await chromium.launch({ headless: false });
const page = await browser.newPage();
await page.goto("https://example.com");

// (1) CSSセレクタで指定する
const heading = page.locator("h1");
console.log("見出し(locator):", await heading.innerText());

// (2) 役割(role)と表示テキストで指定する
const moreLink = page.getByRole("link", { name: "More information..." });
console.log("リンクが見つかったか:", (await moreLink.count()) > 0);

// (3) 表示されているテキストそのもので指定する
const domainText = page.getByText("Example Domain");
console.log("テキスト一致(locator):", await domainText.innerText());

await page.waitForTimeout(2000);
await browser.close();
npx tsx tutorial-3.ts

3つのロケータの違いを整理しておきます。

  • page.locator("h1"): CSSセレクタ(Webページの見た目を装飾するCSSという仕組みで使われる、要素を指定するための書き方)をそのまま使う、一番基本的な方法です。クラス名(.itemのような書き方)やid(#headerのような書き方)、タグ名(h1など)を指定できます
  • page.getByRole("link", { name: "..." }): 「リンク」「ボタン」といった要素の役割(role)と、そこに表示されている名前で探します。人間やスクリーンリーダー(視覚障害のある方向けの読み上げソフト)が「これはリンクだ」「これはボタンだ」と認識する情報をもとに探すため、後からページのクラス名が変わっても壊れにくいのが利点です。クリックする対象のボタンやリンクを指定するときは、まずこの書き方を検討するとよいでしょう
  • page.getByText("..."): 画面に表示されている文字そのもので探します。直感的で分かりやすい反面、同じ文字が複数の場所にあると意図しない要素を掴んでしまうことがあります

どれが正解ということはなく、状況に応じて使い分けます。迷ったら、まずはgetByRolegetByTextのような「人間にも分かりやすい指定方法」を試し、うまくいかないときにCSSセレクタで細かく指定する、という順番がおすすめです。

演習

example.comのページから、説明文の段落(<p>タグ)のテキストを、page.locator("p")を使って取得し、console.logで表示してみましょう。ヒント: 段落は2つあるので、.first()を使うと最初の1つだけを指定できます(.first()については次の章でも説明します)。

チュートリアル4: 入れ子の要素を辿る

実際のWebページでは、「一覧の中の1つの箱の、その中にあるタイトル部分だけ」のように、要素が入れ子(ネスト)になっていることがほとんどです。ロケータは、.locator(...)を連ねて書くこと(チェーンと呼びます)で、この「入れ子」を1段ずつ辿ることができます。

ここから先のいくつかのチュートリアルでは、練習用にシンプルなHTMLファイルを自分のパソコンの中に作って使います。実在のサイトを対象にすると、サイト側の都合で構造が変わってしまったり、意図せず負荷をかけてしまったりするおそれがあるため、安全に何度でも練習できるダミーのページを用意します。

練習用サイトを作っておきましょう

playwright-tutorialフォルダの中にpractice-siteというフォルダを作り、その中に次の3つのファイルを作成してください。架空の商品一覧を模した、ごく簡単なHTMLファイルです。

practice-site/list-page1.html(1ページ目、商品3件)

<!DOCTYPE html>
<html lang="ja">
<head><meta charset="UTF-8"><title>練習用の商品一覧(1ページ目)</title></head>
<body>
  <h1>練習用の商品一覧(1ページ目)</h1>
  <div class="item">
    <p class="title">練習用アイテムA</p>
    <p class="price">1200円</p>
    <a class="detail-link" href="detail-item1.html">詳細を見る</a>
  </div>
  <div class="item">
    <p class="title">練習用アイテムB</p>
    <p class="price">980円</p>
    <a class="detail-link" href="detail-item2.html">詳細を見る</a>
  </div>
  <div class="item">
    <p class="title">練習用アイテムC</p>
    <p class="price">2400円</p>
    <a class="detail-link" href="detail-item3.html">詳細を見る</a>
  </div>
  <a class="next" href="list-page2.html">次へ</a>
</body>
</html>

practice-site/list-page2.html(2ページ目、商品2件)

<!DOCTYPE html>
<html lang="ja">
<head><meta charset="UTF-8"><title>練習用の商品一覧(2ページ目)</title></head>
<body>
  <h1>練習用の商品一覧(2ページ目)</h1>
  <div class="item">
    <p class="title">練習用アイテムD</p>
    <p class="price">1500円</p>
  </div>
  <div class="item">
    <p class="title">練習用アイテムE</p>
    <p class="price">3200円</p>
  </div>
  <a class="next" href="list-page3.html">次へ</a>
</body>
</html>

practice-site/list-page3.html(3ページ目、商品1件。次へボタンなし=最終ページ)

<!DOCTYPE html>
<html lang="ja">
<head><meta charset="UTF-8"><title>練習用の商品一覧(3ページ目)</title></head>
<body>
  <h1>練習用の商品一覧(3ページ目)</h1>
  <div class="item">
    <p class="title">練習用アイテムF</p>
    <p class="price">700円</p>
  </div>
</body>
</html>

さらに、1ページ目の3商品それぞれの詳細ページも作っておきます。3つとも同じ構造で、中身の文字だけ書き換えたものです。

practice-site/detail-item1.html

<!DOCTYPE html>
<html lang="ja">
<head><meta charset="UTF-8"><title>練習用アイテムA 詳細</title></head>
<body>
  <h1 class="item-name" data-item-id="A001">練習用アイテムA</h1>
  <p class="description">これは練習用のダミー商品説明です(アイテムA)。</p>
</body>
</html>

practice-site/detail-item2.html(見出しとdata-item-id、説明文をB002用に書き換え)

<!DOCTYPE html>
<html lang="ja">
<head><meta charset="UTF-8"><title>練習用アイテムB 詳細</title></head>
<body>
  <h1 class="item-name" data-item-id="B002">練習用アイテムB</h1>
  <p class="description">これは練習用のダミー商品説明です(アイテムB)。</p>
</body>
</html>

practice-site/detail-item3.html(同様にC003用に書き換え)

<!DOCTYPE html>
<html lang="ja">
<head><meta charset="UTF-8"><title>練習用アイテムC 詳細</title></head>
<body>
  <h1 class="item-name" data-item-id="C003">練習用アイテムC</h1>
  <p class="description">これは練習用のダミー商品説明です(アイテムC)。</p>
</body>
</html>

これで、practice-siteフォルダの中に6つのHTMLファイルができました。これらはインターネットには公開されておらず、あなたのパソコンの中だけに存在するファイルです。Playwrightからは、file://から始まるURLでこうしたローカルファイルを開くことができます。パスの指定をOSによる違い(WindowsとMacでは区切り文字が違います)を気にせず書けるように、次のような小さな関数を用意しておくと便利です。今後のチュートリアルではこのfileUrl関数を使い回します。

import path from "node:path";
import { pathToFileURL } from "node:url";

// practice-site配下の相対パスを、file://から始まるURL文字列に変換する
function fileUrl(relativePath: string): string {
  return pathToFileURL(path.resolve(relativePath)).href;
}

このチュートリアルのコード例は、すべてplaywright-tutorialフォルダの直下(practice-siteフォルダと同じ階層)でnpx tsx ファイル名.tsを実行する前提で書いています。別のフォルダから実行するとpractice-siteが見つからないので、その場合はcdで正しいフォルダに移動してから実行してください。

入れ子の要素を辿ってみる

準備ができたら、tutorial-4.tsを作ります。

import { chromium } from "playwright";
import path from "node:path";
import { pathToFileURL } from "node:url";

function fileUrl(relativePath: string): string {
  return pathToFileURL(path.resolve(relativePath)).href;
}

const browser = await chromium.launch({ headless: false });
const page = await browser.newPage();
await page.goto(fileUrl("practice-site/list-page1.html"));

// ページの中から「.item」に一致する箱を1つだけ取得する(1つ目)
const firstItem = page.locator(".item").first();

// firstItemの中から、さらに「.title」に一致する部分を辿る(入れ子)
const firstTitle = firstItem.locator(".title");
console.log("1つ目の商品名:", await firstTitle.innerText());

await page.waitForTimeout(2000);
await browser.close();
npx tsx tutorial-4.ts

「練習用アイテムA」と表示されれば成功です。

ポイントは、page.locator(".item").first()でまず「一覧の中の1つの箱」を指し示すロケータを作り、そこからさらに.locator(".title")を呼ぶことで「その箱の中にあるタイトル部分」を辿っている点です。page.locator(".title")のようにいきなりページ全体から.titleを探すのではなく、「まず箱を絞り込んでから、その中を探す」という順番で書くことで、似たクラス名を持つ別の箱と混ざってしまう事故を防げます。

演習

.itemの2つ目(.nth(1)を使います。first()nth(0)と同じ意味です)の中から、.priceのテキストを取得して表示してみましょう。「980円」と表示されれば正解です。

チュートリアル5: テキストや属性を取り出す

要素からテキストを取り出す方法には、実はinnerTextのほかにもtextContentがあります。また、リンクのhrefのような「属性」を取り出す方法も見ていきましょう。tutorial-5.tsを作ります。

import { chromium } from "playwright";
import path from "node:path";
import { pathToFileURL } from "node:url";

function fileUrl(relativePath: string): string {
  return pathToFileURL(path.resolve(relativePath)).href;
}

const browser = await chromium.launch({ headless: false });
const page = await browser.newPage();
await page.goto(fileUrl("practice-site/detail-item1.html"));

const heading = page.locator(".item-name");

// (1) innerText: 画面に実際に表示されているテキストを取得する
console.log("innerText:", await heading.innerText());

// (2) textContent: 表示/非表示にかかわらず、HTML上のテキストをそのまま取得する
console.log("textContent:", await heading.textContent());

// (3) getAttribute: 属性の値を取得する(data-item-idというカスタム属性)
console.log("data-item-id:", await heading.getAttribute("data-item-id"));

await page.waitForTimeout(2000);
await browser.close();
npx tsx tutorial-5.ts

innerTexttextContentはよく似ていますが、違いがあります。

  • innerText: 画面上に実際に表示されている(目に見える)テキストを返します。CSSでdisplay: noneのように隠されている部分は含まれません
  • textContent: 表示/非表示に関係なく、HTML構造上のテキストをそのまま返します

今回のようなシンプルなページではどちらも同じ結果になりますが、「CSSで隠れている部分まで含めて取得したいか」によって使い分けます。迷ったときは、画面に見えている通りの文字がほしいことが多いので、innerTextを選んでおくと安心です。

getAttributeは、hrefdata-*のような、タグに付いている属性の値を取り出すメソッドです。値が存在しない場合はnull(何もない、という特別な値)が返ってくる点も覚えておいてください。

演習

detail-link(practice-site/list-page1.htmlの中にあります)のhref属性をgetAttribute("href")で取得し、表示してみましょう。「detail-item1.html」のような文字列が表示されれば正解です。

チュートリアル6: 複数の要素をまとめて扱う

一覧ページには複数の商品があります。これを1つずつ変数に入れるのではなく、まとめて配列として取得し、for...ofで1つずつ処理する方法を学びます。tutorial-6.tsを作ります。

import { chromium } from "playwright";
import path from "node:path";
import { pathToFileURL } from "node:url";

function fileUrl(relativePath: string): string {
  return pathToFileURL(path.resolve(relativePath)).href;
}

const browser = await chromium.launch({ headless: false });
const page = await browser.newPage();
await page.goto(fileUrl("practice-site/list-page1.html"));

// ページ内の".item"に一致するすべての要素を、ロケータの配列として取得する
const items = await page.locator(".item").all();

console.log(`商品は${items.length}件見つかりました`);

for (const item of items) {
  const title = await item.locator(".title").innerText();
  const price = await item.locator(".price").innerText();
  console.log(`- ${title}: ${price}`);
}

await page.waitForTimeout(2000);
await browser.close();
npx tsx tutorial-6.ts

3件の商品名と価格が順番に表示されれば成功です。

page.locator(".item")自体は「.itemに一致するすべての要素」をまとめて指し示すロケータですが、for...ofで1つずつ取り出して処理するには、.all()を呼んで配列(この場合はロケータの配列)に変換してあげる必要があります。あとは普通のTypeScript/JavaScriptの配列と同じように、for (const item of items)で1つずつ取り出して処理できます。

なぜ.all()が必要なのかというと、page.locator(".item")という書き方自体は「あとで何個ヒットするか分からない検索条件」を表しているだけで、実際に「今何個あるか」を確定させて配列にする操作が別途必要だからです。この「実際に探しに行くタイミングを遅らせる」という考え方は、チュートリアル8以降の待機の話にもつながってくる、Playwrightらしい設計です。

演習

for...ofのループの中で、それぞれの価格から「円」という文字を取り除いて数値に変換し(Number(priceText.replace("円", ""))のように書けます)、合計金額をconsole.logで表示してみましょう。1ページ目の合計は4580円になるはずです。

チュートリアル7: デバッグ

自動化のコードを書いていると、「どこで止まっているのか分からない」「なぜこの要素が見つからないのか分からない」という場面に必ず出会います。Playwrightにはこうしたときに役立つデバッグ用の仕組みが標準で用意されています。tutorial-7.tsを作ります。

import { chromium } from "playwright";
import path from "node:path";
import { pathToFileURL } from "node:url";

function fileUrl(relativePath: string): string {
  return pathToFileURL(path.resolve(relativePath)).href;
}

// slowMo: 各操作の間にわざと待ち時間(ミリ秒)を入れて、動きをゆっくり見えるようにする
const browser = await chromium.launch({ headless: false, slowMo: 500 });
const page = await browser.newPage();
await page.goto(fileUrl("practice-site/list-page1.html"));

// ここで実行がいったん止まり、Playwright Inspectorというウィンドウが開く
await page.pause();

// 途中経過をスクリーンショットとして保存しておく
await page.screenshot({ path: "debug-list-page1.png" });

await browser.close();
npx tsx tutorial-7.ts

このコードを実行すると、page.pause()のところで処理が止まり、「Playwright Inspector」という別ウィンドウが開きます。ここでは次のようなことができます。

  • 画面上部の再生ボタン(Resume)を押すと、次の行から処理を再開できる
  • 「Explore」機能を使うと、実際にマウスでクリックした要素のロケータの書き方を教えてくれる
  • 1行ずつ処理を進めながら、その時点でのページの状態を目で確認できる

page.pause()はコードの好きな場所に一時的に差し込んで使うデバッグ専用の道具です。原因不明のエラーに遭遇したときは、怪しい箇所の直前にpage.pause()を1行足してもう一度実行してみると、「実際にその時点でページがどうなっているか」を自分の目で確認でき、原因の見当がつきやすくなります。デバッグが終わったら忘れずに削除しておきましょう。

page.screenshot({ path: "..." })は、その瞬間のページの見た目を画像ファイルとして保存します。特にheadless: true(画面を表示しない設定)で動かしているときや、後で確認できるように記録を残しておきたいときに重宝します。実行後、playwright-tutorialフォルダの中にdebug-list-page1.pngという画像ファイルができているはずなので、開いて確認してみてください。

演習

tutorial-6.tsfor...ofループの中(価格を取得した直後あたり)にawait page.pause()を差し込んで実行し、Inspectorで1件ずつ処理が進む様子を確認してみましょう。デバッグの感覚をつかんだら、page.pause()は削除して構いません。

チュートリアル8: 「次へ」ボタンでページャを移動する

ここからは、複数ページにまたがる一覧を扱っていきます。まずは「次へ」リンクを1回クリックして、2ページ目に移動してみましょう。tutorial-8.tsを作ります。

import { chromium } from "playwright";
import path from "node:path";
import { pathToFileURL } from "node:url";

function fileUrl(relativePath: string): string {
  return pathToFileURL(path.resolve(relativePath)).href;
}

const browser = await chromium.launch({ headless: false });
const page = await browser.newPage();
await page.goto(fileUrl("practice-site/list-page1.html"));

console.log("移動前のタイトル:", await page.title());

// ".next"に一致するリンクをクリックする。クリックによってページ遷移が発生する
await page.locator(".next").click();

console.log("移動後のタイトル:", await page.title());

const items = await page.locator(".item").all();
for (const item of items) {
  console.log("- ", await item.locator(".title").innerText());
}

await page.waitForTimeout(2000);
await browser.close();
npx tsx tutorial-8.ts

「1ページ目」から「2ページ目」にタイトルが変わり、アイテムD・アイテムEの2件が表示されれば成功です。

ここで地味に重要なのが、.click()のあとに特別な「ページ遷移が終わるまで待つ」コードを何も書いていない点です。クリックした先がページ全体の移動を伴うリンクの場合、Playwrightは新しいページの読み込みが完了するところまで自動的に待ってから次の行に進んでくれます。この「待ってくれる」という性質については、チュートリアル11でまとめて詳しく説明します。

演習

2ページ目にアクセスした状態から、さらに.locator(".next")をクリックして3ページ目に移動し、アイテムFが表示されることを確認してみましょう。

チュートリアル9: 次へがなくなるまでループしてbreak

一覧のページ数があらかじめ分からない場合、「次へ」ボタンがある限りクリックし続け、なくなったら処理を終える、というループを書く必要があります。tutorial-9.tsを作ります。

import { chromium } from "playwright";
import path from "node:path";
import { pathToFileURL } from "node:url";

function fileUrl(relativePath: string): string {
  return pathToFileURL(path.resolve(relativePath)).href;
}

const browser = await chromium.launch({ headless: false });
const page = await browser.newPage();
await page.goto(fileUrl("practice-site/list-page1.html"));

let pageCount = 0;

while (true) {
  pageCount++;
  console.log(`--- ${pageCount}ページ目 ---`);

  const items = await page.locator(".item").all();
  for (const item of items) {
    console.log("- ", await item.locator(".title").innerText());
  }

  // ".next"に一致する要素が「今、何個あるか」を数える
  const nextCount = await page.locator(".next").count();
  if (nextCount === 0) {
    console.log("次へボタンが見つからないので終了します");
    break;
  }

  await page.locator(".next").click();
}

await page.waitForTimeout(2000);
await browser.close();
npx tsx tutorial-9.ts

1ページ目から3ページ目まで、すべてのアイテムが順番に表示され、最後に「次へボタンが見つからないので終了します」と表示されれば成功です。

ポイントはpage.locator(".next").count()です。count()は、その時点でページの中に一致する要素が何個あるかを数値で返してくれます。0件なら「その要素は存在しない」ということなので、if (nextCount === 0) { break; }でループを抜けています。while (true)という「無限に繰り返す」書き方は、抜け出す条件(break)を必ずどこかに用意しておかないと文字通り無限ループになってしまうので注意してください。

ここでつまずいたら

  • 万が一無限ループになってしまい抜け出せなくなった場合、ターミナルでCtrl + Cを押すと強制終了できます

演習

ループの中で処理したアイテムの総数をカウントする変数を用意し、最後に「合計◯件処理しました」と表示してみましょう(答えは6件です)。

チュートリアル10: 一覧から各詳細ページを順に開く

次は、一覧ページに並んでいる「詳細を見る」リンクの行き先を集めておき、それぞれの詳細ページを順番に開いていく処理を書いてみます。tutorial-10.tsを作ります。

import { chromium } from "playwright";
import path from "node:path";
import { pathToFileURL } from "node:url";

function fileUrl(relativePath: string): string {
  return pathToFileURL(path.resolve(relativePath)).href;
}

const browser = await chromium.launch({ headless: false });
const page = await browser.newPage();
await page.goto(fileUrl("practice-site/list-page1.html"));

// (1) まず一覧ページから、詳細ページへのリンク先(href)だけを配列に集める
const detailLinks = await page.locator(".detail-link").all();
const detailUrls: string[] = [];
for (const link of detailLinks) {
  const href = await link.getAttribute("href");
  if (href) {
    detailUrls.push(href);
  }
}
console.log("集めたリンク先:", detailUrls);

// (2) 集めたURLを1つずつ開いて、詳細情報を取得する
for (const url of detailUrls) {
  await page.goto(fileUrl(`practice-site/${url}`));
  const name = await page.locator(".item-name").innerText();
  const description = await page.locator(".description").innerText();
  console.log(`[${name}] ${description}`);
}

await page.waitForTimeout(2000);
await browser.close();
npx tsx tutorial-10.ts

3件それぞれの商品名と説明文が順番に表示されれば成功です。

このコードを2段階に分けているのには理由があります。1段階目でまず一覧ページからリンク先だけをすべて集めてしまい、2段階目でそのリストをもとに1件ずつpage.gotoで移動しています。もし「一覧を見ながら、その場で1件ずつクリックして詳細を見て、戻ってからまた次をクリックする」という書き方をしようとすると、「戻る」操作のたびに一覧の読み込みをやり直す必要があり、コードが複雑になりがちです。先にURLの一覧というシンプルなデータ(文字列の配列)に変換してしまってから処理する、という2段階の考え方は、スクレイピング(Webページから情報を取り出すこと)全般でよく使うパターンなので、覚えておくと役立ちます。

演習

取得した商品名と説明文を、その場でconsole.logするのではなく、いったん{ name: string; description: string }[]という配列に貯めておき、すべて処理し終わった後にまとめて表示するように書き換えてみましょう。次のチュートリアル(CSV書き出し)への準備になります。

チュートリアル11: 待機(Playwrightの自動待機)

ここでPlaywrightの一番の目玉とも言える仕組みを、あらためて説明します。それは、多くの操作が「要素の準備ができるまで自動で待ってくれる」という点です。

Seleniumという別の自動化ツールでは、「要素が画面に表示されるまで待つ」処理を、自分で明示的に書く必要がありました。たとえば次のような書き方です(Rubyの例)。

wait = Selenium::WebDriver::Wait.new(timeout: 10)
wait.until { driver.find_element(id: "submit").displayed? }
driver.find_element(id: "submit").click

これに対してPlaywrightでは、click()のような操作自体が「要素が実際に操作できる状態(画面に表示されていて、クリックを遮る他の要素がなく、動きが安定している状態)になるまで」を自動的に待ってくれます。

// これだけで、要素が操作可能になるまで自動的に待ってからクリックしてくれる
await page.locator(".next").click();

このチュートリアルで扱ってきた練習用サイトはローカルファイルなので表示が一瞬で終わってしまい、この「待ってくれるありがたみ」を実感しにくいのですが、実際にインターネット上のWebサイトを相手にすると、この違いが効いてきます。JavaScriptで後から表示される要素や、通信が終わるまで見えないボタンなどを操作する際、Seleniumでは待機処理を書き忘れると「要素が見つからない」というエラーで頻繁に落ちてしまいますが、Playwrightではclickfill(入力欄に文字を入力する操作)がそのあたりを自動でカバーしてくれるため、初心者が書いたコードでも動きやすい、という利点があります。

とはいえ、「要素が表示されるまで待ちたいが、まだクリックなどの操作はしたくない」という場面もあります。そういうときは、ロケータの.waitFor()というメソッドを使います。

import { chromium } from "playwright";
import path from "node:path";
import { pathToFileURL } from "node:url";

function fileUrl(relativePath: string): string {
  return pathToFileURL(path.resolve(relativePath)).href;
}

const browser = await chromium.launch({ headless: false });
const page = await browser.newPage();
await page.goto(fileUrl("practice-site/list-page1.html"));

const nextButton = page.locator(".next");

// 要素が「表示されている(visible)」状態になるまで、最大30秒待つ(デフォルト)
await nextButton.waitFor({ state: "visible" });

console.log("次へボタンが表示されました");
await nextButton.click();

await page.waitForTimeout(2000);
await browser.close();
npx tsx tutorial-11.ts

waitFor({ state: "visible" })は、「要素が見えるようになるまで待つ」という意味です。ほかにも"hidden"(消えるまで待つ)、"attached"(DOM上に存在するまで待つ)といった状態を指定できます。今回のサンプルはローカルファイルなのですぐに条件を満たしてしまいますが、通信に時間がかかる実際のサイトを相手にするときは、こうした明示的な待機が安心材料になる場面もあります。

まとめると、Playwrightでは基本的に「操作自体が勝手に待ってくれる」ので、まずは何も考えずにclickfillをそのまま書いてみて、それでもうまくいかない特殊なケースでだけ.waitFor()を足す、という順番で考えるとよいでしょう。

演習

tutorial-9.tsのループの中のpage.locator(".next").click()の前に、await page.locator(".next").waitFor({ state: "visible" });を足しても動作が変わらないことを確認してみましょう(すでに自動で待たれているため、目に見える違いは出ません)。

チュートリアル12: 結果をCSVに書き出す

最後に、これまで集めてきたデータを実際にファイルとして保存してみましょう。ここではライブラリを追加せず、Node.js標準のfs(ファイルシステムを扱う機能)モジュールだけを使って、CSVファイルを手書きで組み立てます。tutorial-12.tsを作ります。

import { chromium } from "playwright";
import { writeFile } from "node:fs/promises";
import path from "node:path";
import { pathToFileURL } from "node:url";

function fileUrl(relativePath: string): string {
  return pathToFileURL(path.resolve(relativePath)).href;
}

// CSVの1つの値を、カンマや改行、ダブルクォートを含んでいても安全な形に変換する
function csvField(value: string): string {
  return `"${value.replace(/"/g, '""')}"`;
}

type ItemRow = {
  page: number;
  title: string;
  price: string;
};

const browser = await chromium.launch({ headless: false });
const page = await browser.newPage();

const rows: ItemRow[] = [];
let currentUrl = fileUrl("practice-site/list-page1.html");
let pageNumber = 0;

await page.goto(currentUrl);

while (true) {
  pageNumber++;
  const items = await page.locator(".item").all();

  for (const item of items) {
    const title = await item.locator(".title").innerText();
    const price = await item.locator(".price").innerText();
    rows.push({ page: pageNumber, title, price });
  }

  const nextCount = await page.locator(".next").count();
  if (nextCount === 0) {
    break;
  }
  await page.locator(".next").click();
}

await browser.close();

// ヘッダー行 + データ行を組み立てる
const header = ["ページ番号", "商品名", "価格"].map(csvField).join(",");
const lines = rows.map((row) =>
  [String(row.page), row.title, row.price].map(csvField).join(",")
);

// Excelで開いたときに文字化けしないよう、先頭にBOM(Byte Order Mark)を付ける
const bom = "";
const csvContent = bom + [header, ...lines].join("\r\n") + "\r\n";

await writeFile("items.csv", csvContent, "utf8");
console.log(`items.csv に ${rows.length}件書き出しました`);
npx tsx tutorial-12.ts

playwright-tutorialフォルダの中にitems.csvというファイルができ、Excelや表計算ソフトで開くと、3ページ分・合計6件の商品名と価格が並んでいるはずです。

CSVを手書きするときに気をつけるポイントをまとめておきます。

  • カンマと改行のエスケープ: CSVはカンマ区切りのテキストファイルなので、値そのものにカンマや改行が含まれていると列がずれてしまいます。今回のcsvField関数のように、すべての値をダブルクォートで囲んでおくと、値の中にカンマや改行が含まれていても安全です。値の中にダブルクォートそのものが含まれる場合は、""のように2つ重ねてエスケープします
  • 文字コードとBOM: 日本語を含むCSVをExcelで開くと文字化けすることがあります。ファイルの先頭にという特殊な文字(BOM、Byte Order Markの略)を付けてUTF-8で保存しておくと、Excelが「これはUTF-8のファイルだ」と正しく認識してくれるようになります
  • 改行コード: CSVの世界では慣習的に\r\n(CRLF)という改行コードを使うことが多いです。今回のコードもそれに合わせています

演習

ItemRow型にurlというフィールド(その商品が見つかったページのURL)を追加し、CSVにも4列目として書き出してみましょう。型に新しいプロパティを足すだけで、その後のrow.pageのような書き方をしている箇所でエディタが「あれ、urlも使えるはずだよ」と教えてくれることに気づけるはずです。これがTypeScriptで型を使う嬉しさの一端です。

最後に

お疲れさまでした。ここまでで、ブラウザの起動から、要素の指定、ページ送り、待機、そしてCSV書き出しまで、Playwright + TypeScriptによるブラウザ自動化のひと通りの流れを体験してもらいました。

このチュートリアルではあえて触れませんでしたが、Playwrightにはこの先にも学べることがたくさんあります。

  • @playwright/testという専用のテストランナーと、expect(locator).toBeVisible()のようなアサーション(期待した状態になっているかを検証する仕組み)を使った、本格的なテストコードの書き方
  • Chromiumだけでなく、Firefox・WebKit(Safariの基盤)など複数のブラウザで同じコードを動かす方法
  • 失敗したテストの経過を録画・トレースとして残し、後から詳しく確認する仕組み
  • GitHub Actionsなどを使って、コードを変更するたびに自動でテストを実行する仕組み

もう少し実務的な視点で「SeleniumからPlaywrightに移るとどう考え方が変わるか」を整理した記事も書いていますので、興味があれば合わせて読んでみてください。

自動化の学習は、実際に手を動かしてエラーに何度もぶつかりながら覚えていくのが一番の近道です。このチュートリアルのコードをベースに、ぜひ自分が気になっているサイトの構造に合わせて、少しずつ書き換えてみてください。

コメント

タイトルとURLをコピーしました