動的サイトを全力でcheerioでスクレイピングしてみた

## はじめに
「なんかWebスクレイピングでPuppeteerばかり注目されてる気がするけどcheerioも使えるんだからな」
ということを伝えたくて今回は動的サイトをあえてPuppeteerやSeleniumを使わずにWebスクレイピングしてみた。

**ただ結論から言えば今回のサイトのような場合はpuppeteerを使った方が圧倒的に簡単で確実である**。


また、本投稿で登場するサイトはあくまで例として実際にスクレイピングを行なったサイトの構成を元に作成しております。
本プログラムをコピペしても動作しませんのでご注意ください。


## 今回のチャレンジ
今回は以下のようなニュースサイトでWebスクレイピングを挑戦してみた。

![website_image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/412663/a550931e-06b5-18c3-9d08-9c12e1cf3cfb.png)

## ゴール
ゴールは本記事の趣旨を簡単に理解できるようにするため「2019年に出稿された記事全てを取得する」とする

## 事前調査
分析したところ、サイトの仕様は以下のようになっていることがわかった。
- トップページ`https://www.newsexample.com`より検索を行うことができる
- 各セレクタがURLのパラメータとなり、Searchボタンを押すと`https://www.newsexample.com/【Year】/【Month】/【Country】/【Category】`といった感じでリクエストが送信される
- 条件に合致するニュースが検索結果に表示される。検索結果が０件の場合は「検索結果が見つかりませんでした」と表示される。今回は`タイトル`, `日付`, `本文`をここで取得する
- 各セレクタ内のデータはajax通信により選択肢が動的に変化する。また、ニュースが存在しない条件は選択肢に表示されない。例えば`Year`を2019に指定した際、12月のニュースが存在していなければ`Month`を12に指定することはできない。


## ポイント：検索条件をどのように取得するか
検索結果を表示するページ自体は簡単にスクレイピングできそうだが、問題はその前のプロセスである。
**どのようにして動的に変化する検索条件を取得するか**である。

というのも`cheerio`はHTML形式でページを取得してjQueryのように要素を指定することができるライブラリであって、ページ上のDOM操作をすることはできない。
もしセレクタ内のアイテムが静的であればDOM要素を分析するだけで検索条件を取得することができたが、今回のサイトは左のセレクタから選択していくことでajax通信が発生し、次のセレクタ内のアイテムが決定される。

また、国やテーマにおいては実際に通信しないとどのような選択肢があるのか検討つかないので検索条件を推測することもできない。

つまり何らかの方法で選択肢のデータを取得しなければならない。

## アプローチ
ざっくりまとめるとこんな感じ

```scraping.js

const year = 2019

// Monthの選択肢を取得
const months = getMonths(year);
for(let month of months) {

    // Countryの選択肢を取得
    const countries = getCountries(year, month);
    for(let country of countries) {

        // Categoryの選択肢を取得
        const categories = getCategories(year, month, country);
        for(let category of categories) {

            // 検索結果(タイトル, 日付, 本文)を取得
            const result = getResults(year, month, country, category)
            console.log(result.title, result.date, result.body)
        }
    }
}
```

多重ループを繰り返して選択肢を取得し、実行結果をひとつずつ出力してやるといった感じ。
あとはそれぞれの関数を実装してやればいけるだろう。
そのためにまずは実際に行われているAjax通信を解析してやらねばならない。

### 手順1 : Ajax通信を解析する
まずは実際に行われる通信を解析して必要な情報を割り出す必要がある。
私は実際に以下の手順で行った。
#### Google Developer Toolsを使ってajax通信のリクエストを解析
まずはGoogle Developer Toolsの`Network`を開く。すると、通信履歴が表示される。
履歴をクリアしておき、ajax通信を発生させると、図のようにNetwork内にあらたに履歴が表示され、通信が行われたことがわかる。
今回はセレクタを開いて選択肢を選択することで発生する。
はじめは`Year`セレクタを選択して`Month`が取得されるまでの通信を確認する。

![network1.PNG](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/412663/85797789-0091-40ea-d458-8ad1339b41ea.png)

`getMonthList`をクリックして`Header`情報を開くと図のような情報が表示される。

![network.PNG](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/412663/422bc3fc-9942-522b-bd1a-534e9b5ebe60.png)

…なるほど、POSTで通信しているのか。
他にもRequest URLやcookie情報、Request Payloadより送信したパラメータなどがわかる。

