0
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?

個人開発SaaSの横展開で学んだ「ドメインが変わると何が壊れるか」完全ログ

0
Posted at

はじめに

「一度SaaSを作れば、同じ構成で別ジャンルに横展開できる」

これ、半分正解で半分嘘です。

以前、不動産の新着物件をLINEで通知する物件ウォッチというSaaSを個人開発しました。自作のWebReader AI APIでSUUMO等をスクレイピングし、Claude AIで分析してLINEに通知する仕組みです。

「同じアーキテクチャで中古車版を作れば2日でできるでしょ」

5日かかりました。 コードの7割は書き直しです。

この記事では、横展開で「何がそのまま使えて、何が壊れたか」を具体的なコード付きで記録します。同じことをやろうとしている人の参考になれば。

前提:2つのサービス

物件ウォッチ 中古車ウォッチ
URL https://bukken-watch.jp/ https://car-watch.jp/
対象サイト SUUMO, athome, HOMES, 楽待, 健美家 カーセンサー, グーネット
入力方式 URL貼り付け 車種名テキスト
通知 LINE LINE
AI分析 Claude(投資家目線) Claude(購入者目線)
課金 Stripe月額 ¥480〜 Stripe月額 ¥380〜
技術スタック TS + Express + Supabase + Railway 全く同じ

そのまま動いたもの(約3割)

Stripe課金フロー

createCheckoutSession() → Webhook → handleSubscriptionUpdate() のフロー全体がコピペで動きました。変更したのは商品名と金額だけ。

// property-watch
{ name: "物件ウォッチ ライト", amount: 480 }
// car-watch(ここだけ変えた)
{ name: "中古車ウォッチ ライト", amount: 380 }

Stripeは本当に良くできていて、ドメインに依存する部分がほぼない。

LINE Webhook基盤

verifySignature()handleWebhookEvent() → コマンドルーティングの骨格はそのまま。ただしコマンドの中身は全部書き直し(後述)。

バッチスケジューラ

15分ごとにmonitor実行、9時にfree通知、21時にレポート——このスケジュール管理は完全に流用。

その他

  • プラン管理(free/lite/standard/pro)
  • トライアルロジック
  • エラー検知・ヘルスチェック
  • URL名寄せ(同じURLの複数ユーザーは1リクエスト)

教訓:課金・認証・スケジューリングはドメイン非依存。一度作れば使い回せる。

壊れたもの①:入力方式

不動産は「検索結果ページのURL」を貼り付ける方式。シンプル。

ユーザー → URL送信 → 監視開始

中古車でこれをやると破綻しました。

  • カーセンサーのURLが bTO/s001/ みたいなコードで意味不明
  • ユーザーは「プリウスを監視したい」のであって、URLを探したいわけではない
  • カーセンサーとグーネットの両方を見たいのに、URLは全く別

結局、車種名入力 → 候補表示 → 番号選択の対話フローを新規実装。

ユーザー:「トヨタ プリウス」
Bot:1. プリウス  2. プリウスPHV  3. プリウスα
ユーザー:「1」
Bot:カーセンサー + グーネット を監視開始!

これにはLINEのセッション管理が必要でした。

// 候補表示時にセッションを保存
await setSession(lineUserId, "select_model", {
  candidates: matchedModels,
  makerName: "トヨタ",
});

// 番号受信時にセッションから候補を復元
const session = await getSession(lineUserId);
if (session?.pending_action === "select_model") {
  const selected = session.pending_data.candidates[num - 1];
  // 監視URL作成...
}

物件ウォッチには存在しなかった概念です。

壊れたもの②:車種名の名寄せ(1,900車種)

「トヨタ プリウス」をカーセンサーとグーネットのURLに変換するマッピングが必要。

問題は、サイト間で車種の分類が違うこと。

カーセンサー:「911」(1つ)
グーネット  :「911(Type992)」「911(Type991)」「911(Type997)」(3つ)

カーセンサー:「Eクラスワゴン」
グーネット  :「Eクラス ステーションワゴン」

力技で解決しました。

  1. 両サイトの全メーカー・全車種ページをクローラーで取得
  2. 車種名を正規化(全角半角、スペース、ハイフン統一)
  3. 類似度マッチングで自動対応付け
  4. 手動レビューで修正
  5. model_groups テーブルに格納
-- 各車種に複数のURLパスを持たせる
CREATE TABLE model_groups (
  id serial PRIMARY KEY,
  maker_name text,          -- 'ポルシェ'
  display_name text,        -- '911'
  search_terms jsonb,       -- '["Porsche 911", "ナインイレブン"]'
  carsensor_paths jsonb,    -- '["bPO/s005/"]'
  goonet_paths jsonb,       -- '["PORSCHE__911_TYPE992/", "...991/", "...997/"]'
  is_active boolean DEFAULT true
);

約1,900車種分。 これが一番時間がかかった作業です。

壊れたもの③:パーサー

物件ウォッチのパーサーは「利回り」「築年数」「㎡単価」を正規表現で抽出します。

// property-watch
const yieldMatch = text.match(/利回り[*::\s]*([\d.]+[%%])/);
const ageMatch = text.match(/築(\d+)年/);
const structureMatch = text.match(/(RC造|SRC造|木造|鉄骨造)/);

中古車では抽出対象が全く違う。

// car-watch
const mileageMatch = text.match(/([\d.]+)\s*万km/);
const yearMatch = text.match(/(\d{4})年式?/);
const inspectionMatch = text.match(/車検[::]*\s*([\d年月]+|なし|整備付)/);
const colorMatch = text.match(/(?:色|カラー)[::]*\s*(\S+)/);
const transmissionMatch = text.match(/(AT|MT|CVT|DCT)/);

