行列の待ち時間を予測するアプリを作っています。Googleの口コミ数を使って「行列ができる店」の候補を抽出し、地図に色をつける仕組みです。
設計はシンプルでした。週に1回クロールして、店の情報をFirestoreに保存する。 評価、口コミ数、店名、位置。あとはそれを読むだけ。速いし、APIの呼び出し回数も抑えられる。
この設計が、Places APIの規約と正面から衝突しました。
以下は自分たちが規約を読んで下した判断の記録で、法的な助言ではありません。実際に作るときは、必ず最新の規約を自分で読んでください。
保存していいものは、ほとんど無い
規約を読んで整理すると、キャッシュの扱いはこうなっていました。
| データ | 保存の可否 |
|---|---|
| Place ID | 無期限に保存してよい |
| 緯度経度 | 30日まで |
| 評価(rating) | 原則禁止 |
| 口コミ数(userRatingCount) | 原則禁止 |
| 店名(displayName) | 原則禁止 |
| 営業時間(regularOpeningHours) | 原則禁止 |
例外として明記されているのは、Place ID(無期限)と緯度経度(30日)だけです。
つまり、自分がやろうとしていた「評価と口コミ数をFirestoreに保存して、それを見て候補を判定する」は、規約が例外として認めている枠の外にあります。
採った解決策: 生値を保存せず、派生値だけ残す
作り直した構造がこれです。
クロール時:
Places API から rating / userRatingCount を取得
↓
その場で「候補ランク」を判定する(メモリ上で使い捨て)
↓
Firestore に保存するのは 判定結果 だけ
↓
生の rating / userRatingCount は 保存しない・捨てる
保存するのは、候補ランク・地図ピンの色・予測値といった、自分たちが計算した派生値だけです。
UI側でも、評価の生値は表示しません。 「口コミ4.3」ではなく「行列ができやすい店」と出す。もともと自分たちのアプリが売るのは口コミの数字ではなく待ち時間の予測なので、機能を削ることにはなりませんでした。
Place IDは無期限に保存できるので、ドキュメントIDをPlace IDにしました。これは規約に適合するうえに、副次的な効果が大きかった。同じ店が二重に登録されることが、構造的に起きなくなります。 ユーザーからの店舗登録も、Google検索の結果から選ばせてPlace IDで登録する方式にしたので、重複の排除ロジック自体が不要になりました。
制約に合わせた設計のほうが、素直な設計より綺麗になることがあります。
店名だけは、受容している
正直に書きます。店名(displayName)は保存しています。
これは規約上「一般コンテンツ=原則禁止」の側です。ただし、店名を持たずにこのアプリは成立しません。 地図に「ここが混んでいます」とだけ出しても、使い物にならない。
なので、店名については**「機能に不可欠」として残存リスクを受容する**という判断をしました。そのうえで、
- 週次のクロールで上書きする(古い情報を持ち続けない)
- Googleの帰属表示を出す
としています。適合しているのではなく、受容していると自覚したうえで運用しているという整理です。ここを「たぶん大丈夫」で流さないほうが、後で判断を見直すときに楽でした。
営業時間は、代替があったので保存をやめた
途中で「営業時間も取れる。追加コストはゼロだから保存しよう」という提案が出ました。予測の精度が上がるので、魅力的な案です。
却下しました。 判断の分かれ目は、店名のときとまったく同じ問いです。
「それ無しでアプリは成立するか」。
営業時間にはリスクゼロの代替が存在しました。 ジャンル別のデフォルト営業時間を設定ファイルに持つ方式です。ラーメン店は11時〜23時、カフェは8時〜20時、といった粗い値でも、待ち時間の予測には十分でした。
代替が存在する以上、「機能に不可欠」という抗弁は立ちません。 グレーな保存を1フィールド増やす正当化が、店名のときより明確に弱い。
将来どうしても精度を上げたくなったら、生の営業時間の構造体を保存せず、「30分ごとに営業中かどうか」の真偽値だけを計算して保存するという手があります。派生値なので、生値の保存よりは露出が小さい。完全に適合するわけではありませんが、選択肢としてメモしてあります。
帰属表示は「地図があるかどうか」で決まる
これも実装に効いたので書いておきます。
- 地図の上に表示する場合 — 地図自体にGoogleのロゴが出るので、追加の帰属表示は不要
- 地図が無い画面で表示する場合 — Googleのロゴが必須
自分のアプリだと、複数店を比べるリスト画面と、店舗を検索して選ぶ画面が後者に当たります。地図が無いのにPlacesのコンテンツ(店名など)を出している画面は全部これです。
同じ実装を何度も書くとどこかで漏れるので、共通コンポーネントにして、Placesのデータを出す画面はそれを使うという形にしました。
一時保存の扱い
ユーザーが「この店を追加して」と提案してくる機能があります。提案は承認・却下のフローに乗るので、判断されるまで一時的に保存が必要です。
ここは条件付きで許容としました。
- 緯度経度 — 提案から承認・却下までは日〜週単位なので、30日の例外の範囲に収まる
- 店名 — 恒久保存する店マスタと同じリスクだが、承認されれば移り、却下されれば消える一時的な保持なので、露出はむしろ小さい
- 評価・口コミ数 — 一時保存もしない。 検索結果を表示するために一時的にクライアントへ返すのは構わないが、Firestoreへの永続化は禁止
そして、却下された提案は速やかに削除する運用にしました。放置すると露出期間だけが伸びます。
設計するときに使った問い
規約の条文を毎回引くのは現実的ではないので、判断の型を3つに落としました。
- これは Place ID か緯度経度か。 そうなら保存してよい
- 保存しないと機能が成立しないか。 成立するなら保存しない
- 生値ではなく、自分が計算した派生値で代用できないか。 できるならそうする
3つ目がいちばん使えます。 「評価を保存する」ではなく「評価を見て自分が付けたランクを保存する」。外部から取ったデータを、自分のデータに変換してから保存するという考え方で、ほとんどの場面が処理できました。
まとめ
- Places APIで無期限に保存していいのは Place ID だけ。緯度経度は30日
- 評価・口コミ数・店名・営業時間は原則キャッシュ禁止
- 生値を保存せず、派生値だけを保存する設計にすれば大半は回避できる
- 代替が存在するフィールドは、保存を正当化できない(営業時間がこれ)
- 地図が無い画面でPlacesのコンテンツを出すなら、Googleの帰属表示が必要
- Place IDをドキュメントIDにすると、重複が構造的に起きないという副次的な利点がある
規約に合わせて設計を作り直したのは、動くものができた後でした。最初に規約を読んでいれば、書き直しは要らなかった。外部APIを使うときは、料金表より先に規約のキャッシュの項を読む、と決めました。
同じアプリの費用設計はアプリ3本で請求22円に、ストア公開でつまずいた箇所は全部まとめてあります。