#### Postmanを使ってリクエスト送信に必要な情報を確認
手順1で得られた情報をもとに`Postman`を使って通信に必要なパラメータを確認していく。
公式サイト https://www.getpostman.com/ よりダウンロードしたらpostmanを起動。

今回はPOSTで通信しているのでPOSTにする。
`Headers`をクリックしてそこに`Content-Type : application/json`を追加。
`Body`には`Request Payload`の内容をそのままペーストすればいいのかな？
とりあえずこれで`Send`をクリックしてみる。

![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/412663/51a781da-5d82-2190-3f21-9eeecfb55393.png)

すると以下の実行結果が出力された。

```
[
    "January",
    "February",
    "March",
    "April",
    "May",
    "June",
    "July",
]
```
ひとまずこれでデータは取得できることが分かった。
パラメーター`"field": "news_number"`の意味がよくわかっていないが、固有パラメーターなのだろうと推測。
さすがにAPI側で定められている条件などはわからないので、具体的に何を意味しているのかまで把握することは難しい...
`"field": "year"`はセレクタより選択した年が代入されているので、このパラメータを変更すれば任意の年の情報が取得できる。

### 手順2: 同様に国、カテゴリーに対しても解析する
`Month`を解析したときと同様の手順で`Country`、`Category`の取得に必要なパラメータを確認していく。

### 手順3: `request-promise`を用いて実装
解析した情報をもとにそれぞれ`getMonths(year)`, `getCountries(year, month)`, `getCategories(year, month, country)`を実装する。
実装は以下のようになる。

```scraping.js
const rp = require('request-promise');


const filterByNewsNumber = {
    filters: [{
       field: "news_number",
       value: 5,400,
       condition_type: "eq"
    }]
};

let filterByYear = [];
let filterByMonth = [];
let filterByCountry = [];

// Monthセレクタの選択肢を取得する
async function getMonths(year) {
   fileterByYear = {
        filters: [{
            field: "year",
            value: year,
            condition_type: "eq"
        }]
    };
    const filterGroups = [].concat(filterByNewsNumber, fileterByYear);
    // Request Payloadと同じパラメータになるようにする
    let newRequest = {
        "searchCriteria": {
            "filterGroups": filterGroups
        }
    };

    let option = {
        uri: baseUrl + "/rest/monthSearch/getMonthList",
        method : "post",
        body : newRequest,
        json : true
    }

    // Monthリストを返す
    return rp(option).catch( () => console.log("取得できませんでした"))
}

// Countryセレクタの選択肢を取得
async function getCountries(year, month) {
   fileterByMonth = {
        filters: [{
            field: "month",
            value: month,
            condition_type: "eq"
        }]
    };
    const filterGroups = [].concat(filterByNewsNumber, fileterByYear, fileterByMonth);
    // Request Payloadと同じパラメータになるようにする
    let newRequest = {
        "searchCriteria": {
            "filterGroups": filterGroups
        }
    };

    let option = {
        uri: baseUrl + "/rest/countrySearch/getCountryList",
        method : "post",
        body : newRequest,
        json : true
    }
    // Countryリストを返す
    return rp(option).catch( () => console.log("取得できませんでした"))
}

// Categoryセレクタの選択肢を取得
async function getCategories(year, month, country) {
   fileterByCountry = {
        filters: [{
            field: "country",
            value: country,
            condition_type: "eq"
        }]
    };
    const filterGroups = [].concat(filterByNewsNumber, fileterByYear, fileterByMonth, filterByCountry);
    // Request Payloadと同じパラメータになるようにする
    let newRequest = {
        "searchCriteria": {
            "filterGroups": filterGroups
        }
    };

    let option = {
        uri: baseUrl + "/rest/countrySearch/getCountryList",
        method : "post",
        body : newRequest,
        json : true
    }

    // Categoryリストを返す
    return rp(option).catch( () => console.log("取得できませんでした"))
}
```

### 手順4 : URLを生成して`cheerio`でスクレイピング
これであとはURLを生成してcheerioを使って検索結果の画面を取得しに行けばいいだけ。
そのために`getResults`を実装する。

```scraping.js
const cheerio = require('cheerio');

async function getResult(year, month, country, category) {
    const url = `https://www.newsexample.com/$(year)/$(Month)/$(Country)/$(Category)`;
    // 記事一覧画面のHTMLを取得する
    html = await rp(url);
    $ = cheerio.load(html);

    // 情報を取得するためにそれぞれのhtml要素を指定する
    const title = $('#title');
    const date = $('#date');
    const context = $('#context');

    const data = {
       "title" : title,
       "date" : date,
       "body" : context,
    }
    return data;  
    
}

