はじめに
みなさんは電子書籍、どのプラットフォームで購入していますか?
私は Kindle、DMMブックス、BOOK☆WALKER、ebookjapan と、複数のプラットフォームを使い分けています。理由は簡単で、セールのタイミングや価格がプラットフォームごとに異なるから。少しでも安く購入したいという思いから、気づけば複数サービスを併用するようになっていました。
しかし、この「使い分け」が思わぬ問題を生みました。
「あの本、どのサービスで買ったっけ...?」
本を探すたびにすべてのアプリを開いて確認。最悪なのは、すでに購入済みの本を別のプラットフォームで重複購入してしまうこと。これが何度も起きました。
さらに、私の蔵書は約7,000冊。これらを「未読」「技術書」「ビジネス書」といったカテゴリで管理したかったのですが、各プラットフォームのライブラリ機能は貧弱で、思うような管理ができませんでした。何より複数プラットフォームをまとめて1つの箇所で管理することができませんでした。
そこで、すべての電子書籍プラットフォームを横断して管理できるサービスを自分で作ることにしました。それが eBookshelf です。
よければ実際にシェア機能を用いて作った、自分の本棚をのぞいてみてください。
eBookshelfとは
eBookshelfは、複数の電子書籍プラットフォームの蔵書を一元管理できるWebサービスです。
主な機能
- 📚 マルチプラットフォーム対応: Kindle、DMMブックス、BOOK☆WALKER、ebookjapanの蔵書を統合管理(今後も他サービスを追加予定)
- 🏷️ 柔軟なタグ管理: 「未読」「技術書」など自由にタグ付けして整理
- 📧 Gmail連携による自動取り込み: 購入通知メールから自動で蔵書を登録
- 🔗 本棚のシェア機能: 自分の蔵書コレクションを他の人と共有
- 🔔 新刊通知: お気に入りシリーズの新刊を見逃さない
実は、Kindleの貧弱なライブラリ機能に不満を持つ人は多いようで、自作の本棚を作る人も見かけました。この需要は確実にあると確信しています。
技術スタック - コストを最小限に抑えた構成
個人開発で重要なのはランニングコストを抑えること。しかし、将来的な拡張性も犠牲にしたくありませんでした。
採用した技術スタック
Frontend/Backend:
├── Next.js (Pages Router) + TypeScript
├── tRPC (型安全なAPI層)
├── Prisma
└── Tailwind CSS + shadcn/ui
Database:
└── CockroachDB Serverless
Infrastructure (Google Cloud):
├── Cloud Run (アプリケーションホスティング)
├── Cloud Scheduler (定期実行)
└── Cloud Tasks (非同期処理)
State Management:
├── TanStack Query (React Query)
└── TanStack Virtual (仮想スクロール)
なぜこの構成なのか
1. tRPCで型安全な開発体験を実現
開発当初はNext.jsのAPI Routesを使用してREST APIを実装していましたが、フロントエンドで型がなく、開発に苦労し始めてtRPCを採用。これにより:
- APIの型定義を書く必要がない
- リファクタリング時の安全性が格段に向上
- 開発速度が大幅にアップ
// tRPCの例:型が自動的に推論される
const { data } = trpc.bookstocks.getAll.useQuery({
tags: ['未読'],
limit: 50
});
// dataの型は自動的に BookStock[] として推論
2. CockroachDB Serverlessで無料枠を最大活用
PostgreSQLベースの分散データベースであるCockroachDBを選択。理由は:
- 開発時点で最も無料枠のストレージが大きかった(10 GiB storage)
- PostgreSQL互換であった
- PrismaがCockroachDBのサポートを検討していた(現在はサポート済み)
- 将来的なスケールアウトが容易
7,000冊のデータでも無料枠で十分運用できています。
3. Cloud Runで安価にサーバーレス運用
もともとFly.ioを使用していましたが、最終的にCloud Runに移行しました。理由は:
- Cloud SchedulerやCloud Tasksとの連携が簡単
- Terraformで Infrastructure as Code を実現
- 完全にゼロスケール(アクセスがなければ課金なし)
- コールドスタートも実用上問題なし
GCPの無料枠は超えていますが、月100円かからない程度で運用できています。
技術的困難さと解決策
問題1: 複数プラットフォームからのデータ取得と自動インポート
各電子書籍サービスは公式APIを提供していません。そこで、購入通知メールとアフィリエイトAPIを組み合わせた自動インポートシステムを構築しました。
自動インポートの流れ
利用しているアフィリエイトAPI
| サービス | API | URL |
|---|---|---|
| Kindle | Amazon Product Advertising API | https://affiliate.amazon.co.jp/assoc_credentials/home |
| DMMブックス | DMM webサービス | https://affiliate.dmm.com/api/ |
| BOOK☆WALKER | バリューコマース アフィリエイトAPI | https://www.valuecommerce.ne.jp/feature/webservice.html |
| ebookjapan | バリューコマース アフィリエイトAPI | https://www.valuecommerce.ne.jp/feature/webservice.html |
各サービスのメール解析の課題
Gmail本文の形式は各サービスで大きく異なります。例えばebookjapanでは:
- URLやIDなどの識別子がなく、タイトルのみ
- セット購入時は個別巻ではなくセット名で表示されることがある
// ebookjapanのメール本文例
砂ぼうず (1~5巻セット)
砂ぼうず (6~10巻セット)
砂ぼうず (11~15巻セット)
このような場合、システム側で1〜15巻それぞれに分割して処理:
function extractBookFromMailBody(mailBody: string): Book[] {
const lines = mailBody.split(/\n/);
const books: Book[] = [];
for (const line of lines) {
...
// セット商品を個別巻に分割する処理
if(line.endsWith('巻セット)')) {
const partialSetMatch = line.match(/^(.+)((\d+)~(\d+)巻セット)$/)
if(partialSetMatch) {
const start = Number(partialSetMatch[2]);
const end = Number(partialSetMatch[3]);
books.push(...Array.from(Array(end - start + 1).keys())
.map(i => {title: `${partialSetMatch[1].trim()} ${i + start}`})
);
continue
}
}
books.push({title: line});
}
}
複数候補への対処
タイトルをキーにしてアフィリエイトAPIを叩くと、複数候補が見つかることがあります:
例: ビブリア古書堂の事件手帖
- 角川コミックス・エース版:
ビブリア古書堂の事件手帖(1) - good!アフタヌーン版:
ビブリア古書堂の事件手帖(1)
同じタイトルでも出版社が異なる場合があるため、システムでの自動判定は困難です。
そこで:
- バックエンドで複数候補が見つかった場合はエラーとして返却
- フロントエンドで候補一覧を表示
- ユーザーに正しい書籍を選択してもらう
// 複数候補がある場合の処理
if (searchResults.length > 1) {
throw MultipleCandidatesError('複数の候補が見つかりました。正しい書籍を選択してください。', ...);
}
この仕組みにより、複数候補が見つかっても正確な書籍登録を実現しています。
Gmailインポートの限界とブラウザ拡張での対応
しかし、DMMブックスでは予約以外の購入時に、購入した本の情報が一切Gmailに含まれないという大きな問題がありました。それ以外にも、削除された書籍や、メールに十分な情報が含まれないケースが多々あります。
このような問題に対応するため、eBookshelf Extensionというブラウザ拡張を開発しました。これにより、各サービスのデータを直接取得できるようになっています。
チャレンジ2: 7,000冊を高速に表示
大量のデータを一度に表示すると、当然ブラウザが重くなります。そこで:
- TanStack Virtualで仮想スクロール実装
- TanStack QueryのInfinite Queryで段階的読み込み
- データベースビューで事前集計
// 仮想スクロールの実装例
const { data, fetchNextPage, hasNextPage } =
trpc.bookstocks.infinite.useInfiniteQuery({
limit: 50,
}, {
getNextPageParam: (lastPage) => lastPage.nextCursor,
});
// TanStack Virtualで表示を最適化
const virtualizer = useVirtualizer({
count: data?.pages.flatMap(p => p.items).length ?? 0,
getScrollElement: () => scrollRef.current,
estimateSize: () => 200, // 各アイテムの推定高さ
});
パフォーマンス最適化のため、Prismaでビューも作成:
-- シリーズごとにグループ化しているため、ビューにして最適化
CREATE VIEW GroupedBookStockOrderByCreatedAt AS
SELECT
series_id,
array_agg(book_id) as books,
max(created_at) as latest_created_at
FROM book_stocks
GROUP BY series_id
ORDER BY latest_created_at DESC;
3年間の開発で学んだこと
2022年10月から開始したこの個人開発。3年以上かけてコツコツと開発を続けてきました。Vibe Codingが流行る前からだったので、ほぼ自分でコードを書きました。
個人開発を続けるコツ
- 自分が本当に欲しいものを作る: 7,000冊の管理は自分にとって切実な課題
- 毎日少しでもコミットする: 週末だけでなく、毎日少しずつでも進める習慣が大切
- 完璧を求めず、必要な機能(MVP)から開発: 段階的に機能を追加していく
今後の展望
現在はまだ利用者が少ない(というかほぼ自分だけ)ですが、同じ悩みを持つ人は確実にいるはずです。今後は利用者を増やすとともに、↓のような機能を拡張していきたいと考えています。
- より多くのプラットフォーム対応
- 寄付による持続可能な運営
まとめ
「複数の電子書籍プラットフォームを使い分けている」という個人的な課題から始まったeBookshelf。3年以上の開発期間を経て、7,000冊を快適に管理できるサービスに成長しました。
技術的には、tRPCによる型安全な開発とコストを極限まで抑えたインフラ構成が特徴です。月額ほぼ0円で運用できているのは、サーバーレスアーキテクチャの恩恵です。
もし電子書籍の管理で同じような悩みを抱えている方がいれば、ぜひ eBookshelf を試してみてください。
また、個人開発者の方々へ。自分が本当に欲しいものを作ることが、長期的な開発のモチベーションになります。完璧を求めず、小さく始めて、継続することが大切です。
リンク
- サービス: https://ebookshelf.info/
-
お問い合わせ: サービス用アカウントか自分のXにお問い合わせ、フィードバックあればいってください。
- サービス用アカウント: https://x.com/ebookshelf_info
- X: https://x.com/AHA_oretama
この記事が参考になったら、ぜひシェアやコメントをお願いします。個人開発の励みになります!