🙄

すたみな太郎に行きたくなった時のために

2022/12/07に公開

概要

みなさんはすたみな太郎に行ったことはありますか?
僕はないのですが、今後すたみな太郎に行きたくなった時のために便利なツールを作りました。
https://t-stamina.jp/

店舗情報

とりあえず基本的に僕は東京付近に生息しているので、関東地方のすたみな太郎情報を集めます。

https://t-stamina.jp/shop_search/kanto/

店名と住所は小手先のスクレイピングで一瞬で取得できました。

console
const names = document.getElementsByClassName('shopName')
const addresses = document.getElementsByClassName('shopAddress')
let data = ""
for (let i = 0; i < names.length; i++){
    data += `${names[i].childNodes[0].innerHTML}, ${addresses[i].innerHTML}\n`
}
console.log(data)

が、開店時間は個別ページにありパターンも複雑だったので手動で集めました。
基本的には「平日ランチ/平日ディナー/土日祝」の3パターンなのですが、「金曜だけディナーの終了が遅い」「土曜だけ終了が遅い」というパターンが数店舗あり、目ン玉が千切れそうになりながらまとめました。

Google Apps Script

ここからはGASで処理を書いていきます。
拡張機能 > Apps Script からspreadsheetに紐づくGASプロジェクトを作成できます。

shops.gs
/**
 * 店舗データ取得
 * {
 *  name: 店舗名
 *  address: 住所string
 *  opentime: {
 *    mon: [ランチ開店, ランチ閉店, ディナー開店, ディナー閉店], // 平日はランチとディナーに分かれている
 *    ...
 *    sat: [休日開店, 休日閉店] // 土日祝は一つ
 *    ...
 *  }
 * }
 */
function shopdata() {
  const ss = SpreadsheetApp.openById("YOUR_SPREADSHEET_ID");
  const sheet = ss.getSheetByName("shops")
  const values = sheet.getDataRange().getValues(); // 全データ取得
  values.shift() // headerを捨てる

  return values.map(e => {return {
    name: e[0],
    address: e[1],
    openTime: {
      mon: [e[2], e[3], e[4], e[5]],
      tue: [e[2], e[3], e[4], e[5]],
      wed: [e[2], e[3], e[4], e[5]],
      thu: [e[2], e[3], e[4], e[5]],
      fri: [e[2], e[3], e[8] != '' ? e[8] : e[4], e[9] != '' ? e[9] : e[5]], // 金曜だけ遅い店舗がある
      sat: [e[6], e[7], e[10] != '' ? e[10] : e[6], e[11] != '' ? e[11] : e[7]], // 土曜だけ遅い店舗もある
      sun: [e[6], e[7]], // 祝日は日曜と同じ
    }
  }})
}

Google Distance Matrix API

https://developers.google.com/maps/documentation/distance-matrix/start

APIを有効にしてAPI keyを取っておいてください。

origin/destination共に複数入力する場合は | をデリミタにするっぽい。
https://stackoverflow.com/questions/40650227/google-map-distance-matrix-api-serverside-multiple-address-by-latlong

試しに守谷店から青梅インター店と宇都宮東店まで行ってみると、こんな感じで返ってきます。

{
   "destination_addresses" : [
      "日本、〒321-0901 栃木県宇都宮市平出町557−2 江戸一すたみな太郎宇都宮東",
      "日本、〒198-0024 東京都青梅市新町6丁目15−6 ジョイフル青梅店"
   ],
   "origin_addresses" : [
      "日本、〒302-0110 茨城県守谷市百合ケ丘2丁目2727"
   ],
   "rows" : [
      {
         "elements" : [
            {
               "distance" : {
                  "text" : "73.5 マイル",
                  "value" : 118341
               },
               "duration" : {
                  "text" : "1時間29分",
                  "value" : 5327
               },
               "status" : "OK"
            },
            {
               "distance" : {
                  "text" : "62.7 マイル",
                  "value" : 100967
               },
               "duration" : {
                  "text" : "1時間26分",
                  "value" : 5138
               },
               "status" : "OK"
            }
         ]
      }
   ],
   "status" : "OK"
}

