Appengine(GAE/node)でSPA対応したクローラーで楽々スクレイピング

## Motivation

GAE/SE環境でクローラを作ろうとすると、静的ページならどの言語でも比較的簡単に作れますが、SPA（JavaScriptで動的に生成するサイト）の場合にはJavaScriptを動かしてコンテンツを生成する必要があるため、クローリングが容易ではないです。
GAE/nodeの新しいランタイムではpuppeteerを利用してHeadless Chromeを動かすことができるようで、SPAのようなサイトに対応したクローラが作れそうなので、ざっくりと動かしてみることにします。

## 事前準備

ローカル環境に以下のものが必要です

- gcloudコマンド
- node（npm）
- puppeteerのインストール (npm install puppeteer)

## nodeプロジェクトの初期化を行う

nodeプロジェクトを作成するディレクトリに移動し、以下のコマンドを実行します。

```bash
npm init
```

基本的にはエンター連打でokですが、こだわりたいところは変更すると良いと思います。

## npm start（ローカル実行）用の設定を追加する

以下の値を `package.json` のjson内に追加します。  
JSONとして正しい状態で追加する必要があるので注意してください。

```json
"scripts": {
  "start": "node app.js"
}
```

## ライブラリにexpressとpuppetterを追加する

以下のコマンドを実行し、インストールが終わるまでじっと待ちましょう

```bash
npm install express puppeteer --save
```

## app.jsを作成（その1）

まずは、サイトのスクリーンショットが取得できるか、試しにappを作ってみましょう。  
以下のようなファイルをapp.jsという名前で作成します。  

```javascript

const express = require('express');
const puppeteer = require('puppeteer');
const app = express();

app.use(async (req, res) => {
    const url = req.query.url;

    if (!url) {
        return res.send('Please provide URL as GET parameter, for example: <a href="/?url=https://example.com">?url=https://example.com</a>');
    }

    const browser = await puppeteer.launch({
        args: ['--no-sandbox']
    });

    // ページを作成する
    const page = await browser.newPage();

    // 解像度をセットする
    // await page.setViewport({ width: 720, height: 600 })

    await page.goto(url);
    const imageBuffer = await page.screenshot();
    browser.close();

    res.set('Content-Type', 'image/png');
    res.send(imageBuffer);
});

const server = app.listen(process.env.PORT || 8080, err => {
    if (err) return console.error(err);
    const port = server.address().port;
    console.info(`App listening on port ${port}`);
});
```

## ローカルでテスト実行してみる

ローカル実行はnpmコマンドで実行できます。  

```bash
npm start
```

を実行し、ブラウザで `http://localhost:8080/?url=https://example.com` にアクセスしてみます。  
問題なくサイトの画像が表示されればOKです。

## GAE（Appengine）環境にデプロイしてみる

デプロイには `app.yaml` を作成する必要があります。  
Headless Chromeを利用するためには多くのメモリが必要になるため、以下のように `instance_class` を指定することが推奨されています。  
（実際にデフォルトでデプロイしてもInternal Server Errorがでてまともに動きません）  

ただし、インスタンスクラスを上げることで課金がだいぶ捗りますので注意しましょう（無料枠が減ります）  

```yaml
runtime: nodejs8
instance_class: F4_1G
```

だいたいファイルの配置構成は以下のようになります

```
project_dir
  ├ app.yaml
  ├ app.js
  ├ node_modules <- 勝手にできる
  ├ package.json <- 勝手にできる
  └ package-lock.json <- 勝手にできる
```

### デプロイコマンドを実行する

`app.yaml` が配置されているディレクトリで以下のコマンドを実行し、デプロイしてみましょう。  
事前にGCPのコンソールでプロジェクトの作成と、gcloudコマンドのログインなどが必要になります。  

```bash
gcloud app deploy --project {PROJECT_ID}
```

### デプロイしたアプリケーションを確認します

以下のコマンドを実行することでブラウザが立ち上がりデプロイしたコンテンツを確認することができます。

