【puppeteerでスクレイピング】Alpine Dockerコンテナからpuppeteerで動的サイトのスクレイピングを試してみる


2020/11/13

銀行口座や証券口座のウェブサイトなどでは認証用のjsリソースなどをログイン時に読み込ませる仕様になっていることが多く、ブラウザが適切にロード完了時に処理をしてクライアント側からの操作を受け付ける仕組みです。

puppeteer登場以前のブラウザなしAjax操作だけでは動的ウェブサイトからのスクレイピングは非常に困難だったのですが、puppeteerを利用することでコマンドラインからでも複雑なスクレイピングがいとも簡単に可能となります。

今回はヘッドレスブラウザから操作するモダンな仕組みのスクレイピング・プログラムをDocker Alpineコンテナで開発環境を整えてみたときの記事になります。


alpine:edgeからdockerコンテナを作る

puppeteerの公式のドキュメントにもあるように、通常版のalpineイメージからでなくalpine:edgeを利用するとchromiumが正常に動作します。

            
            FROM alpine:edge

RUN apk update && apk upgrade && \
    apk add --no-cache bash openssh expect

#Dependancies of puppeteer in alpine container
RUN apk add --no-cache \
      chromium \
      nss \
      freetype \
      freetype-dev \
      harfbuzz \
      ca-certificates \
      ttf-freefont \
      nodejs \
      nodejs-npm \
      yarn

RUN npm i -g typescript @babel/core @babel/node ts-node

RUN addgroup -g 1000 -S pptruser && \
    adduser -D -u 1000 -S -G pptruser pptruser
RUN echo '%pptruser ALL=(ALL) NOPASSWD: ALL' >> /etc/sudoers

CMD ["bash"]
        
今回はインタラクティブモードでコンテナの中から操作しながら開発を試みるので、ユーザーにリソースを編集する権限が必要です。

そのため後述しますがpuppeteerのオプションも
--no-sandboxを忘れずに指定しなければなりません。

なお今回は
babel-nodeからtsソースコードをそのままトランスパイルしています。

次にdocker-compose.ymlを作成し、以下の内容で編集します。

            
            version: '3'

services:
  app:
    image: puppeteer-alpine:edge
    build: .
    user: "pptruser:pptruser"
    container_name: puppeteer-alpine
    environment:
      NODE_ENV: "development"
      PUPPETEER_SKIP_CHROMIUM_DOWNLOAD: "true"
      PUPPETEER_EXECUTABLE_PATH: "/usr/bin/chromium-browser"
    volumes:
      - ./:/usr/src/app
    working_dir: "/usr/src/app"
        
ひとまずはこのコンテナがビルド&インタラクティブモードで起動するかを確認します。

ちなみにプロジェクトのルートディレクトリは
/usr/src/appにしてあります。

早速pupperteer開発環境用のDockerコンテナをビルドして立ち上げてみます。

            
            $ docker-compose build
$ docker-compose run --rm app bash
[node@d4f30154b2f7:/usr/src/app]$
        
インタラクティブモードに入ったら、このコンテナ内でnpm --initで新規のnodeプロジェクトを作成し、package.jsonを以下の内容にします。

            
            {
    "name": "puppeteer_ajax",
    "version": "0.1.0",
    "description": "To learn puppetter with docker alpine",
    "main": "./dist/index.js",
    "types": "./dist/index.d.ts",
    "scripts": {
        "build": "tsc",
        "tap": "babel-node dist/index.js",
        "start": "yarn build && yarn tap"
    },
    "devDependencies": {
        "@types/node": "^13.7.1",
        "@types/puppeteer": "^5.4.0"
    },
    "dependencies": {
        "puppeteer": "^5.4.1"
    }
}
        
そこからyarn installするとpuppeteerが最低限動作するAlpine上の開発環境用コンテナが作成できました。


puppeteerを使う(準備編)

この記事ではデフォルトではtypescriptのソースコードを利用します。

まずsrcフォルダを作成し、そこにindex.tsを追加します。

プロジェクトのフォルダ構造(※node_modulesの中は除く)は以下のようになっていると思います。

            
            $ tree -I node_modules
.
├── Dockerfile
├── docker-compose.yml
├── index.ts
├── package.json
├── src
│   └── index.ts
├── tsconfig.json
└── yarn.lock
        
後付ですが、手元の環境のtsconfig.jsonは以下のようになっています。

