RORK LABEN
PLAY — Google Play の target API level 36 要件が昨日8月31日に発効しました。今日以降、新規アプリと既存アプリの更新は Android 16 対応が必須ですVISIBILITY — API 35 のままのアプリは掲載こそ続きますが、新しい Android 版のユーザーには表示されなくなります。エラーが出ないまま新規インストールだけが減る点に注意が要りますEXTENSION — 間に合わなかった場合は、Play Console から2026年11月1日までの延長申請が出せます。恒久対応の計画とセットで進めるのが実務的ですAPPLE — Apple 側は9月9日にイベント、iOS 27 の正式リリースは9月14日と報じられています。生成したアプリの iOS 27 実機確認はリリース週の前に済ませておきたいところですEXPO — Expo が expo-paste-input を公開しました(8月28日)。React Native の TextInput に画像・GIF・ステッカーの貼り付けを追加するネイティブモジュールですEAS — EAS Observe が8月20日に GA になりました。クラッシュや性能の観測を、ビルドや配信と同じ EAS 上で持てるようになっていますPLAY — Google Play の target API level 36 要件が昨日8月31日に発効しました。今日以降、新規アプリと既存アプリの更新は Android 16 対応が必須ですVISIBILITY — API 35 のままのアプリは掲載こそ続きますが、新しい Android 版のユーザーには表示されなくなります。エラーが出ないまま新規インストールだけが減る点に注意が要りますEXTENSION — 間に合わなかった場合は、Play Console から2026年11月1日までの延長申請が出せます。恒久対応の計画とセットで進めるのが実務的ですAPPLE — Apple 側は9月9日にイベント、iOS 27 の正式リリースは9月14日と報じられています。生成したアプリの iOS 27 実機確認はリリース週の前に済ませておきたいところですEXPO — Expo が expo-paste-input を公開しました(8月28日)。React Native の TextInput に画像・GIF・ステッカーの貼り付けを追加するネイティブモジュールですEAS — EAS Observe が8月20日に GA になりました。クラッシュや性能の観測を、ビルドや配信と同じ EAS 上で持てるようになっています
記事一覧/ビジネス
ビジネス/2026-05-13上級

Rork アプリのユーザーレビューを AI に読ませたら、次に作るべき機能が3分で見えてきた

App Store・Google Play のレビューを Claude API で自動収集・感情分析・機能優先度スコアリングするシステムを Rork アプリに構築する全工程。バッチ分析が静かに欠落する問題の切り分けまで、動作確認済みコードとともに解説します。

App Store88フィードバック分析Claude API11機能優先度ユーザーリサーチ感情分析2個人開発206収益化66

壁紙アプリを 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円程度でした。これなら個人開発でも十分に継続できます。

20件送ったのに17件しか返ってこない — 集計の前に照合する

このシステムを組んでしばらく経った頃、同じ月のレビューを2回流すと「機能リクエスト」と「パフォーマンス」の順位が入れ替わる、という現象に気づきました。乱数はどこにも使っていません。入力も同じです。

原因は、上のコードの末尾にあります。

} catch (e) {
  console.error('JSON parse error for batch:', e);
}

パースに失敗したバッチ20件は、ここでログ1行と引き換えに消えます。呼び出し側からは正常終了に見えます。集計関数は「届いた分」を母数として割合を出すので、消えたことに気づく手がかりが残りません。

厄介なのは、この欠落がランダムではないことです。

max_tokens: 2000 に対して、20件 × 6フィールドの JSON を日本語で返させると、1件あたりおよそ90〜120トークン。20件で1,800〜2,400トークンと、ちょうど境界に乗ります。超えるかどうかを決めるのは feature_request の長さで、これが伸びるのは「具体的な要望を丁寧に書いた人」のレビューです。つまり、最も情報量の多いレビューほど、自分を含むバッチを切り詰め側に押し込みます。

そして出力が切れるのは配列の後半です。レビューを取得順(新しい順)に詰めていれば、後半に来るのは古いレビュー。欠落は時間方向にも偏ります。

もう一つ、件数だけでは気づけない壊れ方があります。

review_id: batch[r.index].id

モデルが index を1つ取り違えて範囲外の値を返すと、batch[r.index]undefined になり、.id の参照で TypeError が飛びます。この maptry の中にあるので、例外は同じ catch に落ちます。1件の取り違えで、そのバッチ20件が丸ごと消えるということです。

