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

自宅サーバー1台にAnthropicのbotが1日4万アクセスしてきた、でも全然遅くならなかった話

3
Posted at

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;
3
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
3
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?