いなにわうどん

うどんの話に見せかけて技術的な話をしたい(できない)

GitHub Actions を回してピザを頼みたい

年の瀬ですね。クリスマスの足音も近く、ピザなんかを頼んだら景気が良いかなと思ったので、GitHub 上で Issues を生やすとピザが頼める仕組み(workflows)を構築してみました。

本記事は mast Advent Calendar 2023 の 7 日目の記事です。6 日目は Hitoko T. 先生の記事「我が家に猫3匹がやって来た話|Hiroko T.」でした。猫、癒やしですよね

折角のアドカレの機会ですから、GitHub 上でピザを頼むまでの過程を、GitHub や Web 技術、ピザ等に明るい方にも、そうでない方にもお楽しみいただけるように説明*1*2を進めていきます*3。少し長くなりますが、どうぞお付き合いください。

ピザ

突然ですが、みなさまはピザと呼ばれる食べ物をご存知でしょうか? 初めてピザをご覧になられた方に向けて説明しておくと、小麦粉等を練って構成した生地を円形に伸ばし、トマトソースやチーズ、様々な具材を載せてかまどで焼いた、イタリア発祥の料理を指します。

mast 鬼怒川旅行で食べた謎ピザ

我が国におけるピザの提供手法としては宅配ピザが大きな役割を占めています。さて、代表的な宅配ピザチェーンであるドミノ・ピザ、ピザーラ、ピザハットは、Web サイト*4の技術スタック*5を基に大きく区分することができます。

代表的な宅配ピザサービスの比較
ドミノ・ピザ ピザーラ ピザハット
アーキテクチャ SPA SPA じゃない(MPA) SPA
フレームワーク React ASP.NET Vue.js

ここでキーワードとなるのが SPA(Single Page Application)です。SPA は、MPA*6と称される伝統的な Web サイトとは異なり、表示部分(ビュー)であるフロントエンドと、データ管理やロジックを処理するバックエンドを分離する傾向にあります。従来の MPA をプログラマブルに操作するにはスクレイピング等の操作が要求されますが、SPA ではある意味 API が丸裸の状態になっているため*7、操作しやすいといえば操作しやすいわけです*8。

MAP/SPA の解説については、以下のページに掲載されている図が参考になります。

scrapbox.io

今回はドミノ・ピザを対象として、GitHub Actions を経由した注文を試みます。海外のドミノピザでは、既に有志による API ラッパが開発される*9など、技術との親和性が高いことでお馴染みです。

ドミノ・ピザを支える技術

ドミノ・ピザの注文サイトを開いて Google Chrome のデベロッパツールを観察すると、何やら https://olo-graph-at.dominos.jp/graphql の URL に対して頻繁にリクエストを送信していることが解ります。

謎のエンドポイント /graphql

ここには GraphQL と呼称される API 設計手法が用いられており、単一のエンドポイント*10に対してクエリ言語*11を用いて問い合わせを行うことで情報を取得しています。試しに、curl コマンドを用いて、上記のエンドポイントに対して次のクエリ(問い合わせ)を投げてみます。

curl 'https://olo-graph-at.dominos.jp/graphql' \
  -H 'content-type: application/json' \
  -H 'dpe-application: MobileWeb' \
  -H 'dpe-country: JP' \
  -H 'dpe-language: ja' \
  -H 'user-agent: Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/119.0.0.0 Safari/537.36' \
  --data-raw $'{"operationName":"offersQuery","variables":{"storeNo":87634,"serviceMethod":"Delivery","tradingTime":"2023-11-25T12:00:00+09:00"},"query":"query offersQuery($storeNo: Int\u0021, $tradingTime: String, $serviceMethod: ServiceMethodEnum\u0021, $orderId: String, $deliveryAddress: DeliveryAddressInput, $layouts: [Layouts]) { offers( storeNo: $storeNo tradingTime: $tradingTime serviceMethod: $serviceMethod orderId: $orderId deliveryAddress: $deliveryAddress layouts: $layouts) { offerId name items {id name price} media {name description}}}"}'

