Node.jsとAWSを使って画像検索 BOTを作ってみた～前編～

# 2020/12/2 追記
記事中で書いている g-i-s を使って情報を取得できなくなっていました。
なので改修しました。

flex message になったり、結構変わってます。
やばそうな部分は消したので、変な部分もありますがご了承ください。

```javascript:index.js
'use strict';

const HTMLParser = require('fast-html-parser');
const urllib = require('urllib');
const Client = require('@line/bot-sdk').Client;

const client = new Client({
    channelAccessToken: process.env['ch_access_token'],
    channelSecret: process.env['ch_secret']
});
const num_max_result = 10;


exports.handler = (event, context) => {
    // Line の内容受け取り
    const event_data = JSON.parse(event.body);
    console.log(event_data);
    const messageData = event_data.events && event_data.events[0];
    const replyToken = messageData.replyToken;

    // 頭に検索の文字がある場合のみ実行
    const raw_search_word = messageData.message.text;
    const search_word = trimStrings(raw_search_word);
    switch (true) {
        // google 画像検索
        case /^検索[\x20\u3000\n]*.*/.test(raw_search_word):
            console.log(`検索語 = ${search_word}`);
            const base_url2 = 'https://www.google.co.jp/search';
            const search_url2 =  `${base_url2}?q=${encodeURIComponent(search_word)}&tbm=isch&hl=ja&oe=utf-8&tbs=qdr:m`;

            // 画像検索実行
            google_search(search_url2, search_word, replyToken);
            break;
    }
};


// google 検索メイン処理
function google_search(url, search_word, replyToken, flg=0) {
    urllib.request(url, function(err, data, res) {
        if (err) {
            console.log(err);
        } else {
            var root = HTMLParser.parse(data.toString());
            var links = root.querySelectorAll('table.TxbwNb');
            var MessageObj2 = [];
            links = random_sort(links);
            links.forEach((l, idx) => {
                if (MessageObj2.length < num_max_result) {
                    var tmp = l.querySelectorAll('a');
                    var title2 = tmp[1].querySelector('span.fYyStc').childNodes[0].rawText;
                    var img = tmp[0].querySelector('img.t0fcAb').rawAttrs.match(/src="(.+)"/)[1];
                    var link2 = tmp[0].rawAttrs.match(/q=(.+?)&/)[1];
                    
                    // URL 内のスペースを置換
                    img = img.replace(/[\x20\u3000]/g, '%20');
                    link2 = link2.replace(/[\x20\u3000]/g, '%20');

                    // Line の要件check
                    if ((/^https.*$/.test(img)) && (encodeURIComponent(img).length < 1000) &&
                        (/^https.*$/.test(link2)) && (encodeURIComponent(link2).length < 1000)) {
                        MessageObj2.push(createMessageObj(title2, img, link2));
                    }
                } else {
                    return true;  // foreach では break が使えないのでこうする
                }
            });
            console.log(MessageObj2);
            // Line返信
            reply_line(search_word, MessageObj2, replyToken, flg);
        }
    });
}


// search_word から不要文字列を削除
var trimStrings = function(message) {
    message = message.replace(/^検索[\x20\u3000\n]*/, '');
    switch (message) {
        default:
            return message;
    }
};


// ランダムソート
var random_sort = function(array) {
    for(var i = array.length - 1; i > 0; i--){
        var r = Math.floor(Math.random() * (i + 1));
        var tmp = array[i];
        array[i] = array[r];
        array[r] = tmp;
    }
    return array
};


// Line に返すオブジェクト作成
var createMessageObj = function(title, img, link) {
    return {
        'type': 'bubble',
        'size': 'micro',
        'hero': {
            'type': 'image',
            'url': img,
            'size': 'full',
            'aspectMode': 'cover',
            'aspectRatio': '1.51:1'
        },
        'body': {
            'type': 'box',
            'layout': 'vertical',
            'contents': [
                {
                    'type': 'text',
                    'text': title,
                    'wrap': true,
                    'maxLines': 3,
                    'size': 'xs',
                    'gravity': 'center',
                    'margin': 'md'
                }
            ]
        },
        'action': {
            'type':'uri',
            'label':'View details',
            'uri': link
        },
    };
};


// LINE に返信する関数定義
var reply_line = (search_word, MessageObj, replyToken, flg) => {
    // 返信するオブジェクト
    var SendMessageObject;
    if (MessageObj.length == 0) {
        SendMessageObject = [
        {
            type: 'text',
            text: '検索結果がゼロ件でした。'
        }];
    } else {
        SendMessageObject = [
            {
                type: 'text',
                text: search_word + ' で検索しました'
            },
            {
                "type": "flex",
                "altText": "this is a flex message",
                "contents": {
                    'type': 'carousel',
                    'contents': MessageObj
                }
            }
        ];
    }

    client
        .replyMessage(replyToken, SendMessageObject)
        .catch((err) => {
            console.log('failed in reply post');
            console.log(err);
        });
};
```


