毎朝起きたら、昨夜のうちにユーザーレビューが自動分類されていて、バグ報告はGitHub Issuesに変換済み、ネガティブレビューには丁寧な返信案が生成されていて、あとは承認ボタンを押すだけ——そんな運営体制を、個人開発者でも構築できる時代になりました。
アプリが育つほど、レビュー管理の負担は重くなります。1日10件ならまだ手で対応できますが、100件を超えてくると「レビューを読む時間がない→バグを見逃す→評価が下がる→ダウンロードが減る」という悪循環に陥りがちです。私自身、複数のアプリを運営する中でこの問題に何度も直面しました。
ここではAntigravity IDE と Gemma 4 を組み合わせてレビュー対応を全自動化するシステムを構築する方法を、実際に動くコードとともに解説します。App Store Connect API / Google Play Developer API からレビューを取得し、Gemma 4 で分類・分析し、自動返信を生成してGitHub Issuesを作成するまでの一連のパイプラインです。
システムの全体像と設計思想
まず完成形をイメージしてもらうために、システムの全体像を示します。
┌─────────────────────────────────────────────────────────┐
│ レビュー収集レイヤー(毎時実行) │
├─────────────────────────────────────────────────────────┤
│ App Store Connect API │ Google Play Developer API │
└──────────┬──────────────────────────────┬───────────────┘
│ │
▼ ▼
┌─────────────────────────────────────────────────────────┐
│ Gemma 4 分析エンジン(Firebase Functions) │
├─────────────┬────────────────┬────────────────┬─────────┤
│ バグ報告 │ 機能要望 │ ネガ クレーム │ 高評価 │
└──────┬──────┴───────┬────────┴───────┬────────┴────┬────┘
│ │ │ │
▼ ▼ ▼ ▼
GitHub Issues Notion DB 自動返信生成 SNS素材化
このシステムの設計思想は「判断は AI が行い、最終承認は人間が行う」というセミオートメーションです。完全自動で返信を投稿するのではなく、AIが生成した返信案をSlackやメールで通知し、承認後に投稿するフローにしています。これがポイントで、App Storeの返信ポリシーに違反するリスクを下げながら、作業時間を90%以上削減できます。
使用技術スタック
- Antigravity — システム全体のコード生成・デバッグ・リファクタリング
- Gemma 4 — レビューの分類・センチメント分析・返信文生成
- Node.js 22 — バックエンド処理
- Firebase Functions v2 — サーバーレス実行環境
- App Store Connect API / Google Play Developer API — レビュー取得・返信投稿
- GitHub API — バグ Issue 自動作成
Step 1: Antigravity でプロジェクトを立ち上げる
Antigravityのターミナルで以下を実行し、プロジェクトを初期化します。
mkdir app-review-ai && cd app-review-ai
firebase init functions
# TypeScript を選択、ESLint を有効化、依存関係をインストール続いてAntigravityのAgent Modeに次のプロンプトを渡します:
このプロジェクトはApp Store / Google Playのレビューを自動処理するFirebase Functionsアプリです。
以下の機能を持つTypeScriptプロジェクト構造を作成してください:
- src/collectors/ : APIからレビューを取得するモジュール
- src/analyzers/ : Gemma 4でレビューを分析するモジュール
- src/responders/ : 自動返信を生成・投稿するモジュール
- src/notifiers/ : Slack/メール通知モジュール
- src/types/review.ts : 共通の型定義
Antigravityがプロジェクト全体のスキャフォールディングを1分以内に生成します。手動でディレクトリを作り、型定義を考える時間が節約できます。
型定義を最初に固める
// src/types/review.ts
export interface AppReview {
id: string;
platform: 'ios' | 'android';
appId: string;
rating: number; // 1〜5
title: string;
body: string;
authorName: string;
date: Date;
developerReply?: string;
territory: string; // 国コード ('JP', 'US' など)
}
export type ReviewCategory = 'bug' | 'feature_request' | 'negative' | 'positive' | 'question';
export interface AnalyzedReview extends AppReview {
category: ReviewCategory;
sentiment: number; // -1.0 〜 1.0
urgency: 'low' | 'medium' | 'high' | 'critical';
extractedBugs: BugReport[];
suggestedReply: string;
confidence: number; // 0.0 〜 1.0
}
export interface BugReport {
title: string;
description: string;
reproductionSteps: string[];
severity: 'minor' | 'major' | 'critical';
labels: string[];
}型を最初に定義しておくことで、Antigravityが以降のコード生成で一貫した型チェックを行ってくれます。「ここで型エラーが出たら教えて」とプロンプトに書くだけで、TypeScript の strict モードエラーをゼロにしてくれます。
Step 2: App Store Connect API でレビューを取得する
App Store Connect APIは JWT 認証を使います。まず、Apple Developer Console で API キーを取得してください(秘密鍵は .p8 ファイル)。
// src/collectors/appStoreCollector.ts
import * as jwt from 'jsonwebtoken';
import * as fs from 'fs';
interface AppStoreCollectorConfig {
keyId: string; // App Store Connect の Key ID
issuerId: string; // Issuer ID
privateKeyPath: string; // .p8 ファイルのパス
bundleId: string; // アプリの Bundle ID
}
export class AppStoreCollector {
private config: AppStoreCollectorConfig;
constructor(config: AppStoreCollectorConfig) {
this.config = config;
}
// JWT トークンを生成(20分有効)
private generateToken(): string {
const privateKey = fs.readFileSync(this.config.privateKeyPath, 'utf8');
const token = jwt.sign(
{
iss: this.config.issuerId,
iat: Math.floor(Date.now() / 1000),
exp: Math.floor(Date.now() / 1000) + 20 * 60, // 20分
aud: 'appstoreconnect-v1',
},
privateKey,
{
algorithm: 'ES256',
header: {
alg: 'ES256',
kid: this.config.keyId,
typ: 'JWT',
},
}
);
return token;
}
// 指定した日付以降のレビューを取得
async fetchRecentReviews(appId: string, since: Date): Promise<AppReview[]> {
const token = this.generateToken();
const reviews: AppReview[] = [];
let cursor: string | null = null;
do {
const url = new URL(
`https://api.appstoreconnect.apple.com/v1/apps/${appId}/customerReviews`
);
url.searchParams.set('filter[rating]', '1,2,3,4,5');
url.searchParams.set('sort', '-createdDate');
url.searchParams.set('limit', '200');
if (cursor) url.searchParams.set('cursor', cursor);
const response = await fetch(url.toString(), {
headers: {
Authorization: `Bearer ${token}`,
'Content-Type': 'application/json',
},
});
if (\!response.ok) {
const error = await response.text();
throw new Error(`App Store API error: ${response.status} - ${error}`);
}
const data = await response.json() as any;
for (const item of data.data) {
const reviewDate = new Date(item.attributes.createdDate);
// since より古いレビューが出てきたら終了
if (reviewDate < since) {
cursor = null;
break;
}
reviews.push({
id: item.id,
platform: 'ios',
appId,
rating: item.attributes.rating,
title: item.attributes.title || '',
body: item.attributes.body,
authorName: item.attributes.reviewerNickname || '匿名ユーザー',
date: reviewDate,
developerReply: item.attributes.developerResponse?.body,
territory: item.attributes.territory,
});
}
cursor = data.links?.next ? new URL(data.links.next).searchParams.get('cursor') : null;
} while (cursor);
return reviews;
}
}なぜページネーションを実装するのか: App Store Connect APIは1リクエストで最大200件しか返せません。大規模アプリでは数千件のレビューが溜まることがあり、全件取得にはカーソルベースのページネーションが必須です。since パラメータで「過去24時間分だけ取得」とすれば、毎日の定期実行でも効率よく動きます。
Step 3: Gemma 4 でレビューを分析する
Gemma 4 の呼び出しには Google AI SDK を使います。プロンプト設計がシステムの品質を左右する最重要ポイントです。
// src/analyzers/gemmaAnalyzer.ts
import { GoogleGenerativeAI } from '@google/generative-ai';
const genAI = new GoogleGenerativeAI(process.env.GEMINI_API_KEY\!);
const model = genAI.getGenerativeModel({ model: 'gemma-4' });
const ANALYSIS_SYSTEM_PROMPT = `
あなたはモバイルアプリのカスタマーサポート担当です。
ユーザーレビューを分析し、以下のJSON形式で返してください。
マークダウンやコードブロックは使わず、純粋なJSONのみを返してください。
{
"category": "bug" | "feature_request" | "negative" | "positive" | "question",
"sentiment": 数値(-1.0〜1.0),
"urgency": "low" | "medium" | "high" | "critical",
"extractedBugs": [
{
"title": "バグのタイトル(簡潔に)",
"description": "詳細説明",
"reproductionSteps": ["手順1", "手順2"],
"severity": "minor" | "major" | "critical",
"labels": ["crash", "performance", "ui", "data" など]
}
],
"suggestedReply": "返信案(ユーザーへの敬語で、250文字以内)",
"confidence": 数値(0.0〜1.0)
}
urgencyの判断基準:
- critical: アプリがクラッシュする、データが消える、支払いができない
- high: 主要機能が動作しない、多くのユーザーに影響
- medium: 一部の機能が不便、ワークアラウンドがある
- low: 軽微な不満、個人的な好みの問題
`;
export async function analyzeReview(review: AppReview): Promise<AnalyzedReview> {
const prompt = `
アプリ名: ${review.appId}
評価: ${review.rating}星
タイトル: ${review.title}
本文: ${review.body}
国: ${review.territory}
`;
let retryCount = 0;
const maxRetries = 3;
while (retryCount < maxRetries) {
try {
const result = await model.generateContent([
{ text: ANALYSIS_SYSTEM_PROMPT },
{ text: prompt },
]);
const responseText = result.response.text().trim();
// JSON パースの前にクリーニング
const cleanedResponse = responseText
.replace(/^```json\n?/, '')
.replace(/\n?```$/, '')
.trim();
const analysis = JSON.parse(cleanedResponse);
return {
...review,
category: analysis.category,
sentiment: Math.max(-1, Math.min(1, analysis.sentiment)),
urgency: analysis.urgency,
extractedBugs: analysis.extractedBugs || [],
suggestedReply: analysis.suggestedReply,
confidence: analysis.confidence,
};
} catch (error) {
retryCount++;
if (retryCount === maxRetries) {
// 分析失敗時はデフォルト値で継続(サービス停止より継続を優先)
console.error(`Review analysis failed for ${review.id}:`, error);
return {
...review,
category: 'negative',
sentiment: review.rating < 3 ? -0.5 : 0.5,
urgency: 'low',
extractedBugs: [],
suggestedReply: 'ご利用いただきありがとうございます。ご不便をおかけして申し訳ございません。詳しい状況をサポートまでお知らせいただけると幸いです。',
confidence: 0,
};
}
// 指数バックオフ(1秒、2秒、4秒)
await new Promise(resolve => setTimeout(resolve, 1000 * Math.pow(2, retryCount - 1)));
}
}
throw new Error('Unreachable');
}
// バッチ処理(コスト最適化)
export async function analyzeReviews(
reviews: AppReview[],
options: { batchSize?: number; delayMs?: number } = {}
): Promise<AnalyzedReview[]> {
const { batchSize = 10, delayMs = 500 } = options;
const results: AnalyzedReview[] = [];
for (let i = 0; i < reviews.length; i += batchSize) {
const batch = reviews.slice(i, i + batchSize);
const batchResults = await Promise.all(batch.map(analyzeReview));
results.push(...batchResults);
// レート制限対策: バッチ間に待機
if (i + batchSize < reviews.length) {
await new Promise(resolve => setTimeout(resolve, delayMs));
}
}
return results;
}プロンプト設計のポイント: JSON のみを返してください と明示しているにも関わらず、LLM はしばしばマークダウンのコードブロックで囲んで返すことがあります。そのためのクリーニング処理を入れておくことが実装上の常識です。また、パース失敗時に例外を上げるのではなく、デフォルト値で処理を継続する設計にすることで、1件のレビューが原因でパイプライン全体が止まるのを防いでいます。
Step 4: 自動返信の生成と投稿
分類が終わったレビューのうち、返信が必要なものを自動処理します。
// src/responders/appStoreResponder.ts
const REPLY_TEMPLATES = {
bug_critical: `
{authorName}さん、ご報告いただきありがとうございます。
重大な問題が発生していることを認識いたしました。至急調査を進めております。
詳しい状況をサポート({supportEmail})までお知らせいただけますと、
より迅速に対応できます。ご迷惑をおかけして大変申し訳ございません。
`,
negative_general: `
ご意見をお寄せいただきありがとうございます。
{customFeedback}
今後もご利用いただけますよう、改善に努めてまいります。
ご不明な点がございましたらサポートまでお気軽にどうぞ。
`,
};
export async function generateAndPostReply(
review: AnalyzedReview,
appStoreCollector: AppStoreCollector,
options: { autoPost: boolean; notifySlack: boolean }
): Promise<void> {
// 高評価(4星以上)かつポジティブなレビューはスキップ
if (review.rating >= 4 && review.category === 'positive') {
return;
}
// 既に返信済みのレビューはスキップ
if (review.developerReply) {
return;
}
const replyDraft = review.suggestedReply;
if (options.notifySlack) {
await notifySlackForApproval(review, replyDraft);
}
if (options.autoPost && review.urgency === 'critical') {
// クリティカルなバグ報告のみ自動投稿(その他は承認待ち)
await appStoreCollector.postReply(review.id, replyDraft);
console.log(`✅ Auto-replied to critical review: ${review.id}`);
}
}
async function notifySlackForApproval(
review: AnalyzedReview,
replyDraft: string
): Promise<void> {
const slackWebhookUrl = process.env.SLACK_WEBHOOK_URL\!;
const urgencyEmoji = {
critical: '🚨',
high: '⚠️',
medium: '📋',
low: '💬',
}[review.urgency];
const payload = {
text: `${urgencyEmoji} 新しいレビュー返信案が生成されました`,
blocks: [
{
type: 'section',
text: {
type: 'mrkdwn',
text: `*${review.rating}★ ${review.title}*\n${review.body}`,
},
},
{
type: 'section',
fields: [
{ type: 'mrkdwn', text: `*カテゴリ:*\n${review.category}` },
{ type: 'mrkdwn', text: `*緊急度:*\n${review.urgency}` },
{ type: 'mrkdwn', text: `*感情スコア:*\n${review.sentiment.toFixed(2)}` },
{ type: 'mrkdwn', text: `*国:*\n${review.territory}` },
],
},
{
type: 'section',
text: {
type: 'mrkdwn',
text: `*返信案:*\n${replyDraft}`,
},
},
{
type: 'actions',
elements: [
{
type: 'button',
text: { type: 'plain_text', text: '✅ 承認して投稿' },
style: 'primary',
action_id: `approve_reply_${review.id}`,
value: JSON.stringify({ reviewId: review.id, reply: replyDraft }),
},
{
type: 'button',
text: { type: 'plain_text', text: '✏️ 編集する' },
action_id: `edit_reply_${review.id}`,
},
],
},
],
};
const response = await fetch(slackWebhookUrl, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(payload),
});
if (\!response.ok) {
console.error('Slack notification failed:', await response.text());
}
}Step 5: バグレポートを GitHub Issues に自動変換する
バグと判定されたレビューは、GitHub Issues に自動変換します。重複 Issue の作成を防ぐため、タイトルの類似度チェックも実装します。
// src/notifiers/githubIssueCreator.ts
import { Octokit } from '@octokit/rest';
const octokit = new Octokit({ auth: process.env.GITHUB_TOKEN });
export async function createIssueFromReview(
review: AnalyzedReview,
repoOwner: string,
repoName: string
): Promise<void> {
if (review.extractedBugs.length === 0) return;
for (const bug of review.extractedBugs) {
// 重複チェック(過去7日以内に同名のIssueがないか)
const existingIssues = await octokit.issues.listForRepo({
owner: repoOwner,
repo: repoName,
state: 'open',
labels: 'from-app-review',
since: new Date(Date.now() - 7 * 24 * 60 * 60 * 1000).toISOString(),
});
const isDuplicate = existingIssues.data.some(issue =>
calculateSimilarity(issue.title, bug.title) > 0.8
);
if (isDuplicate) {
console.log(`⏭️ Skipping duplicate issue: ${bug.title}`);
continue;
}
const issueBody = `
## ユーザーレビューから自動生成されたバグ報告
**元レビュー情報**
- プラットフォーム: ${review.platform === 'ios' ? 'App Store' : 'Google Play'}
- 評価: ${review.rating}★
- 国: ${review.territory}
- 日時: ${review.date.toISOString()}
---
## 問題の説明
${bug.description}
## 再現手順
${bug.reproductionSteps.map((step, i) => `${i + 1}. ${step}`).join('\n')}
## ユーザーの原文
> ${review.title}
>
> ${review.body}
---
*このIssueはApp Review AIシステムにより自動生成されました*
`;
await octokit.issues.create({
owner: repoOwner,
repo: repoName,
title: `[App Review Bug] ${bug.title}`,
body: issueBody,
labels: [
'from-app-review',
`severity-${bug.severity}`,
`platform-${review.platform}`,
...bug.labels,
],
});
console.log(`✅ Created GitHub Issue: ${bug.title}`);
// GitHub API レート制限対策
await new Promise(resolve => setTimeout(resolve, 1000));
}
}
// シンプルな文字列類似度計算(Jaccard係数)
function calculateSimilarity(str1: string, str2: string): number {
const tokens1 = new Set(str1.toLowerCase().split(/\s+/));
const tokens2 = new Set(str2.toLowerCase().split(/\s+/));
const intersection = new Set([...tokens1].filter(x => tokens2.has(x)));
const union = new Set([...tokens1, ...tokens2]);
return intersection.size / union.size;
}Step 6: Firebase Functions でスケジュール実行する
// functions/src/index.ts
import { onSchedule } from 'firebase-functions/v2/scheduler';
import { onRequest } from 'firebase-functions/v2/https';
export const processReviews = onSchedule(
{
schedule: '0 * * * *', // 毎時0分に実行
timeZone: 'Asia/Tokyo',
memory: '512MiB',
timeoutSeconds: 540, // 9分(Functions v2 の最大値)
},
async (event) => {
const since = new Date(Date.now() - 60 * 60 * 1000); // 1時間前
const collector = new AppStoreCollector({
keyId: process.env.APP_STORE_KEY_ID\!,
issuerId: process.env.APP_STORE_ISSUER_ID\!,
privateKeyPath: '/tmp/AuthKey.p8',
bundleId: process.env.BUNDLE_ID\!,
});
// レビュー取得
const rawReviews = await collector.fetchRecentReviews(
process.env.APP_STORE_APP_ID\!,
since
);
if (rawReviews.length === 0) {
console.log('No new reviews found');
return;
}
console.log(`Processing ${rawReviews.length} new reviews...`);
// Gemma 4 で分析(バッチサイズ5、間隔1秒でレート制限対策)
const analyzedReviews = await analyzeReviews(rawReviews, {
batchSize: 5,
delayMs: 1000,
});
// 各レビューを処理
for (const review of analyzedReviews) {
// バグのみ GitHub Issues に変換
if (review.category === 'bug' && review.extractedBugs.length > 0) {
await createIssueFromReview(
review,
process.env.GITHUB_REPO_OWNER\!,
process.env.GITHUB_REPO_NAME\!
);
}
// 返信が必要なレビューを処理
if (review.rating <= 3 || review.urgency === 'critical') {
await generateAndPostReply(review, collector, {
autoPost: review.urgency === 'critical',
notifySlack: true,
});
}
}
// 処理サマリーをログに記録
const summary = {
total: analyzedReviews.length,
bugs: analyzedReviews.filter(r => r.category === 'bug').length,
critical: analyzedReviews.filter(r => r.urgency === 'critical').length,
avgSentiment: analyzedReviews.reduce((sum, r) => sum + r.sentiment, 0) / analyzedReviews.length,
};
console.log('Processing summary:', JSON.stringify(summary));
}
);Step 7: 本番運用のコスト最適化
Gemma 4 APIの費用は、呼び出し回数とトークン数に比例します。月間1万件のレビューを処理する場合、何も考えずに実装すると月数万円のコストになることがあります。以下の最適化が重要です。
1. フィルタリングで不要な呼び出しを削減
// 高評価 + 既に返信済み → 分析不要
const reviewsToAnalyze = rawReviews.filter(review => {
if (review.rating >= 5 && \!review.body?.length) return false; // 絵文字のみのレビュー
if (review.developerReply) return false; // 返信済み
if (review.body.length < 10) return false; // 短すぎるレビュー
return true;
});2. Gemma 4 モデルの使い分け
// 通常レビュー: gemma-4-flash(速い・安い)
// バグ報告疑いのレビュー(1-2星 + 長い本文): gemma-4(精度が高い)
const modelName = (review.rating <= 2 && review.body.length > 100)
? 'gemma-4'
: 'gemma-4-flash';3. キャッシュで重複分析を防ぐ
同じ内容のバグ報告が複数のユーザーから届くことがあります。Firestore に分析結果をキャッシュし、類似レビューは再分析せずにキャッシュから取得します。
この最適化を組み合わせることで、実際のコストを予測コストの20〜30%に抑えられます。
Step 8: よくある落とし穴と解決策
実際にこのシステムを運用して直面した問題を共有します。
落とし穴1: App Store Connect APIの返信制限
App Store Connectでは、同じレビューに対して返信を複数回更新できますが、同一IPからの大量リクエストはレート制限を受けます。Firebase Functionsを使う場合、インスタンスのIPが変わるため通常は問題ありませんが、短時間に大量の返信を投稿しようとすると 429 Too Many Requests が返ることがあります。
解決策: 1分あたりの投稿数を10件以下に制限し、超過分はFirestoreのキューに積んで次の実行に持ち越します。
落とし穴2: Gemma 4 の JSON レスポンスが時々崩れる
レビューの本文に引用符や特殊文字が含まれると、Gemma 4 が生成するJSONが壊れることがあります。
解決策: response_mime_type: 'application/json' を指定することで、構造化出力が安定します。
const result = await model.generateContent({
contents: [{ role: 'user', parts: [{ text: prompt }] }],
generationConfig: {
responseMimeType: 'application/json', // 構造化出力を強制
temperature: 0.3, // 決定論的な出力
},
});落とし穴3: バグIssueの重複作成
同じバグが複数のユーザーから報告された場合、単純に実装すると同内容のIssueが何件も作られてしまいます。Jaccard係数による類似度チェックは有効ですが、誤検知が起きることがあります。
解決策: GitHub IssueのラベルとFirestoreの両方でバグのフィンガープリント(バグタイトルのハッシュ値)を管理します。
次のアクション
まずは小さく始めることをお勧めします。最初は「レビューを取得してSlackに転送するだけ」のシンプルな実装から始め、そこに徐々に分析機能、自動返信、GitHub Issues連携を追加していく順序が、実際に稼働するシステムに仕上げる近道です。
App Store Connect API のセットアップが完了したら、fetchRecentReviews メソッドを単独で実行してみてください。レビューのJSONが取得できた時点で、このシステムの半分は完成したも同然です。
Antigravityを使うと、このシステムの初期実装を1日で完成させることができます。型定義とアーキテクチャの骨格をプロンプトで渡すだけで、各モジュールのコード生成、エラーハンドリングの補完、TypeScriptエラーの修正まで一気に進みます。アプリ収益化の参考にはAdMob×Firebase Analytics 収益マスターガイドや、App Store Connect APIの基本についてはApp Store Connect API 完全自動化ガイドもあわせてご覧ください。
実際のプロジェクトに取り入れる際は、まず本番ではなくステージング環境でテストし、Slackへの通知だけが届く状態で数日間様子を見てからAutoPost機能を有効にすることをお勧めします。安全に動かすことが、長期運用の前提条件です。