問い合わせの結果として、下記のレスポンスが得られました。query offersQuery はどうやらクーポン情報を取得するクエリのようです。ピザ 30 % オフとかある。

{
  "data": {
    "offers": [
      {
        "offerId": "99968",
        "name": "1ハッピーSサイズピザ990円~(2ハッピー+200円 3ハッピー+300円 4ハッピー+400円・Mサイズ+300円 Lサイズ+600円)",
        "items": [
          {
            "id": "5fe3905f-885d-4228-9fb9-843ef9e735fb",
            "name": "1ハッピーSサイズピザ990円~(2ハッピー+200円 3ハッピー+300円 4ハッピー+400円・Mサイズ+300円 Lサイズ+600円)",
            "price": null
          }
        ],
        "media": {
          "name": "1ハッピーSサイズピザ990円~(2ハッピー+200円 3ハッピー+300円 4ハッピー+400円・Mサイズ+300円 Lサイズ+600円)",
          "description": "1ハッピーSサイズピザ990円~(2ハッピー+200円 3ハッピー+300円 4ハッピー+400円・Mサイズ+300円 Lサイズ+600円)"
        }
      },
      ...,
      {
        "offerId": "14638",
        "name": "Upsell Panel #4-1 Flex Web Voucher for Delivery",
        "items": [
          {
            "id": "27476",
            "name": "ピザ1枚+サイド2品",
            "price": "¥2099~"
          },
          {
            "id": "27477",
            "name": "ピザ2枚+サイド2品",
            "price": "¥3599~"
          },
          {
            "id": "27478",
            "name": "ピザ3枚+サイド3品",
            "price": "¥4699~"
          }
        ],
        "media": {
          "name": "おトクなおすすめセット",
          "description": ""
        }
      }
    ]
  }
}

調査の結果、ドミノ・ピザの SPA は注文までの全ての通信*12に GraphQL を採用していました。この挙動を読み解くことができれば、公式に提供されるインタフェースを介さずともピザが注文できそうです。

案外複雑なピザドメイン知識

宅配ピザの注文においては、まず注文する商品を選択する工程が発生するため、手始めに提供されるメニュー情報を取得する必要があります*13。調べたところ、メニュー情報は query MenuQuery を用いて取得されていました。このクエリの実行結果は、大まかに以下のフィールド*14から構成されます。

menuTransitional
- pages: カテゴリ(ピザ、マイドミノ、サイドメニュー等)
  - sections: 商品の種類
    - items: 商品
      - sizes: サイズ(サイズ概念が存在しない場合は 1 つのみ)
        - swaps: ユーザが選択可能な候補
          - base: 生地
          - sauce: ソース
          - toppings: トッピング
          - options: オプション

最終的な注文時に要求される情報は、注文する商品の item, size, swaps を指定するコード(ID)です。我々はピザにしか関心がないので、pages に関しては、pages.code が Menu.Pizza, Menu.MyBox のいずれかである要素をフィルタリングすれば良さそうです。このうち、今回は処理の簡略化のためにマイドミノ*15(ピザ S サイズ + オプション 2 点のセット)に焦点を絞ります*16。

これらの問い合わせ + 取得したデータの整形を、TypeScript(実行環境は Node.js)を用いてコーディングしていきます。GraphQL クライアントには graphql-request を、API のモック*17には MSW(Mock Service Worker)を使用します。

GitHub

突然ですが、みなさまは GitHub*18 と呼ばれる Web サービスをご存知でしょうか? GitHub はソフトウェア開発プラットフォームにおけるデファクトスタンダードの存在で、git*19 を用いたソースコード管理、コメントやレビュー、静的サイトホスティング等の多彩な機能が提供されています。

今回は GitHub 上にドミノ・ピザ専用のリポジトリとして inaniwaudon/pizza*20 を作成し、リポジトリ内に Issue*21 を立てることで用いてピザを注文します。具体的には以下に示すフローに基づいて、bot との対話を繰り返しながら注文情報を確定させます。