症状実際に起きていること確認方法
同じ入力なのにカテゴリの順位が変わるバッチ単位の欠落。落ちるバッチが実行ごとに違う入力件数と results.length を毎回ログに残す
「機能リクエスト」だけ件数が不自然に少ないfeature_request が長い回答ほど出力が膨らみ、切り詰めの対象になるstop_reasonmax_tokens だったバッチを数える
古いレビューが毎回いなくなる切れるのは配列の後半。取得順に詰めると後半=古い側欠落した review_id の日付分布を見る
20件がまとめて消えるindex の取り違えで batch[r.index] が undefined になり map 内で throw範囲チェックを一時的に外し、例外を握りつぶさずに送出させる
件数は合うのに中身が入れ替わる同じ index が2回返り、後勝ちで上書きされているindex の重複数を数える

対処は、賢いプロンプトではなく照合です。送った件数と返ってきた件数を突き合わせ、足りない分だけを小さく割って取り直します。

// workers/analyze-reviews.js(照合つき)
 
// 1件あたりの出力トークンを実測の上振れ側で見積もる
const TOKENS_PER_REVIEW = 140;
 
async function callClaude(batch, apiKey) {
  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',
      // 固定値にしない。件数に比例させる
      max_tokens: Math.min(8000, batch.length * TOKENS_PER_REVIEW + 200),
      messages: [{ role: 'user', content: buildPrompt(batchText) }]
    })
  });
 
  if (!response.ok) throw new Error('HTTP ' + response.status);
 
  const data = await response.json();
 
  // JSON.parse を試す前に見る。切れたことはここで分かる
  if (data.stop_reason === 'max_tokens') throw new Error('truncated');
 
  const parsed = JSON.parse(data.content[0].text);
  if (!Array.isArray(parsed)) throw new Error('not an array');
  return parsed;
}
 
// 1バッチを「全件そろうまで」解決する
async function resolveBatch(batch, apiKey, depth = 0) {
  const resolved = new Map(); // batch 内の index -> 分析結果
 
  try {
    for (const item of await callClaude(batch, apiKey)) {
      const i = Number(item.index);
      // 範囲外・重複はここで捨てる。undefined 参照で throw させない
      if (!Number.isInteger(i) || i < 0 || i >= batch.length) continue;
      if (resolved.has(i)) continue;
      resolved.set(i, {
        ...item,
        review_id: batch[i].id,
        rating: batch[i].rating,
        date: batch[i].date
      });
    }
  } catch (e) {
    // 握りつぶさない。下の再取得に進むための失敗として扱う
  }
 
  const missing = batch.map((_, i) => i).filter(i => !resolved.has(i));
  if (missing.length === 0) {
    return { results: [...resolved.values()], failed: [] };
  }
 
  // 1件まで割っても駄目なら、失敗として記録して先へ進む
  if (batch.length === 1 || depth >= 4) {
    return {
      results: [...resolved.values()],
      failed: missing.map(i => batch[i])
    };
  }
 
  const targets = missing.map(i => batch[i]);
  const mid = Math.ceil(targets.length / 2);
  const head = await resolveBatch(targets.slice(0, mid), apiKey, depth + 1);
  const tail = await resolveBatch(targets.slice(mid), apiKey, depth + 1);
 
  return {
    results: [...resolved.values(), ...head.results, ...tail.results],
    failed: [...head.failed, ...tail.failed]
  };
}
 
async function analyzeReviewsBatch(reviews, apiKey) {
  const batchSize = 20;
  const results = [];
  const failed = [];
 
  for (let i = 0; i < reviews.length; i += batchSize) {
    const r = await resolveBatch(reviews.slice(i, i + batchSize), apiKey);
    results.push(...r.results);
    failed.push(...r.failed);
    await new Promise(resolve => setTimeout(resolve, 500));
  }
 
  return {
    results,
    failed,
    lossRate: reviews.length === 0 ? 0 : failed.length / reviews.length
  };
}

戻り値を配列から { results, failed, lossRate } に変えたのは意図的です。呼び出し側が取りこぼし率を無視できなくなります。

const { results, failed, lossRate } = await analyzeReviewsBatch(reviews, apiKey);
 
if (lossRate > 0.03) {
  // 順位を出さない。3%を超えると下位カテゴリの並びが入れ替わる
  return { status: 'degraded', lossRate, analyzed: results.length, dropped: failed.length };
}

3%という線は、上位2カテゴリの差がそれ以上の幅で開いていることが多かったので置いた実務上の閾値です。母数が小さいうちは、もう少し厳しくしても構いません。

