今更Webスクレイピングしてみる(Node.js + Puppeteer)

# はじめに
この記事は [株式会社システムアイのAdvent Calendar 2023](https://qiita.com/advent-calendar/2023/systemi23) の 14日目の記事です。
日々の業務効率化のためにWebスクレイピングを活用しようと思い、本記事を執筆することにしました。
Webスクレイピングを知らないメンバーがいたのでチーム内の共有も兼ねて。

# 環境について
Windowsの方は必要に応じて読み替えてください。
- mac OS 14.1.1
- Node.js v18.18.0
- npm 10.2.5

# 説明

https://ja.wikipedia.org/wiki/%E3%82%A6%E3%82%A7%E3%83%96%E3%82%B9%E3%82%AF%E3%83%AC%E3%82%A4%E3%83%94%E3%83%B3%E3%82%B0
> ウェブスクレイピング（英: Web scraping）とは、ウェブサイトから情報を抽出するコンピュータソフトウェア技術のこと。通常このようなソフトウェアプログラムは低レベルのHTTPを実装することで、もしくはウェブブラウザを埋め込むことによって、WWWのコンテンツを取得する。ウェブスクレイピングはユーザーが手動で行なうこともできるが、一般的にはボットやクローラ(英: Web crawler)を利用した自動化プロセスを指す。


今回はPuppeteerというGoogle製のライブラリを使用し、Webスクレイピングを行っていきたいと思います。

https://pptr.dev/
2023/12/14時点の最新版(21.6.0)を使用します。
```bash
# インストール
npm i puppeteer@21.6.0
```
ヘッドレスChromeを操作するためのライブラリなので、用途としては自動化やテストかと思いますが、スクレイピングにも利用できます。
# 使い方
## 基本
pupeteerは非同期で動作するので async/await が必要です。
以下でgoogleを開きます。
```index.js
const puppeteer = require('puppeteer');

(async () => {
    const url = 'https://google.co.jp';
    const browser = await puppeteer.launch();
    const page = await browser.newPage();
    await page.goto(url);
})()
```
起動時にオプションを設定できます。
詳細は https://pptr.dev/api/puppeteer.puppeteernode.launch
```js
    const browser = await puppeteer.launch({
        defaultViewport:{
            width:375,
            height:667
        }
    });
```
毎回以下の警告が出るのが邪魔なので
```
  Puppeteer old Headless deprecation warning:
    In the near future `headless: true` will default to the new Headless mode
    for Chrome instead of the old Headless implementation. For more
    information, please see https://developer.chrome.com/articles/new-headless/.
    Consider opting in early by passing `headless: "new"` to `puppeteer.launch()`
    If you encounter any bugs, please report them to https://github.com/puppeteer/puppeteer/issues/new/choose.
 ```
 こうします。
 ```js
    const browser = await puppeteer.launch({headless: "new"});
 ```
`{headless: false}`にすると実行時にブラウザが開きます。

## ページの情報を取得する
今開いているURLを取得する。
```js
    console.log(page.url());
    
    // https://www.google.co.jp/
```
全体のHTMLを取得する。
```js
    let content = await page.content();
    console.log(content);
    
    // <!DOCTYPE html><html itemscope="" itemtype="http://schema.org/WebPage" lang="ja"><head><meta charset="UTF-8">...
```

セレクタを使って要素の属性を取得する。
```js
    // document.querySelector('a') がページ内で実行される 
    let a = await page.$eval('a', el => el.innerHTML);
    let href = await page.$eval('a', el => el.href);
    // $$ は document.querySelectorAll('a') が実行される
    let b = await page.$$eval('a', nodes => nodes.map(el => el.innerHTML));
    console.log(a);
    console.log(href);
    console.log(b);

    /*
     Googleについて
     https://about.google/?fg=1
    [
      'Googleについて',
      'ストア',
      'Gmail',
      '画像', ...
    */
```

## スクショを撮る
オプションは https://pptr.dev/api/puppeteer.screenshotoptions
デフォルトのviewportは800x600なので、解像度も800x600になります。
```js
    await page.screenshot({ path: 'ss.png' })
```
![ss.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/3622810/2619e429-f7e2-1cf0-f2f4-c4a373e78e1a.png)

## 操作する
```js
    await Promise.all([
        page.waitForNavigation(), // 読み込みが完了するまで待ちます
        page.click('a'),
    ]);
    console.log(page.url()); // 遷移後のURLを表示

    // https://about.google/?fg=1
```
## 入力する
```js
    const elementHandle = await page.$('textarea');
    await elementHandle.type('Yahoo');
    await Promise.all([
        page.waitForNavigation(), // 読み込みが完了するまで待ちます
        elementHandle.press('Enter')
    ]);
    console.log(page.url()); // 遷移後のURLを表示

    // https://www.google.co.jp/search?q=Yahoo
```
# 実際に試してみる
## リンク先のURLが存在するか確認する
```index.js
const puppeteer = require('puppeteer');
(async () => {
    const url = "https://google.co.jp";
    const browser = await puppeteer.launch({headless: "new"});
    const page = await browser.newPage();
    await page.goto(url);
    const href = await page.$eval('a', el => el.href);
    const new_page = await browser.newPage(); // 新しくページを開く
    const res = await new_page.goto(href);
    console.log(`${res.status()} ${res.url()}`);
    await new_page.close(); // 終わったら閉じる
})()

// 200 https://about.google/?fg=1
```

## テーブルの情報をオブジェクトにして変換する
tabletojsonを使ってtableをオブジェクトに変換し、二つのサイトのテーブルを比較してみます。
~~Puppeteerなくてもできる~~

https://www.npmjs.com/package/tabletojson

```bash
# インストール
npm i tabletojson
```

```js
// 使い方
const { tabletojson } = require('tabletojson');
// URL から取得し変換する（今回は使わない）
tabletojson.convertUrl(url)
.then(function(tablesAsJson) {
let firstTable = tablesAsJson[0];
}
// htmlから変換する
let tablesAsJson = tabletojson.convertUrl(html);
let firstTable = tableAsJson[0];
```


比較するサイトは
```js
    const url_a = "https://npb.jp/scores/2023/1105/b-t-07/index.html";
    const url_b = "https://baseball.yahoo.co.jp/npb/game/2021014380/top";
```
のスコアボードの点数が一致するかチェックします。
```js
const puppeteer = require('puppeteer');
const { tabletojson } = require('tabletojson');
(async () => {
    const browser = await puppeteer.launch({headless: "new"});
    const page = await browser.newPage();
    await page.goto(url_a);
    const table_a = await page.$eval("#tablefix_ls",el => el.outerHTML);
    await page.goto(url_b);
    const table_b = await page.$eval("#ing_brd",el => el.outerHTML);
    let data_a = tabletojson.convert(table_a,{useFirstRowForHeadings: true})[0]; 
    let data_b = tabletojson.convert(table_b)[0];
```
npbの方のテーブルは先頭列に見出しが含まれているため、`useFirstRowForHeadings: true`を設定し、他の列と同じように処理してあげます。
設定しないと↓みたいなのが返ってきます。
`console.table(data_a)`
![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/3622810/9411a15a-d108-d004-f9a6-d0d7c7988a13.png)
直してあげるとこうなります
![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/3622810/906a2b6d-8d63-2887-c89c-ca46f7b55cdd.png)
yahooの方のデータはこうなっています
![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/3622810/d8e23663-34ec-7915-f50c-cfd1841af5f4.png)

チームの見出しがあっていることを確認した上で、点数が一致するか力技で順番に確認します。
一部列名の表記が漢字とアルファベットで異なるので、置き換えています。
```js
    const column_replace = {'H':'安','E':'失'} // 列名置換用
    for (const a of data_a) {
        // チーム名が前方一致する行で確認する
        let b = data_b.find( b => a['0']?.startsWith(b['0']));
        if(!b){
            continue;
        }
        console.log(`${a['0']} ${b['0']}`)
        for(const i in a){
            if(i != 0){
                let j = i;
                // 列名が一致しないので変換する
                if(i == 'H' || i == 'E'){
                    j = column_replace[i];
                }
                if(b[j] == a[i]){
                    console.log(`${i} ${b[j]} ${a[i]} 一致しました`)
                }else{
                    console.log(`${i} ${b[j]} ${a[i]} 一致しません`)
                }
            }
        }
    }
})()
/*
阪神タイガース阪神 阪神
1 0 0 一致しました
2 0 0 一致しました
3 0 0 一致しました
4 3 3 一致しました
5 3 3 一致しました
6 0 0 一致しました
7 0 0 一致しました
8 0 0 一致しました
9 1 1 一致しました
計 7 7 一致しました
H 12 12 一致しました
E 0 0 一致しました
オリックス・バファローズオリックス オリックス
1 0 0 一致しました
2 0 0 一致しました
3 0 0 一致しました
4 0 0 一致しました
5 0 0 一致しました
6 0 0 一致しました
7 0 0 一致しました
8 0 0 一致しました
9 1 1 一致しました
計 1 1 一致しました
H 8 8 一致しました
E 1 1 一致しました
*/
```
今回のような数値の場合は簡単に比較ができますね。


# 最後に
簡単なものしか載せていませんが、役に立てれば幸いです。
最後までご覧いただきありがとうございました。