# はじめに
Google 画像検索をする LINE BOTを作ってみました。
![linebot.gif](https://qiita-image-store.s3.amazonaws.com/0/78126/8d73a232-9cbd-2c87-f194-10c712206cbc.gif)

gif を見ればわかると思いますが、こういうことをやってくれます。

- 「検索」の後に続く文字列で Google 画像検索し、結果を5つ画像カルーセルで返す
- 画像をクリックするとその画像元サイトに飛ぶ

完成までのアレコレを書いていきたいと思います。

# モチベーション
- APIGateway と lambda 使ってサーバレスな BOT を作ってみたいなぁと思っていた
- 普段 python ばっかりなので、たまには別の言語を使ってみたかった（Node.js にしたのは何となく）

# やったこと
## 1. LINE チャンネル作成（Messaging API）
[こちら](https://masatoshihanai.com/php-line-bot-01/) を参考に作成しました。
プランは Developer Trial にしました。

## 2. Node.js でコーディング
### コーディングに入る前に
自分の Node.js バージョン はこのようになってました。

```
node --version
v6.11.2
```
もしも未インストールの場合はこちらから DL してインストール出来ます。(インストール手順は割愛)
https://nodejs.org/en/download/

line_bot_test というフォルダを作り、その中で作業をしていきます。
とりあえず必要なパッケージをインストールしましょう。

```
mkdir line_bot_test
cd line_bot_test

npm install superagent
npm install g-i-s
```

今回、 Google 画像検索をするにあたり [g-i-s](https://www.npmjs.com/package/g-i-s) を使いました。

他にも色々画像検索パッケージがあったんですが、自分が見た限りではそれらの殆どが
Google Custom Search Engine を使っており、無課金だと API 上限（100回/day）があるので
今回は採用しませんでした。

しかし、g-i-s も問題があり、検索すると結果が以下のような json で返ってきます。

```
※「Qiita」で検索した例

{ url: 'https://cdn.qiita.com/assets/qiita-fb-2887e7b4aad86fd8c25cea84846f2236.png',
  width: 200,
  height: 200 }
```

自分は **画像をクリックしたら元のサイトに飛ばしたい** のですが、今のままではその要件を満たせない…
どうにかなんねぇかなぁ～と思いソースコードを見に行くと `node_modules/g-i-s/index.js` にこのような個所が。

```node_modules/g-i-s/index.js
 59           var result = {
 60             url: metadata.ou,
 61             width: metadata.ow,
 62             height: metadata.oh
 63           };
```

ここで元のリンクも返すようにすれば良さそう。

```node_modules/g-i-s/index.js
 59           var result = {
 60             url: metadata.ou,
 61             width: metadata.ow,
 62             height: metadata.oh,
 63             link: metadata.ru　← 追加
 64           };
```

無事、画像元のリンクを取得できるようになりました！
（他にどういう情報が取得できそうか気になった方は `metadata` を確認してみてください）

```
※「Qiita」で検索した例

{ url: 'https://cdn.qiita.com/assets/qiita-fb-2887e7b4aad86fd8c25cea84846f2236.png',
  width: 200,
  height: 200,
  link: 'https://qiita.com/' }
```

### コーディング
いきなり結論。こんな感じに作りました。
初 Node.js なので、変なところもあるかと思いますがご容赦ください（指摘歓迎）

```index.js
'use strict';
var gis = require('g-i-s');
var request = require('superagent');
var crypto = require('crypto');
var webclient = require('request');

exports.handler = (event, context) => {
    // line の内容受け取り
    var event_data = JSON.parse(event.body);
    console.log(event_data);
    var messageData = event_data.events && event_data.events[0];

    // 頭に検索の文字がある場合のみ実行
    var flg_check = messageData.message.text;
    var search_word = trimStrings(flg_check);
    switch (true){
        // 画像検索
        case /^検索[ 　\n]*.*/.test(flg_check):
            gis(search_word, function(error, results) {
                if (error) {
                    console.log(error);
                } else {
                    console.log(results);
                    lineclient_img(search_word, results, messageData.replyToken);
                }
            });
            break;
    }
};

// search_word から不要文字列を削除
var trimStrings = function(message) {
    message = message.replace(/^検索[ 　\n]*/, '');
    switch (message) {
        default:
            return message;
    }
};

// LINE に返信する関数定義
var lineclient_img = (search_word, res, replyToken) => {
    const reply_url = process.env['reply_url'];
    const ch_secret = process.env['ch_secret'];
    const signature = crypto.createHmac('sha256', ch_secret);
    const ch_access_token = process.env['ch_access_token'];

    // https画像を探す
    var urls = [];
    var url_count = 0; 
    var i = 0;
    while(i < res.length) {
        if (res[i].url.match(/^https:.*(jpg|jpeg|png)$/)) {
            urls[url_count] = res[i];
            url_count = url_count + 1;
        }
        
        if (urls.length == 5) {
            break;
        }
        i = i + 1;
       }
    console.log('selected imade: ', urls);

    // 返信するオブジェクト
    var SendMessageObject;
    if (urls.length < 5) {
        SendMessageObject = [
        {
            type: 'text',
            text: '検索結果が少なすぎます。ワードを変えて検索してみてね！'
        }];
    } else {
        SendMessageObject = [
            {
                type: 'text',
                text: search_word + ' で画像検索しました'
            },
            {
                "type": "template",
                "altText": search_word + ' で画像検索しました',
                "template": {
                    "type": "image_carousel",
                    "columns": [
                        {
                            "imageUrl": encodeURI(urls[0].url),
                            "action": {
                                "type": "uri",
                                "label": "Open Link",
                                "uri": encodeURI(urls[0].link)
                            }
                        },
                        {
                            "imageUrl": encodeURI(urls[1].url),
                            "action": {
                                "type": "uri",
                                "label": "Open Link",
                                "uri": encodeURI(urls[1].link)
                            }
                        },
                        {
                            "imageUrl": encodeURI(urls[2].url),
                            "action": {
                                "type": "uri",
                                "label": "Open Link",
                                "uri": encodeURI(urls[2].link)
                            }
                        },
                        {
                            "imageUrl": encodeURI(urls[3].url),
                            "action": {
                                "type": "uri",
                                "label": "Open Link",
                                "uri": encodeURI(urls[3].link)
                            }
                        },
                        {
                            "imageUrl": encodeURI(urls[4].url),
                            "action": {
                                "type": "uri",
                                "label": "Open Link",
                                "uri": encodeURI(urls[4].link)
                            }
                        }
                    ]
                }
            }
        ];
    }

    request
        .post(reply_url)
        .send({ replyToken: replyToken, messages: SendMessageObject })
        .set('X-Line-signature', signature)
        .set('Content-Type', 'application/json')
        .set('Authorization', `Bearer ${ch_access_token}`)
        .end(function(err, res){
            console.log(res);
            if (res.ok) {
                console.log('succeed in reply post');
            } else {
                console.log('failed in reply post');
                console.log(err);

                // 検索失敗の通知
                request
                    .post(reply_url)
                    .send({ replyToken: replyToken, messages: [{'type': 'text', 'text': '見つからないので検索ワードを変えてみてね'}] })
                    .set('X-Line-signature', signature)
                    .set('Content-Type', 'application/json')
                    .set('Authorization', `Bearer ${ch_access_token}`)
                    .end(function(err, res){
                        console.log(res);
                        if (res.ok) {
                            console.log('succeed in error post');
                        } else {
                            console.log('failed in error post');
                            console.log(err);
                        }
                    });
            }
        });
};
```

**少し補足**
- LINE 画像カルーセルの仕様に合わせて、 https 画像のサイトを選択して返すようにしています。  
https://developers.line.me/ja/reference/messaging-api/#image-carousel
- `process.env['reply_url'];` は次回、AWS lambda のところで説明します。


# AWS 環境にデプロイ
あとはこれを AWS 環境にデプロイすればOKです。
…が少し長くなって疲れたので続きは別途書きます。

【追記】書きました
https://qiita.com/dasu1982/items/cd31d4a6b755e133c08f