分割して取り直す分、API 呼び出しは増えます。ただ増えるのは失敗した箇所だけで、しかも Haiku です。私の環境では月あたりの請求額はほとんど動きませんでした。順位表が信用できるかどうかとは釣り合わない額です。

ダッシュボードには、スコアの隣に取りこぼし率をそのまま出しています。順位表というものは、どれだけ雑に作られていても順位表の顔をして出てくる、と実装しながら思い知ったためです。

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)」のように英語キーワードを括弧で補足するだけで、精度が大きく改善しました。

個人開発者に伝えたい「ユーザーの声の聞き方」

レビューを長く読んできて一番強く感じるのは、「ユーザーの声を聞く」ことと「ユーザーの言葉をそのまま実装する」ことは、まったく別の作業だということです。

レビューに「○○の機能を追加してほしい」と書かれていても、それが本当に必要なのかは、その背後にある課題を見なければ分かりません。AI による分析は、個々のレビューの感情ノイズを除去して、「何が本当に問題になっているか」のパターンを浮かび上がらせてくれます。

ただ、スコアは答えではなく、質問の出発点だと考えています。「起動速度が23%」という数字は、なぜ遅いと感じられているのかを調べる理由にはなりますが、何をどう直すべきかまでは教えてくれません。そこから先は結局、該当カテゴリのレビュー原文を10件ほど読み直す作業に戻ります。

AI が減らしてくれたのは、読む量そのものよりも、どれを読むかを決めるときの迷いの方でした。

このシステムを導入してから、機能開発の優先順位について迷う時間が大幅に減りました。もちろん AI の分析が全てではなく、最終的な判断は自分でしますが、データの裏付けがあると、ユーザーとのやり取りがより誠実になったと感じています。

まず App Store Connect API のキーを取得して、手元の1ヶ月分のレビューで走らせてみてください。最初の集計が出たとき、「あ、そうだったのか」と思える瞬間がだいたい一度は来ます。ただしその前に、入力件数と分析件数が一致していることだけは確かめてください。順位を眺めるのは、その後で十分です。

レビューの読み方で似た引っかかり方をしている方に、少しでも届けばと思っています。

シェア

お読みいただきありがとうございます

Rork Lab は広告なしで運営しており、サーバー費用などの運営コストはメンバーシップのご支援で賄っています。実装コード・ベンチマーク・本番設計パターンなど、実務でお役立ていただける記事を毎日更新しています。もし読んでよかったと感じていただけましたら、ぜひご覧ください。

  • コピー&ペーストで使える実装コード付き
  • 毎日新しい上級ガイドを追加
  • ¥580/月 または ¥2,480 の永久アクセス
メンバーシップを見る →

もしこの記事がお役に立ちましたら、チップ(¥150)で応援いただけると大変励みになります。広告なしでの運営を続けるため、皆さまのご支援が大きな力になっています。

関連記事

ビジネス2026-05-15
運用中の壁紙アプリをRork Maxで作り直した記録:工程別の実作業時間と再リリース後の指標
運用中の壁紙アプリをRork Maxでゼロから再実装し、工程ごとに実作業時間を計測しました。生成コードが取り違えた仕様、既存サブスクリプションの引き継ぎ手順、再リリース後30日のクラッシュ率と転換率まで数字で記録しています。
ビジネス2026-04-27
Rork で作ったアプリを「ストアで売れる状態」まで持っていく仕上げ工程
Rorkで素早く形にしたアプリを、ストアに並べて収益化に乗せるまでの仕上げ工程です。アイコンと名前の精度、プライバシー設定、リジェクト頻発ポイント、ローカライズ、AdMobの配置、レビュー依頼のタイミングなど見落としがちな確認項目を共有します。
ビジネス2026-04-27
Rork Max アプリで最初の$1を稼ぐまでの最短チェックリスト — 個人開発者がApp Store公開と初収益までに必ずやること
Rork Max でアプリを作っても、App Storeに出すまでで止まってしまう個人開発者は多いです。SwiftUIネイティブ生成の強みを活かしつつ、初の$1を稼ぐまでに必ずやるべきことを、現実的なチェックリストでまとめました。
📚RECOMMENDED BOOKS
大規模言語モデル入門
山田育矢
LLM開発
生成AIプロンプトエンジニアリング入門
我妻幸長
プロンプト
Claude CodeによるAI駆動開発入門
平川知秀
AI駆動開発
※ アフィリエイトリンクを含みます
もっと見る →