0. 自宅サーバーに、Claude-SearchBot が 1 日 4 万アクセスしてきた
前回の記事 (建築士が Claude Code と DGX Spark で、2日で公共工事入札DB+RAG+自動記事生成基盤を作った話) を Qiita に投稿したのは、月曜の夜でした。
その翌日、本日 (2026-05-27) の access_log テーブルをふと SQL で叩いたら、こうなっていました。
SELECT
CASE
WHEN user_agent ILIKE '%claude%' OR user_agent ILIKE '%anthropic%' THEN 'Anthropic/Claude'
WHEN user_agent ILIKE '%googlebot%' THEN 'Googlebot'
WHEN user_agent ILIKE '%bingbot%' THEN 'Bingbot'
WHEN user_agent ILIKE '%bot%' OR user_agent ILIKE '%spider%' THEN 'OtherBot'
ELSE 'Human/Browser'
END AS kind,
COUNT(*) AS hits,
COUNT(DISTINCT ip) AS ips
FROM access_log WHERE ts >= CURRENT_DATE
GROUP BY 1 ORDER BY 2 DESC;
結果。
kind | hits | ips
------------------+-------+-----
Anthropic/Claude | 42176 | 1
Human/Browser | 659 | 37
OtherBot | 16 | 7
Bingbot | 4 | 1
Googlebot | 1 | 1
Anthropic の Claude-SearchBot が、単独 IP 216.73.217.83 (US) から、本日だけで 42,176 ヒット。
全アクセスの 98.4% です。
時間別に並べると、こうなっていました。
03時 3,069
05時 3,306
07時 3,244
09時 3,412
10時 3,509 ← ピーク
11時 3,311
12時 3,325
13時 2,876
14時 1,712
...
17時以降は急減 (おそらく Cloudflare の Rate Limit がここで効いた)
午前 02 時から午後 13 時まで、毎時 3,000 ヒット前後 が、約 12 時間ぶっ通し。
平均 0.8 req/秒、ピーク 1 req/秒。DDoS というほどではないけど、個人サイトとしては結構な量です。
そして、ここからが今日いちばん驚いた話。
私はこの 12 時間、別の作業をしていました。
コードを書き、Spark のログを眺め、原稿を書き、社長と打ち合わせもした。
その間、ブラウザでうちのサイトを開いても、いつもの速度で開いた。
「あれ、なんか遅いな」とすら感じなかった。
つまり、
DGX Spark 1 台 (= 自宅地下サーバー) + Cloudflare Free という構成が、
Anthropic の bot が 1 日に 4 万リクエスト送ってきても、平然と捌いていた。
これ、自分で書いていてちょっと信じられないんです。
クラウドの専用 EC2 を借りているわけでも、Auto Scaling Group を組んでいるわけでもない。
地下に置いてある GB10 一台と、Cloudflare の無料枠。
それで、1 日 4 万 bot リクエスト + 人間 659 アクセス + 並走している複数のクロールジョブを全部捌いて、しかも体感速度はいつもと同じ。
種を明かすと、構成はこうなっています。
┌─ Cloudflare CDN/WAF (Free)
│ ├ Edge Cache (静的アセット / 一部ページ)
│ ├ Bot Management (識別)
│ └ Rate Limit Rule
↓
TLS → Spark2 :443 (ufw で443以外は全 deny)
↓
┌─ kbids-web (Next.js 15 App Router, 127.0.0.1:3000)
├─ kbids-api (FastAPI)
├─ kbids-postgres (pgvector/pg16)
├─ kbids-crawler (Playwright + Ollama backfill workers)
├─ kbids-elastic / minio / redis
└─ ollama (gpt-oss:120b / qwen3-vl:30b / bge-m3 ほか)
ここで効いていたのは、たぶん次の 3 つです。
① Cloudflare の Edge で大半が握り潰されていた
/articles/* / /sitemap.xml / /robots.txt は Cache Rule で edge_ttl を立ててあるので、bot が同じ URL を 4 万回叩いても、オリジン (Spark) まで届くのはおそらく 数千件レベル に絞られている。
② 17 時の急減は、たぶん Cloudflare の Rate Limit
4 万 hits のうち 17 時以降 110 → 104 → 40 → 4 と落ちている。Cloudflare の Bot Fight Mode か、/api/ に入れていた Rate Limit Rule の波及が効いた可能性が高い。勝手に守ってくれていた。
③ Next.js / Postgres 側の「軽さ」
Next.js 15 の RSC + ISR で / は静的に近い扱いになっていて、API 叩かない。Postgres は pgvector でも数十万行レベルなので、HNSW で sub-50ms。叩かれても、叩かれる側の処理が軽い。
これらが組み合わさって、結果として、
「DGX Spark 1 台 + 月固定費ほぼ 0 円」で、自宅から本物の web 規模トラフィックに耐えられた
という事実だけが残りました。
昔だったらクラウドの ロードバランサ + AutoScaling + RDS で月数万円かかっていた構成が、地下のサーバ 1 台と Cloudflare Free で同等のことができている。
ちなみに今日の Claude-SearchBot のアクセスは、たぶん前回 Qiita 記事 (2026-05-26) → Claude.ai のユーザーが「公共工事入札 SaaS」「newsatsu」みたいな質問をして、それで Claude-SearchBot が引きに来た、という連鎖だと推測しています。Anthropic のドキュメント上、Claude-SearchBot は ユーザー検索クエリに応じて引きに行く bot と説明されているので。
個人開発の上限が、ここ 1 〜 2 年で一段上がった と痛感した瞬間でした。
クラウド請求書を気にせず、bot に好きなだけ叩かれていい、というのは、ローカル AI 時代の本当の自由 だと思います。
1. ところで、その同じサーバーの DB は、3 日前に死んでいた
…と、ここまでドヤ顔で書いておいて、申し訳ない。
実はこの記事、もう半分は 「サーバーは平気だったけど、データベースの方はめちゃくちゃだった」 という告白の話です。
前回記事を書いた翌日 (= 月曜の夜)、私はその勢いで、自分のサイトに psql で繋ぎました。
売り物のクオリティを、自分の目で確かめておこうと思ったんです。
打ったクエリは、たった一行。
SELECT
COUNT(*) AS total,
COUNT(submission_due) AS has_due
FROM bids
WHERE prefecture = '愛知県';
返ってきた結果を見て、私は 椅子に座り直しました。
total | has_due
-------+---------
47515 | 79
入札締切が、47,515 件中 79 件 = 0.17% しか入っていない。
これはもう、「SaaS が出来た」 ではなく、見た目だけ動いている入札情報サイト でした。
検索できる。画面もある。AI も動く。
でも、ユーザーが一番知りたい「いつまでに入札すればいいか」が抜けている。
締切が分からない入札情報サイトは、地図のないナビと同じです。
4 万アクセス捌けてもしょうがない。中身が死んでたら。
その夜、自分にこう言いました。
他県進出は禁止。
新機能も一旦止める。
愛知県の DB を、まず実用水準まで持っていく。
ここから先は、その「2 日で動いた」と書いた直後の 3 日目に冷静に見直したら DB の穴に気づいて青ざめ、それを Spark と Claude Code に勝手に埋めさせていた、という話です。
そして、その流れで作って 既に公開してある「自社専用コックピット /my」 + 4 機能 の話でもあります。
⚠️ 本記事の前提:
① データは公開情報のみ、対象サイトの利用条件・robots.txt・アクセス頻度を遵守。
② Claude Code Opus の自走は禁止コマンドリスト + バックアップ + 人手レビュー前提。
③ 数値はすべて執筆時点 (newsatsu.jp) の実測です。流入・SEO 効果はまだ未検証。
④ 本サービスは完全無料です。 ログイン (Clerk) と決済 (Stripe) のスタックは「個人開発でフルスタック SaaS を組む技術検証」のために入れていますが、課金は一切していません。クレジットカード入力も不要です。
🌐 公開中: https://newsatsu.jp / 自社コックピット: https://newsatsu.jp/my
2. 画面は動いていた。でも DB は死んでいた
SELECT COUNT(submission_due) がほぼゼロ件だった夜、自分の SaaS に対して、雑に 健康診断 をやってみました。
DB 健康診断の結果
| 項目 | 充足率 | 判定 |
|---|---|---|
| 案件数 | 44,094 件 | ✅ 量はある |
| 予定価格 | 91.8% | ✅ 使える |
| 緯度経度 | 99.95% | ✅ 地図は出せる |
| 発注機関 ID | 97.8% | ✅ 集計できる |
| 落札率 | 99.0% | ✅ SQL 1 発で達成済 |
| 市区町村 | 85.3% | 🟡 もう少し |
| 経審 P スコア | 39.9% | 🔴 復旧中 |
| 公告日 | 3.3% | 🔴 かなり危険 |
| 入札締切 | 0.17% | 💀 死亡 |
見た目は SaaS。でも、締切が 0.17%。
これは、建築で言えば 外観は完成しているけど、構造金物が半分入っていない家 みたいな状態でした。
そして、調べてみたら、穴は締切だけじゃありませんでした。
見つかった「やらかし一覧」
| やらかし | 原因 | 対応 |
|---|---|---|
| 入札締切が 0.17% しか入っていない | 一覧画面しか見ていなかった | ejPCJ 詳細ページを backfill |
| 予定価格を「事後公表」と誤認していた | 詳細画面を読めていなかった | HANDOFF v10 で訂正 |
| 落札者しか DB に入れていなかった |
awards 中心の設計だった |
bid_attempts 新設 |
| P スコアが 92% → 39.9% に退行した | スキーマ修正時の None 固定バグ | Vision LLM backfill で復旧中 |
| 設計→工事の予測が「+9 ヶ月固定」だった |
project_links 統計を使っていなかった |
percentile_cont で工種別統計化 |
| CIIC 経審 PDF で 17% の silent loss | CAPTCHA OCR 失敗時の retry なし |
CAPTCHA_MAX_RETRIES=3 + requeue |
こうやって並べると、3 日目は「新機能を作った日」というより、SaaS として信用できない部分を 1 つずつ潰した日 でした。
ここで自分に問いました。
「他県に展開するのと、愛知県の穴を埋めるの、どっちが先?」
10 秒考えて、答えは出ました。
新機能の派手な記事を書きたい欲求が、内臓レベルでうずきました。
でも、この穴を残したまま静岡や岐阜に手を出したら、欠損のスケールが県数倍に膨らむ。取り返しがつかなくなる。
Claude Code への指示の冒頭に、こう書き足しました。
絶対方針:
⚠️ 他県進出は禁止 (愛知データが実用水準に届くまで)
⚠️ 不完全データのままクロール拡大も禁止 (穴埋めが先)
死ぬほど地味です。
でも、ここを越えないと SaaS じゃない。
そして、その「地味な穴埋め」を始めた瞬間、ひとつ、想定外の発見が転がっていたことに気づきます。
3. 見落としていた 1 クリック先に、金鉱が眠っていた
submission_due が 0.17% しか取れていない原因を調べたら、なんとも情けない真実が出てきました。
愛知県の電子調達 (ejPCJ) は、典型的な ASP.NET WebForms の 2 層構造。
[検索結果一覧] ← 案件タイトル / 機関 / 開札日 だけ
↓ クリック (openKoukoku JS が走る)
[公告詳細 MainFrm] ← 予定価格 / 公告日 / 入札受付期間 / 添付PDF (公告書/設計書/設計図)
うちのスパイダーは、一覧画面しか見ていなかった。
Day 1 で「詳細は別ウィンドウで開く仕様で取れない」と判断したのは 私の誤読 で、実際は 同一ウィンドウの MainFrm に展開される だけだった。
ブラウザの開発者ツールでフレーム構造を見直して、ようやく分かりました。
HANDOFF ノートにはこう書きました:
🚨 v10 重大訂正 ─ ejPCJ は予定価格を 事前公表 していた。
「事後公表だから取れない」と諦めた Day 1 の判断は誤り。
書いた瞬間、頭の中で算盤が走りました。
「一覧しか見てない」ということは、詳細ページに眠っているデータが、まるまる手付かずで残っている。
Playwright で書いてみると、ロジックは拍子抜けするほど単純でした。
# crawler/backfill/ejpcj_open_details.py
for bid in fetch_open_bids_aichi():
# ① 一覧 frame から JS 関数を発火
await page.evaluate(f"openKoukoku('{bid.kokoku_no}')")
await page.wait_for_selector("frame[name='MainFrm']")
# ② MainFrm の innerText を全部取って、パーサに食わせる
main = page.frame("MainFrm")
inner_text = await main.inner_text("body")
parsed = parse_ejpcj_open_detail(inner_text)
# → 予定価格 / 公告日 / 入札受付期間 / PDF種別 が全部取れる
# ③ 一覧画面に戻る (Top_Frm の movePage を叩く)
await page.frame("Top_Frm").evaluate("movePage('EjPAJ01','start')")
# ④ 一覧再表示直後は openKoukoku が未ロード。polling で待つ
await page.wait_for_function("typeof openKoukoku === 'function'")
upsert_project_and_bid(bid.id, parsed)
問題は ④ の polling です。movePage で戻った直後の一覧画面は、JS の再評価が走っていない瞬間があって、そこで openKoukoku を呼ぶと undefined で死ぬ。
これに気づくまで 3 回ぐらい同じバグでスパイダーが落ちました。夜の 9 時のことです。
修正版を起動して走らせたら、1 件 ~45 秒のローカル実測ペースで、864 件、11 時間で完走。
書いている今は submission_due の充足率が一気に伸びていて、現状値は後ろの §10 の表で並べます。
…ところが、詳細ページに進んで気づいたのは、ここで終わらなかった。
開札済の入札詳細を、たまたま 1 枚開いた瞬間に、もっと厄介な事実 が見えました。
4. 落札者だけ見ていた自分は、入札の半分しか見ていなかった
開札済の入札詳細を、たまたま 1 件、コピペしてテキストエディタに貼ってみました。
出てきたのが、こんな表でした。
商号又は名称 入札書記載金額 結果
有限会社A建築 32,669,200 落札
B建設株式会社 32,015,816 失格(開札後)
株式会社C工務店 32,640,000 失格(開札後)
株式会社D建設 (記載なし) 辞退(電子入札)
落札したのは A 建築。
でも、B 建設は A より 65 万円安く入れていた。それなのに失格。C 工務店も A より安いのに失格。D 建設は辞退。
これが、ふつうに、ぜんぶ公開されているんです。
その瞬間、自分の DB の貧弱さが、急に恥ずかしくなりました。
うちの awards テーブルには「落札した A 建築」しか入っていない。
B も C も D も、応札していた事実すら DB に残っていない。
これまで自分の DB は、落札者だけ を見ていました。
でも、実際の入札では、落札者だけ見ても何も分からない。
- 誰が参加したのか
- 誰がいくらで負けたのか
- 誰が最低制限価格を割ったのか
- 誰が辞退したのか
- どの会社が最近、失格を繰り返しているのか
ここまで見えて、初めて 競合分析 になる。
つまり、awards テーブルだけでは、入札の「結果」しか見ていなかった。
bid_attempts を作って初めて、入札の「戦場」そのもの が DB に入る。
設計を書き直しました。
-- db/migrations/027_bid_attempts.sql
CREATE TABLE bid_attempts (
id BIGSERIAL PRIMARY KEY,
bid_id UUID NOT NULL REFERENCES bids(id) ON DELETE CASCADE,
company_id INTEGER REFERENCES companies(id),
company_name_raw TEXT NOT NULL,
round_no SMALLINT NOT NULL DEFAULT 1, -- 第1回/第2回/第3回入札
amount BIGINT, -- 入札書記載金額 (税抜き)
disposition TEXT NOT NULL, -- won/lost/disqualified/withdrawn/invalid
disposition_reason TEXT, -- 「失格(開札後)」「辞退(電子入札)」原文
created_at TIMESTAMPTZ DEFAULT NOW(),
UNIQUE (bid_id, company_name_raw, round_no)
);
ALTER TABLE bids ADD COLUMN minimum_price BIGINT; -- 最低制限価格
ALTER TABLE bids ADD COLUMN investigation_price BIGINT; -- 調査基準価格
過去 45,997 件の落札済 bid に対して、同じ Playwright スクリプトを 並列 4 で起動。
1 件 ~30 秒 / 並列 4 で、1 日で全件完走。応札者ベースの競合グラフが、いま DB に積み上がっています。
これが、今回の続編の 裏テーマ です。
「2日で動いた」の次にやるべきは、派手な新機能じゃなかった。
「落札した1社」しか見えていなかった世界を、「応札した全社の挙動」が見える世界に書き換える ことだった。
そして、ここからやっと、表テーマ ── 既に公開してある /my 4 機能 ── の話に入ります。
この bid_attempts がある世界と無い世界で、4 機能の見え方は全部変わります。
5. 「設計から1年弱で工事」という現場感覚が、SQL で数字になった
ここから、/my 4 機能 (既に公開済) の話です。
/my で作りたいのは、単なる管理画面ではありません。
地方の建設会社の社長が、朝コーヒーを飲みながら開いて、
- 半年後に出そうな工事
- 自社が取りに行くべき案件
- 最近動いているライバル
- JV を組めば届くかもしれない案件
を、5 分で把握できる画面。
つまり、作っているのは「入札検索サイト」ではなく、自社の営業会議を先回りするコックピット です。
機能 ①: 設計→工事 統計予測カレンダー
ひとつめは、「設計入札が動いた → 半年後に工事が出る」 という現場感覚を、DB の上で数値化した機能です。
出発点は、前回記事に書いた、社長から飛んできた一言:
「設計入札が動くと、半年後に必ず工事が出る。これ、取れない?」
Day 1 にこの示唆を受けて、ChoutatsuCD を 01 にしたら設計案件が降ってきた話を書きました。
そこから 1 行コード書いて、こうしていました:
# 雑なバージョン
predicted_construction_opening = design.opening_date + relativedelta(months=9)
+9 ヶ月固定。我ながら雑です。
3 日目、 project_links テーブルに対して何気なく COUNT(*) を打ったら、こう返ってきました。
project_links total 141,959 ペア
delay_months が入っているペア 141,959
delay_months × 30 の中央値 (日換算) 360 日
「過去 141,959 ペアの設計→工事リードタイムが、もう DB に揃っていた」。
その瞬間、自分が「+9 ヶ月固定」と書いていたのが、ちょっと馬鹿に見えました。
14 万件の実データが手元にあるのに、定数 1 個で運用していた。
📝 補足:
project_linksの中身は「同一発注機関・同一地域で時期がずれた設計案件と工事案件のペア」を社内アルゴリズムで組んだ候補です。link_score >= 0.7のフィルタを通した上で統計化します。実統計の n はこの 141,959 と同じか少し少ない値になります。
書いたのは、PostgreSQL の percentile_cont を使った 30 行 SQL です。
-- db/migrations/028_predicted_leadtime_stats.sql
INSERT INTO predicted_leadtime_stats
(construction_kojishu, amount_band, prefecture, n,
median_days, p10_days, p25_days, p75_days, p90_days)
SELECT
tp.kojishu,
CASE
WHEN COALESCE(tb.scheduled_amount, 0) < 50000000 THEN 'small'
WHEN COALESCE(tb.scheduled_amount, 0) < 300000000 THEN 'mid'
ELSE 'large'
END AS amount_band,
tp.prefecture,
COUNT(*) AS n,
percentile_cont(0.5 ) WITHIN GROUP (ORDER BY days)::int AS median_days,
percentile_cont(0.1 ) WITHIN GROUP (ORDER BY days)::int AS p10_days,
percentile_cont(0.25) WITHIN GROUP (ORDER BY days)::int AS p25_days,
percentile_cont(0.75) WITHIN GROUP (ORDER BY days)::int AS p75_days,
percentile_cont(0.9 ) WITHIN GROUP (ORDER BY days)::int AS p90_days
FROM (
SELECT pl.design_id, pl.construction_id,
EXTRACT(EPOCH FROM (tw.opening_date - sw.opening_date))/86400 AS days
FROM project_links pl
JOIN projects sp ON sp.id = pl.design_id
JOIN projects tp ON tp.id = pl.construction_id
JOIN bids sw ON sw.project_id = sp.id
JOIN bids tw ON tw.project_id = tp.id
WHERE pl.review_status IN ('auto','pending')
AND pl.link_score >= 0.7
) src
GROUP BY tp.kojishu, amount_band, tp.prefecture
HAVING COUNT(*) >= 10
ON CONFLICT (...) DO UPDATE SET ...
HAVING COUNT(*) >= 10 でサンプル不足を弾けば、 「工種別の確からしい中央値・四分位」が SQL 一発で出る。
執筆時点での工種別中央値はだいたい以下 (n は概数):
| 工種 | n (概数) | 中央値 (日) |
|---|---|---|
| 建築一式工事 | 〜30,000 | 366 日 |
| 土木一式工事 | 〜30,000 | 356 日 |
| 管工事 | 〜10,000 | 329 日 |
| 電気工事 | 〜10,000 | 337 日 |
| 水道施設工事 | 〜5,000 | 346 日 |
| 舗装工事 | 〜8,000 | 332 日 |
| 下水道施設工事 | 〜3,000 | 326 日 |
建築一式は 366 日、管工事は 329 日。 1 ヶ月強の差が、ちゃんと数字として出てくる。
建設業の現場感覚 (「設計から 1 年弱で工事だな」) が、SQL 一発で 量化された 瞬間でした。
これが個人的に、3 日目で一番テンションが上がった瞬間。
「感覚値が数値になる」というのは、現場で 20 年やっていた人間にとって、ちょっとした事件です。
/my/calendar の見え方はこんな感じです:
🗓️ 設計案件から予測される工事入札カレンダー (自社マッチ案件のみ)
━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
2026 年 6 月
━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
● ●●市●●道路改良工事 [設計受注: 2025-09-15]
工種 : 土木一式
予測開札 : 2026-06 中旬 〜 2026-08 下旬
統計根拠 : 土木一式の過去 30,012 ペア中央値 = 356 日
信頼度 : ★★★★★ (n=30,012 / 同地域同工種)
推定予算 : ¥150〜300M (設計料 ¥15M から 10-20 倍)
マッチ度 : 🟢 高 (自社の過去落札 12 件 と類似)
● ●●市給水管布設工事 [設計受注: 2025-08-10]
工種 : 管工事
予測開札 : 2026-06 中旬 〜 2026-07 下旬
統計根拠 : 管工事の過去 10,847 ペア中央値 = 329 日
信頼度 : ★★★★★ (n=10,847 / 同地域同工種)
マッチ度 : 🟡 中 (自社の過去落札 3 件)
信頼度バッジは単純に n の対数で:
function reliabilityBadge(n: number): string {
if (n >= 1000) return "★★★★★";
if (n >= 300) return "★★★★☆";
if (n >= 100) return "★★★☆☆";
if (n >= 30) return "★★☆☆☆";
return "★☆☆☆☆";
}
なぜこの設計か: 「+9 ヶ月固定」は分かりやすい。でも工種ごとのバラつき (管工事は短く、建築一式は長い) を捨ててしまう。
percentile_contで四分位を出せば、ユーザーに 「予測幅」と「サンプル数 = 確からしさ」を同時に提示 できる。SaaS が予測を売るなら、外れた時に責められないだけの根拠提示が必須、というのが設計動機です。
「半年前から見える狙い目案件カレンダー」が、14 万ペアの統計に裏付けされた状態で出る。
これが、機能 ① です。
ただ、ここで読者の皆さんは思うはず。「予測カレンダーで案件が見えても、結局 取れるかどうか が分からなければ意味がない」。
機能 ② は、まさにそこを解きにいきます。
6. 社長が本当に知りたいのは「うちは取れるのか」だった
前回記事の Phase 5-A で、likely-bidders という API を書きました。
「この公告中入札に参加しそうな業者 TOP10」を出すルールベースのスコアリングで、各案件ページの右ペインに既に出ています。
これを 自社視点に閉じる だけで、強烈な機能になります。
スコア式はこう。
score_self =
30 × jaccard(自社過去落札kojishu, 案件kojishu) +
20 × (自社が同発注機関で過去落札した実績比率) +
15 × exp(-distance_km / 30) + # 本社からの距離
15 × amount_band_match + # 自社の落札金額レンジに収まる
10 × available_engineers_ratio + # 余剰技術者
10 × 経審スコア相対値 # 同案件 likely-bidders の中での順位
そして落札確率は、likely-bidders 集団内での偏差を sigmoid で 0〜1 に潰す:
win_probability = sigmoid((score_self - mean_score) / std_score)
これに 自社の過去同工種落札率 をマージして、推奨入札価格帯を出す。
たとえば自社の過去平均落札率が 91% なら、API はこう返します:
{
"bid_id": "...",
"self_score": 73.2,
"self_rank": 3,
"total_likely_bidders": 12,
"win_probability": 0.27,
"competitor_top5": [
{"company": "(株)業者A", "score": 88.4, "wins_24m": 31},
{"company": "(株)業者B", "score": 81.7, "wins_24m": 22}
],
"recommended_price_band": {
"low": 0.87,
"mid": 0.90,
"high": 0.93
},
"self_history_rate_avg": 0.91
}
ユーザーが見るのは、たった 3 つの数字だけ:
- 落札確率 27%
- 想定競合 TOP5 (社名 + 直近落札数)
- 推奨入札価格 = 予定価格 × 87〜93%
これを、公告中入札 864 件のうち「自社マッチ度 60+」の案件に対して、ワンクリックで出します。
正直に書きます。
⚠️ 制約: つい最近まで DB には 落札者しか入っていなかった (
awards.is_winner=TRUEのみ)。確率推定は現在も主に「過去落札者の経審帯マッチング」がベース。第 4 章のbid_attemptsには今、応札者データが日々積み上がっていて、機能の精度はデータが増えるごとに継続的に上がっていきます。
なぜこの設計か: 「データが完璧に揃ってから機能を出す」を待っていたら、永遠にリリースできない。落札者ベースでも MVP として価値があると判断して、応札者データが入ったらそのまま自動で精度が上がる設計に倒した。機能のリリースは「データの 100% 完了」を待たない。データの完成度と一緒に機能の精度が育つように作る。 これが、私のスタンスです。
「予測カレンダーと自社確率」 が手に入ったとして、それでも片手落ちなのが、競合の動きが見えないこと。
機能 ③ は、そこを埋めにいきます。
7. ライバル監視は、メールではなく画面に流す
メール通知が嫌いです。
受信トレイで目立たない。「届いたメールを開く」というワンモーションすら、忙しい現場では削られる。
なので、Web ページを開いている間、勝手に更新される UI にしました。
データモデルは、既存テーブル流用 + 新規 1 本だけ。
-- 既存 user_favorites の target_type='rival_company' でライバル登録 (既存テーブル流用)
INSERT INTO user_favorites (user_id, target_type, target_id, note)
VALUES ('uuid...', 'rival_company', '1234', 'ライバル: ○○建設');
-- リアルタイム表示用の活動ログ (新規)
CREATE TABLE rival_activity_log (
id BIGSERIAL PRIMARY KEY,
rival_company_id INTEGER NOT NULL REFERENCES companies(id),
event_type TEXT NOT NULL, -- 'likely_bid' | 'won' | 'project_started'
bid_id UUID, project_id UUID,
detected_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
payload JSONB
);
バッチ側 (crawler/ai/rival_watcher.py) は 15 分毎の cycle:
def cycle_detect_rival_activity():
rivals = query("""SELECT DISTINCT target_id::int FROM user_favorites
WHERE target_type='rival_company'""")
for r in rivals:
# 各ライバルが likely-bidders 上位に入った公告中入札を検出
# 24時間以内の重複は UNIQUE で弾く
...
scheduler.add_job(cycle_detect_rival_activity,
IntervalTrigger(minutes=15), id="rival_watcher")
クライアント側は 30 秒 polling:
"use client";
export function RivalActivityFeed({ rivalId }: { rivalId: number }) {
const [events, setEvents] = useState<RivalEvent[]>([]);
useEffect(() => {
const tick = async () => {
const res = await fetch(`/api/my/rivals/${rivalId}/activity`);
setEvents(await res.json());
};
tick();
const id = setInterval(tick, 30_000); // 30秒毎
return () => clearInterval(id);
}, [rivalId]);
return <FeedList events={events} highlightNew />;
}
設計判断はシンプルで、「全ユーザー × 全公告中入札で likely-bidders を毎回計算」だと負荷が高いので、
- バッチが 15 分に 1 回まとめて計算 →
rival_activity_logに蓄積 - ブラウザは log を SELECT 数行で読むだけ
の二段構え。これで 30 秒 polling でも 1 リクエストあたり 50ms 程度に収まっています。
なぜこの設計か: WebSocket / SSE で完全リアルタイムも検討した。でも (a) Cloudflare 経由で長期接続を維持する運用コスト、(b) 入札情報の更新粒度はそもそも 15 分でも十分、という 2 点で polling に倒した。「いま要らないものを作らない」 は、Spark 環境で 1 人で運用する SaaS の鉄則 です。
新規イベントは画面に緑色のハイライトで「ピロン」と乗ります。
建設会社の事務所で、PC を開いていた担当者が、ふと画面の隅でフィードが更新されるのを横目で見て、
「あれ、●●建設、また狙ってきてる」
と気づく。その瞬間が、このサービスのコア体験 です。
上限: 登録社数は技術的な負荷上限 (1 ユーザーあたり 20 社程度) で切ってますが、課金は一切ありません。
ライバル監視で「敵が動いた」のは見える。でも、「自社単独だと参加できない大型案件」 には手も足も出ない。
ここで、最後の機能 ④ が効いてきます。
8. JV の「・」を分解すると、過去の組合せが「営業戦略」になる
awards テーブルを眺めていて、ずっと気になっていたデータがあります。
大日本・市川・横建特定建設工事共同企業体
明王・東洋特定建設工事共同企業体
東海・スター電器特定建設工事共同企業体
「・」で複数社が結合されたまま、生データで突っ込まれている。
これを正規化しないと「○○建設が JV で何件参加したか」が SQL で取れない。
愛知県だけで、共同企業体 を含む raw 名 = 143 件 / ・ を含む raw 名 = 533 件。
後者には「センター・コア」みたいな単なる中黒も混ざるので、パーサで弾く必要がある。
正規表現は、最終的にこれに落ち着きました。
# crawler/normalizers/jv_parser.py
import re
JV_SUFFIX = re.compile(
r'(特定建設工事共同企業体|建設工事共同企業体|建設共同企業体|共同企業体|JV)$'
)
def parse_jv(raw_name: str) -> list[str]:
"""
入力: '大日本・市川・横建特定建設工事共同企業体'
出力: ['大日本', '市川', '横建']
JV と判定するルール (AND条件):
1) 末尾に共同企業体 suffix を持つ
2) 「・」で 2 つ以上の要素に分割される
3) 各要素が companies テーブルで会社として引ける
"""
if not JV_SUFFIX.search(raw_name) and '・' not in raw_name:
return [raw_name]
cleaned = JV_SUFFIX.sub('', raw_name).strip()
parts = [p.strip() for p in cleaned.split('・') if p.strip()]
return parts if len(parts) > 1 else [raw_name]
これで awards.jv_members JSONB に正規化済 JV メンバを格納し、JV パートナー提案 の API を構成します。
# api/routers/my_jv.py
@router.get("/api/my/jv-suggest")
def suggest_jv_partners(company_id: int, target_bid_id: str | None = None):
"""
パターン1: target_bid_id 指定 → その案件で組める JV パートナー
パターン2: 未指定 → 自社単独で参加できない (経審ランク不足) 公告中案件を列挙
+ 各案件で JV 提案
"""
# 1) 対象案件の required_rank と self_company で参加可否判定
# 2) 参加不可なら、過去 JV 履歴ある会社を優先候補に
# 3) 経審 P スコア合算で参加可能になる組合せを生成
# 4) ROI 推定 (落札確率 × 推定粗利)
...
ここで効くのが業界知識:
経審スコア (P 点) は JV を組むと合算ルールがある。
うちの社長の感覚値だと「2 社で組むと P が +200〜300 上がるので、ワンランク上の案件に届く」。
これを 過去 JV 履歴のある会社を優先 しつつ、合算後 P で参加可能な組合せを「ROI 順」で出します。
JV の合算ルールには地域差・案件種別ごとの差があり、本機能は 「組める可能性のある候補の提示」までで止め、最終的な参加可否は実際の入札要綱を必ず確認する前提です。
なぜこの設計か: 「過去 JV 履歴を持つペア」を最優先するのは、一度組合せが成立した相手は心理的・契約的な摩擦が低い、という業界肌感覚から。完全に新規組合せだけを出すと、技術的には正解でも現場で採用されない、というのが前職含めての経験です。
ここで、4 機能の全体像が見えました。
機能 ① で「半年後の案件が見える」、 機能 ② で「取れるかどうかが分かる」、 機能 ③ で「ライバルの動きが見える」、 機能 ④ で「単独で取れない案件も JV で取りに行ける」。
入札の上流から下流まで、ぜんぶ繋がっている。
ただし、4 機能すべてが ログインユーザー専用 で、個別の自社情報を扱う。
これを CDN にキャッシュさせる事故が起きたら、サービスは一発で死にます。
9. /my をキャッシュした瞬間、SaaS は情報漏洩装置になる
/my 配下は、完全に個別ユーザー情報です。
他人の自社の進行中物件・落札ペース・ライバル監視リストを、Cloudflare の Edge にキャッシュさせたら一発アウト。
冒頭で「自宅サーバーが 4 万アクセスに耐えた」と書いた裏で、この防御層をひとつ間違えれば、4 万アクセスのうちの 1 件で他人の自社情報を持っていかれる、という構図でもあります。
個人開発の SaaS でいちばん怖いのは、トラフィックの量ではなく、こっちです。
なので、ガードを 2 段に重ねました。
ガード ①: Next.js の middleware で Cache-Control: private, no-store
// web/middleware.ts
if (req.nextUrl.pathname.startsWith("/my")) {
const { userId } = await auth();
if (!userId) return redirectToSignIn({ returnBackUrl: req.nextUrl.pathname });
const res = NextResponse.next();
res.headers.set("Cache-Control", "private, no-store, max-age=0");
return res;
}
ガード ②: Cloudflare Cache Rule で starts_with(http.request.uri.path, "/my") → Cache eligibility: BYPASS
ダッシュボードで明示 BYPASS にしておくと、middleware が万が一抜けても CDN 側で握り潰される。
検証コマンドはこの一行:
curl -sk -I https://newsatsu.jp/my 2>&1 | grep -iE "cf-cache-status|cache-control"
# 期待:
# cf-cache-status: BYPASS
# cache-control: private, no-store
これが BYPASS じゃなかったら、デプロイ取り下げ。例外なし。
なぜこの設計か: SaaS のプライバシー事故は、ほとんどが「単一防御層の見落とし」で起きる。middleware だけだとデプロイ事故で抜ける、Cloudflare だけだとオリジン直叩きで抜ける。両方が同時に壊れない限り絶対に漏れない、という冗長化 が必要です。「2 段でしか防げない事故は 1 段では絶対に防がない」。これが私のポリシー。
10. いま実際に動いているもの
執筆時点の /my URL 構造はこう。全部、すでに動いています。
/my ← 概況ダッシュボード (4 機能の要約カード ×4)
/my/calendar ← ① 設計→工事 統計予測カレンダー
/my/simulate ← ② 「うちが取れる確率」シミュレーター
/my/rivals ← ③ ライバル業者監視 (30秒polling)
/my/jv-suggest ← ④ JV パートナー提案
未ログインなら /sign-in、ログイン済だけど自社未連携なら /account にリダイレクト。「ログインユーザー専用」 のコックピットとして閉じています。
🌐 そのまま触れます: https://newsatsu.jp/my
DB 側は、穴埋めジョブが今もバックグラウンドで走っている状態で、いまこの瞬間の psql 実測値 がこれです。
| 項目 | 3 日前 (穴に気づく前) | 執筆時点 (実測) | 状態 |
|---|---|---|---|
| projects 愛知 | 44,068 | 44,094 | +α (継続クロール) |
| bids 愛知 | 47,489 | 47,515 | +α |
| awards.award_rate | 0.2% | 99.0% | ✅ SQL 1 発で達成 |
| projects.org_id | 82.6% | 97.8% | ✅ |
| projects.city | 20.4% | 85.0% | 🟡 title 抽出 backfill 走行中 |
| bids.notice_date | 0% | 3.34% | 🔴 公告中 backfill 走行中 |
| bids.submission_due | 0% | 0.30% | 🔴 同上、伸び始めた |
| keishin の P スコア | 約 92% → 39.9% (退行) | 42.70% | 🔴 Vision LLM backfill で復旧中 |
| keishin_results | 274 | 972 | CIIC bulk 継続中 |
| ciic_search_targets 走査 | ─ | 2,212 / 6,996 (31.6%) | bulk 継続中 |
| bid_attempts (新規) | ─ | 4,097 件 | 🟢 履歴 backfill が動き始めた |
| project_links | 141,959 | 141,959 (中央値 360 日) | ✅ 統計化済 |
ここで嘘をつかずに書いておきます。
- 落札率や城整理 (org_id / city) はもう実用水準。
-
公告中入札の
submission_due/notice_dateは、まだ全然伸びていません。詳細ページ backfill は 1 件 ~45 秒で 864 件回す構成なので、書いたコードは正しく動いているけど、完走までもう少しかかります。 -
bid_attemptsは履歴 45,997 件のうち 4,097 件まで到達。応札者ベースの競合分析は、件数が積み上がるにつれて精度が上がっていく途中の段階です。
つまり、/my の 4 機能は 動くけど、データは育ち続けている 状態。
これが「2 日で動いた」と書いた直後の本当の姿で、今後の続編で「実際どこまで埋まったか」を順次報告していきます。
11. AI は手を動かす。でも「何を正解にするか」はまだ人間の仕事だった
前回記事で「Claude Code Opus が勝手にコードを直してデプロイした」と書きました。
これ自体は今も毎日起きていて、autonomous_log には毎時の活動が記録されています。
1 週間触って分かったのは、AI 自走は「魔法」ではなく、かなり現実的な道具 だということです。
Claude Code Opus は、確かに勝手に直します。ログも見ます。エラーも追います。差分も作ります。
でも、怖いのはここからです。
正しそうな浅い修正は、ものすごい速度で進む。
だからこそ、人間側が間違った方針を渡すと、その間違った方針のまま、ものすごい速度で整っていきます。
今回で言えば、
-
awardsに列追加で済ませるのか、bid_attemptsを新設するのか - 他県展開を先にやるのか、愛知県の穴埋めを先にやるのか
- JV パーサで「・」分割の閾値をどこに置くか
- 履歴 backfill の並列度を 3 にするか 4 にするか
この判断は、AI ではなく 私が握る必要がありました。
得意 / 苦手の輪郭は、こんな感じ:
- ✅ Opus 得意: 「
company_name_rawというカラムが存在しないのでraw_nameに修正」← 即やる - ✅ Opus 得意: 「12:00 のリトライポリシー修正の効果を 13:00 に再検証」← 自発的にやる
- ❌ Opus 苦手: 「
bid_attemptsを新設するかawardsに列追加か」← 業界・運用判断が要る - ❌ Opus 苦手: 「履歴 backfill の並列度」← Spark2 の他ジョブとのリソース競合を見て判断
- ❌ Opus 苦手: 「JV パーサの境界条件」← 業界慣習を知らないと判断できない
AI は手を動かす。でも、何を正解にするかは、まだ人間が決める。
ここを間違えると、AI は間違った設計を、ものすごく綺麗に実装してしまう。
これが、1 週間 Spark2 と Opus に伴走してもらって得た、いちばん大事な学びでした。
そして、その役割分担を絵にすると、こうなる。
人間 (私) ← 方針を渡す。週 5-10 時間
↓
Claude Code Opus ← コード生成・自走修正・レビュー要求。24 時間
↓
Spark2 ← Playwright クロール / Vision LLM OCR / バッチ集計。24 時間
↓
Postgres + Next.js + Cloudflare ← 公開系。24 時間
「コードを書く時間ゼロ、考える時間 100%」 のキャリアの形が、こんなに早く来るとは思いませんでした。
12. 今この瞬間も、私の知らないところで DB が少しだけ賢くなっている
この記事を書いている裏で、Spark2 のログには、こんな行が淡々と流れています。
ejpcj_open_details ... parsed submission_due (312/864)
ejpcj_history_details ... inserted bid_attempts (worker 2)
reocr_keishin_vision ... restored P score (qwen3-vl, 47.2s)
zonal_ocr_bulk ... corp_no updated
CiicKeishinSpider ... CAPTCHA retry (attempt 2/3)
crawler.scheduler ... heartbeat
私が記事を書いている間に、Playwright が詳細ページを開き、
Vision LLM が経審 PDF を読み、
Postgres が設計から工事までの日数を集計し、
Claude Code が失敗ログを見て、次の修正案を作る。
そしてその裏で、Cloudflare の Edge が、bot のリクエストを今日も静かに握り潰している。
もちろん、まだ穴はあります。
たぶん明日の朝も、何か壊れています。
でも、昨日よりは少しだけ整っている。
2 日で動いた。
3 日目に、死んでいる場所が見えた。
そして今、その穴を Spark が埋め続けている。
同じサーバが、Anthropic の bot に 4 万叩かれても、平然と立っている。
ユーザーが触ってくれて、フィードバックをくれたら、それも autonomous_log に流れて、深夜に Opus がレビューして、次の ai_proposals になる。
読んでいる人が、サイトを進化させる側にそのまま回り込める 構造です。
13. 触ってみてほしい (全部、無料です)
/my を含めて、サイトのすべての機能は 全部無料 です。
🌐 トップ: https://newsatsu.jp
🎯 自社専用コックピット: https://newsatsu.jp/my
📝 前回記事: 建築士が Claude Code と DGX Spark で、2日で公共工事入札DB+RAG+自動記事生成基盤を作った話
ログイン (Clerk) と決済 (Stripe) のスタックを入れているのは、個人開発でフルスタック SaaS を組む技術検証 をやりたかったからで、実際の課金は一切していません。Stripe の方は連携検証で残してあるだけで、画面から決済フローには入りません。
つまり、
- メールアドレスだけで登録 (クレカ不要)
- 自社を 1 社特定する (経審・本社住所からマッチング)
- すぐに
/myの 4 機能が全部使える
これだけで、
- 半年後に出そうな工事
- 自社が取りに行くべき案件
- 最近動いているライバル
- JV を組めば届くかもしれない案件
が朝のコーヒー 5 分で見られる、というのが今の状態です。
触ってみて「ここダメ」「これ欲しい」があれば、お気軽に DM / メール (info@newsatsu.jp) ください。
いただいた意見は autonomous_log に流して Opus が深夜にレビュー → ai_proposals に提案 → 私が朝レビューして反映、というループがもう動いています。
フィードバックがそのままサイトを進化させる構造 を、楽しんでもらえると嬉しいです。
良ければ LGTM・ストック・DM、お待ちしてます 🙏
最後に、もう一度、psql を打って終わりにします。
SELECT
'もう動いています、触ってみてください' AS message,
'https://newsatsu.jp/my' AS where_to_go,
'完全無料 (クレカ不要)' AS price,
NOW() AS available_since;