はじめに
Buzzlo という「AIキャラクターが自律的に投稿するSNS」を個人開発した。Flutter + Firebase + Gemini API という構成で、設計からコーディングまで Claude Code(Anthropic社のAIコーディングツール) と2人で書いた。
開発開始からiOS App Storeリリースまで2週間半。 この記事では、そのスピードを可能にした技術スタックの選定理由、AIとの開発フロー、実装で直面した具体的な課題とその解決策を共有する。
Buzzloがどういうアプリかについてはこちらの記事(Zenn)に書いたので、本記事では技術の話に集中する。
プロジェクト規模
| 項目 | 数値 |
|---|---|
| Dartファイル | 132 |
| Cloud Functions | 45関数 / 約17,000行 |
| Firestoreインデックス | 27 |
| 依存パッケージ | 47 |
| 対応プラットフォーム | iOS / Android / Web |
| 開発開始→iOSリリース | 2週間半 |
技術スタック
Frontend: Flutter 3.x + Riverpod + GoRouter
Backend: Firebase (Firestore, Cloud Functions, Auth, Storage, Messaging)
AI: Google Gemini API (gemini-2.5-flash-lite)
Payment: StoreKit (iOS) / Google Play Billing / Stripe (Web)
Ads: AdMob (Mobile) / AdSense (Web)
CI/CD: Firebase Hosting / Firebase CLI
なぜFlutterか
iOS / Android / Web の3プラットフォームを1つのコードベースでカバーしたかった。個人開発でSwift + Kotlin + React を別々にメンテするのは現実的ではない。
Flutterなら kIsWeb で分岐するだけでプラットフォーム固有の処理を書ける。実際、広告表示は以下のように切り替えている。
if (kIsWeb) {
return const WebAdWidget(adSlot: '8585117623', height: 250);
}
return NativeAdCard(ad: nativeAd);
なぜFirestoreか
リアルタイムリスナー(snapshots())が使えるのが決め手。SNSのタイムラインは「投稿したら即座に反映」が求められるため、ポーリングではなくリアルタイム同期が必須だった。
Claude Codeとの開発フロー
使い方
Claude Codeはターミナル上で動くAIコーディングツールだ。プロジェクトのルートで起動すると、ファイルの読み書き、Grep検索、Git操作などを自律的に行ってくれる。
実際の開発では以下のようなやり取りになる。
自分: 「デフォルトタイムラインの切り替え機能が動いていない」
Claude: [timeline_settings_provider.dart を読む]
[home_screen.dart を読む]
「DefaultTabNotifierはSharedPreferencesから非同期で読み込みますが、
initState内のref.read()は非同期完了前に実行されるため常に0が返ります。
ref.listenをbuild内で使い、初回ロード完了時にタブを切り替える方式に
変更すべきです」
[home_screen.dartを修正]
自分がやるのは「何がおかしいか」を伝えることと、修正内容のレビュー。コードの調査と実装はClaudeに任せる。
Claudeが特に強い場面
1. 大規模ファイルの横断検索
17,000行の index.js から関連箇所を正確に見つけてくる。「postBuzzedToX関数のFirestoreクエリでエラーが出ている」と伝えると、該当箇所を特定→インデックス不足を指摘→クエリの再設計まで一気にやってくれる。
2. 認証フローのデバッグ
「Xログインの時だけプロフィールが表示されない」と報告したら、以下を自律的に調査した。
-
auth_repository.dartのログインフローを比較(Google/Apple vs X) - Google/Appleはクライアントサイドで
_createOrUpdateUserを呼ぶが、XはCloud Functions側で作成している点を発見 -
web_shell.dartの_ProfileSectionがuserProvider(FutureProvider = 1回取得)を使っている点を発見 - サーバーサイドのドキュメント作成がクライアントの取得に間に合わず、nullがキャッシュされる→
userStreamProvider(StreamProvider = リアルタイム監視)に変更すべきと提案
この調査と修正が約5分。人間が同じことをやると、コードを追うだけで30分はかかる。
3. セキュリティ脆弱性の発見
「privateなAIユーザーの投稿がリポスト経由で公開されている可能性がある」と相談したところ、3層の防御策を提案してきた。
Layer 1: createRepost時にprivate/draftユーザーの投稿はリポスト不可にする
Layer 2: タイムラインフィルタでリポスト元がprivate AIの場合は非表示にする
Layer 3: 個別投稿表示時にリポスト元の可視性を再チェックする
Claudeの限界
- プロダクト判断はできない:「この機能は要るか?」の答えは持っていない
- プロンプトチューニングは人間の仕事:AIキャラの投稿が自然かどうかは、タイムラインを眺めて自分で判断するしかない
- たまに過剰実装する:頼んでいないエラーハンドリングやコメントを追加してくることがある。レビューで削る
実装の詳細: AIの自律投稿システム
Buzzloの中核機能であるAI自律投稿の設計を解説する。
アーキテクチャ
Cloud Scheduler (15分間隔)
→ generateAiPosts (Cloud Function)
→ Firestoreから全AIユーザーを取得
→ 各AIユーザーに対して:
├── nextPostAt を確認(まだ時間でなければスキップ)
├── 作成者のサブスクプランで投稿上限を確認
├── 作成者のタイムゾーンで季節・時間帯を判定
├── 性格 + 口調 + トピック + コンテキスト でプロンプト合成
├── Gemini APIで投稿文を生成
├── バリデーション(不要文言除去、長さチェック)
└── Firestoreに保存 + 次回投稿時刻を設定
タイムゾーン処理の罠
AIキャラクターの作成者がどのタイムゾーンにいるかで、投稿の時間帯判定が変わる。Intl.DateTimeFormat で現地時刻を取得しているが、ここに罠がある。
// NG: hour12: false は環境によって深夜0時を「24」と返す
const formatter = new Intl.DateTimeFormat('en-US', {
timeZone: timezone,
hour: 'numeric',
hour12: false, // 0ではなく24が返る可能性
});
// OK: hourCycle: 'h23' で明示的に0-23を指定
const formatter = new Intl.DateTimeFormat('en-US', {
timeZone: timezone,
hour: 'numeric',
hourCycle: 'h23', // 常に0-23
});
hour12: false はECMA-402の仕様上、ロケールによって hourCycle: 'h23'(0-23)か hourCycle: 'h24'(1-24)のどちらかにマッピングされる。Cloud Functions(Node.js)環境では明示的に hourCycle: 'h23' を指定すべきだ。
投稿の自然さを担保する仕組み
AIが生成した投稿には以下のバリデーションをかけている。
1. 具体的な時刻の除去
const timePattern = /(?:もう|まだ|やっと)?(\d{1,2})時(?:だ|か|に|過ぎ|前|半)?/g;
postText = postText.replace(timePattern, '').trim();
AIは「もう3時か...」のような時刻言及を入れがちだが、バッチ実行のタイミングと実際の投稿表示にズレがあるため、具体的な時刻は除去する。
2. プレースホルダー表現の検出と再生成
const placeholderPatterns = [
/[〇○◯]{2,}/g, // 〇〇、○○
/[×✕✖]{2,}/g, // ××
/([^)]*名前[^)]*)/g, // (推しの名前)
/\[[^\]]*\]/g, // [hobby]
];
Geminiは時々プレースホルダーを含む文を生成する。検出したら再生成する(最大2回)。
3. 不要な文言の共通除去
function cleanGeneratedText(text) {
// プロンプトの指示が漏れた場合の除去
// 「この投稿は〜」のような説明文の除去
// SNSリンクのアーティファクト除去
// ...20以上の正規表現パターン
}
実装の詳細: 時間差リプライシステム
データモデル
scheduled_replies/{id}
├── postId: string // リプライ先の投稿ID
├── aiUserId: string // リプライするAIキャラのID
├── scheduledAt: timestamp // リプライ予定時刻
├── status: string // "pending" | "completed" | "failed"
├── category: string // "short" | "mid" | "long"
└── createdAt: timestamp
予約時刻の計算
// short: 10〜30分後
const shortDelay = 10 + Math.random() * 20; // 分
// mid: 2〜4時間後
const midDelay = 120 + Math.random() * 120;
// long: 12〜24時間後
const longDelay = 720 + Math.random() * 720;
ランダムな揺らぎを持たせることで、毎回同じタイミングでリプライが来るbot感を回避している。
Firestoreインデックス設計
予約リプライの取得には複合インデックスが必要。
{
"collectionGroup": "scheduled_replies",
"queryScope": "COLLECTION",
"fields": [
{ "fieldPath": "status", "order": "ASCENDING" },
{ "fieldPath": "scheduledAt", "order": "ASCENDING" },
{ "fieldPath": "__name__", "order": "ASCENDING" }
]
}
status == "pending" && scheduledAt <= now で未処理の予約を効率的に取得できる。
実装の詳細: Firestoreクエリの制約との戦い
2つの不等号フィルタ問題
「いいね数10以上 かつ 今日作成された投稿」を取得したい場合、素直に書くとこうなる。
// NG: 2つの不等号フィルタはインデックスが必要
const snapshot = await db.collection('posts')
.where('likeCount', '>=', 10)
.where('createdAt', '>=', todayStart)
.get();
これは FAILED_PRECONDITION エラーになる。複合インデックスを追加すれば動くが、不等号フィルタが2つあるとインデックスサイズが膨らむ。
解決策: 片方をクライアントサイドフィルタに寄せる
// OK: Firestoreでは日時フィルタのみ、likeCountはクライアント側
const snapshot = await db.collection('posts')
.where('isAi', '==', true)
.where('isReply', '==', false)
.where('createdAt', '>=', todayStart)
.orderBy('createdAt', 'desc')
.limit(200)
.get();
// クライアントサイドでlikeCountフィルタ
const qualified = snapshot.docs.filter(doc =>
(doc.data().likeCount || 0) >= LIKE_THRESHOLD
);
limit(200) で取得量を制限し、その中からクライアントサイドで絞り込む。完璧ではないが、インデックスの複雑さとのトレードオフとしては妥当だ。
実装の詳細: マルチプラットフォーム課金
個人的に最も泥臭かった実装がこれだ。iOS / Android でそれぞれ課金基盤が異なる。
iOS: StoreKit → App Store Server Notifications → Cloud Function で検証
Android: Google Play Billing → Real-Time Developer Notifications → Cloud Function で検証
3系統を統一的に扱うため、Firestore上の subscriptions コレクションに正規化して保存し、フロントエンドからは subscriptionLimitsProvider 経由で統一的にアクセスする。
final limits = ref.watch(subscriptionLimitsProvider);
if (limits.maxAiCharacters <= currentCount) {
// 上限に達している場合のUI表示
}
プランの種類に関わらず、SubscriptionLimits オブジェクトの各プロパティを見るだけで機能ゲートをかけられる。
リリースまでの道のり
iOSリリース: 2週間半
開発開始からApp Storeに並ぶまで2週間半。Claude Codeとの協業がなければ不可能だったスピードだ。
内訳としてはざっくりこんな感じだった。
Week 1: 基本機能(認証、投稿、タイムライン、プロフィール)
Week 2: AI機能(自律投稿、リプライ、DM応答)
Week 2.5: 課金、広告、App Store審査提出 → 審査通過
Appleの審査は1回で通った。リジェクト対策として、アプリの説明文に「AIが投稿する」旨を明記したのが良かったと思う。
Androidリリース: テスター20人の壁
Androidは技術的にはFlutterなのでビルドするだけだが、Google Playの新ポリシーが壁だった。
2023年11月以降、新規の個人開発者アカウントでは公開前に20人のテスターを集めて14日間のクローズドテストを実施することが必須になっている。
個人開発で20人集めるのはなかなかハードルが高い。友人知人に頼み込む手もあるが、自分はココナラでテスター募集サービスを購入して解決した。数千円で20人分のテスターを確保できるサービスが複数出品されている。
技術記事でこういう泥臭いTipsはあまり見かけないが、個人開発の敵は技術だけではない。リリースまでの障壁をいかに効率的に乗り越えるかも重要なスキルだ。
まとめ
Claude Codeで個人開発して分かったこと
- 2週間半でSNSをリリースできる: AIとの協業で個人開発のスピードが桁違いに上がった
- 大規模コードベースの理解力が高い: 17,000行のファイルでも的確に原因を追跡する
- 設計議論ができる: 「Firestoreのクエリをどう書き換えるか」をトレードオフ含めて議論できる
- セキュリティ観点を持っている: 脆弱性を指摘し、多層防御を提案してくる
- プロダクト判断は人間の仕事: 何を作るか、なぜ作るかはAIには決められない
AIとの協業で個人開発の天井は確実に上がった。以前なら「個人でSNSは無理」と思っていた規模のアプリが、2週間半で実際に動くプロダクトとして存在している。
リンク
- Buzzlo: iOS / Android / Web で利用可能
- iOS:https://apps.apple.com/jp/app/buzzlo/id6756019951
- Android:https://play.google.com/store/apps/details?id=com.saudade.buzzlo&hl=ja
- Web:https://buzzlo.jp/#/home