なので、こんな感じにすれば距離と所要時間が一発で取れます。

distance.gs
const API_KEY = "YOUR_API_KEY"

/**
 * (現在地と、目的地の配列) => (距離と所要時間)の配列
 * here: string
 * theres: string[]
 * return: [距離(m), 所要時間(s)][]
 */
function distance(here, theres) {
  const url = `https://maps.googleapis.com/maps/api/distancematrix/json?destinations=${theres.join("|")}&origins=${here}&units=imperial&key=${API_KEY}`
  const json = UrlFetchApp.fetch(encodeURI(url)).getContentText()
  const res = jsonData = JSON.parse(json);
  return res.rows[0].elements.map(e => [e.distance.value, e.duration.value])
}

今から行って開いている店舗を取得

さて、いよいよ今から行ける店舗を取得します。
UrlFetchApp.fetch に渡すURLの長さに上限があるようで、5件ずつバッチ処理して取得しています。
その日の営業時間を出す際に祝日かを取得しなければならないので、Google Calendar APIを使用しています。深夜に出発した場合は日付を跨ぐので要注意です。
Spreadsheetに時間だけ書いた場合、JSのDateとしてparseすると1899年12月30日になるので、Time型の比較の時は1899年12月30日の中で勝負します。

getAvailableShopData.gs
const BATCH_SIZE = 5

/**
 * 住所から今出発したとして、着いた瞬間に入店できる店を返す
 * {
 *  name: 店名
 *  duration: 所要時間(分)
 *  distance: 距離(km)
 * }
 */
function getAvailableShopData(here) {
  const shops = getShops()

  // 距離データ取得(全部一気に取得するとURLが長すぎるのでバッチ処理)
  const addresses = shops.map(e => e.address)
  const split = (array, n) => array.reduce((a, c, i) => i % n == 0 ? [...a, [c]] : [...a.slice(0, -1), [...a[a.length - 1], c]], [])
  const distances = split(addresses, BATCH_SIZE).map(theres => distance(here, theres)).flat()

  const data = []
  for (let i = 0; i < shops.length; i++) {
    const shop = shops[i]

    // 今から出発したら何時に着くか
    const arriveDate = new Date()
    arriveDate.setSeconds(arriveDate.getSeconds() + distances[i][1])

    // 着いた日の開店時間
    const day = isShukujitsu(arriveDate) ? "sun" : ["sun", "mon", "tue", "wed", "thu", "fri", "sat" ][arriveDate.getDay()] // すたみな太郎的には何曜日?(祝日を日曜扱い)
    const openTime = shop.openTime[day]
    arriveDate.setFullYear(1899, 11, 30)// timeだけの場合、Date型としては1899年12月30日の時刻になるので変換
    if (isOpen(arriveDate, openTime)) data.push({
      name: shop.name,
      duration: Math.round(distances[i][1]/60),
      distance: distances[i][0]/1000,
    })
  }
  return data
}
/** 祝日か判定 */
function isShukujitsu(date){
  var calendarId = "ja.japanese#holiday@group.v.calendar.google.com"
  var calendar = CalendarApp.getCalendarById(calendarId)
  var events = calendar.getEventsForDay(date)
  return events.length > 0
}

/** この時間に行って空いているか? */
function isOpen(date, openTime) {
if (openTime.length == 4) { // 平日
      return (openTime[0] <= date && date < openTime[1]) || (openTime[2] <= date && date < openTime[3])
    } else { // 休日
      return openTime[0] <= date && date < openTime[1]
    }
}

おっと、ラストオーダーの存在を忘れていました!すたみな太郎は閉店60分前がラストオーダーなので、そこまでに行かなければなりません。

