Edited at

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

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

下記のUR DRがとりたい。SEOと密接な関係があると言われている。


背景

会社の偉い人に

「DR URを取ってきて、それを貯めつつレポーティングしてほしい」

と言われたのがきっかけ。

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


やり方

ahrefsはAPIを提供している。やすいプランでも$500(だった気がする)。

UR DRを取得するだけだと流石に高い。

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

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

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

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


開発環境

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

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

httpリクエスト起点でLambda上のクローラーを実行して、UR DRを取得する感じでいくことに。

LambdaとAPI連携は下記の記事がわかりやすかった。

API GatewayとLambdaでAPI作成のチュートリアル


できたもの

ソースコード


Lambda Layer

$ mkdir nodejs && cd nodejs

$ npm -i chrome-aws-lambda puppeteer-core
$ cd ..
$ zip -r modules.zip nodejs

上記のようにして、出来上がったzipファイルをLambdaのLayerとして定義。

簡単に説明すると、ライブラリなどの共通コンテンツを切り出しておけます。

詳しくはこの記事


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でも良くなったので、急なレポートライン変更にも耐えうるようになった。

以前よりは、安定運用できる環境を整えられたのかなって思う。