筆者が個人開発・運用している無料の市場価値診断サービス(Next.js + Supabase + Vercel)の実装解説です。本記事は自作サービスの技術解説であり、扱うのはアフィリエイトクリック計測のボット除外と、その実装で踏んだ障害の記録です。
起きていたこと
サービスの収益導線は 自社比較ページ → /go/{slug}(リダイレクタ) → ASPリンク で、/go がクリックを click_events テーブルに記録します。ある日、3つの数字を突き合わせました。
| 数字 | 件数 |
|---|---|
| 自社DBの click_events | 300 |
| ASP管理画面の実クリック | 34 |
| user_id(人間のセッション)に紐づく行 | 0 |
差の266件はASP側のボットフィルタが破棄した分。精査すると、うち138件は誰も作業していない4日間に深夜2〜6時を含む24時間帯へ均等に分布し、全ページ×全リンクを機械的に網羅していました。クローラーです。
つまり自社の計測は約8.8倍に水増しされた幻で、この数字で意思決定していたら必ず間違えるところでした。さらに深刻なのは、ボットのクリックをASPへ送り続けること自体が不正クリック判定(提携解除・アカウント停止)のリスクだったことです。
対策1: リダイレクタでボットを判定し、ASPへ送らない
robots.txt の Disallow と rel="nofollow sponsored" は善意のクローラーにしか効きません。/go のRoute Handlerに、UAデニーリストと Sec-Fetch-* ヘッダの併用判定を入れました。
// app/go/[slug]/route.ts(抜粋)
const BOT_UA_RE =
/bot|crawler|spider|slurp|bytespider|gptbot|claudebot|ccbot|perplexity|amazonbot|applebot|yandex|baidu|curl\/|wget|python-requests|axios|node-fetch|go-http-client|okhttp|headlesschrome|phantomjs|puppeteer|playwright/i;
function detectBot(req: Request): string | null {
const ua = req.headers.get('user-agent') ?? '';
if (!ua) return 'no-ua';
if (BOT_UA_RE.test(ua)) return 'ua';
const mode = req.headers.get('sec-fetch-mode');
const dest = req.headers.get('sec-fetch-dest');
if (mode && mode !== 'navigate') return `sec-fetch-mode:${mode}`;
if (dest && dest !== 'document') return `sec-fetch-dest:${dest}`;
return null;
}
設計上のポイント:
- Sec-Fetch単独では落とさない。iOS Safari 16.3以前などヘッダを送らないブラウザを巻き込まないため、「ヘッダが存在して、かつ navigate/document でない」ときだけ弾き、UA判定と併用する
- 判定理由(bot_reason)を保存する。後から「何で弾いたか」を集計できないと、誤遮断のデバッグができない
-
ボットは404にせず自社の比較ページへ302で返す。ASPの成果地点に到達させないことが目的で、ボットへの応答自体は正常に返す。
x-robots-tag: noindex, nofollowも付与 - 判定コストの非対称性を確認してから入れる。人間のクリックは実測0件だったので、誤遮断で失う収益は0円。得るものしかない変更であることを、実数で確かめてから入れた
対策2: 後付けカラムで過去データを「人間に化けさせない」
click_events に is_bot boolean / bot_reason text を後付けするとき、一番やりがちなのが is_bot boolean not null default false です。これをやると、判定材料(UA)を保存していなかった既存300行が全部「人間」として確定してしまいます。
採った設計は:
- カラムは nullable にし、既存行は
null= 「分類不能」のまま残す - 集計は
is_bot = false(明示的に人間と判定できた行)だけを数える -
nullは「昔の、真偽不明の行」として集計から常に外す
「デフォルト値は過去に対する主張になる」というのが学びです。
障害1: マイグレーション未適用で、insertが3日間全滅した
このカラム追加のマイグレーションは事情によりすぐ本番へ適用されず、コードだけが先行デプロイされました。is_bot 付きのinsertは存在しない列を指すので全件失敗。しかもinsertは after()(応答後実行)内でエラーをcatchしていたため、リダイレクトは正常に動き続け、記録だけが3日間静かに全滅しました。
是正として、記録関数に「列が無ければ基本列だけで再挿入する」フォールバックを入れました。
async function recordClick(supabase, { slug, source, userId, isBot, botReason }) {
const base = { slug, source, user_id: userId };
const { error } = await supabase
.from('click_events')
.insert({ ...base, is_bot: isBot, bot_reason: botReason });
if (!error) return;
const missingColumn =
error.code === 'PGRST204' ||
error.code === '42703' ||
/could not find .*column|does not exist/i.test(error.message ?? '');
if (missingColumn) {
await supabase.from('click_events').insert(base); // 基本列のみで記録を落とさない
}
}
教訓: スキーマ変更の適用が非同期になる運用では、「適用されていなくても壊れない」コードを既定にする。読み取り側だけ互換を取って書き込み側を忘れる、という非対称な見落としをしました。
障害2: そのフォールバックが発動しなかった — 想像で書いたエラーでテストしていた
上のフォールバックには続きがあります。最初の実装では発動条件を error.code === '42703'(生PostgreSQLの undefined_column)にしていました。ユニットテストも書いて通しました。それでも本番では1件も記録されませんでした。
原因: Supabase(PostgREST)が実際に返すのは PGRST204 で、メッセージは
Could not find the 'bot_reason' column of 'click_events' in the schema cache
です。生Postgresのエラーコードともメッセージ(does not exist)とも一致せず、フォールバックは一度も発動していませんでした。
ではなぜテストが通ったのか。テストのモックに、自分が想像で書いた42703のエラーを置いていたからです。テストは「自分の想像」を検証しているだけで、実際のエラー形状とは無関係でした。テストは通り、本番は直らない、という最悪の組み合わせです。
是正:
- まず実際にエラーを発生させて
code/messageを記録した - テストのモックを実測したエラー(PGRST204と実際の文言)に差し替えた
- コード修正後、モックではなく実DBに対して同じ判定ロジックを走らせてからデプロイした
教訓: 外部サービスのエラー処理は、想像したエラーではなく実際に発生させたエラーで書く。モックは実物を観測してから作る。「テストが通った」は「実物で動く」を意味しません。
障害3: prefetchが生む幽霊クリック
もう1つ、ボットでも障害でもないのに計測を汚していたのが Next.js の next/link です。<Link> はビューポートに入ったリンクを prefetch するため、比較ページのアフィリンクを <Link href="/go/..."> にしていると、ユーザーがページを表示しただけで /go が叩かれ、クリックが記録されます(さらにASP側の計測にも届きうる)。
対策は単純で、計測やリダイレクトの副作用を持つ出口リンクは素の <a> にする。Sec-Fetch判定(sec-fetch-mode: navigate 以外を弾く)も二重の防波堤になりますが、そもそもprefetchさせないのが先です。
対策3: 自分自身を集計から除外する
ボットの次に計測を汚すのは自分です。開発・検証時のアクセスに流入元パラメータ(launch_check 等)を必ず付け、KPI集計スクリプトのデフォルトで除外します。
const SELF_SOURCE_RE = /^(direct|launch_check|final_check|healthcheck|verify_|test_|share)/;
このサービスではDBの診断21件のうち18件が自分のテストでした。除外を最初から入れていれば初日に分かった事実に、3週間かかりました。集計スクリプトは実行のたびに履歴ファイル(JSONL)へ追記し、「先週比」を人力でなく機械に言わせています。
まとめ
- 自社DBのイベント数は「自分のINSERTが成功した回数」であって「人が来た回数」ではない。外部(ASP・プラットフォーム)の実数と突き合わせて初めて計測になる
- ボット除外はUA+Sec-Fetch併用、古いブラウザへの配慮、判定理由の保存、外部へ送らない、の4点セット
- 後付けカラムのデフォルト値は過去データへの主張。分類不能はnullのまま残す
- スキーマ適用とデプロイが非同期なら、未適用でも壊れないフォールバックを書く
- そのフォールバックのテストは、実物のエラーを観測してから書く
- prefetchは計測の敵。出口リンクは素の
<a> - ボットの次に危険なのは自分のテストトラフィック
解説したサービス(無料・8問60秒・登録不要の市場価値診断): キャリア査定AI