GAS + LINE Messaging API + 駅データjp で待ち合わせにピッタリの駅を探すBotを作ってみた

## LINE Botの概要
地方から都心に出てきた人、次のようなことで困ることありませんか？

 - 友人と待ち合わせする時に、お互い乗換なしで行ける駅がすぐにわからない
 - 引っ越しをする時に、夫婦ともに乗換なしで通える駅がすぐにわからない

僕はもうすぐ東京に出てきて3年ですが、いまだに東京の電車がよくわかりません。というわけで、2つの駅に対して、乗換なしで行ける駅を教えてくれるBotを作ってみました。

### 中間駅くん
![Screenshot_20191221-233943.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/147086/a2e21306-5e98-49f0-70aa-5841b3c978e2.png)
#### 友達登録はこちら
![793xrjku.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/147086/3b5af5de-6a11-44a1-a738-7a54783bd67c.png)
<a href="https://lin.ee/j4cK3iz"><img src="https://scdn.line-apps.com/n/line_add_friends/btn/ja.png" alt="友だち追加" height="36" border="0"></a>
以下、設計や実装のときの思考を書いていきます。

## Botの設計
![LINE_路線中間_設計.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/147086/fddb28b8-93e5-01ea-e1be-d6c255c2271c.png)

### User Interface
LINEを採用しました。「○○駅、☓☓駅」とメッセージを送れば、その2つの駅に乗換なしで行ける駅をリストアップして返すシンプルなUIにしています。ユーザーの入力値をLINEから取得するのは、[Messaging API](https://developers.line.biz/ja/services/messaging-api/)を使用します。

### データベース
駅や路線の情報は[駅データjp](http://www.ekidata.jp/)から取得しています。APIもあるのですが、検索に使用するstation_cdは、リクエストを投げる時点でわかっておく必要があるらしく、事前にダウンロードして保持しています。

### Google Apps Script
お手軽にGASでサーバーレスのWebアプリーケーションを立ち上げて、ここをエンドポイントに駅の探索ロジックを作りました。バックエンドの知識がなくても、そして費用をかけなくても、運用ができるの本当ありがたいです。


## 実装のポイント

### 駅データjpのAPI、癖がすごい
`http://www.ekidata.jp/api/l/(路線コード).json`に対してリクエストを送って取得できるのが以下のデータです（※ 一部省略しています）。`.json`とはいったい...。

```
if(typeof(xml)=='undefined') xml = {};
xml.data = {"line_cd":11302,"line_name":"JR山手線","line_lon":139.73522275686264,"line_lat":35.69302730762992,"line_zoom":12,"station_l":[{"station_cd":1130201,"station_g_cd":1130201,"station_name":"大崎","lon":139.728439,"lat":35.619772},
{"station_cd":1130202,"station_g_cd":1130202,"station_name":"五反田","lon":139.723822,"lat":35.625974},
{"station_cd":1130203,"station_g_cd":1130203,"station_name":"目黒","lon":139.715775,"lat":35.633923},
{"station_cd":1130204,"station_g_cd":1130204,"station_name":"恵比寿","lon":139.71007,"lat":35.646685},
if(typeof(xml.onload)=='function') xml.onload(xml.data);
```

やむを得ず、正規表現でデータを抽出することで対応しました。

```staitonFinder.js
function getTrainLine(line_cd) {
  var URL = 'http://www.ekidata.jp/api/l/'+line_cd+'.json'
  var response = UrlFetchApp.fetch(URL).getContentText();
  var data = response.match(/(data\s=\s)(.*?\n.?)(if)/);
  if (data === null) {
    // 'APIのレスポンス値がないか、正規表現での抽出に失敗しています。';
    return {
      station_l: []
    };
  }
  return (JSON.parse(data[2]));
}
```

### 乗換なし駅の探索ロジック
駅には次の特徴があります。

- 一つの駅に複数の路線が存在する （例: 五反田駅 ... 山手線、浅草線）
- 駅は別だが近接していて乗り換えできる （例: 馬喰横山駅と東日本橋駅）

なので、処理としては以下のようにしています。駅の情報はデータベースとして事前に保持しておき、路線の駅を取得するのに駅データjpのAPIを利用しています。

- 対象の2つの駅に対して、グループ駅（=他路線や近接駅）の情報を取得
- その情報の中で、重複するグループ駅があれば乗り換えなし駅と見なす


```staitonFinder.js

// 駅名から探す場合は ("錦糸町", index_station_name)
function getStationInfo(searchValue, searchIndex){
  var result = {};
  // 駅データjsから抽出した駅データから探索
  data_stations.forEach(function(stationInfo){
    if(stationInfo[searchIndex] === searchValue) {
      lineInfo = getTrainLine(stationInfo[DSindex_line_cd]); //路線情報の追加取得
      result[stationInfo[DSindex_station_cd]] = {
        station_cd: stationInfo[DSindex_station_cd],
        station_g_cd: stationInfo[DSindex_station_g_cd],
        station_name: stationInfo[DSindex_station_name],
        line_cd: stationInfo[DSindex_line_cd],
        line_name: lineInfo.line_name,
        lineStations: lineInfo.station_l,
      };
    }  
  })
  if(Object.keys(result).length !== 0) {
    return result;
  } else {
    // searchValueと一致なし
    return false;
  }
}

// stationsInfo = object
function addGroupStationInfo(stationsInfo){
  result = stationsInfo;
  // objectにある駅の情報一つ一つにグループの駅の情報を取得し、重複チェックをしながら結果に反映する
  Object.keys(stationsInfo).forEach(function(s_key){
    var groupStaionsInfo = getStationInfo(stationsInfo[s_key].station_g_cd, DSindex_station_g_cd);
    if(groupStaionsInfo) {
      Object.keys(groupStaionsInfo).forEach(function(g_key){
        if(result[groupStaionsInfo[g_key].station_cd] === undefined) {
          result[groupStaionsInfo[g_key].station_cd] = groupStaionsInfo[g_key]
        }
      })
    }
  })
  return result
}


function findTheStationsInBetween(stationsInfoWithGroupList){
  var result = [];
  var firstStation = 0;
  var secondStation = 1;
  
  // 1つ目の駅の路線を探索
  Object.keys(stationsInfoWithGroupList[firstStation]).forEach(function(firstLine_key){
    stationsInfoWithGroupList[firstStation][firstLine_key]
      .lineStations.forEach(function(firstLine_station){
        
        // ２つ目の駅の路線を探索して一致するところを探す
         Object.keys(stationsInfoWithGroupList[secondStation]).forEach(function(secondLine_key){
           stationsInfoWithGroupList[secondStation][secondLine_key]
             .lineStations.forEach(function(secondLine_station){
               // グループ駅コードで照合して一致する場合は駅名で返す
               if(firstLine_station.station_g_cd === secondLine_station.station_g_cd){
                 result.push(firstLine_station.station_name)
               }
             })
        })
      })
  })
  // 重複を配列から削除
  return result.filter(function (value, index, self) {
            return self.indexOf(value) === index;
        });
}
```

### User Interfaceとエラーハンドリング
LINEのメッセージ上で「○○駅、☓☓駅」と書いてもらうシンプルなUIなのですが、様々なケースを考慮してユーザーに処理の結果を伝えてあげないといけません。次のケースに対処しました。

- ユーザーの入力値のフォーマットが間違っている
- ユーザーの入力値に誤りがある、もしくはデータベースに一致する駅がない
- 同じ路線上の駅を入力している
- 乗換なしで行ける駅が存在しない
- 乗換なしで行ける駅が存在する

それぞれをチェックしていき、状況に応じてメッセージを返すロジックを入れています。

```front.js

function doPost(e) {
  
  // LINEとの連携を想定したコード
  // WebHookで受信した情報の取得
  var replyToken = JSON.parse(e.postData.contents).events[0].replyToken;
  var userMessage = JSON.parse(e.postData.contents).events[0].message.text;
  
  // 受け取った投稿を記録。デバッグ用
  var memo = PropertiesService.getScriptProperties();
  memo.setProperty('messageEvent', JSON.stringify(e));
  
  
  // 受け取ったメッセージに対して処理を実行
  var stationNames = findStationNamesByRepExp(userMessage);
  
  // ユーザーがフォーマットを間違って入力した場合のエラー
  if(!stationNames){
    var message = '入力内容が正しくないよ。「○○、☓☓」のように駅名を「、」で区切ってね。'
    replyToLINE(message, replyToken);
    return false;
  }
  
  var stationsInfoList = stationNames.map(function(stationName){
    return getStationInfo(stationName, DSindex_station_name);
  })
  
  // ユーザーが駅名を正しく入力していない、もしくはデータベースに該当がない場合のエラー
  var isMatch = stationsInfoList.indexOf(false); // falseに該当しなければ -1が返る。
  if(isMatch >= 0) {
    var message = stationNames[isMatch] + '駅が見つからないよ。漢字や平仮名の揺れに気をつけて再入力してくれる？ もしかしたら僕の知らない駅なのかも。'
    replyToLINE(message, replyToken);
    return false;
  }
  
  var stationsInfoWithGroupList = stationsInfoList.map(function(stationInfo){
    return addGroupStationInfo(stationInfo)
  })
  
  // 2つの駅が同じ路線上の場合はその路線名を返す
  var isSameLine = checkIsSameLine(stationsInfoWithGroupList);
  if(isSameLine) {
    var message = writeMsgBasedOnAry('同じ路線の駅だったよ。','', isSameLine);
    replyToLINE(message, replyToken)
    return false;
  }
  
  var theStations = findTheStationsInBetween(stationsInfoWithGroupList);
  if(theStations.length === 0) {
    var meesage = '残念。乗換なしで行ける駅はなかったよ。';
    replyToLINE(meesage, replyToken);
    return false;
  } else {
    var message = writeMsgBasedOnAry('次の駅から乗換なしで行けるよ。', '駅', theStations);
    replyToLINE(message, replyToken);
    return true;
  }
}


// フロントから受けとる「〇〇駅、○○駅」の○○の部分を抽出して配列にする
function findStationNamesByRepExp(text){
  try {
    var stationNames = text.match(/(.*)[、](.*)/)
  　　.map(function(stationName){
    　　return stationName.replace(/駅$/g, '')
    });
    stationNames.shift();
    return stationNames;
  } catch (e) {
    // 正規表現で抽出できなければ直後のmapでエラーになる
    return false;
  }
}


function replyToLINE(message, replyToken) {
  var url = 'https://api.line.me/v2/bot/message/reply';
  var LINE_BOT_TOKEN = PropertiesService.getScriptProperties().getProperty('LINE_BOT_TOKEN');
  UrlFetchApp.fetch(url, {
    'headers': {
      'Content-Type': 'application/json; charset=UTF-8',
      'Authorization': 'Bearer ' + LINE_BOT_TOKEN,
    },
    'method': 'post',
    'payload': JSON.stringify({
      'replyToken': replyToken,
      'messages': [{
        'type': 'text',
        'text': message,
      }],
    }),
  });
  return ContentService.createTextOutput(JSON.stringify({'content': 'post ok'})).setMimeType(ContentService.MimeType.JSON);
}

```

## 終わり
というわけで、LINE Bot「中間駅くん」でした。LINE Developerのフリープランのため、月の累計で1000メッセージに達すれば止まってしまいますが、よろしければ以下のQRから友達登録してください（よく考えたら乗換なしの駅を探すだけで別に中間の駅というわけじゃないか ^^;）。
しかし、自分で設計して仕様を書いて実装するのはやっぱり楽しい。普段は請負のPM業をしているので、自分で作るものを決めることに自由の風を感じます。良い週末のリフレッシュになりました。また、気が向いたらなにか作って投稿します。

![793xrjku.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/147086/3b5af5de-6a11-44a1-a738-7a54783bd67c.png)
<a href="https://lin.ee/j4cK3iz"><img src="https://scdn.line-apps.com/n/line_add_friends/btn/ja.png" alt="友だち追加" height="36" border="0"></a>