Puppeteerを使って動作確認のための作業を効率化したい

CAPMFIREコミュニティでエンジニアをしている阿部です．
今日は機能追加や修正などの後の動作確認するときやデータ収集するときに，トラックパッドでクリックをしすぎて指がつりそうになるのを防ぐため（完全私欲），Puppeteerにこちらの作業をを全部投げたい！！！という気持ちでやっていきます！！！
とは言っても，かなり膨大な量になるので，今日はちょっと振り返りをしていこうと思います．

# Puppeteerって？
PuppeteerはChrome，Chromiumを制御することができるNode.jsのAPIです．Puppeteerではデフォルトでヘッドレスモード(ブラウザ表示をさせず，バックグラウンドで制御が可能なモード)が設定されており，高速かつメモリ使用を押さええながらwebテストなどを行うとこができます．ヘッドフルモード(ブラウザ表示をしながらのモード)もオプションを選択することで使用できます．

私たちが日常ブラウザを使う上でできること(クリックや文字入力など)はほとんど可能で，SPA，SSRのWebページの制御，Chromeの拡張機能などもテストすることができます．
もっと詳細を知りたい方は[こちら](https://github.com/puppeteer/puppeteer)


# 使用してる環境
Jest + Puppeteerで作ったスクレイピング専用プロジェクトを使用
環境構築に関しては後日追記

## バージョンなど
| 使用してるもの     | バージョン |
| -------------- | ---------- |
| Node.js        |            |
| Puppeteer      |            |
| Npm            |            |
| csv-witer      |            |
| date-fns       | 2.16.1     |
| dotenv         | 8.2.0      |
| jest           | 26.6.3     |
| jest-puppeteer | 4.4.0      |
| mkdirp         | 1.0.4      |


## ディレクトリ構成
```
.
├── __test__
│   ├── ... フローごとに分類，一つのフローに1ファイル   
│
├── config
│   ├── jest-setup.js             ... Timeoutの時間を設定
│   ├── puppeteer-environment.js  ... setupとteardownの呼び出し
│   ├── setup.js                  ... ブラウザ起動時の設定(Headfullなど）
│   └── teardown.js　　　　　　　　　... ブラウザクローズ時の処理をメイン
├── global
|   ├── ... 部品(チェックボックスなど)や全般的に使う動作(クリックやフォーム絵のタイプなど)をまとめる
| 
├── project
│   ├── ... ページ(またはサービス)ごとに使う動作をまとめる
│   
├── node_module /
├── output
│   ├── ... スクリーンショットやcsvなどのexportしたもののまとめ
│   
├── .env ... ログインパスなどPublicにしたくないもの
├── package.json
└── package-lock.json
   
```

# コーディング一部
```javascript:__test__/sample.js
const variables = require("../global/variables")
const functions = require("../global/functions")
const top = require("../project/example/top")
const fs = require('fs')
require('dotenv').config()
const {createObjectCsvWriter} = require('csv-writer')
const OUTPUT_PATH = "output"

const VIEWPORT = {
  width : 1280,
  height: 1024
}

describe('Index page', () => {

  let page
  let testIndex = 0

  let topPage


  it("トップページ表示", async () => {
    await page.goto("https://example.com/", { waitUntil: "networkidle2" })
  })

  it("力技で取得", async () => {
    await topPage.getArticlesTrend()
  })

  async function csvWrite(data) {
    if (!fs.existsSync(OUTPUT_PATH)) {
      fs.mkdirSync(OUTPUT_PATH)
    }
    var exec = require('child_process').exec
    exec(`touch ${OUTPUT_PATH}/page.csv`, function(err, stdout, stderr) {
      if (err) { console.log(err) }
    })
    const csvfilepath = `${OUTPUT_PATH}/page.csv`
    const csvWriter = createObjectCsvWriter({
      path: csvfilepath,
      header: [
        {id: 'id', title: 'No.'},
        {id: 'title', title: 'タイトル'},
        {id: 'published_at', title: '公開日'},
        {id: 'url', title: 'URL'}
      ],
      encoding:'utf8',
      append :false,
    })
    csvWriter.writeRecords(data)
        .then(() => {
          console.log('...Done')
        })
  }

  beforeAll(async () => {
    context = await global.__BROWSER__.createIncognitoBrowserContext()
    page = await context.newPage()
    await page.setViewport({
      width : VIEWPORT.width,
      height: VIEWPORT.height
    })

    topPage = new top.topPage(page)
  })

  afterAll(async () => {
    await page.close()
  })

  beforeEach(() => {
    console.log(`[START]:\t${testIndex}`)
  })

  afterEach(() => {
    console.log(`[END]:\t${testIndex++}`)
  })
});
```

```javascript:projects/example/top.js
const basicAction = require("../../global/basic-action")

const selector = {
    articles: 'div',
    article_title: 'h2 > a',
    article_lgtm: 'footer > div > div > div',
    article_published_at: 'headler > time'
}

module.exports = {
    topPage: class {
        constructor(page) {
            this.page = page
        }

        async moveArticleTrend() {
            await basicAction.click(this.page, xpath.sideMenu.article.trend)
        }

        async getArticlesTrend() {
            await this.page.waitForSelector(selector.articles)
            const lists =  await this.page.$$(selector.articles)

            let datas = []

            for(let i = 0; i < lists.length; ++i) {
                let title = await lists[i].$(selector.article_title)
                let lgtm = await lists[i].$(selector.article_lgtm)
                let published_at = await lists[i].$(selector.article_published_at)
                let href = await lists[i].$$eval(selector.article_title, tep => tep.map(item => item.href));
                const dataArray = await Promise.all([
                    i + 1,
                    basicAction.getTextBySelector(title),
                    basicAction.getTextBySelector(lgtm)
                    basicAction.getTextBySelector(published_at),
                    href.join('')
                ])

                datas.push({id: dataArray[0], title: dataArray[1], lgtm: dataArray[2], published_at: dataArray[3], url: dataArray[4]})
                console.log(datas[i])
            }
            return datas
        }


    }
}

```

```javascript:global/basic-action.js
module.exports = {
    async type(page, xpath, text) {
        await page.waitForXPath(xpath)
        const elementHandleList = await page.$x(xpath)
        await elementHandleList[0].type(text)
    },
    async click(page, xpath) {
        const elementHandle = await page.waitForXPath(xpath)
        await Promise.all([
            page.waitForNavigation({waitUntil: "networkidle2"}),
            elementHandle.click()
        ])
    },
    async pressEnter(page, xpath) {
        await page.waitForXPath(xpath)
        const elementHandleList = await page.$x(xpath)
        await elementHandleList[0].press('Enter')
    },
    async clickNoWaitting(page, xpath) {
        const elementHandle = await page.waitForXPath(xpath)
        await elementHandle.click()
    },
    async getText(page, xpath) {
        await page.waitForXPath(xpath)
        const elementHandleList = await page.$x(xpath)
        const textContent = await elementHandleList.getProperty("textContent")
        const text = (await textContent.jsonValue()).replace(/[\s　]/g, "")
        return text
    },
    async getTextBySelector(elementHandleList) {
        const textContent = await elementHandleList.getProperty("textContent")
        const text = (await textContent.jsonValue()).replace(/[\s　]/g, "")
        return text
    },
    async selectOption(page, selector, optionText) {
        const selectElement = await page.$(selector)
        await page.evaluate(
            (selectElem, text) => {
                let hasChanged = false
                for (let i = 0; i < selectElem.options.length; ++i) {
                    if (selectElem.options[i].innerText == text) {
                        selectElem.selectedIndex = i
                        hasChanged = true
                        break
                    }
                }
                if (hasChanged) {
                    const event = new Event("change")
                    selectElem.dispatchEvent(event)
                } else {
                    console.log(`${text} not found.`)
                }
            },
            selectElement,
            optionText
        )
    }
}

```

# 個人的に面白かったもの
## dialog
今回は触れてないのですが，ダイアログの回答などを行うClass
つまり，ダイアログはClickの対象ではないということ

なので，事前にダイアログの回答を入力しておかないといけない．
さらに，ダイアログが複数回出現し，回答パターンが違う場合は下記リンクの記事のようにonceの中にonceを配置して，1度目と2度目の回答を変えるなどの回避策が必要のようです．
ダイアログに表示される文章を取ってくることもできるので，それによって回答を変えるなどの回避策もあります．
https://qiita.com/khsk/items/0b7ef6d012f0167ed2bb

API
[puppeteer/api.md at v5.5.0 · puppeteer/puppeteer · GitHub](https://github.com/puppeteer/puppeteer/blob/v5.5.0/docs/api.md#class-dialog)

## getProperty

上記のコードではタグ内にあるテキストを取って来ているが，hrefやtypeなどのプロパティの値も指定できるみたいです．
自分はまだ使ったことがないので使ってみたい．
API
https://github.com/puppeteer/puppeteer/blob/v5.5.0/docs/api.md#elementhandlegetpropertypropertyname

# まとめ
今回は試しにサンプルプログラムを作ってみました．
汎用性高く書こうとするとXPathやSelectorの書き方を工夫する必要がある部分はかなり工数かかるなと改めて感じました．
[Headless Recorder](https://chrome.google.com/webstore/detail/headless-recorder/djeegiggegleadkkbgopoonhjimgehda?hl=ja)なども出てきてるし，小ちゃい修正や機能追加などはこれを使う方がいいのかもと思いました．
ただ，Headless Recorderだけだと，matcherでの確認とかは記録されないから，そこは自力で頑張るしかなさそうです．
逆に定期的に行うケース，データ収集とかで使う場合は大きなUI変更とかがない限りはちゃんとclass名とかをみて判断するので使い勝手良さそうです．

そして，サービスによってはスクレイピングを禁止しているものもあるので，利用規約などを確認し，スクレイピングでのデータの扱いには十分な注意をお願いします．


# 参考資料
- https://qiita.com/k1832/items/87a8cf609b4ccf2c6195
- https://qiita.com/kazu56/items/4246d491fa434e37c944