LoginSignup
6
8

More than 3 years have passed since last update.

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

Last updated at Posted at 2018-09-24

2020/12/2 追記

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

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

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

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

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

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

モチベーション

  • APIGateway と lambda 使ってサーバレスな BOT を作ってみたいなぁと思っていた
  • 普段 python ばっかりなので、たまには別の言語を使ってみたかった(Node.js にしたのは何となく)

やったこと

1. LINE チャンネル作成(Messaging API)

こちら を参考に作成しました。
プランは 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 を使いました。

他にも色々画像検索パッケージがあったんですが、自分が見た限りではそれらの殆どが
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

6
8
0

Register as a new user and use Qiita more conveniently

  1. You get articles that match your needs
  2. You can efficiently read back useful information
  3. You can use dark theme
What you can do with signing up
6
8