App Store と Google Play を合わせて 6 アプリ運用していると、レビューは毎日 30〜80 件届きます。Beautiful HD Wallpapers のような累計 5,000 万 DL を超えるアプリだと、その中に重大なバグ報告と機能要望、ストア独自の言語(タイ語・ペルシャ語・ウクライナ語など)の比率が混じってきます。私が今までこれを目視で消化していた時間は、平日 1 日 60 分前後でした。
今月、Gemini Flash + Cloud Run の小さな ETL に置き換えたところ、Slack を流し読むだけで済むようになり、消化時間は 12 分前後まで縮みました。レビュー返信そのものは引き続き手で書いていますが、「どのレビューを今日返信するか」の選別が自動化されました。実装の構成と、運用してみて分かった現実的な落とし穴を残しておきます。
なぜ翻訳と感情分類だけでは足りなかったか
最初は Google Play Console と App Store Connect のレビューを Slack に投げるだけの素朴な仕組みでした。問題は、6 アプリ分のレビューが毎朝 50〜100 件、原文のまま Slack に並ぶことで、結局チャンネルを開かなくなったことです。
11 言語に分散している点も体力を奪います。タイ語のバグ報告は「読む準備」をするだけで 30 秒かかります。Gemini を間に入れたのは、「読む準備」を要しない形に変換する ためでした。
レビュー 1 件を変換すると、以下の 5 項目が出る形にしました。
- 日本語訳(原文の言語に関わらず)
- 感情(positive / neutral / negative)
- カテゴリ(bug / feature_request / praise / question / spam)
- 緊急度(low / medium / high)
- 関連 Crashlytics Issue 候補(あれば)
このうち 4 と 5 が、目視運用にはなかった次元です。
全体構成 — Cloud Run + Cloud Scheduler + Gemini Flash
ETL の構成は意外なほどシンプルです。
Cloud Scheduler (毎日 8:50 JST)
↓
Cloud Run (Python 3.12, 1 vCPU, 512MB)
├── App Store Connect API (6 アプリのレビュー差分取得)
├── Google Play Developer API (6 アプリのレビュー差分取得)
├── Gemini API (gemini-3-2-flash, 構造化出力)
├── Firebase Crashlytics REST (関連 Issue マッチング)
└── Slack Incoming Webhook (整形メッセージ送信)
Cloud Run のコールドスタートは最初気にしていましたが、毎日 1 回しか起動しないので無視できる範囲でした。実行時間は 6 アプリ合計で 40〜90 秒に収まっています。
Gemini への構造化出力プロンプト
私が使っている最小のプロンプトテンプレートです。
import google.generativeai as genai
genai.configure(api_key=os.environ["GEMINI_API_KEY"])
PROMPT_TEMPLATE = """\
あなたはモバイルアプリのカスタマーサポート担当です。
以下のアプリレビュー 1 件を読み、構造化 JSON を出力してください。
App: {app_name}
Store: {store}
Rating: {rating}
Language: {language}
Body: {body}
返すフィールド:
- ja_translation: 原文の日本語訳(既に日本語なら原文と同一)
- sentiment: positive | neutral | negative
- category: bug | feature_request | praise | question | spam
- urgency: low | medium | high
- bug_summary: bug の場合のみ、症状を 1 文に要約
- feature_request_summary: feature_request の場合のみ、要望を 1 文に要約
緊急度の基準:
- high: クラッシュ、アプリ起動不可、課金エラー、データ損失
- medium: 特定機能の不具合、頻発する不便
- low: それ以外(不満一般・要望・称賛)
JSON だけを返してください。説明文は不要です。
"""
def classify(review: dict) -> dict:
prompt = PROMPT_TEMPLATE.format(**review)
model = genai.GenerativeModel(
"gemini-3-2-flash",
generation_config={
"response_mime_type": "application/json",
"temperature": 0.1,
},
)
res = model.generate_content(prompt)
return json.loads(res.text)
response_mime_type = "application/json" を指定すると、Gemini が JSON 以外の文字を返さなくなります。実運用ではこのフラグが効きます。
温度は 0.1 にしました。0.0 だと同じ原文に対する分類が決定的になりますが、まれに JSON の形式エラー(プロパティ漏れ)が出ることがあったため、0.1 でブレを残しつつ JSON の安定性を確保しています。
Crashlytics との突合せ — 「このレビューはこのクラッシュ Issue か?」
ユーザーレビューに「起動しない」と書かれていても、それが今回のリリースのバグなのか、端末固有の事象なのかは判別できません。Gemini にもう一段、Crashlytics の最近 48 時間の Top 10 Issue をコンテキストとして渡します。
def match_crashlytics_issue(review_text: str, recent_issues: list[dict]) -> str | None:
context = "\n".join(
f"- {i['issue_id']}: {i['title']} ({i['affected_users']} users)"
for i in recent_issues
)
prompt = f"""\
以下のレビューと、直近 48 時間に発生した Crashlytics Issue の一覧を見比べ、
同じ問題を指している可能性が高い Issue ID を 1 つだけ返してください。
該当なしの場合は "none" とだけ返してください。
レビュー: {review_text}
最近の Crashlytics Issue:
{context}
"""
model = genai.GenerativeModel("gemini-3-2-flash")
res = model.generate_content(prompt)
issue_id = res.text.strip()
return issue_id if issue_id != "none" else None
このマッチングは 100% の精度ではありませんが、「直近のクラッシュとレビューを 1 件ずつ手で見比べる時間」を取り戻すには十分でした。Beautiful HD Wallpapers の v2.0.0 で発生した RecyclerView クラッシュは、レビュー 4 件と Crashlytics Issue 1 件をこの仕組みが自動で結びつけてくれました。
Slack 通知の形 — スレッドで「読む順番」を強制する
Slack に流す形式は、最初に親メッセージで 1 行サマリ、スレッドの中で詳細を展開する構造にしました。
:large_yellow_circle: 6 apps daily review digest — 2026-05-27
Total: 53 (positive 31, neutral 12, negative 10)
:red_circle: high: 2 / :large_orange_circle: medium: 6 / :large_green_circle: low: 45
[1] Beautiful HD Wallpapers, Google Play, ★1, ja
high / bug / 起動して 3 秒で落ちる、Android 6.0.1
Crashlytics: maybe → ABC-1234
↪ thread に原文と返信案
親メッセージに数だけ書くと、ネガティブが少ない日は開かずに済みます。ネガティブが 10 件を超える日にだけスレッドを開く運用です。
返信ドラフトはこの ETL では自動投稿していません。返信は私が手で書くと決めていて、Slack には「これは私が今日返信する」という選別の補助だけをさせています。AdMob の eCPM や App Store の価格と同じく、5,000 万 DL を超える既存ユーザーへの言葉は、自動化の対象外と決めています。
月額コスト — Gemini Flash $4、Cloud Run ほぼ $0
実際の請求書から書きます。
レビュー件数: 1 ヶ月で約 2,000 件(6 アプリ合計)
Gemini API: 1 件あたり平均 350 トークン入力 / 220 トークン出力
gemini-3-2-flash の単価で月額 $3.80
Cloud Run: 月 30 回 × 60 秒 = 30 分相当、無料枠内で $0
Cloud Scheduler: 月 30 ジョブ、無料枠内で $0
合計: 月額 $3.80
Gemini Pro 系に切り替えるかは何度か悩みましたが、6 アプリ程度の運用なら Flash で十分という結論に至りました。Pro が必要になる目安は、感情分類の精度を 95% 以上に上げたい、または同じレビュー文に対する多段推論(要約 → 機能要望抽出 → 競合機能との比較)を入れたい場合です。私のユースケースは前者・後者ともに該当しないため、Flash で運用しています。
個人開発 12 年で見えた「自動化してよい仕事」と「手で書く仕事」
両家の祖父が宮大工で、見えない接合部にこそ時間をかけていたのを子供の頃から見ていました。アプリのレビュー対応も、見える部分(実際の返信文)と、見えない部分(どれを今日返信するかの選別)に分けると、自動化の対象が自然に見えてきます。
選別は機械の仕事として一切迷いなく任せられます。返信文を書くのは、5,000 万 DL のユーザーに対して責任を負っている個人開発者として、私自身が手を動かす領域です。Gemini はこの選別と Crashlytics 突合せを月 $4 で肩代わりしてくれて、私はその分の時間を実装に戻せるようになりました。
1997 年からインターネットに触れてきて、技術が一番気持ちよく効くのは、人間が時間をかけるべきところを見極めて、それ以外の時間を取り戻したときです。Gemini Flash と Slack の組み合わせは、今のところその気持ちよさが残っている運用になっています。
まず試すなら — 1 アプリ・1 ストアから
このパイプラインを丸ごと組むのは初期コストが高いです。最初は 1 アプリの App Store Connect API だけで、Gemini Flash のレスポンスを Slack 通知に流すところから始めるのを推奨します。Cloud Run も不要で、ローカル PC の cron で十分です。1 日 5 件しか分類しなくても、JSON のフィールド設計と Slack のメッセージ整形だけは早めに固められます。
同じ規模のアプリポートフォリオを個人で運用している方の参考になれば幸いです。お読みいただきありがとうございました。