tsc --initで吐かれたほぼデフォルト設定ままですが参考までに。

            
            {
  "compilerOptions": {
    "target": "es6",
    "module": "commonjs",
    "declaration": true,
    "sourceMap": true,
    "outDir": "./dist",
    "strict": true,
    "moduleResolution": "node",
    "esModuleInterop": true
  },
  "include": [
    "./src/index.ts",
  ],
  "exclude": [
    "./test/*.spec.ts"
  ],
  "compileOnSave": false
}
        
さてまず簡単なスクリーンショットを撮って画像を保存してみます。

            
            import puppeteer from 'puppeteer';

(async () => {
    try {
        const browser = await puppeteer.launch({
            executablePath: '/usr/bin/chromium-browser',
            args: ['--disable-dev-shm-usage', '--no-sandbox']
        });
        const page = await browser.newPage();
        await page.goto('https://www.google.com/');
        await page.screenshot({
            path: '/usr/src/app/result.png'
        });
        await browser.close();
    } catch (e) {
        console.error(e);
    }
})();
        
編集しおえたら、先程のpackage.jsonの中にあったスクリプトを利用して、ビルド&実行してみます。。

            
            $ yarn start
        
正常に処理が通るとルートに以下のような画像が保存されていると思います。

合同会社タコスキングダム|蛸壺の技術ブログ

見ての通りで、chrome単体で日本語フォントを導入しなかった場合には日本語を含むページは文字化けしてしまいます。

chromeの日本語対応

説明が前後して恐縮ですが、alpine:edgeの場合、先程のDockerfileへ以下の2つのパッケージを追加します。

            
            FROM alpine:edge
#...中略
RUN apk add --no-cache font-noto-cjk unifont
        
これらのパッケージをdocker-compose buildで導入後にもう一度インタラクティブモードで実行しなおすと、先程のページは日本語化されていることが分かります。

合同会社タコスキングダム|蛸壺の技術ブログ

ということで、puppeteerの動作確認はこれでOKですので、次は実践的なログインを伴うページを操作してみます。


puppeteerでログイン認証後にページを操作する

puppeteerは人形遣いの意味する通り、(ヘッドレスな)ブラウザ越しに、動的なレンダリング処理をおこなうような複雑なウェブサイトさえも操作することのできるパワフルなツールです。

その処理としては主に、ページ全体や抽出したHTML要素を処理したい場合には
pageクラスのメソッドを使います。

ではpuppeteerの実力を確認してみましょう。

今回は色んな証券口座のページの中でもログイン時に色んなjsコードのダウンロードと認証用の処理が走るため単純なajaxでは到底不可能で、個人的に諦めていた某M証券のウェブサイトのログインを試してみます。

            
            import puppeteer from 'puppeteer';

(async () => {
    try {
        const browser = await puppeteer.launch({
            executablePath: '/usr/bin/chromium-browser',
            args: ['--disable-dev-shm-usage', '--no-sandbox']
        });
        const page = await browser.newPage();
        await page.goto('https://trade.m******.co.jp/mgap/login');

        //👇①input要素のname属性を利用したフォームの入力
        await page.type('input[name="user"]', 'xxxxxxxx');
        await page.type('input[name="password"]', 'xxxxxxxxxxxxx');

        //👇②idがsubmit-btnのボタン要素をクリック
        page.click('#submit-btn');

        //👇③クリック後にページが遷移するまで待機
        await page.waitForNavigation({
            waitUntil: 'domcontentloaded'
        });

        await page.screenshot({
            path: '/usr/src/app/result.png'
        });
        await browser.close();
    } catch (e) {
        console.error(e);
    }
})();
        
項目①が普段はキーボードからタイプしているユーザー名とパスワードです。

入力後に②の部分からヘッドレスchromeをまたいでプログラム的に擬似クリックさせてログインしているので、感覚的な操作ができるところもpuppeteerの凄いところです。

③の部分でクリック後のページの遷移が完了するまで待機した後に、正常に処理されるとログイン状態になったページに移行する、という流れです。

このプログラムを
yarn startで走らせ、ログイン出来ているかキャプチャ画像を確認してみると、

合同会社タコスキングダム|蛸壺の技術ブログ

...あれまー絶賛読み込み中です。

こちらのサイトでも紹介されています通り、クリック後でページ遷移する場合、ログインするサイトのページによって、page.waitForNavigationメソッドのwaitUntilオプションを適切なものに指定しないといけません。

今回のページはログイン認証を処理するのに、何回かのページのロードが繰り返し発生しているようです。

そこでコネクション数が0個になった状態から500ミリ秒経過したときを完了とみなす
networkidle0オプションに変えてみます。

            
            //...
//👇③クリック後にページが遷移するまで待機
await page.waitForNavigation({
    waitUntil: 'networkidle0'
});
//...
        