対話的なピザ注文フロー

商品の選択には、チェックボックスを表示してタスクを管理するタスクリスト機能を転用します。GFM(GitHub GitHub Flavored)上でチェックボックスは - [ ] と表記され、ユーザがチェックを付けるとこれが - [x] へと変化します。

GitHub Actions

GitHub 上で提供される強力な機能の一つに、GitHub Actions があります。これは CI/CD*22の実行基盤として存在しており、ワークフローと称される処理を定義することで、様々な条件を契機に処理を自動実行することが可能となります。要は GitHub が提供する計算資源を用いて *23ガンガン自動化していこうぜ!!みたいな機能です。しかも、private repository では無料枠で 2,000 分/月まで、public repository であれば無制限に利用可能という太っ腹*24っぷり。今回はこの機能を用いて、上記フローのうち bot 部分を実装します。

デバッグ用に大量の Issues が並んでおり、CI のバグフィックスは困難である様子が伺える
ワークフローの定義

ワークフローは YAML*25 という言語を用いて、jobs – steps の階層構造を持って記述されます。例として、ある Issue が open された場合にピザのメニューを取得して、その結果を当該 Issue にコメントするワークフローを以下に示します。

name: issue-opened

on:
  issues:
    types: [opened]
jobs:
  ci:
    runs-on: ubuntu-latest
    permissions:
      issues: write
      contents: read
    steps:
      - name: Checkout
        uses: actions/checkout@v4
      - name: Setup node.js
        uses: actions/setup-node@v4
        with:
          node-version: 18.17.1
      - name: Use cache
        uses: actions/cache@v3
        id: node_cache
        with:
          path: "**/node_modules"
          key: ${{ runner.os }}-node_modules-${{ hashFiles('**/yarn.lock') }}
      - name: Install dependencies
        if: ${{ steps.node_cache.outputs.cache-hit != 'true' }}
        run: yarn install --frozen-lockfile
      - name: Get welcome message
        run: |
          {
            echo 'welcome<<EOF'
            npx ts-node ./src/index.ts welcome
            echo EOF
          } >> $GITHUB_ENV
      - name: Get menu
        run: |
          {
            echo 'menu<<EOF'
            npx ts-node ./src/index.ts menu
            echo EOF
          } >> $GITHUB_ENV
      - name: Add comment
        run: |
          gh issue comment "$NUMBER" --repo "$REPO" --body "$welcome"
          gh issue comment "$NUMBER" --repo "$REPO" --body "$menu"
        env:
          GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
          NUMBER: ${{ github.event.issue.number }}
          REPO: ${{ github.repository }}

ワークフロー中では、src/index.ts を呼び出し、実行結果を gh コマンドを用いてコメントとして投稿しています。環境変数はステップを跨いで共有することができないため、ts-node の実行結果は GITHUB_ENV に格納します*26。

実際に Issue が立てられ、ワークフローが実行されると以下のコメントが投稿されます。

特定のコメントにのみ反応して処理を実行することも可能です。ワークフローに次の記述を追加すると、「次へ」の文字が含まれるコメントに対してのみ処理を遂行するようになります。

- run: |
    if [[ "${{ github.event.comment.body }}" != *"次へ"* ]]; then
      exit 1
    fi

選択状態を取得する

ワークフローは独立して実行されるため、各環境に跨ってコンテキストを共有する仕組みはありません。従って、今回はコメント上にダンプ/パースを適宜繰り返して、ユーザが入力した情報や以前処理した内容を次のワークフローへと引き継ぐことにしました。Issue の取得には、GitHub が公式に提供するライブラリである Octokit を Node.js 上で実行します。

取得したコメントの本文中からチェックボックスが選択されている行を取得するには、正規表現を用いて - \[x\] (.+?) を抽出します。下記のソースコードに示す関数では、選択中の商品コードの取得が実現されています。この辺りはバグを生みやすいので入念にテストコード*27を書く必要がありそうです。

