はじめに
「一度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クラス ステーションワゴン」
力技で解決しました。
- 両サイトの全メーカー・全車種ページをクローラーで取得
- 車種名を正規化(全角半角、スペース、ハイフン統一)
- 類似度マッチングで自動対応付け
- 手動レビューで修正
-
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の時間で作れたのは事実。
「横展開」は「コピペしてちょっと直す」ではなく、「設計判断を高速に下せる」 という意味で効率化されます。
- 中古車ウォッチ: https://car-watch.jp/
- 物件ウォッチ: https://bukken-watch.jp/
- WebReader AI API: https://rapidapi.com/bitsap/api/webreader-ai