getAvailableShopData.gs
/** この時間に行って空いているか? */
function isOpen(date, openTime) {
if (openTime.length == 4) { // 平日
      return (openTime[0] <= date && openTime[1] < date) || (openTime[2] <= date && openTime[3])
    } else { // 休日
      return openTime[0] <= date && openTime[1] < date
    }
}

/** すたみな太郎のラストオーダーは1時間前 */
function lastOrder(date) {
  date.setHours(date.getHours()-1)
  return date
}

LINE Messaging API

今回はUIとしてLINEの公式アカウントを使います。
https://developers.line.biz/en/

LINE Messaging APIで適当にアカウントを作ってください。

アカウントのアクセストークンを取得し、控えておきます。

以下のテンプレはLNE BOTを作るときに毎回使えるので便利です。
今回は位置情報を取得しますが、普通はテキストをparseして何かしたりすることが多いです。

位置情報は↑ここから入力できるアレです。

main.gs
const ACCESS_TOKEN = "YOUR_MESSAGING_API_ACCESS_TOKEN";
const URL = 'https://api.line.me/v2/bot/message/reply'; // 応答メッセージ用のAPI URL

function doPost(e) {
    const json = JSON.parse(e.postData.contents);

    const reply_token = json.events[0].replyToken;
    if (typeof reply_token === 'undefined') {
        return;
    }
    // ユーザーが送信した「位置情報」の住所string
    const address = json.events[0].message.address

    // ユーザーの入力テキストとりたければこう
    // const user_message = json.events[0].message.text 

    const post_message = address


    UrlFetchApp.fetch(URL, {
      'headers': {
          'Content-Type': 'application/json; charset=UTF-8',
          'Authorization': 'Bearer ' + ACCESS_TOKEN,
      },
      'method': 'post',
      'payload': JSON.stringify({
          'replyToken': reply_token,
          'messages': [{
              'type': 'text',
              'text': post_message,
          }],
      }),
    });
    return ContentService.createTextOutput(JSON.stringify({ 'content': 'post ok' })).setMimeType(ContentService.MimeType.JSON);
}

post_messageが返信するテキストです。上記コードだと、位置情報の入力に対してその住所をテキストで返します。

繋ぎこみ

最後に、ここまでのものを繋ぎこみます。
得られたデータを所要時間順にソートし、テキストにします。
今から行ける店がなかった場合のハンドリングもして、完成です。

main.gs
function doPost(e) {
    const json = JSON.parse(e.postData.contents);

    const reply_token = json.events[0].replyToken;
    if (typeof reply_token === 'undefined') {
        return;
    }
    // ユーザーが送信した「位置情報」の住所string
    const address = json.events[0].message.address
    const res = getAvailableShopData(address)
      .sort((a, b) => a.duration - b.duration)
      .map(e => `${e.name} ${e.duration}${e.distance}km`)

    const post_message = res.length ? res.join("\n") : "今から行けるすたみな太郎はありません😭"

    UrlFetchApp.fetch(URL, {
      'headers': {
          'Content-Type': 'application/json; charset=UTF-8',
          'Authorization': 'Bearer ' + ACCESS_TOKEN,
      },
      'method': 'post',
      'payload': JSON.stringify({
          'replyToken': reply_token,
          'messages': [{
              'type': 'text',
              'text': post_message,
          }],
      }),
    });
    return ContentService.createTextOutput(JSON.stringify({ 'content': 'post ok' })).setMimeType(ContentService.MimeType.JSON);
}

これで今すぐいけるすたみな太郎がわかるようになりました!

(本当はこのLINE公式アカウントを公開したかったのですが、Google Maps APIに課金上限があるので公開できなくてすみません)

今後の展望

  • 関東以外の地域に対応する。
  • Googleカレンダーと連携して空き時間に自動ですたみな太郎に行く予定をセットする。
  • すたみな太郎NEXTにも対応する。
  • すたみな太郎に行く。

Discussion