Operations2026.08.29 · 6 min read

Google Play の reviews.list は直近1週間しか返さない — 同期前のレビューを Play Console の CSV から埋め戻す

認証基盤・クラウドインフラ

勤怠アプリのサポート管理画面で、ストアレビューへの返信数を日別に集計しようとした。App Store Connect API のほうは全件取れたのに、Google Play だけ過去のレビューが欠けていた。Google Play Developer API の reviews.list は直近1週間ぶんしか返さず、同期を始める前のレビューは API では二度と取れない。Play Console が Cloud Storage に出している月次の CSV を読んで、キャッシュの穴を埋めた。

Google Play だけ、同期を始める前のレビューが無い

管理画面は App Store と Google Play のレビューを1つの一覧に取り込み、そこから返信している。Google Play 側は reviews.list の結果を DynamoDB(pk=APP_SUPPORT#<app>、sk=ITEM#google_play#<reviewId>)にキャッシュし、6時間ごとの cron で API の結果を id でマージして貯めている。

「返信を日毎に集計したい」となって、まずキャッシュを数えた。App Store は API で全レビューと返信が取れているのに、Google Play の返信数だけ明らかに少ない。同じ画面で同じように返信してきたのだから、ストアの人気差では説明がつかない量だった。

reviews.list は今日叩いても 8/21〜8/28 しか返さない

API の生の返りを見るのが早い。maxResults=100 で nextPageToken が尽きるまで読み切る実装なので、ページ送りの取りこぼしではない。

reviews.list とキャッシュの範囲
# 2026-08-29 JST に reviews.list を最後のページまで読んだ結果
reviews.list : 38 件  lastModified 2026-08-21 〜 2026-08-28
cache        : 47 件  createdAt    2026-08-19 〜 2026-08-28
                     ← 同期を始める前のレビューは、どちらにも無い

返ってきたのは 38 件、lastModified は 2026-08-21 から 2026-08-28 まで。今日は 8/29 なので、きっちり直近1週間だ。キャッシュは 47 件で、一番古いものは 8/19 のレビューだ。その前に書かれたレビューは、API にもキャッシュにも存在しない。

これは公式ドキュメントに書いてある挙動だ。reviews.list が返すのは直近1週間のレビューだけで、それ以前は Play Console のレポートを使え、と注記されている。問題はドキュメントを読んでいなかったことではなく、この注記が「同期を始める前のレビューは永久に取れない」という意味だと、集計しようとするまで気づかなかったことにある。

同期は毎回成功していたので、穴に気づくのが遅れた

6時間ごとの同期は一度も失敗していない。API は毎回 200 を返し、新しいレビューは一覧に載り、返信もできる。「動いている」を疑う材料が運用の中に無かった。

1週間の窓は、同期を回し続けている限りは問題にならない。窓が動くたびに新しいレビューが入り、古いものはキャッシュに残る。欠けるのは、キャッシュを始めた日より前の1回きりで、それは同期を始めた瞬間に確定している。日々の運用では見えず、過去にさかのぼって数えようとした瞬間にだけ現れる欠けだった。

取得窓のある API をキャッシュで延長すると、キャッシュを始めた日がそのままデータセットの開始日になる。それより前を後から埋める経路は、始める前に確保しておかないと存在しない。

Play Console の月次 CSV から穴だけ埋める

Play Console の「レポートをダウンロード」→「レビュー」に、Cloud Storage の URI が載っている。gs://pubsite_prod_<developer-account-id>/reviews/ で、この ID はコードからは分からず、コンソールで一度コピーする必要があった。中には reviews_com.example.timeclock_202608.csv のように月ごとに1ファイルが置かれている。

ここで捨てた案が1つある。コンソールからこの CSV を手でダウンロードして、1回だけ流し込む、というものだ。今回の穴を埋めるだけならそれで足りる。捨てたのは、レポートが月ごとに増え、返信の更新もこの CSV にしか残らない以上、次の月も、返信し直すたびも、同じ手作業になるからだ。Cloud Storage から読む形にして、いつでも再実行できるようにした。

認証は既にある Play 用のサービスアカウント鍵を使い、scope に devstorage.read_only を足す。Play Console 側でそのサービスアカウントに「レポート閲覧」権限が付いていれば、storage/v1/b/<bucket>/o?prefix=reviews/ で一覧できる。ファイルの中身は素直ではない。

scripts/migrate-backfill-google-play-reviews.ts
// GCS のメタデータは contentType "text/csv; charset=utf-16le", contentEncoding "gzip"。
// alt=media で取ると gzip は fetch が透過的に解凍するが、UTF-16LE と BOM はこちらで剥がす。
async function downloadCsv(bucket: string, name: string, token: string): Promise<string> {
  const url = `https://storage.googleapis.com/storage/v1/b/${bucket}/o/${encodeURIComponent(name)}?alt=media`;
  const response = await fetch(url, { headers: { Authorization: `Bearer ${token}` } });
  if (!response.ok) throw new Error(`GCS download failed for ${name}: ${response.status}`);
  return Buffer.from(await response.arrayBuffer()).toString("utf16le").replace(/^\uFEFF/, "");
}

// レビュー ID は Review Link の reviewId= からしか取れない。本文の無い星だけの評価には
// Review Link 自体が無いので、この時点で落ちる(API 側のキャッシュと突き合わせようがない)。
const reviewId = /reviewId=([^&\s]+)/.exec(column(row, "Review Link"))?.[1];
if (!reviewId) return null;

UTF-16LE で BOM 付き、gzip 圧縮。gzip は fetch が解凍してくれるので、UTF-16LE のデコードと BOM の除去だけ自前でやる。ヘッダーはこうなっている。

CSV ヘッダー(そのまま)
Package Name,App Version Code,App Version Name,Reviewer Language,Device,Review Submit Date and Time,Review Submit Millis Since Epoch,Review Last Update Date and Time,Review Last Update Millis Since Epoch,Star Rating,Review Title,Review Text,Developer Reply Date and Time,Developer Reply Millis Since Epoch,Developer Reply Text,Review Link

ID は最後の Review Link の中にしか無く、本文の無い星だけの評価にはその列自体が空で、キャッシュと突き合わせようがない。それから、レポートは数日遅れで更新される。中の Submit Date は 8/21 で止まっていて、Developer Reply 列は全て空だった。つまり CSV は API より古く、API のほうが正確だ。マージは「穴を埋める」以外をしないように絞った。

マージ方針
// API の方が新しく正確なので、CSV は「穴を埋める」だけ。
//   - キャッシュに無いレビュー           → CSV から新規作成(canReply: false)
//   - キャッシュにあり返信が無い & CSV に返信がある → 返信だけ足す
//   - それ以外(キャッシュに返信あり等) → 触らない
for (const row of rows.values()) {
  const cached = cache.get(row.reviewId);
  if (!cached) { creates.push(buildItemFromCsv(row)); continue; }
  const merged = mergeReply(cached.item, row);
  if (merged) replyMerges.push(merged);
}

CSV 由来のレビューには投稿者名が無いので author は固定文字列にし、返信できるかは API でしか判定できないので canReply: false にした。dry-run で対象を出し、本実行の前にキャッシュ全件を JSON に退避する。

admin コンテナで実行
$ npx tsx scripts/migrate-backfill-google-play-reviews.ts --dry-run
csv files: reviews/reviews_com.example.timeclock_202608.csv
csv reviews: 14
cached reviews: 47
  create 2026-08-18 ...   # 4 行、すべて 2026-08-18 のレビュー
create=4 replyMerge=0

$ npx tsx scripts/migrate-backfill-google-play-reviews.ts
dumped 47 cached reviews to scripts/dumps/google-play-reviews-....json
create=4 replyMerge=0
done            # キャッシュ 47 → 51 件

CSV にあってキャッシュに無かったのは、すべて 8/18 のレビューだった。これが同期開始日より前の穴で、これで埋まった。6時間ごとの同期はそのまま残し、このスクリプトは月次 CSV が更新されたときに再実行すれば、返信の追記も同じ方針で埋まる。

まとめ

reviews.list の1週間窓はドキュメントに書いてある。読んで分かることを記事にしたいのではない。書いてある制限が自分のデータにどう跡を残すかは、制限を読んだ時点では分からず、キャッシュを始めた日が確定した瞬間に決まっていた。窓のある API を貯めて使うなら、貯め始める前に「それより前はどこから取れるか」を確かめておく。今回それは Play Console のレポートで、Cloud Storage から機械的に読めたから救えたが、そこに無ければ埋めようがなかった。

もう1つ。API が毎回成功することと、データが揃っていることは別だ。窓のある取得を運用に組み込んだら、成功ログではなく「一番古いレコードはいつか」を一度見ておく。それだけで、集計を始めてから気づく代わりに、始める前に気づける。

Google PlayGoogle Cloud StorageDynamoDBApp Store Connect