puppeteerでドットインストールの総学習時間を取得する

オンライン学習サイトのドットインストールには学習時間を確認できる機能がある。
![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/731232/1e2279e6-ada8-23a2-cc12-43ef90e3de22.png)
~~……がこの時間がどうも正しくない。実際の学習時間よりだいぶ少ない。おそらく回線が不安定な環境で動画を視聴しても視聴時間に加算されない。~~ → 自動取得した合計と視聴時間が一致していたので、ドットインストールの視聴時間は正しかった。

正しい学習時間が欲しいのでブラウザ自動操作ツールであるpuppeteerを利用して学習時間の合計を自動で求める。

### 注意

自動操作ツールでのアクセスは頻度が高すぎるとサービスへ負荷を与えてアクセスブロック等の対象となるので、適切にwaitを入れてあげると良い。

### 戦略

①puppeteerでヘッドレスブラウザ(目に見えないブラウザ)を起動する。
②ドットインストールのユーザ認証を行う。
③ドットインストールのプロフィールページへ遷移し、受講した講座のURL一覧を取得する。
<img width="250" src="https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/731232/b00155a6-bdf4-0742-4a15-3a7e3ce56570.png">
④講座ページへ遷移し、受講完了した動画の視聴時間の合計を求める。（これを③で取得したURL分繰り返す）
<img width="250" src="https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/731232/18682b52-9283-96a1-3252-ed4098a5e5ac.png">
⑤集めたデータを以下の形で出力する。

```json
[{
    lessonName: 'C#入門',
    lessonUrl: 'https://dotinstall.com/lessons/basic_csharp',
    completeTime: '01:49:23',
    incompleteTime: '00:21:01'
}, {
    ......
}]
```

### 実装

①npmプロジェクトを作成し、puppeteerとログイン情報入力に使用するreadline-syncをインストールする。

```sh
$ npm init -y
$ npm i -S puppeteer readline-sync
```

②puppeteerの動作確認で、googleにアクセスしてみる。

```js:main.js
const puppeteer = require('puppeteer');

// awaitを使うためasync関数を定義する。
async function main() {
    // puppeteerのブラウザを起動する。
    const browser = await puppeteer.launch();

    // ブラウザの新しいタブを開く。
    const page = await browser.newPage();

    // googleにアクセスする。
    await page.goto('https://google.com');

    // スクショを撮る。
    await page.screenshot({path: 'test.png'});

    // ブラウザを閉じる。
    await browser.close();
}

main();

```

上記を実行してpuppeteerからgoogleにアクセスできることを確認した。

<img width="250" src="https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/731232/155d2bb8-390e-39c0-71d8-b04f510d97aa.png">

②ログイン情報を入力させる。

ドットインストールにログインするためのログイン情報をユーザに入力させる。パスワード入力時は入力内容を出力させず履歴に残させないことがミソだ。

```js:main.js
const puppeteer = require('puppeteer');
const readlineSync = require('readline-sync');

async function main() {
    // メールアドレスを入力させる。
    const mail = readlineSync.question('mail: ');

    // パスワードを入力させる。オプションで入力内容を出力させない。
    const password = readlineSync.question('password: ', {hideEchoBack: true});

    console.log(mail, password);
}

main();

```


③ドットインストールにログインする。

ドットインストールへのログインは以下の流れで行う。

ログインページへの遷移(https://dotinstall.com/login)
↓
ユーザ名とパスワードのinput欄へ自動入力する。
↓
ログインボタンを押下させる。

```js:main.js
const puppeteer = require('puppeteer');
const readlineSync = require('readline-sync');

async function main() {

    ......

    // ログインページへの遷移
    await page.goto('https://dotinstall.com/login');

    // メールアドレスとパスワードの入力
    await page.evaluate(text => document.querySelector('#mail').value = text, mail);
    await page.evaluate(text => document.querySelector('#password').value = text, password);

    // ログインボタン押下
    await page.click('#login_button');

    ......
```

④受講した講座のURL一覧を求める。

ユーザ名をクリックしプロフィールページへ遷移する。
↓
各講座へのリンクのaタグのhref属性を取得する。
↓
講座のページへ遷移し時間を求める。

```js
async function main() {
    
    ......

    await Promise.all([
        page.waitForNavigation({waitUntil: ['load', 'networkidle2']}),
        page.click('#login_button')
    ]);

    await Promise.all([
        page.waitForNavigation({waitUntil: ['load', 'networkidle2']}),
        page.click('a.user-name')
    ]);

    const urls = await page.evaluate(() => {
        const urls = [];
        const aElements = document.querySelectorAll('.cardBox > h3 > a');
        for (const aElement of aElements) {
            urls.push(aElement.getAttribute('href'));
        }
        return urls;
    });

    const result = [];

    for (const url of urls) {
        const lessonUrl = 'https://dotinstall.com' + url;
        await page.goto(lessonUrl, {waitUntil: ['load', 'networkidle2']});

        // 負荷軽減のため3秒待機する
        await page.waitForTimeout(3000);

        const lessonName = await page.$eval('.package-info-title span', element => element.innerHTML);

        const [completeTime, incompleteTime] = await page.evaluate(() => {
            let completeTime = 0;
            let incompleteTime = 0;
            const sectionElements = document.querySelectorAll('#lessons_list > li');
            for (const sectionElement of sectionElements) {
                const time = sectionElement.querySelector('.lessons-list-title > span').innerHTML;
                const [, min, sec] = time.match(/\((\d\d)\:(\d\d)\)/);
                const seconds = parseInt(min) * 60 + parseInt(sec);
                const isCompleted = sectionElement.querySelector('.lesson_complete_button > span').innerHTML === '完了済';
                if (isCompleted) {
                    completeTime += seconds;
                } else {
                    incompleteTime += seconds;
                }
            }
            return [completeTime, incompleteTime];
        });

        function sec2time(sec) {
            return `${parseInt(sec / 3600)}:${parseInt((sec / 60) % 60)}:${sec % 60}`;
        }

        result.push({
            lessonName,
            lessonUrl,
            completeTime: sec2time(completeTime),
            incompleteTime: sec2time(incompleteTime)
        });
    }

    console.log(result);

    ......

```