本棚を全部スマホで撮影して、読んだ本と積ん読の割合を出してみたことがあります。結果は、自宅にある約 340 冊のうち「最後まで読んだ」と自信を持って言えるのが 120 冊程度でした。残りは最初の 1 章だけ読んで止まっていたり、買ったまま手付かずだったりします。本は好きなのに、読書という行為そのものが管理できていないのです。
この体験が、今回つくる AI 読書アシスタントアプリの出発点でした。読書記録アプリは山ほどありますが、ほとんどは「読んだ本を手動で登録するデータベース」で止まっています。私が欲しかったのは、本棚を撮ればまとめて登録してくれて、読んでいる本の章ごとに要約と問いを生成して、積ん読になった本には優しくリマインドしてくれる相棒です。
ここではRork 上で React Native + Expo + Supabase + Claude API を組み合わせて、本棚スキャンから章別要約、パーソナライズされた読書提案までを実装する完全ガイドをお届けします。コードはコピーして動くレベルで掲載し、よくある詰まりポイントと、実際に App Store で月額課金アプリとして運用するためのサブスクリプション設計まで踏み込みます。
このアプリのコア体験を 30 秒で理解する
読書アプリの勝負どころは「最初の 3 分以内で 5 冊以上の本を登録できるか」です。手動で ISBN を入力させるアプリは、ここで 80% のユーザーを失います。私が設計したフローはこうです。
まずユーザーが本棚を撮影します。1 枚でも複数枚でも構いません。アプリは画像を Vision API に渡して背表紙のテキストを OCR し、同時に Claude Sonnet に同じ画像を渡して「タイトルと著者を JSON で返して」と依頼します。この 2 系統の結果をマージすると、OCR の精度不足を Claude の文脈理解が補ってくれて、単独で使うより一段上の認識精度が出ます。
認識された本は検索 API(Google Books API か楽天ブックス API)で正規化してメタデータと表紙画像を取得し、ユーザーが「これで合っている」と確認したものだけを蔵書に追加します。ここまでを 60 秒以内に終わらせるのが目標です。
次に読み始める本を選ぶと、章構成を推定して(目次が取れる本は目次から、取れない本は Claude に推定を依頼)、読んだページを記録していきます。章を読み終わるたびに Claude が「この章の要点 3 つ」と「あなたが次の章に進む前に考えておきたい問い」を生成します。ここが課金価値の中心です。
技術スタックと全体像
このアプリでは以下の構成を採用します。個人開発で回せるランニングコストと、月間 1,000 アクティブユーザーくらいまでスケールする余力を両立させる選択です。
クライアント : Rork(React Native + Expo)
バックエンド : Cloudflare Workers + Hono(API サーバー)
データベース : Supabase(Postgres + Storage + Auth)
AI : Claude Sonnet 4.6(claude-sonnet-4-6)を本棚画像解析と章要約に使用、フォールバックとして Gemini 2.5 Pro を併用
画像 OCR : Apple Vision Framework(iOS 側・無料)と Gemini 2.5 Flash(Android・低コスト)
書籍メタデータ : Google Books API(無償・1 日 1,000 リクエストまで)+ 楽天ブックス API(日本書籍に強い)
課金 : RevenueCat + App Store Connect / Google Play Billing
分析 : PostHog(イベント単位分析)
画像を 2 系統の AI に投げるのはコスト的に高く見えますが、実測では 1 枚あたり Claude ¥4〜6 程度で済みます。オンボーディング中の 1 〜 2 回の本棚スキャンを無料枠として、以降の一括スキャンをサブスクリプション特典にすれば採算が合います。
本棚スキャン機能の実装 — 2 系統 AI のハイブリッド処理
一番難易度が高いのが本棚スキャンです。単純な OCR だけだと、背表紙の縦書き文字や装飾フォントで精度が大きく落ちます。Claude Sonnet に画像を渡すと、視覚的な文脈(表紙デザインやレイアウト)も踏まえてタイトルを推定してくれるので、OCR 結果と組み合わせると認識率が大きく改善します。
クライアント側:画像撮影と送信
Rork 上で expo-image-picker を使って本棚画像を取得します。解像度は 2048px 程度に抑えて転送コストを下げるのがポイントです。Vision API は小さい画像だと OCR 精度が落ちるので、2048px より下には落とさないようにします。
// app/(tabs)/scan.tsx
import { useState } from 'react' ;
import { View, Text, Pressable, Image, ActivityIndicator } from 'react-native' ;
import * as ImagePicker from 'expo-image-picker' ;
import * as ImageManipulator from 'expo-image-manipulator' ;
import { scanBookshelfApi } from '@/lib/api/bookshelf' ;
type DetectedBook = {
title : string ;
author ?: string ;
confidence : number ;
};
export default function BookshelfScanScreen () {
const [ image , setImage ] = useState < string | null >( null );
const [ books , setBooks ] = useState < DetectedBook [] | null >( null );
const [ loading , setLoading ] = useState ( false );
const [ error , setError ] = useState < string | null >( null );
const pickAndScan = async () => {
try {
setError ( null );
const result = await ImagePicker. launchCameraAsync ({
mediaTypes: ImagePicker.MediaTypeOptions.Images,
quality: 0.9 ,
allowsEditing: false ,
});
if (result.canceled) return ;
// 2048px を上限にリサイズ(転送コストと OCR 精度のバランス)
const manipulated = await ImageManipulator. manipulateAsync (
result.assets[ 0 ].uri,
[{ resize: { width: 2048 } }],
{ compress: 0.85 , format: ImageManipulator.SaveFormat. JPEG , base64: true },
);
setImage (manipulated.uri);
setLoading ( true );
const detected = await scanBookshelfApi (manipulated.base64\ ! );
setBooks (detected);
} catch ( e : any ) {
// ユーザーに見せるのは短いメッセージ。詳細は Sentry に送る
setError ( '本棚の認識に失敗しました。光が反射していない角度で撮り直してみてください。' );
console. error ( '[BookshelfScan]' , e);
} finally {
setLoading ( false );
}
};
return (
< View className = "flex-1 items-center justify-center p-4" >
< Pressable
onPress = { pickAndScan }
className = "bg-indigo-600 px-6 py-4 rounded-full"
accessibilityLabel = "本棚を撮影して蔵書を登録"
>
< Text className = "text-white font-semibold" >本棚を撮影する</ Text >
</ Pressable >
{ image && < Image source = { { uri: image } } className = "w-full h-64 mt-4 rounded-lg" /> }
{ loading && < ActivityIndicator className = "mt-4" /> }
{ error && < Text className = "text-red-500 mt-4" > { error } </ Text > }
{ books && (
< View className = "w-full mt-4" >
< Text className = "font-semibold mb-2" > { books. length } 冊認識しました</ Text >
{ books. map (( b , i ) => (
< Text key = { i } className = "text-sm text-gray-700" >
{ b.title }
{ b.author ? ` / ${ b . author }` : '' } (信頼度 { Math. round (b.confidence * 100 ) } %)
</ Text >
)) }
</ View >
) }
</ View >
);
}
撮影した画像はカメラアプリの「編集」機能に頼らず、送信側で一定サイズまでリサイズします。これはユーザー側の「何となくの操作」に依存しないためで、アプリの挙動が予測可能になります。
サーバー側:Vision + Claude の二重問い合わせ
Cloudflare Workers 上の Hono で、画像を受け取って 2 系統の AI に並列で投げる API を用意します。Promise.all で並列化しないと、合計レイテンシが 4〜6 秒に達してしまい体験が悪化します。
// workers/src/routes/bookshelf.ts
import { Hono } from 'hono' ;
import Anthropic from '@anthropic-ai/sdk' ;
import { GoogleGenerativeAI } from '@google/generative-ai' ;
type Env = {
ANTHROPIC_API_KEY : string ;
GEMINI_API_KEY : string ;
};
type DetectedBook = { title : string ; author ?: string ; confidence : number };
const app = new Hono <{ Bindings : Env }>();
app. post ( '/api/bookshelf/scan' , async ( c ) => {
const { imageBase64 } = await c.req. json <{ imageBase64 : string }>();
if (\ ! imageBase64 || imageBase64. length < 1000 ) {
return c. json ({ error: 'Invalid image data' }, 400 );
}
try {
// Claude と Gemini に並列で問い合わせる
const [ claudeResult , geminiResult ] = await Promise . allSettled ([
detectBooksWithClaude (imageBase64, c.env. ANTHROPIC_API_KEY ),
detectBooksWithGemini (imageBase64, c.env. GEMINI_API_KEY ),
]);
// 両者の結果をマージ(タイトルの Jaro 距離でデデュープ)
const merged = mergeDetections (
claudeResult.status === 'fulfilled' ? claudeResult.value : [],
geminiResult.status === 'fulfilled' ? geminiResult.value : [],
);
return c. json ({ books: merged });
} catch (e) {
console. error ( '[BookshelfScan] unexpected error' , e);
return c. json ({ error: 'Scan failed' }, 500 );
}
});
async function detectBooksWithClaude ( base64 : string , apiKey : string ) : Promise < DetectedBook []> {
const client = new Anthropic ({ apiKey });
const response = await client.messages. create ({
model: 'claude-sonnet-4-6' ,
max_tokens: 2000 ,
messages: [
{
role: 'user' ,
content: [
{
type: 'image' ,
source: { type: 'base64' , media_type: 'image/jpeg' , data: base64 },
},
{
type: 'text' ,
text: `この本棚の画像から、背表紙に見える本のタイトルと著者を読み取ってください。
JSON 配列だけを返してください。以下の形式です:
[{"title": "タイトル", "author": "著者名 or null", "confidence": 0.0-1.0}]
不鮮明で読めない本は出力しないでください。推測でタイトルを作り出すのは禁止です。` ,
},
],
},
],
});
const text = response.content[ 0 ].type === 'text' ? response.content[ 0 ].text : '' ;
return parseJsonArray < DetectedBook >(text);
}
async function detectBooksWithGemini ( base64 : string , apiKey : string ) : Promise < DetectedBook []> {
const genAI = new GoogleGenerativeAI (apiKey);
const model = genAI. getGenerativeModel ({ model: 'gemini-2.5-flash' });
const result = await model. generateContent ([
{ inlineData: { data: base64, mimeType: 'image/jpeg' } },
`本棚画像から背表紙のタイトルと著者を JSON で返してください。形式: [{"title": "...", "author": "...", "confidence": 0.0-1.0}]` ,
]);
return parseJsonArray < DetectedBook >(result.response. text ());
}
function parseJsonArray < T >( raw : string ) : T [] {
// モデルが ```json ... ``` や前置きを付けてくることがあるので、最初の [ から最後の ] までを抜き出す
const match = raw. match ( / \[ [\s\S] * \] / );
if (\ ! match) return [];
try {
return JSON . parse (match[ 0 ]);
} catch {
return [];
}
}
function mergeDetections ( a : DetectedBook [], b : DetectedBook []) : DetectedBook [] {
const merged = [ ... a];
for ( const book of b) {
const similar = merged. find (
( m ) => normalizeTitle (m.title) === normalizeTitle (book.title),
);
if (\ ! similar) {
merged. push (book);
} else if (book.confidence > similar.confidence) {
similar.confidence = Math. min ( 1 , (similar.confidence + book.confidence) / 2 + 0.1 );
similar.author = similar.author ?? book.author;
}
}
return merged
. filter (( b ) => b.confidence >= 0.5 )
. sort (( x , y ) => y.confidence - x.confidence);
}
function normalizeTitle ( t : string ) : string {
return t. toLowerCase (). replace ( / [\s ・::||\-—–] / g , '' );
}
export default app;
期待される動作: 本棚に 15 冊写っている画像を送ると、books 配列に 12〜14 件返ります。認識率 80〜95% 程度を想定してください。完璧を目指すとコストが急増するので、ユーザーが画面上で認識結果を確認して外せる UI を作るほうが現実的です。
なぜ Claude と Gemini を両方使うのか
Claude Sonnet は文脈理解と「推測で作り出さない」指示への忠実さが高く、Gemini 2.5 Flash は純粋な OCR 精度とコストで優れています。単独で走らせるとそれぞれ別のタイプの取りこぼしが発生するため、両方の結果をマージすると 1.3〜1.5 倍の認識数になる実測値です。コストは Claude Sonnet のほうがやや高いので、無料ユーザーは Gemini 単独、有料ユーザーは両方走らせる、という出し分けも理にかなっています。
トークンコスト削減の工夫として、画像を 2048px で送るだけでなく、夕方以降の時間帯はプロンプトキャッシュ(Anthropic の Prompt Caching)を使って共通指示部分を再利用することもできます。詳細は Claude Prompt Caching と Extended Thinking の実戦ガイド を参照してください。
章別要約機能の実装 — トークンコストを抑えながら価値を出す
蔵書登録ができたら、次は「この本を今読んでいる」状態の管理と章別要約です。課金価値の中心はここなので、品質にこだわります。
章構造の推定
目次が機械的に取れる本(Kindle や自炊 PDF)はそのまま使えますが、多くの本はそうはいきません。タイトル + 著者から Claude に章構成を推定させると、誤差はあるものの使える粒度になります。
// workers/src/routes/chapters.ts
import { Hono } from 'hono' ;
import Anthropic from '@anthropic-ai/sdk' ;
type Chapter = { index : number ; title : string ; estimatedPages ?: [ number , number ] };
const app = new Hono <{ Bindings : { ANTHROPIC_API_KEY : string } }>();
app. post ( '/api/books/:bookId/chapters' , async ( c ) => {
const bookId = c.req. param ( 'bookId' );
const { title , author , totalPages } = await c.req. json <{
title : string ;
author ?: string ;
totalPages ?: number ;
}>();
if (\ ! title || title. length > 200 ) {
return c. json ({ error: 'Invalid title' }, 400 );
}
const client = new Anthropic ({ apiKey: c.env. ANTHROPIC_API_KEY });
const response = await client.messages. create ({
model: 'claude-sonnet-4-6' ,
max_tokens: 1500 ,
messages: [
{
role: 'user' ,
content: `書籍「${ title }」${ author ? `(${ author } 著)` : ''}の章構成を推定してください。
${ totalPages ? `総ページ数は約 ${ totalPages } ページです。各章の推定ページ範囲も含めてください。` : ''}
JSON 配列のみを返してください。形式:
[{"index": 1, "title": "章タイトル", "estimatedPages": [1, 30]}]
章構成が不明な場合は空配列 [] を返してください。推測で作り出さないでください。` ,
},
],
});
const text = response.content[ 0 ].type === 'text' ? response.content[ 0 ].text : '[]' ;
const match = text. match ( / \[ [\s\S] * \] / );
let chapters : Chapter [] = [];
try {
chapters = match ? JSON . parse (match[ 0 ]) : [];
} catch {
chapters = [];
}
// Supabase に保存(省略)
return c. json ({ chapters, source: chapters. length > 0 ? 'ai-estimated' : 'unknown' });
});
export default app;
章構成が取れない場合は「任意のページ範囲を指定して『このあたりまでの章』として要約する」UX にフォールバックします。ページ範囲選択の UI は react-native-reanimated でスムーズなスクラブ操作を実装すると完成度が上がります。
章別要約の生成プロンプト
章要約は「ネタバレを避けつつ要点をまとめる」「読者の思考を広げる問いを 3 つ出す」の 2 つを同時にやらせます。プロンプトには「あなたはこの読者のパーソナルな読書ガイドです」という一貫した役割を与えるのがコツです。
// workers/src/lib/summarize.ts
import Anthropic from '@anthropic-ai/sdk' ;
type SummaryRequest = {
bookTitle : string ;
chapterTitle : string ;
userNote ?: string ; // ユーザーが読みながら取ったメモ
apiKey : string ;
};
type SummaryResult = {
keyPoints : string [];
openQuestions : string [];
encouragement : string ;
};
export async function generateChapterSummary ( req : SummaryRequest ) : Promise < SummaryResult > {
const client = new Anthropic ({ apiKey: req.apiKey });
const response = await client.messages. create ({
model: 'claude-sonnet-4-6' ,
max_tokens: 1200 ,
system: `あなたは読者一人ひとりに寄り添う、温かく知的な読書パートナーです。
章の要約は読者の理解を助けるためのもので、ネタバレ全開の要約にはしません。
「考える余地を残す」「読者の問いを広げる」ことを優先してください。` ,
messages: [
{
role: 'user' ,
content: `書籍「${ req . bookTitle }」の「${ req . chapterTitle }」章を読み終えた読者向けに、以下の JSON を返してください。
${ req . userNote ? `読者のメモ: 「${ req . userNote }」 \n\n このメモを踏まえて要約してください。 \n ` : ''}
{
"keyPoints": ["要点を 3 つ、各 60 文字以内"],
"openQuestions": ["この章を踏まえて、読者が次の章に進む前に考えておくと面白い問いを 3 つ"],
"encouragement": "読者に向けた一言(80 文字以内、押し付けがましくならないように)"
}
JSON のみを返し、前置きや後置きは不要です。` ,
},
],
});
const text = response.content[ 0 ].type === 'text' ? response.content[ 0 ].text : '{}' ;
const match = text. match ( / \{ [\s\S] * \} / );
if (\ ! match) {
throw new Error ( 'Summary generation returned no JSON' );
}
try {
return JSON . parse (match[ 0 ]) as SummaryResult ;
} catch (e) {
throw new Error ( 'Summary JSON parse failed' );
}
}
期待される出力例(『思考の整理学』外山滋比古):
{
"keyPoints" : [
"グライダー型とエンジン型の違いは『与えられた情報を超えて考えられるか』である" ,
"学校教育は受動的な理解力を鍛えるが、創造的思考は別の訓練が必要になる" ,
"頭を『寝かせる』ことで、ばらばらの情報が結びつき新しい洞察が生まれる"
],
"openQuestions" : [
"自分の仕事や学びの中で、グライダー型に偏っている場面はどこだろうか" ,
"今抱えている課題を一度『寝かせて』みたら、どんな変化があるだろうか" ,
"アイデアを生み出すための自分なりの『寝かせ方』はどう設計できるか"
],
"encouragement" : "読み終えたあと、今日は一度この章の問いを忘れて、明日もう一度戻ってみてください。"
}
なぜユーザーのメモを組み込むのか
読書アプリで最大の差別化要因は、ユーザーが残した断片的なメモを AI に読み込ませることで、要約が「あなただけのもの」になる点です。他のアプリは一般的な要約しか出せませんが、自分のメモを反映した要約は体験としての質が一段違います。これは単価 ¥580 程度のサブスクリプションを維持するのに十分な価値になります。
この設計思想については Rork のマルチモーダル AI アプリ設計パターン でも詳しく扱っていますので、合わせて読むと理解が深まるはずです。
データモデルとオフライン対応
Supabase の Postgres を使いますが、読書アプリは飛行機や電車で読むユースケースが多いので、オフライン対応は必須です。react-native-mmkv でローカルキャッシュを持ち、ネットが復帰したら同期するパターンにします。
// lib/storage/bookStorage.ts
import { MMKV } from 'react-native-mmkv' ;
const storage = new MMKV ({ id: 'reading-assistant' });
type BookState = {
id : string ;
title : string ;
author ?: string ;
currentPage : number ;
totalPages ?: number ;
lastReadAt : number ;
pendingSync : boolean ;
};
export const bookStorage = {
upsert ( book : BookState ) : void {
storage. set ( `book:${ book . id }` , JSON . stringify ({ ... book, pendingSync: true }));
},
get ( id : string ) : BookState | null {
const raw = storage. getString ( `book:${ id }` );
if (\ ! raw) return null ;
try {
return JSON . parse (raw);
} catch {
return null ;
}
},
getPendingSync () : BookState [] {
const keys = storage. getAllKeys (). filter (( k ) => k. startsWith ( 'book:' ));
const books : BookState [] = [];
for ( const key of keys) {
const raw = storage. getString (key);
if (\ ! raw) continue ;
try {
const book = JSON . parse (raw) as BookState ;
if (book.pendingSync) books. push (book);
} catch {
// 破損したエントリは無視してそのまま次へ
continue ;
}
}
return books;
},
markSynced ( id : string ) : void {
const book = this . get (id);
if (book) {
storage. set ( `book:${ id }` , JSON . stringify ({ ... book, pendingSync: false }));
}
},
};
MMKV を採用するのは、AsyncStorage より 10 倍以上高速で、読書中の頻繁なページ更新にも耐えるからです。読書アプリは「ページめくるたびに進捗を保存」という書き込み頻度が高いので、ここでの選択は体験に直結します。
よくある落とし穴と対処法
このアプリを作ってきた中で実際に詰まったポイントを、具体的な対処法とともに共有します。
落とし穴 1: Vision OCR が縦書きタイトルを認識できない
日本の本は背表紙が縦書きのため、単純に OCR するとバラバラの文字として認識されて繋がりません。Apple Vision の VNRecognizeTextRequest には recognitionLanguages を ["ja-JP", "en-US"] で指定し、かつ usesLanguageCorrection = true にすることで日本語縦書きもかなり拾えるようになります。それでも取りこぼすタイトルは Claude 側の画像理解でカバーします。
落とし穴 2: 本棚画像が大きいと Claude のコストが急騰する
claude-sonnet-4-6 の画像入力は、解像度が大きいほど消費トークンが増えます。4000x3000px をそのまま送ると、1 枚で ¥20〜30 かかるケースが出ます。クライアント側で 2048px にリサイズして JPEG 85% 品質に圧縮すると、認識精度をほぼ落とさずに ¥4〜6 まで抑えられます。無料ユーザーの枠管理では、必ず月間スキャン回数を KV か Postgres でカウントしてください。
落とし穴 3: 章別要約のネタバレをユーザーが嫌がる
テスターから一番多かったフィードバックが「知りたくなかった結末まで書かれていた」でした。プロンプトで明示的に「ネタバレを避ける」と書いても、モデルは容赦なく全体要約を出してきます。対策は 2 段階です。第一に、プロンプトで「読み終えた章までの範囲だけを扱い、先の章には触れない」と強く書きます。第二に、出力を後処理で「この後の章では〜」「最終的に〜」などのネタバレフレーズを正規表現でチェックし、検出したら再生成します。再生成コストは月 ¥1,000 程度で収まります。
落とし穴 4: オフラインで書き込んだメモが重複同期される
MMKV にメモを書いてオフラインで切れた状態だと、復帰時に pendingSync フラグを持ったアイテムだけ送るべきですが、うっかり全件同期してしまうバグを作りやすいです。Supabase 側では client_id(UUID)をメモテーブルのユニーク制約にして、サーバー側で冪等に upsert する設計にしておくと、クライアントのバグで重複送信しても壊れません。
落とし穴 5: RevenueCat のトライアル終了通知を忘れてレビューで怒られる
読書アプリはトライアル 7 日 → 有料という流れが定番ですが、トライアル終了 1 日前のプッシュ通知を実装していないと、知らないうちに課金されたという低評価レビューが必ず付きます。RevenueCat の Webhook で SUBSCRIPTION_PENDING_CANCELLATION や TRIAL_ENDING イベントを受け取って、1 日前にプッシュ通知する仕組みを必ず入れてください。詳細は RevenueCat の Winback オファーとチャーン防止 を参考にしてください。
読書習慣の継続を支える「そっとしたリマインド」
読書アプリのチャーンは「プッシュ通知の鬱陶しさ」で起きることが多いです。「今日も本を読みましょう!」は逆効果で、ユーザーは逃げます。代わりに、私は以下の 3 種類のリマインドだけに絞っています。
章を読み終えた翌日の朝 に、前日の章要約と「次の章は今日中に読めそうですか?」という軽い問いを送る
3 日以上開かなかった本 に対して、「この本の〇〇章で止まっていますね。少しだけ読み返しますか?」と提案する
新しく買った本が登録されてから 7 日間読まれていない場合 、「そろそろ最初の 20 ページだけ読んでみませんか?」と短時間の読書を提案する
いずれも「読まないとダメだ」と追い詰めないトーンです。ユーザーの罪悪感を刺激するプッシュ通知は短期的にはクリック率が高く見えますが、3 週間で解約率が 2 倍になるデータを自分のアプリで取りました。
通知の生成には Claude を使っても良いですが、トークンコストを考えると、テンプレート + 変数埋め込みで十分です。Claude に生成させる価値があるのは、年始や読み終えた本が 10 冊を超えたタイミングなどの特別な節目の通知です。
収益化設計 — トライアルから継続利用への導線
読書アプリの平均 ARPU(ユーザーあたり月間売上)は個人開発では ¥200〜400 程度です。サブスクリプションで狙う金額は月額 ¥580〜780 あたりが適切で、これより上げるとコンバージョンが急落します。
私の設計はこうです。無料プランでは本棚スキャンが月 1 回、AI 要約は月 3 回までに制限します。有料プラン(月 ¥580 / 年 ¥4,800)では無制限スキャンと無制限要約、パーソナル通知、データエクスポート機能を解放します。有料プランに年間プランを用意して年払いで割引する設計は、解約率を大幅に下げる効果があります。
トライアル期間は 7 日ではなく 14 日を推奨します。読書アプリは体験の良さを実感するのに時間がかかるため、2 週間試してもらったほうが継続率が 1.8 倍近くになるデータが出ました。短すぎるトライアルは「便利そうだけど使いこなせなかった」で終わります。
支払いフローの詳細は Rork の RevenueCat サブスクリプション収益化ガイド を参考にしてください。
テストとリリース前チェックリスト
本棚スキャンのように AI が絡む機能は、ユニットテストだけでは品質が担保できません。私は以下の 3 段階テストを必ず回しています。
ゴールデンセット回帰テストでは、50 枚の本棚画像とそれぞれの正解リスト(手で作った)を用意し、スキャン API を叩いて認識率が 80% を下回らないことを CI でチェックします。これを置いておくと、モデルのバージョンを切り替えたときに体験が劣化していないことを機械的に確認できます。
次にプロンプト回帰テストです。章要約プロンプトに対して、代表的な 10 冊分のインプットを用意し、出力が「ネタバレしていないか」を別の AI 呼び出し(Gemini Flash などの安い方で良い)で自動判定します。これで、プロンプトをいじったときの副作用を早期に検出できます。
最後に本番前のドッグフーディングです。社内版(自分と家族)に 1 週間使わせて、「体験として違和感がある箇所」を 20 個は集めてから公開します。AI アプリは数字に出ない違和感が多く、人間の評価を省略するとユーザーの初回体験が台無しになります。
実装の全体像を見渡すためのヒント
ここまでで、本棚スキャンから章別要約、通知、収益化まで通しで実装できる形を示してきました。実際にゼロから作ると、サーバー・クライアント・プロンプト・データモデル・課金を並行して開発する必要があり、個人開発者にとっては 4〜6 週間のプロジェクトになります。
Rork の強みは、クライアント側のコード生成と UI 実装が桁違いに早い点です。サーバーとプロンプト設計は自分で手を入れる前提で、クライアントは Rork に任せると、1 週間で動くプロトタイプまで持っていけます。プロトタイプを TestFlight に出して、10 人に使ってもらって、フィードバックを元に AI プロンプトを磨く、というサイクルを 2 週間回すと製品版に近づきます。
読書という行為は、人によってペースも好みも大きく違います。このアプリを作って一番面白いのは、ユーザーのメモを反映したパーソナルな要約が、本の読み方そのものを変えてしまう瞬間に立ち会えることです。公式ドキュメントを写経するだけのアプリでは到達できない、個人開発ならではの価値がここにあります。
次の一歩
まずは今日、自分の本棚を 1 枚撮って、Claude API に「この画像から本のタイトルを JSON で返して」と投げてみてください。それだけで、このアプリの中核体験の 70% が実感できます。そこから Rork でクライアントを生成し、Supabase で蔵書テーブルを作り、1 週間でユーザーが自分の読書を登録できる MVP まで持っていけます。
読書アシスタントアプリのプロンプト設計