GAミント至上主義

Web Monomaniacal Developer.

Puppeteer + TypeScriptでWEBサイトをスクレイピングしたメモ

業務でとある業界のいわゆるアタックリストが必要になり、複数のサイトをスクレイピングしてCSVにしました。

スクレイピング処理には、WEBブラウザ(Chrome or Chromium)を操るPuppeteerを使いました。

https://pptr.dev/

その過程で得たことをメモ。

スクレイピングにPuppeteerを使うメリット

ブラウザと同じ動きをする

HTMLの解析しかしないツールと比較すると、当たり前ですがChromeを使うので、
JavaScriptCSSなども全く同じ用に動くのでSPAやAJAXを多用したサイトのHTMLも取得できます(簡単とはいっていない)。
ユーザーエージェントや画面サイズも自由に変更できます。

複雑なフォーム送信もボタンをクリックさせるだけでOK

特に複雑な検索フォームだと、POSTするのに、CSRFトークンなどいろいろなパラメータの準備が面倒ですが、Chromeを操作するので
手と同じくボタン要素を指定して、click()一発です。

Puppeteerを覚えられる

Puppeteerはスクレイピング以外にもテスト用途やスクリーンショット生成、PDF生成など数多な用途に使えます。
私はPDF生成に特化したサーバーアプリケーションを下記で作ってます。

github.com

Puppeteerを使うデメリット

覚えることたくさん

Puppeteerはスクレイピングに特化したツールではなく、あくまでWEBブラウザを操作するライブラリなので、Puppeteer自体のメソッド、オブジェクト、
JavaScriptや、Promiseの知識も必要になります。

非同期の嵐

サンプルコード見ればわかりますが、大半が非同期処理です。それらが巻き起こす問題と戦う必要があります。

処理が重い

Chromeを起動するので、単純なHTMLパーサーと比べると重いです。
でも今回は1回動かしてCSVできたら終わりだし、ローカルでの実行だったのでこれは大きな問題にならなかったです。
クラウド上でずっと動かすような用途だとコストが問題になるかもしれません。

TypeScriptを使った理由

特にJavaScriptでも問題ないのですが、TypeScriptに慣れてきてJavaScriptで書くのがつらくなってきたため使いました。

今回のような最終的な同じCSVに書き出す場合などは、項目漏れなどがVSCode上ですぐ分かったので便利でした。
また非同期処理、同期処理が混在するため、その間違いにすぐ気付けるのも大きなメリットでした。

デメリットしては数秒のビルド時間がかかるぐらいでしょうか。

環境構築

Node(私の環境ではv14.14.0を使用)やyarnなどはインストール済みとします。

Puppeteerインストール

puppeteerとCSV用のツールを入れました。

yarn add puppeteer csv-write

TypeScript周りは省略しますが、package.jsonはこんな感じ。
ビルドしたファイルは特に使わないので、ts-nodeを使ってビルド&実行してました。

{
  "name": "scrapuppeteer",
  "version": "1.0.0",
  "main": "index.js",
  "license": "MIT",
  "scripts": {
    "start:sample": "ts-node src/sample.ts",
    "lint": "eslint --fix ./ --ext ts"
  },
  "devDependencies": {
    "@types/node": "^14.14.14",
    "@types/puppeteer": "^5.4.2",
    "@typescript-eslint/eslint-plugin": "^4.11.0",
    "@typescript-eslint/parser": "^4.11.0",
    "eslint": "^7.16.0",
    "eslint-config-prettier": "^7.1.0",
    "eslint-plugin-import": "^2.22.1",
    "eslint-plugin-prettier": "^3.3.0",
    "prettier": "^2.2.1",
    "ts-node": "^9.1.1",
    "typescript": "^4.1.3"
  },
  "dependencies": {
    "csv-writer": "^1.6.0",
    "puppeteer": "^5.5.0"
  }
}

Puppeteerを使ったスクレイピング処理の流れ