すると、難攻不落だったページにもログインが可能となりました。

合同会社タコスキングダム|蛸壺の技術ブログ

ついでですが、ログインしたあとのページに存在する任意のDOMが生成された時点を遷移完了とみなす方法も良く利用すると思います。

たとえばログイン後のページによくあるログアウトのボタンやリンクなどのDOMをターゲットに、
page.waitForSelectorメソッドでそのセレクタを指定します。

たとえば以下のような感じです。

            
            //...
//👇③クリック後にページが遷移するまで待機
await page.waitForSelector('.logout');
//...
        
しかしながらこの方法も万能ではなくウェブサイトのレンダリング処理方法で変わってしまいます。

今回のサイトではDOM要素を生成したあとにinnerHtmlなどのコンテンツが挿入される形式だったため、この方法はログインに適さなかったようです。

合同会社タコスキングダム|蛸壺の技術ブログ

DOMの生成後にvisibleになったり、hiddenされたりする要素を使った処理のあるサイトであったら、
page.waitForSelectorメソッドの第二引数にvisible: truehidden: trueなどを利用すると良いかも知れません。

            
            //...
//👇③クリック後にページが遷移するまで待機
await page.waitForSelector('.logout', {visible: true});
//...
        
ただしDOMが全てvisibleの状態になったとしても更に後段のスクリプトが走っている仕様のページもあるので、こちらも必ずしも最良の手段にはならない場合があります。


スクレイピング

では本題のスクレイピングを行います。

通常puppeteerによるスクレイピングを行う場合、
page.evaluateメソッドを介してページ全体を操作する方法、もしくは、page.$evalpage.$$eval等のDOM操作用のメソッドを用いる方法などが考えられます。

もちろんpuppeteerでゴリゴリとスクレイピングしても良いのですが、お目当てのページまで遷移できたなら、従来通りjs標準の正規表現のテクニックでhtmlのテキストを捌いていくことも出来ます。

今回は後者の方を採用し、puppeteerでログインした後に正規表現でスクレイピングを行うことを想定して解説してみます。

前節でログイン後に画像をキャプチャしたときのコードを以下のように修正します。

            
            import puppeteer from 'puppeteer';
import { writeFile } from 'fs'

(async () => {
    try {
        const browser = await puppeteer.launch({
            executablePath: '/usr/bin/chromium-browser',
            args: ['--disable-dev-shm-usage', '--no-sandbox']
        });
        const page = await browser.newPage();
        await page.goto('https://trade.m******.co.jp/mgap/login');

        //👇①input要素のname属性を利用したフォームの入力
        await page.type('input[name="user"]', 'xxxxxxxx');
        await page.type('input[name="password"]', 'xxxxxxxxxxxxx');

        //👇②idがsubmit-btnのボタン要素をクリック
        page.click('#submit-btn');

        //👇③クリック後にページが遷移するまで待機
        await page.waitForNavigation({
            waitUntil: 'domcontentloaded'
        });

        //👇④現在のHTMLをテキストに変換
        let bodyHtml = await page.content();

        //👇⑤正規表現で生のhtmlを好きなように操作
        //例として空白行部分を全て削除し、テキストファイルとして保存
        bodyHtml = bodyHtml.replace(/(^\n|^\s*?\n)/gm, '');
        console.log(bodyHtml);
        writeFile('/usr/src/app/result.txt', bodyHtml, (err) => {
            if (err) { throw err }
            console.log('Done');
        });

        await browser.close();
    } catch (e) {
        console.error(e);
    }
})();
        
前節と違うところは、④でpage.contentメソッドからページをまるごとテキストとして抽出できます。

このメソッドは文字列として返してくれるので、⑤の部分のように自由に正規表現処理を挿入することができます。


まとめ

以上、puppeteerを利用して動的ウェブサイトからのスクレイピングを行う手順を簡単に解説してみました。

今回スクレイピングに使ったpuppeteerの機能はほんの一部で、アイデア次第でかなり複雑な操作を行うアプリケーションも作成できるようです。

まだpuppeteerを利用したことがないなら是非ともこの機会に一度試してみてはいかがでしょうか。

Puppeteer APIのもっと詳しいコーディング例などは以下の参考サイト内をご参照ください。


参考サイト

Puppeteer API 公式ドキュメント

記事を書いた人

記事の担当:taconocat

ナンデモ系エンジニア

主にAngularでフロントエンド開発することが多いです。 開発環境はLinuxメインで進めているので、シェルコマンドも多用しております。 コツコツとプログラミングするのが好きな人間です。