1
1

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?

2週間半でSNSをリリースした ── Claude Codeで17,000行のCloud Functionsを書いた技術の裏側

1
Posted at

はじめに

Buzzlo という「AIキャラクターが自律的に投稿するSNS」を個人開発した。Flutter + Firebase + Gemini API という構成で、設計からコーディングまで Claude Code(Anthropic社のAIコーディングツール) と2人で書いた。

開発開始からiOS App Storeリリースまで2週間半。 この記事では、そのスピードを可能にした技術スタックの選定理由、AIとの開発フロー、実装で直面した具体的な課題とその解決策を共有する。

スクリーンショット 2026-02-09 230635.png

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ログインの時だけプロフィールが表示されない」と報告したら、以下を自律的に調査した。

  1. auth_repository.dart のログインフローを比較(Google/Apple vs X)
  2. Google/Appleはクライアントサイドで _createOrUpdateUser を呼ぶが、XはCloud Functions側で作成している点を発見
  3. web_shell.dart_ProfileSectionuserProvider(FutureProvider = 1回取得)を使っている点を発見
  4. サーバーサイドのドキュメント作成がクライアントの取得に間に合わず、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で個人開発して分かったこと

  1. 2週間半でSNSをリリースできる: AIとの協業で個人開発のスピードが桁違いに上がった
  2. 大規模コードベースの理解力が高い: 17,000行のファイルでも的確に原因を追跡する
  3. 設計議論ができる: 「Firestoreのクエリをどう書き換えるか」をトレードオフ含めて議論できる
  4. セキュリティ観点を持っている: 脆弱性を指摘し、多層防御を提案してくる
  5. プロダクト判断は人間の仕事: 何を作るか、なぜ作るかはAIには決められない

AIとの協業で個人開発の天井は確実に上がった。以前なら「個人でSNSは無理」と思っていた規模のアプリが、2週間半で実際に動くプロダクトとして存在している。

リンク

1
1
0

Register as a new user and use Qiita more conveniently

  1. You get articles that match your needs
  2. You can efficiently read back useful information
  3. You can use dark theme
What you can do with signing up
1
1

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?