```

### 手順5 : 逐次的に実行されるように修正する
ここまでスクレイピングに必要な処理を記載してきたが、このまま実行しようとするとそれぞれの処理が非同期で行われてしまうため、エラーが発生する。
全ての処理が逐次的に行われるように以下のように修正してまとめる。

```scraping.js

const cheerio = require('cheerio');
const rp = require('request-promise');

const filterByNewsNumber = {
    filters: [{
       field: "news_number",
       value: 5,400,
       condition_type: "eq"
    }]
};
let filterByYear = [];
let filterByMonth = [];
let filterByCountry = [];

const year = 2019;

async function main(){ 
   // Monthの選択肢を取得
   const months = getMonths(year);
   for(let month of months) {

        // Countryの選択肢を取得
        const countries = await getCountries(year, month);
        for(let country of countries) {
   
            // Categoryの選択肢を取得
            const categories = await getCategories(year, month, country);
            for(let category of categories) {

                // 検索結果(タイトル, 日付, 本文)を取得
                const result = await getResults(year, month, country, category)
                console.log(result.title, result.date, result.body)
            }
        }
    }
}

// Monthセレクタの選択肢を取得する
async function getMonths(year) {
   fileterByYear = {
        filters: [{
            field: "year",
            value: year,
            condition_type: "eq"
        }]
    };
    const filterGroups = [].concat(filterByNewsNumber, fileterByYear);
    // Request Payloadと同じパラメータになるようにする
    let newRequest = {
        "searchCriteria": {
            "filterGroups": filterGroups
        }
    };

    let option = {
        uri: baseUrl + "/rest/monthSearch/getMonthList",
        method : "post",
        body : newRequest,
        json : true
    }

    // Monthリストを返す
    return rp(option).catch( () => console.log("取得できませんでした"))
}

// Countryセレクタの選択肢を取得
async function getCountries(year, month) {
   fileterByMonth = {
        filters: [{
            field: "month",
            value: month,
            condition_type: "eq"
        }]
    };
    const filterGroups = [].concat(filterByNewsNumber, fileterByYear, fileterByMonth);
    // Request Payloadと同じパラメータになるようにする
    let newRequest = {
        "searchCriteria": {
            "filterGroups": filterGroups
        }
    };

    let option = {
        uri: baseUrl + "/rest/countrySearch/getCountryList",
        method : "post",
        body : newRequest,
        json : true
    }
    // Countryリストを返す
    return rp(option).catch( () => console.log("取得できませんでした"))
}

// Categoryセレクタの選択肢を取得
async function getCategories(year, month, country) {
   fileterByCountry = {
        filters: [{
            field: "country",
            value: country,
            condition_type: "eq"
        }]
    };
    const filterGroups = [].concat(filterByNewsNumber, fileterByYear, fileterByMonth, filterByCountry);
    // Request Payloadと同じパラメータになるようにする
    let newRequest = {
        "searchCriteria": {
            "filterGroups": filterGroups
        }
    };

    let option = {
        uri: baseUrl + "/rest/countrySearch/getCountryList",
        method : "post",
        body : newRequest,
        json : true
    }

    // Categoryリストを返す
    return rp(option).catch( () => console.log("取得できませんでした"))
}
async function getResult(year, month, country, category) {
    const url = `https://www.newsexample.com/$(year)/$(Month)/$(Country)/$(Category)`;
    // 記事一覧画面のHTMLを取得する
    html = await rp(url);
    $ = cheerio.load(html);

    // 情報を取得するためにそれぞれのhtml要素を指定する
    const title = $('#title');
    const date = $('#date');
    const context = $('#context');

    const data = {
       "title" : title,
       "date" : date,
       "body" : context,
    }
    return data;  
    
}

```
Asyncで`main`関数を作成し、Awaitで`getMonths`, `getCountries`, `getCategories`, `getResult`を順番に処理させることで正しく動作する。


## まとめ
かなり荒技ではあったが動的サイトでも`puppeteer`を使わずに、Networkからajaxを解析してWebスクレイピングすることができた。
また、HTTP通信を解析することにもなり非常に面白かった。
とはいえ非常に手間がかかるし、何より今回のようにPostリクエスト内に含まれているパラメータの意味がわからないということもあるので特に理由がなければ今後動的サイトをスクレイピングする際は`pupeteer`を採用しようと思う。