リスク検知も全面書き換え。

// property-watch: 旧耐震、再建築不可、借地権、既存不適格...
// car-watch:
if (/修復歴あり/.test(text)) risks.push("修復歴あり");
if (/水没|冠水/.test(text)) risks.push("水没車");
if (/メーター交換/.test(text)) risks.push("メーター注意");
if (/塩害/.test(text)) risks.push("塩害あり");

LINE通知のフォーマットも全部作り直し。

// property-watch: 💰 7,850万円 / 📈 利回り12% / 🏢 RC造 築34年
// car-watch: 💰 185万円 / 📏 走行3.2万km / 🔧 車検2027年3月 / 🎨 ホワイト

教訓:正規表現の9割はドメイン固有。「パーサーを流用できる」は幻想。 流用できるのはパーサーの「構造」(Markdownを受け取ってリストに分割する部分)だけ。

壊れたもの④:AI分析プロンプト

物件ウォッチでは「投資家目線」でプロンプトを組んでいます。

あなたは不動産投資の専門家です。
以下の物件情報を分析し、投資判断に必要な
メリット・デメリットを簡潔に指摘してください。
エリアの中央利回りは{medianYield}%です。

中古車では「購入者目線」に変更。

あなたは中古車アドバイザーです。
以下の車両情報を分析し、購入判断に必要な
メリット・注意点を簡潔に指摘してください。
同車種の走行距離中央値は{medianMileage}万kmです。

「AI分析」という機能は同じでも、プロンプトに入れるコンテキストデータが全く違う。不動産ならCF試算・残耐用年数・エリア相場、中古車なら走行距離分布・年式分布・車検残。

新規で作ったもの:掲載終了検知

中古車は売れたら即消える。不動産より回転が速い。

「昨日あったプリウス、もう売れた?」を通知したい。

最初の設計(失敗)

毎日深夜に全ページスキャンして、消えた車両を検知。

80ページのURLで37分。 全URLで毎日やるのは非現実的。

最終設計(巡回スキャン)

15分ごとのバッチジョブに「+1ページ」の巡回を組み込む。

async function runDelistingScan(watches, config) {
  const lead = watches.find(w => w.scan_cycle_started_at) || watches[0];
  const cursor = lead.scan_cursor; // 前回の続きから

  const pageUrl = config.paginateUrl(lead.url, cursor);
  const listings = extractListings(await fetchPage(pageUrl));

  // 見つかった車両のlast_seen_atを更新
  await touchProperties(watch.id, listings.map(l => l.external_id));

  if (listings.length < 10) {
    // 最終ページ到達 → 48時間見つからない車両を「掲載終了」
    const stale = activeProps.filter(p =>
      new Date(p.last_seen_at) < staleThreshold
    );
    await markDelisted(stale.map(p => p.id));
    await updateScanCursor(lead.id, 1); // 最初に戻る
  } else {
    await updateScanCursor(lead.id, cursor + 1); // 次のページへ
  }
}

80ページでも 80 × 15分 ≈ 20時間 で1周。追加負荷は1リクエスト/回だけ。

安全策もいくつか:

// 50%以上が消えたら異常(サイト側の問題)
if (toMark.length > activeProps.length * 0.5) { skip; }

// 150ページ超のURLは自動停止(対象が多すぎる)
if (cursor >= 150 && listings.length >= 10) { autoStop; }

この仕組みは良くできたので、物件ウォッチにも逆輸入しました。

おまけ:トライアル再利用防止

7日間無料トライアルで、ブロック→再フォローすると何度でもトライアルできてしまう問題。

プライバシーポリシーで「退会後にデータ削除」と謳っているので、LINE IDは残せない。

解決:SHA256ハッシュだけ残す

// ブロック時
const hash = crypto.createHash("sha256").update(lineUserId).digest("hex");
await db.from("trial_history").upsert({ line_id_hash: hash });
await db.from("profiles").delete().eq("id", profile.id); // 本体は削除

// 再フォロー時
const { data } = await db.from("trial_history")
  .select("id").eq("line_id_hash", hash).single();
if (data) await db.update({ trial_used: true }); // トライアル不可

ハッシュから元のIDは復元できないので、プライバシー上も問題なし。

流用率まとめ

カテゴリ 流用率 具体例
課金・認証 95% Stripe, トライアル, プラン管理
インフラ 90% Express, スケジューラ, Railway設定
監視基盤 70% バッチ処理, URL名寄せ, 異常検知
通知基盤 50% LINE送信は流用、フォーマットは全書き直し
パーサー 10% 構造だけ流用、正規表現は全部書き直し
UX 0% URL入力→車種名対話、名寄せ、セッション管理
AI分析 20% 呼び出し部分は流用、プロンプト・データは書き直し

全体で約30%が流用、70%が新規 or 大幅修正。

結論

横展開で流用できるもの

  • 課金(Stripe)
  • 認証(LINE OAuth)
  • スケジューリング(cron的なもの)
  • エラーハンドリング・ヘルスチェック
  • DBアクセス層の構造

横展開で流用できないもの

  • ドメインデータの抽出(パーサー)
  • ドメイン固有のUX(入力方式)
  • AIプロンプトとコンテキストデータ
  • 通知フォーマット
  • リスク判定ロジック
  • マスタデータ(車種テーブル等)

アーキテクチャの知見は100%流用できるが、コードの流用率は30%。 それでもゼロからの5分の1の時間で作れたのは事実。

「横展開」は「コピペしてちょっと直す」ではなく、「設計判断を高速に下せる」 という意味で効率化されます。


0
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
0
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?