export const parseItemSelectionCodes = (comment: string): SelectionCode[] => {
  const lines = comment.split("\n");

  const searchItem = (i: number) => {
    const sizeResult = /- \[x\] ((.+?): .+)?(¥\d+)/.exec(lines[i]);
    if (!sizeResult) return;

    for (let j = i - 1; j >= 0; j--) {
      const itemResult = /#### (\d+): .+/.exec(lines[j]);
      if (itemResult) {
        return {
          item: itemResult[1],
          size: sizeResult[1] ? sizeResult[2] : null,
        };
      }
    }
  };
  return lines.flatMap((_, i) => searchItem(i) ?? []);
};

マイドミノを注文する場合はオプションを 2 つ選ぶ必要があるため、まず商品の選択画面を表示し、続いてオプションの選択を促すチェックボックスを提示します。チェックを付けて再度「次へ」を入力すると、配達先住所や連絡先についても入力を求めた後、同様にパースして取得します。また、決済関連は現金支払いに固定します。

注文を確定させる

最後に、選択した商品や配達先住所を送信して注文を確定する必要があるのですが、これが思わぬ難局でした。注文確定周りに関して、API の挙動を注視することで得られた推察を、ひとまず箇条書きで列挙します*28。

  • カート内容は、注文確定時までローカルに保存されている。
    • minify されたコードをよくよく観察すると、localStorage 内の persist:dominosApplication にカート内容や ID、セッション等の情報が保持されている。
    • 中身はバイナリ。状態管理に Redux が採用されており、State の維持や圧縮を目的として redux-persist-transform-compress が用いられている。lz-string.decompressFromUTF16(str) でデコード可能。
    • ページリロード時に、カート内容を復元すべく LocalStorage の内容を参照する。サーバ側からカート内容を取得するような通信は、この段階では特に見受けられない。
  • mutation validateBusket を実行して、カート内容を検証(バリデーション)する。与えた値が正常な場合は、サーバ側で計算された注文総額等が取得される。
    • variables の id, advanceOrderId として UUID (v4) を送信する。適当に生成した UUID を与えても怒られない。
    • productCode 等に商品として存在しないコードを与えると、商品が販売休止中である旨が validationErrors に返される。
    • validateBusket は度々呼ばれている(商品のカート追加時、住所入力時等)。住所等の入力が求められるのは最後の画面であるため、カート追加時には住所の情報は欠損している。
  • mutation validateBusket の実行後に query orderQuery に同一の UUID を渡すと、validateBusket で与えた内容が得られる。
    • 従って mutation validateBusket はカート内容を検証し、その内容を UUID に紐づけてサーバ側に保管する役割を担うと考えられる。
  • 最後に、mutation placeOrder で注文を確定する(ここがよく解っていない)。

問題となるのは mutation placeOrder の引数に与える入力型(input type)です。minify されたコードを読み解くと、ID や決済情報を与える必要があることは漠然と判明したものの、具体的なスキーマに関しては手掛かりが得られない状態が続いていました*29。こうなると実際の API コールを監視するのが手っ取り早いのですが、このクエリが呼ばれるためには、実際に決済を走らせてピザを注文する必要があります。ドミノ・ピザのピザメニューは最低でも 1,310 円しますので、無闇に注文していては破産まっしぐらです。

LT*30 の場で率直に話したところ「この場で割り勘にすれば?」との提案が

――周囲の協力により割り勘に落とし込むことに成功し、その際*31の注文ページの Network タブをダンプすることによりピザ注文への完全な理解を得ることができました。ちなみに LT で用いた資料は以下のスライドになります。

speakerdeck.com

こたえあわせ

注文確定に際しては、下記 3 クエリを順に投げることで処理が遂行されていました。各種 ID がクライアントサイドで生成されていたことには驚きを隠せません。

  1. カート内容の更新:mutation validateBusket を実行する。適当に採番した UUID である id, advanceOrderId を引数に与える。
  2. 決済処理?:mutation initialiseOrder を実行する。適当に採番した UUID である orderPaymentId を引数に与える。
  3. 注文確定:mutation placeOrder を実行する。引数に先程と同一の id, orderPaymentId を与える。