WEBサイトによって構造はバラバラなので、基本的にオーダーメイドになりますがよくある処理をまとめておきます。

CSVの型決め

いろんなサイトを同じCSVにまとめたので、こんな感じのinterfaceを作ってつかってました。
不足してたり、誤字に気付けるので便利です。
```
interface CsvItem {
companyName: string
name: string
postalCode: string
address: string
tel: string
fax: string
email: string
hp: string
}
```

よく使う処理

サイト構造に限らずよく使うメソッドなどは別に管理して使いまわします。

utils/index.ts

// ゆらぎをもたせたいわゆるsleep
export const randomSleep = (ms: number): Promise<void> => {
  const sleepMs = ms + ms * Math.random()
  console.log(`sleep: ${sleepMs}`)
  return new Promise((resolve) => setTimeout(resolve, sleepMs))
}

// HTMLをローカルに落としてやるときに使う
export const getContentsFromFile = async (path: string): Promise<string> => {
  return await readFile(path, { encoding: 'utf-8' })
}

// 指定したElementHandleのテキストとかを抽出する、CSSセレクタで絞り込んだり、propertyNameでhrefとかinnerHTMLとかいろいろ取れる
export const getTextFromElement = async (
  element: ElementHandle,
  selecter = '',
  propertyName = 'textContent'
): Promise<string> => {
  if (selecter) {
    const selected = await element?.$(selecter)
    if (selected) {
      element = selected
    } else {
      return ''
    }
  }
  const text = await (await element?.getProperty(propertyName))?.jsonValue()
  if (typeof text === 'string') {
    return text
  }
  return ''
}

// User Agentの文字列。普段のブラウザと合わせた
const uaString =
  'Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/87.0.4280.88 Safari/537.36'

// おまじない
export const launchOptions: ChromeArgOptions = {
  args: [
    '--no-sandbox',
    '--disable-setuid-sandbox',
    '--disable-gpu',
    '--disable-dev-shm-usage',
  ],
}

// ページの起動とUAのセットと、デフォルトタイムアウトが30秒だと長いので10秒にしておく
export const initPage = async (browser: Browser): Promise<Page> => {
  const page = await browser.newPage()
  await page.setUserAgent(uaString)
  page.setDefaultTimeout(10000)
  return page
}

ブラウザとページの起動

ブラウザと最初のページを用意

  const browser = await launch(launchOptions)
  const page = await initPage(browser)
  await page.goto('http://example.com/')
  # 次の動作に必要な要素を待っておく
  await page.waitForSelector('#searchButton')

ページから必要な情報を抜き出す関数を実行

これは内部で一覧から直接情報を抜き出す場合(tableの trごと、ulのliなど)のparseListPage()と
さらに詳細リンクがある場合そちらから取得する場合parseDetailPage()がよくあるので関数に分けることが多かった。

最後に一覧と詳細の情報を統合して、1件ずつCSVに書き込む。

await writeCsvPageContent(browser, page)
const writeCsvPageContent = async (
  browser: Browser,
  page: Page
): Promise<void> => {
  const items = await page.$$('#list table tr')

  let isFirst = true
  for (const item of items) {
    if (isFirst) {
      isFirst = false
      continue
    }
    const listResult = await parseListPage(item)
    if (!listResult) {
      console.error('listResultが空です')
      continue
    }
    // 詳細ページあり
    const detailLink = await item.$('.detail-link a')
    let detailResult = null
    if (detailLink) {
      detailResult = await parseDetailPage(browser, page, detailLink)
      //console.log('detailResultあり')
    }

    const result = {
      companyName: listResult.companyName,
      name: listResult.name,
      postalCode: listResult.postalCode,
      address: listResult.address,
      tel: listResult.tel,
      fax: detailResult?.fax ?? '',
      email: detailResult?.email ?? '',
      hp: '',
    }
    await csvWriter.writeRecords([result])
  }
}
詳細ページへ移動するリンクの場合

