PuppeteerとLambdaとAPI Gatewayで、ahrefsのUR DRを毎日自動で取得する

ahrefsのレーティングを日時取得できて、それをどこかにためたい。

下記のUR DRがとりたい。SEOと密接な関係があると言われている。
<img width="344" alt="スクリーンショット 2019-09-11 18.39.52.png" src="https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/119715/252891a0-0f12-87a1-9752-37d67999c1d9.png">

# 背景
会社の偉い人に
「DR URを取ってきて、それを貯めつつレポーティングしてほしい」
と言われたのがきっかけ。

毎日ahrefsにログインして見て、どこかに入力するのは面倒だから自動化することに。

# やり方
ahrefsはAPIを提供している。やすいプランでも$500(だった気がする)。
UR DRを取得するだけだと流石に高い。

![ダウンロード.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/119715/56cceabb-a909-6789-9aae-8f6f0bfc11da.png)

じゃあ、お金がかからない~~感じがする~~、クローリングで行きましょう。

今までクローリンングやってきたけど、

**コードの可読性、拡張性等が皆無&一々EC2で実行して、CSVを渡すツライ運用**

してきていて、~~自分の精神の~~安定運用が課題だったので、せっかくなら運用しやすいようにやる。

# 開発環境
ahrefsクローリングするにはヘッドレスブラウザ必要だよね。じゃあ使い慣れたPuppeteer使っとこ。 -> node

Puppeteer使ってクローリングするだけならLambdaでいいよね。レーティングを見たいドメインの数って10サイトくらいか。出力先は、DBでもSlackでもスプレッドシートでもいいように、API作っちゃお。 -> Lambda × API Gateway

httpリクエスト起点でLambda上のクローラーを実行して、UR DRを取得する感じでいくことに。
LambdaとAPI連携は下記の記事がわかりやすかった。

[API GatewayとLambdaでAPI作成のチュートリアル](https://qiita.com/vankobe/items/ab5bc6487c7e07cb3aba)

# できたもの
[ソースコード](https://github.com/tetsuya-ogawa/ahrefs_ratings)

## Lambda Layer
```
$ mkdir nodejs && cd nodejs
$ npm -i chrome-aws-lambda puppeteer-core
$ cd ..
$ zip -r modules.zip nodejs
```
上記のようにして、出来上がったzipファイルをLambdaのLayerとして定義。
簡単に説明すると、ライブラリなどの共通コンテンツを切り出しておけます。

[詳しくはこの記事](https://qiita.com/t_okkan/items/394a15577bd1aad46ec3)


## Lambda関数
index.jsが外部のライブラリに依存してないのが良い感じ。
受け取ったパラメーターを使って、レーティングのデータを取ってきて返却することだけに関心を持つように。こうすることで、出力先が、ファイルでもメモリでも、DBでも問題なし。
最後の方集中力切れて、puppeteerHandler.closeBrowser() をしちゃってるのは良くない。「コネクションをきる」といったような抽象に対してプログラミングした方が良かった。

```index.js
const DomainRepository = require('./domainRepository')
const PuppeteerHandler = require('./puppeteerHandler')

exports.handler = async (event, context) => {
  const domains = event['domains'].split(',')
  let results = [];
  
  const puppeteerHandler = new PuppeteerHandler()
  await puppeteerHandler.launchBrowser()
  
  const domainRepository = new DomainRepository(puppeteerHandler)
  
  try {
    for(let i = 0; i < domains.length; i++) {
      results.push(await domainRepository.fetch(domains[i]))
    }
  } catch (error) {
    return context.fail(error);
  } finally {
    await puppeteerHandler.closeBrowser()
  }
  
  return results;
};
```

ただのドメイン層のクラス
これは要らなかったかな。

```domain.js
class Domain {
    constructor(name, ur, dr) {
        this.name = name
        this.ur = ur
        this.dr = dr
    }
    
    name() {
        return this.name
    }
    
    ur() {
        return this.ur
    }
    
    dr() {
        return this.dr
    }
}

module.exports = Domain;
```

割と大きくなっちゃているクラス。ahrefsの仕様に対してかなり具体的な実装をしている。
レビューがほしい部分。
とはいえ、今後継続的に運用していくにあたり、ahrefsの仕様が変わってもここを修正すればokなので、メンテしやすい。

```pupeteerHandler.js
const chromium = require('chrome-aws-lambda');
const puppeteer = require('puppeteer-core');

class PuppeteerHandler {
    constructor() {
    }
    
    async fetch(domain) {
        const url = `https://ahrefs.com/site-explorer/overview/v2/prefix/live?target=${domain}`
        await this.page.goto(url, { waitUntil: 'networkidle2' });
        const ur = await this.page.$eval('#UrlRatingContainer span', item => {
            return item.textContent;
        });
        const dr = await this.page.$eval('#DomainRatingContainer span', item => {
            return item.textContent;
        });
        return { 'domain': domain, 'ur': ur, 'dr': dr }
    }
    
    async launchBrowser() {
        const browser = await puppeteer.launch({
          args: chromium.args,
          defaultViewport: chromium.defaultViewport,
          executablePath: await chromium.executablePath,
          headless: chromium.headless,
        });
        this.browser = browser
        this.page = await browser.newPage()
        await this._login('https://ahrefs.com/user/login', process.env.EMAIL, process.env.PASS)
    }
    
    async closeBrowser() {
        if(this.browser !== null) {
            await this.browser.close();
        }
    }
    
    async _login(url, id, pass) {
        await this.page.goto(url, { waitUntil: 'networkidle2' });
        await this.page.waitFor('form#formLogin', {timeout: 5000});
        await this.page.type("#email_input", id);
        await this.page.type('input[name="password"]', pass);
        const buttonElement = await this.page.$('#SignInButton');
        await buttonElement.click();
        await this.page.waitFor('body#dashboard', {timeout: 5000});
    }
}

module.exports = PuppeteerHandler;
```

これは渡すhandlerを変えれば、APIだろうが、スクレイピングだろうが、DBだろうが対応できるようになってる(気がする)。Repositoryパターンかどうかはちょっと指摘が欲しい。

```domainRepository.js
const Domain = require('./domain');

class DomainRepository {
    constructor(handler) {
        this.handler = handler
    }
    
    async fetch(domain) {
        const data = await this.handler.fetch(domain)
        return new Domain(data['domain'], data['ur'], data['dr'])
    }
}

module.exports = DomainRepository;
```

# エンドポイント
`
https://hogehoge.amazonaws.com/ahrefs/ratings?domains=qiita.com,facebook.com
`
domainsにafrefsレーティングを知りたいドメインを入れる。

レスポンスにDR URが入ってるので幸せ。

これで、レポーティング先が、スプレッドシートでもSlackでも良くなったので、急なレポートライン変更にも耐えうるようになった。
以前よりは、安定運用できる環境を整えられたのかなって思う。