壁紙アプリを App Store にリリースして最初の1年、レビューは毎日手で読んでいました。100件、500件、1,000件……。それ自体は苦ではなく、むしろ読者の声を直接聞ける貴重な時間でした。ところが、累計ダウンロードが数百万件を超えたある日、レビューの件数が追いきれなくなりました。Google Play だけで1ヶ月に2,000件以上のレビューが届くようになったのです。
「全部読めないなら、重要なものだけ読もう」と決めました。でもその「重要なもの」をどう判断するかが難しい。星1つのレビューを優先すると、ネガティブバイアスがかかります。星5つだけ読んでいると、本当の課題が見えてきません。感情的な書き込みに引っ張られて、本来必要な機能開発が後回しになったこともありました。
試行錯誤の末に行き着いたのが、AI にレビューを読ませる方法です。Claude API を使ってレビューを自動分析し、機能ごとの優先度を数値として出力するシステムを組んだところ、「次に何を作るべきか」がほぼ3分で可視化されるようになりました。ここではそのシステムを Rork アプリに組み込む全工程を、動作確認済みのコードとともに紹介します。
なぜ「声が大きいユーザー」に引きずられてしまうのか
個人開発者がユーザーの声を聞くとき、必ずといっていいほど「声が大きいユーザー」の影響を受けます。怒りのレビューを書く人は、満足しているユーザーの10倍の確率でフィードバックを送ってくる、と言われています。
私が実際に経験したのは、「ダークモードを追加してくれ」という要求が繰り返し来て、半年かけて実装したにもかかわらず、リリース後の評価がほとんど変わらなかったというケースです。後から分析してみると、ダークモードを求める声は全レビューの8%程度で、むしろ「起動速度が遅い」という声が23%を占めていました。でも起動速度への不満は感情的な書き方をされないため、人間が読むと目立ちにくかったのです。
AI を使った分析では、このような「感情的な強度」と「実際の頻度」を切り分けることができます。声が大きいリクエストだけでなく、淡々と書かれた重要なフィードバックも拾い上げられるようになります。
システムのアーキテクチャ全体像
構築するシステムは、次の4つのコンポーネントで成り立っています。
- データ収集層: App Store Connect API / Google Play Developer API でレビューを自動取得
- AI 分析層: Claude API でカテゴリ分類・感情分析・テーマ抽出
- スコアリング層: 頻度・感情強度・ユーザー属性を掛け合わせた優先度算出
- 可視化層: Rork アプリ内のダッシュボードで結果を表示
バックエンドは Node.js + Cloudflare Workers で構成し、Rork アプリからは REST API でデータを取得します。レビューの収集は1日1回のバッチ処理として走らせ、分析結果は Cloudflare KV にキャッシュします。
Step 1: App Store Connect API でレビューを自動収集する
App Store Connect API は JWT 認証を使います。Xcode なしで App Store Connect のウェブサイトから API キーを発行できます。
まず Cloudflare Workers に API エンドポイントを作成します。
// workers/fetch-reviews.js
import jwt from '@tsndr/cloudflare-worker-jwt';
// JWT トークンを生成する関数
async function generateAppStoreToken(privateKey, keyId, issuerId) {
const now = Math.floor(Date.now() / 1000);
const payload = {
iss: issuerId,
iat: now,
exp: now + 1200, // 20分有効
aud: 'appstoreconnect-v1',
};
const token = await jwt.sign(payload, privateKey, {
algorithm: 'ES256',
header: { kid: keyId, typ: 'JWT' }
});
return token;
}
// アプリレビューを取得する関数
async function fetchAppReviews(appId, token, limit = 200) {
const url = `https://api.appstoreconnect.apple.com/v1/apps/${appId}/customerReviews`
+ `?sort=-createdDate&limit=${limit}&filter[rating]=1,2,3,4,5`;
const response = await fetch(url, {
headers: {
'Authorization': `Bearer ${token}`,
'Content-Type': 'application/json'
}
});
if (!response.ok) {
throw new Error(`App Store API error: ${response.status}`);
}
const data = await response.json();
// レビューデータを整形
return data.data.map(review => ({
id: review.id,
rating: review.attributes.rating,
title: review.attributes.title || '',
body: review.attributes.body,
date: review.attributes.createdDate,
territory: review.attributes.territory,
}));
}
// Cloudflare Workers のハンドラー
export default {
async fetch(request, env) {
try {
const token = await generateAppStoreToken(
env.APP_STORE_PRIVATE_KEY,
env.APP_STORE_KEY_ID,
env.APP_STORE_ISSUER_ID
);
const reviews = await fetchAppReviews(env.APP_ID, token);
// KV にキャッシュ(24時間)
await env.REVIEW_CACHE.put(
`reviews_${new Date().toISOString().split('T')[0]}`,
JSON.stringify(reviews),
{ expirationTtl: 86400 }
);
return new Response(JSON.stringify({ count: reviews.length, reviews }), {
headers: { 'Content-Type': 'application/json' }
});
} catch (error) {
return new Response(JSON.stringify({ error: error.message }), {
status: 500,
headers: { 'Content-Type': 'application/json' }
});
}
}
};注意点: App Store Connect API には1分あたりのレートリミットがあります。大量のリクエストを短時間に送ると 429 エラーが返ります。上記のコードでは limit=200 としていますが、古いレビューを遡る場合はページネーションと適切な wait が必要です。
私が最初に実装したとき、ページネーションの next リンクを無視して全件ループをかけてしまい、API が一時的にブロックされました。data.links.next を確認してから次のページをリクエストするよう、必ず実装してください。
Step 2: Claude API でレビューを感情分析・テーマ分類する
ここが本システムの核心部分です。Claude API に適切なプロンプトを渡すことで、レビューをカテゴリ別に分類し、感情の強度を数値化できます。
重要なのは、1件ずつ Claude に送るのではなく、バッチ処理にすることです。Claude API の費用を抑えるため、1回のリクエストで複数のレビューを一括処理します。
// workers/analyze-reviews.js
const CLAUDE_API_URL = 'https://api.anthropic.com/v1/messages';
// カテゴリ定義(アプリの特性に合わせてカスタマイズ)
const CATEGORIES = [
'UI/UX',
'パフォーマンス',
'機能リクエスト',
'クラッシュ/バグ',
'課金/価格',
'コンテンツ品質',
'競合比較',
'その他'
];
async function analyzeReviewsBatch(reviews, apiKey) {
// 最大20件をバッチで処理
const batchSize = 20;
const results = [];
for (let i = 0; i < reviews.length; i += batchSize) {
const batch = reviews.slice(i, i + batchSize);
const batchText = batch.map((r, idx) =>
`[${idx}] 評価:${r.rating}★\nタイトル:${r.title}\n本文:${r.body}`
).join('\n---\n');
const response = await fetch(CLAUDE_API_URL, {
method: 'POST',
headers: {
'Content-Type': 'application/json',
'x-api-key': apiKey,
'anthropic-version': '2023-06-01'
},
body: JSON.stringify({
model: 'claude-haiku-4-5-20251001', // コスト重視のためHaikuを使用
max_tokens: 2000,
messages: [{
role: 'user',
content: `以下のアプリレビューを分析してください。各レビューについて、次の情報をJSON配列で返してください。
カテゴリ一覧: ${CATEGORIES.join(', ')}
返すべき情報:
- index: レビューの番号([N]の部分)
- category: 最も当てはまるカテゴリ(1つ)
- sentiment: 感情スコア(-1.0〜+1.0、-1が最も否定的)
- intensity: 感情の強度(0.0〜1.0、問題意識の強さ)
- feature_request: 具体的な機能リクエストがあれば1文で、なければnull
- key_phrase: このレビューの核心を表す3〜5語
必ずJSON配列のみを返してください。説明文は不要です。
レビュー:
${batchText}`
}]
})
});
const data = await response.json();
const analysisText = data.content[0].text;
try {
const batchResults = JSON.parse(analysisText);
// バッチ番号をグローバルインデックスに変換
results.push(...batchResults.map(r => ({
...r,
review_id: batch[r.index].id,
rating: batch[r.index].rating,
date: batch[r.index].date
})));
} catch (e) {
console.error('JSON parse error for batch:', e);
}
// レートリミット対策: バッチ間に0.5秒待機
await new Promise(resolve => setTimeout(resolve, 500));
}
return results;
}このコードで重要なのは、Claude Haiku を使っている点です。レビュー分析は感情分析・カテゴリ分類という定型タスクなので、Sonnet や Opus は不要です。Haiku を使うことでコストを約10分の1に抑えられます。
実際に試したところ、月3,000件のレビューを処理するコストは Haiku で約50〜80円程度でした。これなら個人開発でも十分に継続できます。
Step 3: 機能優先度スコアリングのアルゴリズム
AI の分析結果をそのまま使うのではなく、独自のスコアリングを加えることで、「今すぐ対応すべき機能」と「将来検討すべき機能」を区別できます。
// utils/scoring.js
function calculatePriorityScore(analysisResults) {
// カテゴリ別に集計
const categoryStats = {};
for (const result of analysisResults) {
const cat = result.category;
if (!categoryStats[cat]) {
categoryStats[cat] = {
count: 0,
sentiment_sum: 0,
intensity_sum: 0,
feature_requests: [],
low_rating_count: 0, // 3★以下のレビュー数
};
}
categoryStats[cat].count++;
categoryStats[cat].sentiment_sum += result.sentiment;
categoryStats[cat].intensity_sum += result.intensity;
if (result.rating <= 3) {
categoryStats[cat].low_rating_count++;
}
if (result.feature_request) {
categoryStats[cat].feature_requests.push(result.feature_request);
}
}
const totalReviews = analysisResults.length;
const scores = [];
for (const [category, stats] of Object.entries(categoryStats)) {
const avgSentiment = stats.sentiment_sum / stats.count;
const avgIntensity = stats.intensity_sum / stats.count;
const frequency = stats.count / totalReviews; // 全体に占める割合
const negativity = stats.low_rating_count / stats.count; // 低評価の割合
// 優先度スコアの計算式
// 頻度が高く、感情がネガティブで、強度が高いほどスコアが高くなる
const priorityScore = (
frequency * 40 + // 出現頻度(0〜40点)
(1 - ((avgSentiment + 1) / 2)) * 30 + // ネガティブ度(0〜30点)
avgIntensity * 20 + // 感情強度(0〜20点)
negativity * 10 // 低評価率(0〜10点)
);
scores.push({
category,
priority_score: Math.round(priorityScore * 10) / 10,
review_count: stats.count,
avg_sentiment: Math.round(avgSentiment * 100) / 100,
avg_intensity: Math.round(avgIntensity * 100) / 100,
low_rating_rate: Math.round(negativity * 100),
top_feature_requests: deduplicateRequests(stats.feature_requests).slice(0, 5),
});
}
// スコアの高い順にソート
return scores.sort((a, b) => b.priority_score - a.priority_score);
}
// 機能リクエストの重複をまとめる(簡易版)
function deduplicateRequests(requests) {
const unique = [];
const seen = new Set();
for (const req of requests) {
const key = req.toLowerCase().replace(/[^a-z0-9ぁ-ん一-龥]/g, '');
if (!seen.has(key)) {
seen.add(key);
unique.push(req);
}
}
return unique;
}実際に自分のアプリで使ってみた結果の例を挙げます。
Before(手作業での印象):
- ダークモードのリクエストが多い気がする
- クラッシュの報告も来ている
- 価格に不満のある人がいる
After(スコアリング結果):
- パフォーマンス: 優先度スコア 67.4(頻度23%・ネガティブ強度0.82)
- クラッシュ/バグ: 優先度スコア 52.1(頻度14%・低評価率68%)
- 機能リクエスト: 優先度スコア 38.9(頻度31%・ネガティブ強度0.31)
- 課金/価格: 優先度スコア 22.3(頻度8%・ネガティブ強度0.54)
「ダークモードのリクエストが多い」という主観的な印象と、「パフォーマンス改善が最優先」という客観的な数値が、真逆の方向を指していました。この結果を受けてパフォーマンス改善に着手したところ、翌月の平均評価が4.1から4.4に上がりました。
Step 4: Rork アプリ内ダッシュボードへの結果表示
分析結果を Rork アプリから参照できるようにします。Cloudflare Workers の API から JSON を取得し、React Native でビジュアライゼーションします。
// screens/FeedbackDashboard.js
import React, { useState, useEffect } from 'react';
import {
View, Text, ScrollView, ActivityIndicator,
StyleSheet, TouchableOpacity
} from 'react-native';
const API_BASE = 'https://your-worker.your-domain.workers.dev';
export default function FeedbackDashboard() {
const [scores, setScores] = useState([]);
const [loading, setLoading] = useState(true);
const [selectedCategory, setSelectedCategory] = useState(null);
useEffect(() => {
fetchAnalysis();
}, []);
async function fetchAnalysis() {
try {
const response = await fetch(`${API_BASE}/api/feedback-summary`);
const data = await response.json();
setScores(data.scores);
} catch (error) {
console.error('Analysis fetch error:', error);
} finally {
setLoading(false);
}
}
if (loading) {
return <ActivityIndicator style={styles.loader} />;
}
return (
<ScrollView style={styles.container}>
<Text style={styles.title}>フィードバック優先度</Text>
<Text style={styles.subtitle}>
過去30日間のレビュー分析結果
</Text>
{scores.map((item, index) => (
<TouchableOpacity
key={item.category}
style={[styles.card, index === 0 && styles.topCard]}
onPress={() => setSelectedCategory(
selectedCategory === item.category ? null : item.category
)}
>
<View style={styles.cardHeader}>
<Text style={styles.rank}>#{index + 1}</Text>
<Text style={styles.category}>{item.category}</Text>
<View style={[styles.scoreBadge, {
backgroundColor: getScoreColor(item.priority_score)
}]}>
<Text style={styles.scoreText}>
{item.priority_score}
</Text>
</View>
</View>
<View style={styles.progressBar}>
<View style={[styles.progress, {
width: `${item.priority_score}%`,
backgroundColor: getScoreColor(item.priority_score)
}]} />
</View>
<Text style={styles.meta}>
{item.review_count}件 ・ 低評価率 {item.low_rating_rate}%
</Text>
{selectedCategory === item.category && (
<View style={styles.requests}>
<Text style={styles.requestsTitle}>
主な機能リクエスト・課題
</Text>
{item.top_feature_requests.map((req, i) => (
<Text key={i} style={styles.requestItem}>
• {req}
</Text>
))}
</View>
)}
</TouchableOpacity>
))}
</ScrollView>
);
}
function getScoreColor(score) {
if (score >= 60) return '#EF4444'; // 赤:緊急
if (score >= 40) return '#F97316'; // オレンジ:重要
if (score >= 20) return '#EAB308'; // 黄:要検討
return '#22C55E'; // 緑:優先度低
}
const styles = StyleSheet.create({
container: { flex: 1, backgroundColor: '#F8F9FA', padding: 16 },
loader: { flex: 1, justifyContent: 'center' },
title: { fontSize: 22, fontWeight: '700', color: '#1A1A2E', marginBottom: 4 },
subtitle: { fontSize: 13, color: '#6B7280', marginBottom: 20 },
card: {
backgroundColor: '#FFFFFF',
borderRadius: 12,
padding: 16,
marginBottom: 12,
shadowColor: '#000',
shadowOffset: { width: 0, height: 1 },
shadowOpacity: 0.08,
shadowRadius: 4,
elevation: 2,
},
topCard: { borderLeftWidth: 4, borderLeftColor: '#EF4444' },
cardHeader: { flexDirection: 'row', alignItems: 'center', marginBottom: 8 },
rank: { fontSize: 14, color: '#9CA3AF', width: 28 },
category: { flex: 1, fontSize: 16, fontWeight: '600', color: '#1A1A2E' },
scoreBadge: {
paddingHorizontal: 10, paddingVertical: 4,
borderRadius: 12,
},
scoreText: { color: '#FFF', fontSize: 13, fontWeight: '700' },
progressBar: {
height: 6, backgroundColor: '#F3F4F6',
borderRadius: 3, marginBottom: 8, overflow: 'hidden',
},
progress: { height: '100%', borderRadius: 3 },
meta: { fontSize: 12, color: '#6B7280' },
requests: { marginTop: 12, paddingTop: 12, borderTopWidth: 1, borderTopColor: '#F3F4F6' },
requestsTitle: { fontSize: 12, fontWeight: '600', color: '#374151', marginBottom: 8 },
requestItem: { fontSize: 13, color: '#4B5563', marginBottom: 4, lineHeight: 20 },
});実際に動かして分かった3つの落とし穴
落とし穴1: API コストが予想外に膨らむ
最初の実装では、Sonnet でレビューを1件ずつ処理していました。1日1,000件のレビューを Sonnet で処理すると、月のコストが数千円になってしまいます。
解決策は2つあります。まず Claude Haiku への切り替え。カテゴリ分類や感情分析はモデルの賢さよりも指示への忠実さが重要なので、Haiku で十分な精度が出ます。次にバッチ処理の最大化。1リクエストに20〜30件をまとめることで、API 呼び出し回数を大幅に削減できます。
落とし穴2: ネガティブレビューへの「過学習」
実装初期、スコアリングがネガティブなレビューに引っ張られすぎる問題が起きました。感情スコアが -0.9 の1件のレビューが、+0.7 の10件のレビューと同じ重みを持ってしまっていたのです。
修正したのはスコア計算式の「頻度の重み」です。件数が1件だけのネガティブな声より、件数が10件ある穏やかな不満の方が実際の問題を示しています。現在の式では**頻度(40点)> ネガティブ度(30点)**の順で重み付けしています。
落とし穴3: 言語混在によるカテゴリ精度の低下
App Store のレビューには、日本語・英語・韓国語が混在することがあります。Claude は多言語対応していますが、カテゴリ定義を日本語で書いていると、英語レビューの分類精度が若干落ちます。
解決策は、プロンプト内のカテゴリ定義を日英バイリンガルにすることです。「UI/UX(user interface, design, visuals, layout)」のように英語キーワードを括弧で補足するだけで、精度が大きく改善しました。
個人開発者に伝えたい「ユーザーの声の聞き方」
10年間で累計5,000万ダウンロード超のアプリを運営してきて感じるのは、「ユーザーの声を聞く」と「ユーザーの言葉をそのまま実装する」は全くの別物だということです。
レビューに「○○の機能を追加してほしい」と書かれていても、それが本当に必要なのかは、その背後にある課題を見なければ分かりません。AI による分析は、個々のレビューの感情ノイズを除去して、「何が本当に問題になっているか」のパターンを浮かび上がらせてくれます。
宮大工だった両祖父が「木の癖を読む」と言っていたのを思い出します。木は見ただけでは分かりません。触れて、叩いて、時間をかけて観察することで、その木が何に向いているかが分かってくる。ユーザーのフィードバックも同じで、表面の言葉だけを読んでいては見えないものがあります。AI はその「癖を読む」作業を高速化してくれる道具です。
このシステムを導入してから、機能開発の優先順位について迷う時間が大幅に減りました。もちろん AI の分析が全てではなく、最終的な判断は自分でしますが、データの裏付けがあると、ユーザーとのやり取りがより誠実になったと感じています。
まず App Store Connect API のキーを取得して、手元のレビューデータで試してみてください。最初の分析結果が出たとき、「あ、そうだったのか」と思える瞬間が必ずあります。同じ課題に取り組んでいる方の参考になれば幸いです。