ピザトラッカー

公式サイトには、ピザの行方がリアルタイムで表示されるピザトラッカーなる機能が実装されています。この仕様を調査したところ、以下に示す事柄が判明しました。

  • 進捗状況を 8 つの状態に区分する
    • Basket(注文中), Pending(待機中), SentToStore(店舗送信中), Making(調理中), Cooking(焼成中), Ready(配達準備中), Leaving(配達中), Complete(配達完了)
  • Making 以降になると、ETA(到着予定時刻)が 最短–最長 の形式で表示される。
  • これらの情報は query OrderQuery で取得可能。eta は当初 null である。
公式ピザトラッカーと、GitHub 上に出現したピザトラッカー(右下)

到着予定時刻が Issue 上にも表示されると嬉しいわけですが、これを取得するには一定間隔で繰り返し問い合わせを実行*32しなければなりません。以下の手法を用いた定期更新も検討しましたが、様々な事情から今回は workflow_dispatch による手動実行で配達状況を確認することにしました。

  1. schedule トリガを用いて定期実行(cron)する
    • 注文中以外も定時にワークフローが実行され、リソースが無駄に消費される。また遅延が生じる可能性がある。
  2. 外部サービスに依存する
    • Cloudflare Workers の Cron Triggers 等を利用する。KV 等で注文中であるかを上手く管理する必要がある。GitHub 上で完結しなくなってしまうので今回は見送り。
  3. Issue が close されたらワークフローを削除する
    • 一瞬良さそうに思えたが上述の通り遅延問題があるので却下。


かくしてピザ注文に必要な API の解析と、ワークフローの実装*33が終わりました。ここで、ピザの注文に必要な最低限のクエリ呼び出しを改めて図示して整理します。

あとは Issue を立てて、実際に頼んでみるだけです!

実際にやってみた

12月5日(火)、筑波大学 7A 棟。一台の PC を注視した

ピザの選択、配達先住所の入力、全ては順調に事が運んでいた。今度こそ――そうした期待に胸を膨らませながら、CI の動作に目を落とす。

「注文」と入力したのも束の間、画面を覆い尽くす GraphQL エラー*34に落胆の表情を隠しきれない

一同はその後も黙々とエラーの分析に勤しんだ。

場所を変え、4 回目の施行。その時は偶然に訪れた
注文成功を知らせる画面は予想以上に味気ない*35

玄関のチャイムが部屋に轟く。

師走の寒空の下に駆けつけたピザに畏怖の念を抱きながら、現金 1,510 円を手渡した*36

というわけでピザの注文に成功しました! 事前の検証でバグを取り切ることができず、最後の注文画面で入力型のエラーに見舞われましたが、4 回の試行を経て無事に注文することができました。現実とパソコンが繋がった気がして嬉しかったです。

むすびにかえて

遊びの一環で始めましたが、進めていくと予想以上に興味深い展開となりたのしかったです。ピザに詳しくなりたければ API の解析から始めると、ピザに対する解像度が上がって良いんじゃないかなと思います。

Discord でも盛り上がる一同

所感として、公式サイトは非常に優れた設計であると感じました。例えば、表示内容・レイアウト・画像・アイコン等はすべて GraphQL 側に情報が寄せられており、フロント側にハードコーディングされた要素はかなり少なかったです。ゆえに柔軟性が高く、今後ピザに加えて寿司も売るよ!などの展開になった際にも、大規模な改修作業を経ずに商品を追加可能なものと思われます*37。本稿では取り上げませんでしたが、公式ではハーフアンドハーフ等の多種多様なメニューの注文も当然受け付けており、複雑なドメイン*38に対応するために GraphQL を上手く駆使しているという印象を受けました。

今後の展望