```bash
gcloud app browse --project {PROJECT_ID}
```

## ひとまず Yahoo のスクショを取得してみる

以下のようなURLにアクセスし、Yahooのスクショを取得してみます。  

```
http://YOUR_PROJECT_ID.appspot.com/?url=https://www.yahoo.co.jp
```

![chida-node-se-quickstart.appspot.png](https://qiita-image-store.s3.amazonaws.com/0/28681/b5cbc2cc-42ca-7206-f6dd-c6e51e61fe9f.png)

このような感じでスクショが撮れました！

## Yahooのニュース一覧を取得してみる（その2）

app.jsを以下のように変更してみる

```javascript
const express = require('express');
const puppeteer = require('puppeteer');
const app = express();

app.use(async (req, res) => {

    const browser = await puppeteer.launch({
        args: ['--no-sandbox']
    });

    // ページを作成する
    const page = await browser.newPage();

    await page.goto("https://www.yahoo.co.jp");

    var data = await page.$eval("#topicsfb", item => {
        return item.textContent;
    });

    console.log(data)

    browser.close();

    res.send(data)
});

const server = app.listen(process.env.PORT || 8080, err => {
    if (err) return console.error(err);
    const port = server.address().port;
    console.info(`App listening on port ${port}`);
});
```

## 新たに作成したapp.jsをAppengineにデプロイしてみる

先程と同様に以下のコマンドでデプロイする

```bash
gcloud app deploy --project {PROJECT_ID}
```

## デプロイしたアプリケーションを実行し、出力を確認してみる

```bash
gcloud app browse --project {PROJECT_ID}
```

と実行すると以下のような出力が得られる

```
1時15分更新台風24号 関東で暴風雨ピーク写真NEW台風24号 不明1人負傷64人写真NEW混乱防ぐ計画運休 定着するか写真NEW沖縄知事に玉城氏が初当選写真玉城氏が当選 安倍政権に打撃写真仮想通貨 自主規制ルール検討中田ジャパン 悔しい競り負け写真NEWRIZIN 那須川が堀口に勝利写真もっと見るトピックス一覧玉城氏が当選確実玉城氏が当選確実9月30日21時30分配信毎日新聞
```

<img width="419" alt="スクリーンショット 2018-10-01 1.57.52.png" src="https://qiita-image-store.s3.amazonaws.com/0/28681/160cce60-e5ea-bdfc-f7ae-46ffd55345a3.png">


実際にこんな感じなので、JSで動的に生成しているコンテンツからのスクレイピングが成功です！！  

（注）終わったあとは、高級なインスタンスをデプロイしているので、app.yamlの設定で弱いインスタンスにしてデプロイし直すか、プロジェクトを消しておきましょう。

めでたしめでたし。

## おまけ : ちなみに、Yahooのサイトにcurlコマンドを実行しても通常はコンテンツを得ることができません。

```
curl https://www.yahoo.co.jp
```

```html:www.yahoo.co.jp
<!DOCTYPE HTML PUBLIC "-//W3C//DTD HTML 4.01 Transitional//EN" "https://www.w3.org/TR/html4/loose.dtd">
<html lang="ja">
<head>
<meta http-equiv="Content-Type" content="text/html; charset=utf-8">
<meta http-equiv="Content-Style-Type" content="text/css">
<meta http-equiv="Content-Script-Type" content="text/javascript">
<link rel="shortcut icon" href="https://s.yimg.jp/favicon.ico" type="image/vnd.microsoft.icon" />
<link rel="icon" href="https://s.yimg.jp/favicon.ico" type="image/vnd.microsoft.icon" />
<title>ページが表示できません - Yahoo! JAPAN</title>
<style type="text/css"><!--
/* yjTmplCommon */
body{margin:0;padding:0;text-align:center;font-family:"メイリオ", "ヒラギノ角ゴ", Helvetica, Arial, sans-serif;}dl,dt,dd,ul,ol,li,h1,h2,h3,h4,h5,h6,pre,form,input,p,blockquote,fieldset,div{margin:0;padding:0;}h1,h2,h3,h4,h5,h6{font-size:100%;font-weight:normal;}ul li, ol li{list-style:none;}table{margin:0;padding:0;border-collapse:collapse;border-spacing:0;font-size:100%;}caption{text-align:left;}table,pre,code,select,input,textarea,kbd,var,ins,del,samp{font-size:100%;}address,cite,dfn,em,strong,var,th,ins,del,samp{font-weight:normal;font-style:normal;}a img{border:0;}hr.yjSeparation{display:none;}fieldset{border:none;}#wrapper{text-align:left;font-size:medium;line-height:1.56;}#yjContentsBody{position:relative;}.yjGuid{display:block;height:0;overflow:hidden;font-size:0;line-height:0;text-indent:-9999px;}.yjSkip{display:block;height:0;overflow:hidden;font-size:0;line-height:0;text-indent:-9999px;}.yj950-1 #wrapper{ width:950px;margin:0 auto;padding:0 10px;}.yj950-1 #contents{text-align:left;}

/* fonts */
.s115{line-height:115%;}.s130{line-height:130%;}.s150{line-height:150%;}.yjXXL{font-size:x-large;voice-family:"\"}\"";voice-family:inherit;font-size:xx-large;font-size /**/:x-large;}html>body .yjXXL{font-size:180%;font-size/**/:xx-large;}.yjXL{font-size:large;voice-family:"\"}\"";voice-family:inherit;font-size:x-large;font-size /**/:large;}html>body .yjXL{font-size:150%;font-size/**/:x-large;}.yjL{font-size:medium;voice-family:"\"}\"";voice-family:inherit;font-size:large;font-size /**/:medium;}html>body .yjL{font-size:120%;font-size/**/:large;}.yjM{font-size:small;voice-family:"\"}\"";voice-family:inherit;font-size:medium;font-size /**/:small;}html>body .yjM{font-size:100%;font-size/**/:medium;}.yjMt{font-size:small;line-height:1.4em;voice-family:"\"}\"";voice-family:inherit;font-size:medium;font-size /**/:small;}html>body .yjMt{font-size:100%;font-size/**/:medium;}.yjS{font-size:x-small;voice-family:"\"}\"";voice-family:inherit;font-size:small;font-size /**/:x-small;}html>body .yjS{font-size:84%;font-size/**/:small;}.yjSt{font-size:x-small;line-height:1.3em;voice-family:"\"}\"";voice-family:inherit;font-size:small;font-size /**/:x-small;}html>body .yjSt{font-size:84%;font-size/**/:small;}.yjXS{font-size:xx-small;voice-family:"\"}\"";voice-family:inherit;font-size:x-small;font-size /**/:xx-small;}html>body .yjXS{font-size:70%;font-size/**/:x-small;}

/* masthead */
.yjmth{*height:1%;}.yjmth img{vertical-align:middle;border:0px;}.yjmth a{border:0px;}div.yjmthproplogoarea{float:left;}div.yjmthloginarea{float:left;margin:0px 0px 0px 3px;font-size:smaller;text-align:left;line-height:110%}div.yjmthcplogoarea{float:right;}div.yjmthcmnlnkarea{/*\*/float:right;/* */margin:10px 3px 0px 0px;font-size:smaller;text-align:right;line-height:110%;}br.yjmthclear{clear:both;}div.yjgrplink{text-align:right;font-size:smaller;line-height:115%;}div#music div.yjmthloginarea{margin-top:16px;margin-left:7px;}div#music div.yjmthcmnlnkarea{margin-top:26px;}div#music div.yjmthcplogoarea{margin-top:14px;}#masthead{width:100%;height:41px;margin:10px auto;text-align:left;}#masthead strong{font-weight:bold;}#masthead:after{content:"."; display:block; position:relative;height:0; clear:both; visibility:hidden;}/*\*/* html #masthead{height:1%;}* html #masthead .yjmth{margin:0;padding:0;}/**//* ie/mac \*//*/#masthead{display:inline-table;}/**/
@media print{div.yjmthloginarea{display:none;}}

/* footer */
#footer{text-align:center;}#footer address{padding:10px 0 20px;border-top:1px solid #ccc;font-size:small;line-height:1.4;}

/* contents */
.msg{margin:2.5em 0 4em;}.msg h1{font-size:130%;font-weight:bold;}.msg p{margin-top:2em;background-color:#fff;}.msg p.lnk{text-align:center;}
--></style>
</head>
<body class="yj950-1">

<div id="wrapper">

<div id="header">
<span class="yjGuid"><a name="yjPagetop" id="yjPagetop"></a><img src="https://s.yimg.jp/yui/jp/tmpl/1.1.0/audionav.gif" width="1" height="1" alt="このページの先頭です"></span>
<span class="yjSkip"><a href="#yjContentsStart"><img src="https://s.yimg.jp/yui/jp/tmpl/1.1.0/audionav.gif" alt="このページの本文へ" width="1" height="1" ></a></span>
<div id="masthead">
<div class="yjmth">
<div class="yjmthproplogoarea">
<a href="https://www.yahoo.co.jp/"><img src="https://s.yimg.jp/c/logo/f/2.0/yj_r_34.png" alt="Yahoo! JAPAN" width="136" height="34" border="0"></a></div>
<div class="yjmthcmnlnkarea">
<a href="https://www.yahoo.co.jp/">Yahoo! JAPAN</a>&nbsp;-&nbsp;<a href="https://www.yahoo-help.jp/">ヘルプ</a></div>
</div>
</div><!--/#masthead-->

</div><!--/#header-->

<hr class="yjSeparation">

<div id="contents">

<div id="yjContentsBody">
<span class="yjGuid"><a name="yjContentsStart" id="yjContentsStart"></a><img src="https://s.yimg.jp/yui/jp/tmpl/1.1.0/audionav.gif" alt="ここから本文です" width="1" height="1"></span>

<div id="yjMain">
<div class="yjMainGrid">
<div class="msg">
<h1>ページが表示できません</h1>
<p>障害が発生しているため、しばらくしてから、再度アクセスしてください。</p>
<p class="lnk"><a href="https://www.yahoo.co.jp/">Yahoo! JAPAN</a></p>
</div><!--/.msg-->
</div><!--/.yjMainGrid-->
</div><!--/#yjMain-->

</div><!--/#yjContentsBody-->

<div id="yjContentsFooter">
<span class="yjGuid"><img src="https://s.yimg.jp/yui/jp/tmpl/1.1.0/audionav.gif" width="1" height="1" alt="本文はここまでです"></span>
<span class="yjSkip">
<a href="#yjPagetop"><img src="https://s.yimg.jp/yui/jp/tmpl/1.1.0/audionav.gif" alt="このページの先頭へ" width="1" height="1"></a></span>
</div><!--/#yjContentsFooter-->

</div><!--/#contents-->

<hr class="yjSeparation">

<div id="footer">
<address><a href="https://about.yahoo.co.jp/docs/info/terms/chapter1.html#cf2nd">プライバシーポリシー</a> - <a href="https://about.yahoo.co.jp/docs/info/terms/">利用規約</a> - <a href="https://www.yahoo-help.jp/">ヘルプ・お問い合わせ</a><br>
Copyright (C) 2018 Yahoo Japan Corporation. All Rights Reserved.
</address>
</div><!--/#footer-->

</div><!--/#wrapper-->

</body>
</html>
```

こんなかんじ。  

なので puppetter の威力がわかっていただけると思います。  

## 課題とか、いろいろ

- puppetterを動かそうとすると少しリッチなインスタンスを使用する必要があるのでちょっとコストが気になる
- メモリ1GぐらいほしいだけなのにCPUも一緒に上がってしまうので、メモリだけ上げるなどのオプションができると嬉しいな。