0
0

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?

自社DBのクリック300件、ASP実測34件、人間0件 — 個人開発の計測をボットと自分自身から守る

0
Posted at

筆者が個人開発・運用している無料の市場価値診断サービス(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のエラーを置いていたからです。テストは「自分の想像」を検証しているだけで、実際のエラー形状とは無関係でした。テストは通り、本番は直らない、という最悪の組み合わせです。

是正:

  1. まず実際にエラーを発生させて code / message を記録した
  2. テストのモックを実測したエラー(PGRST204と実際の文言)に差し替えた
  3. コード修正後、モックではなく実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

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

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?