【せんべろボット】近場のリーズナブルな居酒屋を教えてくれるLINEbotを作ってみた

##追記
こちらでスクレイピングしていた食べログのページが、提供を終了してしまったため動作しなくなりました。残念。

##概要
位置情報を送ると、近場の安酒場を教えてくれるLINEbotを作ってみた。

食べログの情報を元に、周囲500m＆ジャンル居酒屋＆夜予算～2,999円までのお店をスクレイピングして返信。「せんべろ（～1,999円）」絞り込み機能も実装^^)b

ここではコード全文、ポイントなどを紹介。
最近、GAS×LINEmessagingAPIでのBot作りに夢中^^;

今回のは、この前作った[位置情報を送ると近場の深夜営業レストランを教えてくれるLINEbot](https://qiita.com/YasumiYasumi/items/2fc531d6dfd62f3a260a)の応用。

##実行結果
実際に使ってみるとこんな感じ。
![GIF-201226_142425.gif](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/708687/d6ff86b1-f03a-82c0-c565-3e3f9e820a4f.gif)

まずはラインに位置情報を送る。
![Screenshot_20201223-013726-01.jpeg](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/708687/d96f3d77-52bf-1805-3c83-e9389d9ac1cd.jpeg)
検索結果がカルーセルテンプレートで表示される。画像やテキストをタップすると食べログの店舗ページへ、各ボタンは基本情報・地図へのリンク・店舗名でのGoogle検索。![Screenshot_20201223-013803-01.jpeg](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/708687/14fdebe6-aade-fd21-030f-9f702dda4935.jpeg)
一番右のカラム（以下 おかわりカラム）に、検索結果をもっと見るボタンや「せんべろ」絞り込みボタンを用意。「せんべろ」絞り込み中の場合、絞り込み解除ボタンが表示される。
![Screenshot_20201223-014104-01.jpeg](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/708687/a510e862-b016-9e03-061e-e288ff964080.jpeg)
位置情報以外を送ると位置情報を求めるメッセージと位置情報画面を開くボタンメッセージ（左）、検索がヒットしない場合は見つかりませんでしたのメッセージ（中）、おかわりカラムの「もう見ない」をタップした場合は体を気遣うメッセージ（右）が表示される。

絞り込んだ事で表示できる店がなくなってしまった場合、見つかりませんでしたメッセージに「絞り込み解除」ボタンが表示される。

##事前準備
ありがたいことに全て無料<(_ _)>
###GASとLINEの紐付け
・GASでスクリプトファイル作成
・GASをWEBアプリとして導入
・LINEデベロッパーズでBotを作成
・LINEのAPIトークンを発行
・LINEのWebhookを有効にする。

ここまでのLINEbot導入については、それぞれ詳しく説明しているサイトがあるので割愛。

###ライブラリ
GASを使って食べログの検索結果をスクレイピングするのに「TextPicker」というライブラリを使用する。長い文字列から必要部分を切り出すのにとっても便利！

導入方法については割愛、使い方については後述。

###アイコン他画像
今回もいらすとやさんの画像を使用。
・アイコン：[酔っ払ったクマのイラスト](酔っ払ったクマのイラスト)
![animal_yopparai_kuma.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/708687/a7bc8575-d011-1219-958e-ea037bd1b8eb.png)
・絞り込み検索：[外で騒ぐ酔っぱらいのイラスト](https://www.irasutoya.com/2016/06/blog-post_70.html)
![yopparai_machi.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/708687/9f782ed1-da17-fb56-2a2a-784d3f4aa35c.png)
・「せんべろ」絞り込み検索：[アルコール中毒のイラスト](https://www.irasutoya.com/2015/11/blog-post_408.html)
![sick_alcohol_chudoku.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/708687/1bfafb6a-8e6a-a327-87fa-06a2a3b113e9.png)
ほんと何でもある（笑）

##スクレイピング
###TextPickerの使い方
プロジェクトID：Ms1_ywyxDUyXZlf1HE1E2_ydpxDUCDjPE

①`TextPicker.open('ソース');`
`ソース`を格納。

②`TextPicker.pickUp('文字１','文字２');`
ソース内の`文字１`から`文字２`の間を切り抜く。

③`TextPicker.skipTo('文字３');`
ソース内の`文字３`まで飛ばす。
以降、`文字３`の前は検索対象から外れる。

①②③の操作でページソースから必要な情報を取得していく。

###検索URLの生成
「位置情報＆500m圏内＆居酒屋＆夜予算 ～2,999円」のURLがこちら

```
http://m.tabelog.com/mobile_gps/RC/RC21/RC2101/A/0/0/l/rstlstgps/?RdoCosTp=2&amp;LstCos=0&amp;LstCosT=3&amp;lat={緯度}&lon={経度}&amp;SrtT=trend&amp;img_on=1
```

最初の方の「A」が500m検索。「B」にすると1km。

`LstCosT=3`が、夜予算 ～2,999円を表していて、「せんべろ」で絞り込む場合は`LstCosT=2`にすることで夜予算 ～1,999円の検索URLになる。

画像も欲しいので`&img_on=1`で画像ありにしている。

###店舗情報
検索結果ページのソースを表示させると、1ページにつき10件ずつ、こんな感じで店舗情報が並んでいる。

```html
<a href="http://m.tabelog.com/tokyo/A1304/A130401/13218754/">銘柄焼き鳥ともつ鍋の居酒屋 鳥京 新宿総本店</a>
</span>
<br />
<table style="width:100%;">
<tr>
<td style="width:53px; vertical-align:top;">
<img src="https://tblg.k-img.com/restaurant/images/Rvw/84299/50x50_square_84299970.jpg" width="50" height="50" alt="" align="left" style="float:left; margin:0 1px;" /></td>
<td style="vertical-align:top;">
<span style="font-size:xx-small;">
<span style="color:#666666;">(西武新宿､新宿西口､新宿三丁目/居酒屋､焼鳥､もつ鍋)</span>
<br /><span style="color:#ffaa00;">★★★</span><span style="color:#999999;">☆☆</span><span style="color:#ff0000;">3.09</span><br /><span style="color:#ff00ff;"></span><span style="color:#000000;">口ｺﾐ:</span>12件<br /><span style="color:#ff0000;"></span>夜:\2,000～\2,999<br /><span style="color:#ff0000;"></span>昼:-<br />
<br /><span style="color:#ff5555;"><span style="color:#000000;"></span>現在地から635m</span>
</span>
```

店舗URLや店舗名、ジャンル、評価などをスクレイピングしていく。スクレイピングしたところまで`TextPicker.skipTo();`すれば重複しない。

###その他の情報
検索結果の件数や、次ページのURLなどもスクレイピングする。

##コード
###全体のイメージ

```
function doPost(event) {

  eventが位置情報なら →スクレイピングへ

  eventがポストバックの場合
    「見る」なら　　  →２ページ目のスクレイピングへ
    「絞り込む」なら  →絞り込み用のスクレイピングへ
    「解除」なら　　  →同位置最初のスクレイピングへ
    「見ない」なら　　→終了メッセージ return;

  それ以外なら　　　　→位置情報を要求 return;

  スクレイピング結果を送信

}

function スクレイピング {

  「普通の検索」か「絞り込み検索」か判断

  検索結果がゼロなら その結果をdoPostへ返す return;

  1ページ 最大10件分繰り返し
    10件目かつ次のページがあるなら　→おかわりカラム作成
    1件目～最大10件目まで　→店舗情報カラム作成

  作成したテンプレートをdoPostへ返す

}

function 位置情報を要求 {

  位置情報を求めるボタンテンプレートを送信

}
```

こんな感じの三部構成。

LINEにメッセージを送ると、Webhookでイベントが送られる。そのイベントに応じて、検索したり、更に絞り込んだりするようコードを書いた。

おかわりカラムの「もっと見る」「もう見ない」「絞り込む」「絞り込み解除」には、それぞれポストバックイベントが仕込まれていて、それらをタップした場合は検索結果の「次ページのURL」を格納したWebhookなどが発動する。

###コード全文
取り合えず何の説明もなしにコード全文。
アクセストークンだけ変えれば、コピペで同じことができるはず。

<details><summary>説明なしコード</summary><div>

```javascript
function doPost(event) {

  const TOKEN = 'xxxxxxxxxxxxxxxxxxxxxxxxx';
  const reply = "https://api.line.me/v2/bot/message/reply";
  
  var refill = 0;

  var json = JSON.parse(event.postData.contents);
  var replyToken = json.events[0].replyToken;
  var eventsType = json.events[0].type;
  
  if(eventsType==='postback'){
    var pbData = json.events[0].postback.data;
    var sta = pbData.slice(0, 3);
    
  }else{
    var messageType = json.events[0].message.type;
  }

  if(messageType==='location'){

    var lat = json.events[0].message.latitude;
    var lng = json.events[0].message.longitude;
    
    var searchURL = 'http://m.tabelog.com/mobile_gps/RC/RC21/RC2101/A/0/0/l/rstlstgps/?RdoCosTp=2&amp;LstCos=0&amp;LstCosT=3&amp;lat=' + lat + '&lon=' + lng + '&amp;SrtT=trend&amp;img_on=1';
    
    var payload = carousel(searchURL, replyToken, refill);

    UrlFetchApp.fetch(reply, {
          "headers": {
          "Content-Type": "application/json; charset=UTF-8",
          "Authorization": "Bearer " + TOKEN,
        },
        "method": "post",
        "payload": payload
    });

  }else if(sta==='yes'){
  
    var nextPage = pbData.replace('yes', '');
    
    TextPicker.open(nextPage);
    refill = TextPicker.pickUp('LstCosT=','&amp;lat');
    refill = Number(refill);
    
    var payload = carousel(nextPage, replyToken, refill);

    UrlFetchApp.fetch(reply, {
          "headers": {
          "Content-Type": "application/json; charset=UTF-8",
          "Authorization": "Bearer " + TOKEN,
        },
        "method": "post",
        "payload": payload
    });
    
  }else if(sta==='nar'){
  
    var nextPage = pbData.replace('nar', '');
    
    refill = 2;
    
    var payload = carousel(nextPage, replyToken, refill);

    UrlFetchApp.fetch(reply, {
          "headers": {
          "Content-Type": "application/json; charset=UTF-8",
          "Authorization": "Bearer " + TOKEN,
        },
        "method": "post",
        "payload": payload
    });
  
  }else if(sta==='rel'){
  
    var nextPage = pbData.replace('rel', '');
    
    TextPicker.open(nextPage);
    lat = TextPicker.pickUp('lat=','&amp');
    lng = TextPicker.pickUp('lon=','&amp');
    
    var searchURL = 'http://m.tabelog.com/mobile_gps/RC/RC21/RC2101/A/0/0/l/rstlstgps/?RdoCosTp=2&amp;LstCos=0&amp;LstCosT=3&amp;lat=' + lat + '&lon=' + lng + '&amp;SrtT=trend&amp;img_on=1';

    var payload = carousel(searchURL, replyToken, refill);

    UrlFetchApp.fetch(reply, {
          "headers": {
          "Content-Type": "application/json; charset=UTF-8",
          "Authorization": "Bearer " + TOKEN,
        },
        "method": "post",
        "payload": payload
    });
    
  }else if(pbData==='no'){
  
    var botMessage = '飲みすぎんなよ (￣ー￣)b';
    var payload = JSON.stringify({
        "replyToken": replyToken,
        "messages": [{
          "type": "text",
          "text": botMessage
        }]
    });
    UrlFetchApp.fetch(reply, {
        "headers": {
          "Content-Type": "application/json; charset=UTF-8",
          "Authorization": "Bearer " + TOKEN,
        },
        "method": "post",
        "payload": payload
    });
  
  }else{
  
    searchButton(reply,replyToken,TOKEN);
    
  }
}

function carousel(searchURL, replyToken, refill) {

  var thumbnailRifill = 'https://3.bp.blogspot.com/-YaOrhSXc06o/VyNd5FJHu0I/AAAAAAAA6QQ/GXheosWKUI01VpkimQQ_QioxrIFXc6U4ACLcB/s800/yopparai_machi.png';
  var thumbnailSenbel = 'https://3.bp.blogspot.com/-hJtrtGeAey4/VkXsfoJgSnI/AAAAAAAA0Xs/W1u7sbHqzlQ/s800/sick_alcohol_chudoku.png';

  if(refill===2){
  
    TextPicker.open(searchURL);
    var LstCosT = TextPicker.pickUp('LstCosT=','&amp;lat');

    if(LstCosT==3){
      var urlHead = 'http' + TextPicker.pickUp('http','3&amp;lat');
      var urlFoot = refill + TextPicker.pickUp('LstCosT=3','&amp;page');
      searchURL = urlHead + urlFoot;
    }
  
  }

  var data = UrlFetchApp.fetch(searchURL).getContentText();

  TextPicker.open(data);

  if(TextPicker.pickUp('指定の条件に','見つかりませんでした')==='該当するお店は'){
  
    var botMessage = '指定の条件に該当する安酒場は見つかりませんでした。';
    
    if(refill===2){
    
      TextPicker.open(searchURL);
      var lat = TextPicker.pickUp('lat=','&amp');
      var lng = TextPicker.pickUp('lon=','&amp');
      
      var relURL = 'http://m.tabelog.com/mobile_gps/RC/RC21/RC2101/A/0/0/l/rstlstgps/?RdoCosTp=2&amp;LstCos=0&amp;LstCosT=3&amp;lat=' + lat + '&lon=' + lng + '&amp;SrtT=trend&amp;img_on=1';
      var rel = 'rel' + relURL;
      
      var payload = JSON.stringify({
        "replyToken": replyToken,
        "messages": [{
          "type": "template",
          "altText": "絞り込み解除ボタン",
          "template": {
            "type": "buttons",
            "text": botMessage,
            "actions": [
              {
                "type": "postback",
                "label": "絞り込み解除",
                "data": rel
              }
            ]
          }
        }]
      });
      return payload;
    }
    
    var payload = JSON.stringify({
        "replyToken": replyToken,
        "messages": [{
          "type": "text",
          "text": botMessage
        }]
    });
    
    return payload;
  }
  
  TextPicker.skipTo('点数について');
  var start = TextPicker.pickUp('<span style="color:#ff0000;">','～');
  var page  = TextPicker.pickUp('～','件');
  var end   = TextPicker.pickUp('全','件');
  start = Number(start);
  page  = Number(page);
  end   = Number(end);
  
  if(refill===0){
    var num = end;
  }else{
    num = end - start + 1;
  }
  
  var ken = num; 
  if(num>10){num=10;}
  
  var numer = Math.ceil(page/10); 
  var denom = Math.ceil(end/10); 
  var columns = [];
  
  TextPicker.skipTo('</span>/全');
  var nextPage = TextPicker.pickUp('"#" href="','">次へ');
  nextPage = 'http://m.tabelog.com' + nextPage;
  
  for(var i=0; i<num; i++){
   
    if(ken>11 && i===9){ 
     
      if(refill===2){ 
     
        var text = '表示できる安酒場があります\n◆ ' + numer + 'ページ／' + denom + 'ページ\n◆ せんべろ';
        var yes = 'yes' + nextPage;
        var rel = 'rel' + nextPage;
     
        var column = { 
                        "thumbnailImageUrl": thumbnailSenbel, 
                        "imageBackgroundColor": "#FFFFFF", 
                        "title": 'もっと見る？', 
                        "text": text, 
                        "actions": [ 
                          {
                            "type": "postback",
                            "label": "もっと見る",
                            "data": yes
                          },
                          {
                            "type": "postback",
                            "label": "もう見ない",
                            "data": "no"
                          },
                          {
                            "type": "postback",
                            "label": "絞り込み解除",
                            "data": rel
                          } 
                        ] 
                      } 
                       
      }else{ 
     
        var text = '表示できる安酒場があります\n◆ ' + numer + 'ページ／' + denom + 'ページ';
        var yes = 'yes' + nextPage;
        var nar = 'nar' + nextPage;
     
        var column = { 
                        "thumbnailImageUrl": thumbnailRifill, 
                        "imageBackgroundColor": "#FFFFFF", 
                        "title": 'もっと見る？', 
                        "text": text, 
                        "actions": [ 
                          {
                            "type": "postback",
                            "label": "もっと見る",
                            "data": yes
                          },
                          {
                            "type": "postback",
                            "label": "もう見ない",
                            "data": "no"
                          },
                          {
                            "type": "postback",
                            "label": "絞り込み：せんべろ",
                            "data": nar
                          } 
                        ] 
                      } 
                       
      }
      
    }else{
    
      var URL = TextPicker.pickUp('http://m.tabelog.com/','">'); 
      TextPicker.skipTo(URL); 
      if(URL.slice(-5)==='="red'){i--; continue;} 
      if(URL==='billing_mobile/register_mymenu?msgid=6'){i--; continue;} 
      var title = TextPicker.pickUp('">','</a>'); 
      var thumbnailHead = TextPicker.pickUp('<img src="','50x50'); 
      var thumbnailFoot = TextPicker.pickUp('50x50','" '); 
      TextPicker.skipTo('style="color:#666666;">('); 
      var cuisine = TextPicker.pickUp('/',')</span>'); 
      TextPicker.skipTo(';"'); 
      var blStar = TextPicker.pickUp('>','</span>'); 
      var whStar = TextPicker.pickUp(';">','</span>');  
      var rating = TextPicker.pickUp('color:#ff0000;">','</span>'); 
      var price = TextPicker.pickUp('夜:','<br />'); 
      var distance = TextPicker.pickUp('現在地から','</span>'); 
      
      var shopURL = 'http://m.tabelog.com/' + URL; 
      var dataURL = shopURL + '#rstdtl_data_info'; 
      var mapURL  = shopURL + 'dtlmap/'; 
       
      title  = title.replace(/&#39;/g, "'")
                    .replace(/&lt;/g, "<")
                    .replace(/&gt;/g, ">")
                    .replace(/&amp;/g, "&"); 
       
      var searchWord  = title.replace(/ /g, '%20')
                             .replace(/　/g, '%20');

      var gURL = 'https://www.google.co.jp/search?hl=ja&source=hp&q=' + searchWord; 
      
      var thumbnailURL = thumbnailHead + '200x200' + thumbnailFoot;
      
      if(blStar==='－'){blStar = '評価'; whStar = ' '; rating = '未登録';}
      if(price==='-'){price = '未登録';}
      
      title = title.substr(0, 33) + '(' + distance + ')';
      var price = '\n予算 ' + price;
      price = price.replace(/\\/g, "￥");
      cuisine = cuisine.replace('（その他）','');
      var text = cuisine.substr(0, 16) + '\n' + blStar + whStar + rating + price;
      
      var column = { 
                      "thumbnailImageUrl": thumbnailURL, 
                      "imageBackgroundColor": "#FFFFFF", 
                      "title": title, 
                      "text": text, 
                      "defaultAction": { 
                        "type": "uri", 
                        "label": "View detail", 
                        "uri": shopURL 
                      }, 
                      "actions": [ 
                        { 
                          "type": "uri", 
                          "label": "基本情報を見る", 
                          "uri": dataURL 
                        }, 
                        { 
                          "type": "uri", 
                          "label": "地図を見る", 
                          "uri": mapURL 
                        }, 
                        { 
                          "type": "uri", 
                          "label": "Google検索", 
                          "uri": gURL 
                        } 
                      ] 
                    } 
                     
    } 
     
    columns[i] = column; 
  } 
  var payload = JSON.stringify({ 
      "replyToken": replyToken, 
      "messages": [{ 
        "type": "template", 
        "altText": "検索結果", 
        "template": { 
          "type": "carousel", 
          "columns": columns, 
          "imageAspectRatio": "square", 
          "imageSize": "cover" 
        } 
      }] 
  }); 
  return payload; 
}

function searchButton(reply,replyToken,TOKEN) {

  var botMessage = '位置情報を送ってね。\n近くの安酒場を調べるよ^^)ゝ';
  
  var payload = JSON.stringify({
      "replyToken": replyToken,
      "messages": [{
        "type": "template",
        "altText": "位置情報ボタン",
        "template": {
          "type": "buttons",
          "text": botMessage,
          "actions": [
            {
              "type": "uri",
              "label": "位置情報を送る",
              "uri": "https://line.me/R/nv/location/"
            }
          ]
        }
      }]
  });
  UrlFetchApp.fetch(reply, {
        "headers": {
        "Content-Type": "application/json; charset=UTF-8",
        "Authorization": "Bearer " + TOKEN,
      },
      "method": "post",
      "payload": payload
  });
}

```
</div></details>

この後、各コードの解説をしていく。

###補足：LINEメッセージオブジェクトの表記法
Messaging APIリファレンス：[【メッセージオブジェクト】](https://developers.line.biz/ja/reference/messaging-api/#message-objects)
Qiita記事：[【GAS】LINEbotでテンプレートメッセージを活用する](https://qiita.com/YasumiYasumi/items/fee6f43bef7f96fb7b22)

###メッセージを送ったら動く部分

```javascript
function doPost(event) {

  //APIトークンと返信用HTTPリクエスト
  const TOKEN = 'xxxxxxxxxxxxxxxxxxxxxxxxx';
  const reply = "https://api.line.me/v2/bot/message/reply";
  
  //初回・おかわり・絞り込みなど判定用 初期値はゼロ
  var refill = 0;

  //JSON形式のイベントオブジェクトを読み込む
  var json = JSON.parse(event.postData.contents);
  var replyToken = json.events[0].replyToken;
  var eventsType = json.events[0].type;
  
  //イベントタイプがポストバックならそのデータを取得
  if(eventsType==='postback'){
    var pbData = json.events[0].postback.data;
    var sta = pbData.slice(0, 3);

  //そうでなければメッセージのタイプを取得
  }else{
    var messageType = json.events[0].message.type;
  }

  //メッセージタイプが位置情報なら
  if(messageType==='location'){

    //位置情報から緯度経度を取得
    var lat = json.events[0].message.latitude;
    var lng = json.events[0].message.longitude;
    
    //緯度経度から食べログ検索URLを生成
    var searchURL = 'http://m.tabelog.com/mobile_gps/RC/RC21/RC2101/A/0/0/l/rstlstgps/?RdoCosTp=2&amp;LstCos=0&amp;LstCosT=3&amp;lat=' + lat + '&lon=' + lng + '&amp;SrtT=trend&amp;img_on=1';
    
    //メッセージ作成関数へURL,おかわり判定などを送る
    var payload = carousel(searchURL, replyToken, refill);

    //作成したメッセージを送信
    UrlFetchApp.fetch(reply, {
          "headers": {
          "Content-Type": "application/json; charset=UTF-8",
          "Authorization": "Bearer " + TOKEN,
        },
        "method": "post",
        "payload": payload
    });

  //ポストバック「見る」の場合 ※１
  }else if(sta==='yes'){
  
    //次ページURLを取得
    var nextPage = pbData.replace('yes', '');
    
    //おかわり種別判定 ※２
    TextPicker.open(nextPage);
    refill = TextPicker.pickUp('LstCosT=','&amp;lat');
    refill = Number(refill);
    
    //メッセージ作成関数へURL,おかわり判定などを送る
    var payload = carousel(nextPage, replyToken, refill);

    //作成したメッセージを送信
    UrlFetchApp.fetch(reply, {
          "headers": {
          "Content-Type": "application/json; charset=UTF-8",
          "Authorization": "Bearer " + TOKEN,
        },
        "method": "post",
        "payload": payload
    });

  //ポストバック「絞り込む」の場合
  }else if(sta==='nar'){
  
    //次ページURLを取得
    var nextPage = pbData.replace('nar', '');
    
    //「せんべろ」判定は２
    refill = 2;
    
    //メッセージ作成関数へURL,おかわり判定などを送る    
    var payload = carousel(nextPage, replyToken, refill);

    //作成したメッセージを送信
    UrlFetchApp.fetch(reply, {
          "headers": {
          "Content-Type": "application/json; charset=UTF-8",
          "Authorization": "Bearer " + TOKEN,
        },
        "method": "post",
        "payload": payload
    });

  //ポストバック「解除」の場合  
  }else if(sta==='rel'){
  
    //次ページURLを取得  
    var nextPage = pbData.replace('rel', '');
    
    //次ページURLから緯度経度を取得 ※３
    TextPicker.open(nextPage);
    lat = TextPicker.pickUp('lat=','&amp');
    lng = TextPicker.pickUp('lon=','&amp');
    
    //緯度経度から食べログ検索URLを生成
    var searchURL = 'http://m.tabelog.com/mobile_gps/RC/RC21/RC2101/A/0/0/l/rstlstgps/?RdoCosTp=2&amp;LstCos=0&amp;LstCosT=3&amp;lat=' + lat + '&lon=' + lng + '&amp;SrtT=trend&amp;img_on=1';
    
    //メッセージ作成関数へURL,おかわり判定などを送る
    var payload = carousel(searchURL, replyToken, refill);

    //作成したメッセージを送信
    UrlFetchApp.fetch(reply, {
          "headers": {
          "Content-Type": "application/json; charset=UTF-8",
          "Authorization": "Bearer " + TOKEN,
        },
        "method": "post",
        "payload": payload
    });

  //ポストバック「見ない」の場合
  }else if(pbData==='no'){
  
    //テキストメッセージを送信
    var botMessage = '飲みすぎんなよ (￣ー￣)b';
    var payload = JSON.stringify({
        "replyToken": replyToken,
        "messages": [{
          "type": "text",
          "text": botMessage
        }]
    });
    UrlFetchApp.fetch(reply, {
        "headers": {
          "Content-Type": "application/json; charset=UTF-8",
          "Authorization": "Bearer " + TOKEN,
        },
        "method": "post",
        "payload": payload
    });

  //それ以外の場合は位置情報を求めるボタンを送信 ※４
  }else{
  
    searchButton(reply,replyToken,TOKEN);
    
  }
}
```

※１：ポストバックデータは、例えば「見る」なら`yeshttp://～～～/`と言う形にしたので、最初の三文字を切り離せば押されたボタンの種類と次ページURLを取得できる。

※２：前述の「検索URLの生成」より、夜予算の部分が3なら普通のおかわり、2なら絞り込んだ後のおかわりだと判別できる。

※３：絞り込みを解除したいので、次ページURLの緯度経度から最初の食べログ検索と同じURLを復元する。

※４：位置情報かポストバック以外のメッセージ（テキストや画像など）の場合は一律して位置情報をを求めるメッセージを送信。位置情報を求めるメッセージについては後述。

###メッセージを作成する部分
doPostから受け取ったURLなどを元にスクレイピングを行い、メッセージを作成する部分。

```javascript
function carousel(searchURL, replyToken, refill) {

  //おかわりカラム用のいらすとやさん画像URL
  var thumbnailRifill = 'https://3.bp.blogspot.com/-YaOrhSXc06o/VyNd5FJHu0I/AAAAAAAA6QQ/GXheosWKUI01VpkimQQ_QioxrIFXc6U4ACLcB/s800/yopparai_machi.png';
  var thumbnailSenbel = 'https://3.bp.blogspot.com/-hJtrtGeAey4/VkXsfoJgSnI/AAAAAAAA0Xs/W1u7sbHqzlQ/s800/sick_alcohol_chudoku.png';

  //通常のおかわりか、絞り込みおかわりか判定
  if(refill===2){
  
    TextPicker.open(searchURL);
    var LstCosT = TextPicker.pickUp('LstCosT=','&amp;lat');

    //初絞り込みの場合 ※１
    if(LstCosT==3){
      var urlHead = 'http' + TextPicker.pickUp('http','3&amp;lat');
      var urlFoot = refill + TextPicker.pickUp('LstCosT=3','&amp;page');
      searchURL = urlHead + urlFoot;
    }
  
  }

  //検索結果のソースをTextPickerに渡す
  var data = UrlFetchApp.fetch(searchURL).getContentText();
  TextPicker.open(data);

  //検索結果がゼロの場合 見つからなかったと言うメッセージを返す
  if(TextPicker.pickUp('指定の条件に','見つかりませんでした')==='該当するお店は'){
  
    var botMessage = '指定の条件に該当する安酒場は見つかりませんでした。';
    
    //絞り込んだ結果ゼロになった場合 絞り込み解除ボタン付き ※２
    if(refill===2){
    
      TextPicker.open(searchURL);
      var lat = TextPicker.pickUp('lat=','&amp');
      var lng = TextPicker.pickUp('lon=','&amp');
      
      var relURL = 'http://m.tabelog.com/mobile_gps/RC/RC21/RC2101/A/0/0/l/rstlstgps/?RdoCosTp=2&amp;LstCos=0&amp;LstCosT=3&amp;lat=' + lat + '&lon=' + lng + '&amp;SrtT=trend&amp;img_on=1';
      var rel = 'rel' + relURL;
      
      var payload = JSON.stringify({
        "replyToken": replyToken,
        "messages": [{
          "type": "template",
          "altText": "絞り込み解除ボタン",
          "template": {
            "type": "buttons",
            "text": botMessage,
            "actions": [
              {
                "type": "postback",
                "label": "絞り込み解除",
                "data": rel
              }
            ]
          }
        }]
      });
      return payload;
    }
    
    //最初の検索でゼロの場合 ただのメッセージ
    var payload = JSON.stringify({
        "replyToken": replyToken,
        "messages": [{
          "type": "text",
          "text": botMessage
        }]
    });
    
    return payload;
  }
  
  //ヒット件数 ページ数取得
  TextPicker.skipTo('点数について');
  var start = TextPicker.pickUp('<span style="color:#ff0000;">','～');
  var page  = TextPicker.pickUp('～','件');
  var end   = TextPicker.pickUp('全','件');
  start = Number(start);
  page  = Number(page);
  end   = Number(end);
  
  if(refill===0){
    //初回検索なら総件数
    var num = end;
  }else{
    //おかわりなら残りの件数
    num = end - start + 1;
  }
  
  //表示件数設定 ※３
  var ken = num; 
  if(num>10){num=10;}
  
  //ページ数の 分子／分母 作成
  var numer = Math.ceil(page/10); 
  var denom = Math.ceil(end/10); 

  //テンプレートのカラムを格納する配列を宣言
  var columns = [];
  
  //次ページURLを取得
  TextPicker.skipTo('</span>/全');
  var nextPage = TextPicker.pickUp('"#" href="','">次へ');
  nextPage = 'http://m.tabelog.com' + nextPage;
  
  //件数分の繰り返し
  for(var i=0; i<num; i++){

    //検索結果が11件以上なら 10個目のカラムをおかわりカラムにする ※４ 致命的欠陥！？
    if(ken>11 && i===9){ 

      //絞り込み検索判定
      if(refill===2){ 

        //絞り込み用おかわりカラム
        var text = '表示できる安酒場があります\n◆ ' + numer + 'ページ／' + denom + 'ページ\n◆ せんべろ';
        var yes = 'yes' + nextPage;
        var rel = 'rel' + nextPage;
     
        var column = { 
                        "thumbnailImageUrl": thumbnailSenbel, 
                        "imageBackgroundColor": "#FFFFFF", 
                        "title": 'もっと見る？', 
                        "text": text, 
                        "actions": [ 
                          {
                            "type": "postback",
                            "label": "もっと見る",
                            "data": yes
                          },
                          {
                            "type": "postback",
                            "label": "もう見ない",
                            "data": "no"
                          },
                          {
                            "type": "postback",
                            "label": "絞り込み解除",
                            "data": rel
                          } 
                        ] 
                      } 
                       
      }else{ 

        //通常のおかわりカラム
        var text = '表示できる安酒場があります\n◆ ' + numer + 'ページ／' + denom + 'ページ';
        var yes = 'yes' + nextPage;
        var nar = 'nar' + nextPage;
     
        var column = { 
                        "thumbnailImageUrl": thumbnailRifill, 
                        "imageBackgroundColor": "#FFFFFF", 
                        "title": 'もっと見る？', 
                        "text": text, 
                        "actions": [ 
                          {
                            "type": "postback",
                            "label": "もっと見る",
                            "data": yes
                          },
                          {
                            "type": "postback",
                            "label": "もう見ない",
                            "data": "no"
                          },
                          {
                            "type": "postback",
                            "label": "絞り込み：せんべろ",
                            "data": nar
                          } 
                        ] 
                      } 
                       
      }

    //店舗情報のカラム作成
    }else{

      //店舗ページURLの一部を取得
      var URL = TextPicker.pickUp('http://m.tabelog.com/','">'); 
      TextPicker.skipTo(URL); 

      //クーポンページ 途中のリンクなどを飛ばす
      if(URL.slice(-5)==='="red'){i--; continue;} 
      if(URL==='billing_mobile/register_mymenu?msgid=6'){i--; continue;} 

      //必要情報の取得
      var title = TextPicker.pickUp('">','</a>');                  //店舗名 
      var thumbnailHead = TextPicker.pickUp('<img src="','50x50'); //サムネイルURL前半 
      var thumbnailFoot = TextPicker.pickUp('50x50','" ');         //サムネイルURL後半 
      TextPicker.skipTo('style="color:#666666;">('); 
      var cuisine = TextPicker.pickUp('/',')</span>');             //ジャンル 
      TextPicker.skipTo(';"'); 
      var blStar = TextPicker.pickUp('>','</span>');               //★★★
      var whStar = TextPicker.pickUp(';">','</span>');             //☆☆☆ 
      var rating = TextPicker.pickUp('color:#ff0000;">','</span>');//点数 
      var price = TextPicker.pickUp('夜:','<br />');                //夜の価格帯 
      var distance = TextPicker.pickUp('現在地から','</span>');      //位置情報からの距離
      
      //トップページ 基本情報 地図ページのURLを作成 
      var shopURL = 'http://m.tabelog.com/' + URL; 
      var dataURL = shopURL + '#rstdtl_data_info'; 
      var mapURL  = shopURL + 'dtlmap/'; 
       
      //店舗名のHTML特殊文字「＆キーワード;」を修正 
      title  = title.replace(/&#39;/g, "'")
                    .replace(/&lt;/g, "<")
                    .replace(/&gt;/g, ">")
                    .replace(/&amp;/g, "&"); 
       
      //google検索URL作成 文字列の空欄を検索文字のスペースに書き換え
      var searchWord  = title.replace(/ /g, '%20')
                             .replace(/　/g, '%20');

      var gURL = 'https://www.google.co.jp/search?hl=ja&source=hp&q=' + searchWord;
      
      //サムネイルのURLを作成 ※５
      var thumbnailURL = thumbnailHead + '200x200' + thumbnailFoot;

      //評価 価格帯が無い場合
      if(blStar==='－'){blStar = '評価'; whStar = ' '; rating = '未登録';}
      if(price==='-'){price = '未登録';}
      
      //タイトルを作成 ※６
      title = title.substr(0, 33) + '(' + distance + ')';
      var price = '\n予算 ' + price; 

      //半角￥マークを全角￥マークに書き換え
      price = price.replace(/\\/g, "￥");

      //ジャンル名から（その他）を除外
      cuisine = cuisine.replace('（その他）','');

      //ジャンル 星 評価 価格帯からカラムようのテキストを作成 ※７
      var text = cuisine.substr(0, 16) + '\n' + blStar + whStar + rating + price;
      
      //カラム作成
      var column = { 
                      "thumbnailImageUrl": thumbnailURL, 
                      "imageBackgroundColor": "#FFFFFF", 
                      "title": title, 
                      "text": text, 
                      "defaultAction": { 
                        "type": "uri", 
                        "label": "View detail", 
                        "uri": shopURL 
                      }, 
                      "actions": [ 
                        { 
                          "type": "uri", 
                          "label": "基本情報を見る", 
                          "uri": dataURL 
                        }, 
                        { 
                          "type": "uri", 
                          "label": "地図を見る", 
                          "uri": mapURL 
                        }, 
                        { 
                          "type": "uri", 
                          "label": "Google検索", 
                          "uri": gURL 
                        } 
                      ] 
                    } 
                     
    } 

    //カラムを配列に格納 ※８
    columns[i] = column; 
  }

  //メッセージ作成 ※９
  var payload = JSON.stringify({ 
      "replyToken": replyToken, 
      "messages": [{ 
        "type": "template", 
        "altText": "検索結果", 
        "template": { 
          "type": "carousel", 
          "columns": columns, 
          "imageAspectRatio": "square", 
          "imageSize": "cover" 
        } 
      }] 
  }); 

  //完成したメッセージを戻す
  return payload; 
}
```

※１：ポストバックされているのは絞り込みではない次ページのURLなので、初めて絞り込む場合はURLの夜予算部分を ～1,999円となるように変えてあげる。

※２：おかわりURLの緯度経度から最初の検索URLを復元し、解除ボタンのポストバックデータに仕込む。

※３：カルーセルテンプレートのカラムは最大10個までなので、検索結果が10より多い場合は10に丸める。

※４：1ページに10件のレストランが表示されているが、カラムの上限が10個のため、10個目をおかわりカラムにすることで10件目が表示できないorz

※５：ソースのサムネイルは 50×50 と小さいので、200×200 の画像URLに変更。150×150 も存在している。

※６：タイトルの文字数制限は40文字。`(0000m)`の7文字を残して33文字に制限している。

※７：テキスト部分のジャンル名で改行されると夜予算が表示されなくなるので、ジャンル名の`（その他）`を削除＆16文字に制限している。

※８：`{カラム},{カラム},{カラム}`と言う形になるように繰り返し`columns[]`に格納しているので、そのままメッセージイベントとして使える。

※９：画像のアスペクト比はデフォルトだと`rectangle：1.51:1`なので、`square：1:1`にしている。

###位置情報を求めるメッセージ
位置情報以外が送られてきた場合の、位置情報を送ってねというメッセージを送信する部分。

```javascript
function searchButton(reply,replyToken,TOKEN) {

  var botMessage = '位置情報を送ってね。\n近くの安酒場を調べるよ^^)ゝ';
  
  var payload = JSON.stringify({
      "replyToken": replyToken,
      "messages": [{
        "type": "template",
        "altText": "位置情報ボタン",
        "template": {
          "type": "buttons",
          "text": botMessage,
          "actions": [
            {
              "type": "uri",
              "label": "位置情報を送る",
              "uri": "https://line.me/R/nv/location/"
            }
          ]
        }
      }]
  });
  UrlFetchApp.fetch(reply, {
        "headers": {
        "Content-Type": "application/json; charset=UTF-8",
        "Authorization": "Bearer " + TOKEN,
      },
      "method": "post",
      "payload": payload
  });
}
```

`"uri": "https://line.me/R/nv/location/"`のURLは「LINE URLスキーム」と言って、各種スキームのURLへリンクすることでカメラロールを開いたりテキストメッセージを送ったりできる。この場合は位置情報画面を開くURLスキーム。

Messaging APIリファレンス：[【LINEで利用できるURLスキーム】](https://developers.line.biz/ja/docs/messaging-api/using-line-url-scheme/#line%E3%81%A6%E3%82%99%E5%88%A9%E7%94%A8%E3%81%A6%E3%82%99%E3%81%8D%E3%82%8Burl%E3%82%B9%E3%82%AD%E3%83%BC%E3%83%A0)

##ポイント
LINEbotを作る時、エラーがあると返事が返ってこないだけで、どの部分でエラーが出ているのか分かりにくい・・・。そんな中、特にハマった箇所がこちら。

###スクレイピング
より多くの情報を得ようと、検索URLから店舗ごとのURLを獲得して、そこから更にスクレイピングしてみたところ、データ容量が大きくなってしまうのか検索結果数の多いエリアで動作しなくなった。

１回の表示のために、検索結果ページ＋店舗ページ（最大10件分）をスクレイピングするのは断念。

###ポストバック
JSON形式で送られてくるメッセージイベントを読み込む際、メッセージタイプやポストバックデータなど、箇条書きにしていたところbotが作動しなかった。

[【Messaging APIリファレンス】](https://developers.line.biz/ja/reference/messaging-api/#webhook-event-objects)を参照するとWebhookイベントオブジェクトの構造が、普通のメッセージとポストバックで微妙に違うため、同じようにJSONを読もうとすると止まってしまう。

```javascript
//この書き方だとエラー
var json = JSON.parse(event.postData.contents);
var replyToken  = json.events[0].replyToken;
var eventsType  = json.events[0].type;
var pbData      = json.events[0].postback.data; //ポストバックでなければ存在しない
var messageType = json.events[0].message.type;  //ポストバックの場合は存在しない
```

ポストバックの時とそうでない時で、読み込む内容を条件分けする必要がある・・・分かってしまえば当たり前なんだけど、ここが一番ハマったorz

###アクションオブジェクト
カラムのアクションオブジェクトは最大で３個まで設定可能。カルーセルテンプレートで複数のカラムを作成するとき、それぞれのアクションオブジェクトの数が違うと動作しない。

当初、店舗情報のカラムのアクションオブジェクトは「基本情報」と「地図」だけだったけど、おかわりカラムのアクションオブジェクト数に合わせて「Google検索」を追加した。

###カラムの最大文字数
カラムのタイトル部分やテキスト部分には、それぞれ最大文字数が設定されている。それを超えた場合、超えた文字が削られるのではなく単純に動作しなくなる。

やたらと文字数の多い店舗があると、普段は問題なく使えているのにその店舗がある周辺でのみbotが動作しないという不思議現象が起きてしまう。

###表示される文字の形式
店舗名に「'」や「&」などが含まれている場合、LINEbotで返ってくる文字列が「&xxx;」のようなHTML特殊文字になってしまう。また、食べログ上の半角￥マークは「\」なので、そのまま使うと価格帯の表記が「\1,000～\1,999」となってしまう。

そのままでも動作するけど、気持ち悪いのでそれぞれ置き換えている。

###おしまい