今後の展望として、実装予定の機能を挙げておきます(多分やらない)。

  • 高速化
    • メニュー情報等はキャッシュ等に持たせることで高速化が実現されそう
  • マイドミノ以外のピザや、複数商品の注文に対応
  • トッピングへの対応
  • インタフェースの改善
    • ピザ柄のルーレットダーツの結果に応じてピザが注文される仕組みなど
  • 現金以外の決済手段への対応

あまりコードを書かないがちの情報メディア創成学類ですが、プログラムがちょっと書けると日々の生活をより便利に楽しく、そして豊かにすることができると思います。mast Advent Calendar 2023 はクリスマスまで続いていきますので、今後の記事にもご期待ください*39!

学内で受け渡されるピザ

記事中に登場するサービスや運営会社とは一切関係がありません。本稿は技術検証を目的とした記事であり、記事中のプログラム・手順はすべて自己責任で実行してください。また過度な API コールなど、店側に迷惑となるような行為は厳かに慎むようお願いいたします。

*1:初学者向けの完全な解説は流石に難しいので、雰囲気だけでも掴んでいただければという趣旨です

*2:技術的に不正確な記述があるかもしれませんが許してんぽ〜

*3:Zenn に書くのもアレだなあと思ってはてなブログに書いた

*4:ここでは注文用 Web サイトを指す

*5:使用技術のこと

*6:Multiple Page Application. SPA と区別するためのレトロニム

*7:例えば Twitter の API 騒動の際に Twitter 内部で利用されている API を抽出したリポジトリなどが存在した: https://github.com/tsukumijima/tweepy-authlib

*8:利用規約的にはグレー、過度な利用は控えましょう

*9:当然のことながら、日本国内の注文サイトとは API の仕様が異なるっぽい

*10:API にアクセスする URI のこと

*11:データの取得や更新に用いられる言語

*12:リソース(画像等)の読み込みは除く

*13:「1 枚買うと 1 枚無料」に代表されるようなクーポン情報も存在しますが、今回は簡単のため省略

*14:雑に解釈すると「データ」のような意味

*15:ドミノ・ピザ有益情報ですが、マイドミノというのを頼むと一人でも安くピザが食べられるっぽい

*16:公式に提供される SPA では、シュッとした UI を介して何ら迷うことなく商品注文に辿り着けるのですが、内部で扱われているスキーマはかなり複雑であることが伺えます

*17:テストや動作確認の度に API にリクエストを投げると、相手方に負荷が掛かるほか、テストの結果等も API に依存するという現象が生じてしまいます。これらの懸念を解決するために、モックと呼ばれる API のような振る舞いをするプログラムを用意します

*18:設計図共有サイト

*19:バージョン管理システム

*20:個人情報を扱うため private

*21:ソフトウェアに対するアイデアやバグ、要望等を議論する場。よくタスク管理等にも用いられる

*22:テスト、ビルド、デプロイ等を自動で回すこと

*23:厳密にはSelf-hosted Runner も可能。宅配にも持ち帰りにも対応するピザ業界と思わぬ類似点

*24:さらに我々学生は GitHub Education に登録することで無償で 3,000 分/月まで利用可能。今回の CI の構築ではそのうち 600 分程度の枠を使用しました

*25:ヤムルかヤメルと呼ばれている

*26:初めて知った。ニワカですみません

*27:多義な言葉ですが、この文脈で「テスト」と言えばプログラムの動作を機械的に検証することを指す

*28:急に初心者置き去りになってしまい申し訳ない

*29:イントロスペクションも制限されていた

*30:Lightning Talks. 5 分等の短い時間で雑にプレゼンテーションを行う会

*31:ピザの配達を見てこれが真の Continuous Delivery だねみたいな話をしていた

*32:公式でもそのような実装

*33:詳細な実装は inaniwaudon-public-pizza を参照

*34:エラー時に注文は店舗に送信されないことを確認しているため御安心ください

*35:再撮って雰囲気が出ていいよね

*36:高い

*37:実際にできるかは不明

*38:ある領域に限定した知識のことをドメイン知識と呼ぶ。現実世界は大変だ〜

*39:12/23 にもう一度執筆担当します