一覧ページを移動するといろいろややこしくなるので素直に新しいPageつくってgotoするのが楽だった。

const parseDetailPage = async (
  browser: Browser,
  page: Page,
  detailLink: ElementHandle
): Promise<Partial<SyaroshiItem> | null> => {
  const href = await getTextFromElement(detailLink, undefined, 'href')
  const newPage = await browser.newPage()
  await newPage.goto(href)
  // パースする処理
  await newPage.close()

取得したらPageをclose()するのを忘れないこと(メモリ食う)。

詳細ページが新しいページの場合

これがかなりややこしい。
browser.waitForTarget()を使う。
https://pptr.dev/#?product=Puppeteer&version=v5.5.0&show=api-browserwaitfortargetpredicate-options

const parseDetailPage = async (
  browser: Browser,
  page: Page,
  detailLink: ElementHandle
): Promise<ScrapingItem | null> => {
  const [newPage] = await Promise.all([
    browser
      .waitForTarget((t) => t.opener() === page.target())
      .then((t) => t.page()),
    detailLink.click(),
  ])
  // 取得処理
  await newPage.close()

次のページのリンク要素を取得する関数

pageから次のページへのリンク要素を抜き出す関数をつくる
次のページがとれないときはnullを返すようにしておく。

let nextPageLink = await getNextPageLink(page)

どれが次のページへのリンクかサイトによってバラバラだけど、
これはリンクが「次へ」の例。

const getNextPageLink = async (page: Page): Promise<ElementHandle | null> => {
  const pagenation = await page.$$('.pagination > a')

  for (const a of pagenation) {
    const label = await getTextFromElement(a)
    if (label === '次へ') {
      return a
    }
  }
  return null
}

あと現在のページだけclassがついてたり、aが無いなどのパターンがあり、その場合はfor ofで回して、その次の要素を返すようにすることが多かった。

const getNextPageLink = async (page: Page): Promise<ElementHandle | null> => {
  const pagenation = await page.$$('.pagination li')
  let current = false
  let nextPage = null

  for (const p of pagenation) {
    if (current) {
      nextPage = await p.$('a')
      break
    }
    const className = await getTextFromElement(p, 'li', 'className')
    if (className == 'current') {
      current = true
    }
  }

  if (nextPage) {
    return nextPage ?? null
  } else {
    return null
  }
}

次のページがなくなるまでwhileループ

// ページネーション
    let nextPageLink = await getNextPageLink(page)
    while (nextPageLink) {
      await nextPageLink.click()
      await page.waitForSelector('.pageNav li')
      console.log(page.url())
      await writeCsvPageContent(browser, page)
      await randomSleep(3000)
      nextPageLink = await getNextPageLink(page)
      if (!nextPageLink) {
        break
      }
    }

以上の雑にmain関数にいれて実行してました

sample.ts

// import系は省略

const main = async (): Promise<void> => {
  const browser = await launch(launchOptions)
  const page = await initPage(browser)
    await page.goto('http://example.com')
    await page.waitForSelector('.pagination ul li')
  }
  console.log(page.url())
  await writeCsvPageContent(browser, page)

  // ページネーション
  let nextPageLink = await getNextPageLink(page)
  while (nextPageLink) {
    await nextPageLink.click()
    await page.waitForSelector('.pagination ul li')
    console.log(page.url())
    await writeCsvPageContent(browser, page)
    await randomSleep(5000)
    nextPageLink = await getNextPageLink(page)
    if (!nextPageLink) {
      break
    }
  }
  await browser.close()
}
main()

package.jsonに書いたscriptで実行

yarn start:sample

よくあるエラー集

ページ遷移しちゃってたり、target=_blankなリンクをclickしてたり、いろいろあるけど、記録残して無くて書けない・・・。
あとは単純にセレクタ間違いで要素取れてないのがありました。innerHTMLを確認すると直しやすいです。