アクセスログのPVは実際の約39倍だった — PWAマニフェストで「実ブラウザ」だけを数える

自作の管理画面が表示していた月間PVは、Google Analytics の同期間の値の 約39倍 だった。

どちらかが間違っている。調べたら管理画面のほうが間違っていて、しかも「よくあるbot除外」では原理的に直せない種類の間違いだった。

最終的に、PWAマニフェスト(manifest.webmanifest)の取得数を数えるという方法で、GAとの誤差6%まで一致させられた。その調査過程と実装を書く。

この記事の比率・割合はすべて実測値です。事業上の理由から絶対値は伏せていますが、議論に必要な情報は比率側にあります。


前提:何を作っていたか

日本語のコンテンツサイトを Laravel で運用している。SSR で、PWA 対応済み。全リクエストを access_logs テーブルに記録し、管理画面で PV を集計していた。

bot除外はしていた。よくあるやつだ。

public function scopeHumans(Builder $query): Builder
{
    return $query
        ->whereNotNull('user_agent')
        ->where('user_agent', '<>', '')
        ->whereRaw('LOWER(user_agent) NOT REGEXP ?', [
            'bot|crawl|spider|slurp|python-requests|curl|wget|headless|...'
        ]);
}

管理画面にはこう書いてあった。

ボット(クローラ)を除いた数値です。実ブラウザのみを数える Google アナリティクスに近い集計になります。

この一文が完全な嘘だった。


数字が合わない

同じ30日間を比べると、こうなっていた。

指標管理画面のPVを 100 としたときの相対値
管理画面のPV(bot除外後)100
GA4 の表示回数2.5
GA4 のセッション2.0

97%以上が説明できない。

まず疑ったのは GA 側の計測漏れだ。しかし全ページ種別(トップ・一覧・詳細・サブドメイン側)で gtag の記述を確認したところ、どこにも漏れはなかった。

では、この差は何なのか。


決め手:ブラウザしか取りに来ないファイルを数える

サイトは PWA 対応していて、レイアウトに <link rel="manifest" href="/manifest.webmanifest"> が入っている。このファイルは ページを描画したブラウザしか取りに来ない。HTMLだけ抜いていくスクレイパーは、そもそも <link> を解釈しないので要求しない。

数えてみたら、manifest.webmanifest の取得数が GA のセッション数と誤差6%で一致した。

同じ期間のアイコン(icon/512, icon/32)の取得数も、同じオーダーに揃っていた。つまり 実ブラウザは全リクエストの約2% で、残りの98%は何か別のものだった。


裏付け1:HTMLを踏んだIPの98.5%が、CSSもアイコンも取りに来ていない

本物のブラウザが1ページ開けば、HTMLだけでは終わらない。CSS・JS・アイコン・マニフェストを続けて取得する。

そこで「HTMLを踏んだIP」のうち「資産も取得したIP」の割合を出した。

SELECT
  COUNT(*) AS ips,
  SUM(has_asset) AS with_asset
FROM (
  SELECT ip,
    MAX(CASE WHEN path = 'manifest.webmanifest'
              OR path LIKE 'icon/%'
              OR path LIKE 'build/%' THEN 1 ELSE 0 END) AS has_asset,
    SUM(CASE WHEN <HTMLとみなすパス条件> THEN 1 ELSE 0 END) AS html
  FROM access_logs
  WHERE created_at >= ? AND <bot除外>
  GROUP BY ip
) x
WHERE html > 0

結果、資産も取得したIPは全体の 1.5% しかなかった。

裏を返せば 98.5%のIPが、HTMLだけ抜いて消えていた。


裏付け2:人間の生活リズムがない

時間帯別に並べると、日本語サイトとしてありえない形をしていた。相対値で示す。

 4時  ██████
 5時  ████████████████████████████████████████  ← 最大
 6時  ████████████
 9時  ████████████████████████████████
18時  ██████

深夜5時が最大。日本語のコンテンツサイトを人間が最も見る時間ではない。人間のアクセスなら、朝・昼休み・夜に山ができ、深夜に谷ができる。


裏付け3:UAとIPが機械的にローテーションしている

「bot除外を通過した」UAの上位を出したところ、こうなっていた。

UA-A  Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7)...    件数 100 / 92 IP
UA-B  Mozilla/5.0 (Windows NT 10.0; Win64; x64)...          件数 100 / 91 IP
UA-C  Mozilla/5.0 (Windows NT 10.0; Win64; x64)...          件数 100 / 92 IP
UA-D  Mozilla/5.0 (Windows NT 10.0; Win64; x64)...          件数  99 / 91 IP
UA-E  Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7)...    件数  98 / 91 IP
                                          (最多UAを100とした相対値)

上位10件の件数がわずか 2%以内のばらつきに収まっている。しかもそれぞれがほぼ同数の異なるIPに分散している。人間の分布はこうならない。ラウンドロビンでUAを配っている典型的な形だ。

さらに、1つのIPから 800種類を超えるUA が飛んできていた。中身を見ると壊れている。

Moz111a/5.0 (1Pad; CPU 0S 17_2 like Mac OS X)
      ↑Mozilla   ↑iPad    ↑OS      ← ランダムに文字を置換している

sqlmap による脆弱性スキャンも混ざっていた。

数万個のIPが各1〜5PVずつアクセスしてくる分散型スクレイパー。IP単位のレート制限も遮断も、この形には原理的に効かない。


なぜUAキーワードのbot除外では捕まらないのか

bot|crawl|spider|... というキーワード一致は、「自分はbotです」と名乗っているUAしか落とせない

相手は正規のChrome/SafariのUAを名乗る。名乗る文字列に「bot」は入らない。だから素通りする。

一方 GA は JavaScript を実行しないと計上しない。スクレイパーは JS を実行しないので、1件も入らない。

この2つの差が、そのまま「JSを実行しないアクセス」の量だった。


実装

考え方はシンプルで、ブラウザの動作そのものを数える。ヒューリスティックではない。

モデル

class AccessLog extends Model
{
    /**
     * ブラウザがページを描画したときだけ取りに来るファイル。
     * PWA マニフェストは <link rel="manifest"> を解釈した実ブラウザしか要求しないため、
     * 「実際に人が見た回数」の代理指標として使える。
     */
    public const BROWSER_BEACON_PATH = 'manifest.webmanifest';

    /**
     * UA が明らかにボットのものを除く。
     *
     * ★これは「人間のアクセス」ではない。UA キーワード一致でしか判定できないので、
     *   実ブラウザの UA を名乗る自動アクセスは素通りする。
     */
    public function scopeHumans(Builder $query): Builder
    {
        return $query->where('is_bot', false);
    }

    /** 実ブラウザの表示回数(推定)。マニフェスト取得だけを数える。 */
    public function scopeBrowserBeacons(Builder $query): Builder
    {
        return $query->where('path', self::BROWSER_BEACON_PATH);
    }
}

インデックス

(path, created_at) の複合インデックスがあれば、期間集計でも一瞬で返る。

EXPLAIN SELECT COUNT(*) FROM access_logs
        WHERE path = 'manifest.webmanifest' AND created_at >= ?

type=range   key=access_logs_path_created_at_index   Extra=Using where

100万行規模のテーブルでも実測 20ms前後。この指標を出すコストはほぼゼロだ。

なお、既存の bot 除外が LOWER(user_agent) NOT REGEXP '...' だったせいで、集計のたびに全走査が起きていた。判定は書き込み時に is_bot 列へ確定させてインデックスを張るだけで、管理画面の描画は数十倍速くなる。ここは併せてやる価値がある。

画面

大事なのは2つの数字を分けて出すこと。片方に寄せると、また別の誤解を生む。

$cards = [
    ['label' => 'リクエスト数',   'value' => $pvTotal,      'sub' => '直近30日(自動アクセス込み)'],
    ['label' => '実ブラウザ表示', 'value' => $browserViews, 'sub' => '直近30日(推定)', 'accent' => true],
];

管理画面の説明文も書き換えた。

ボットと名乗る UA を除いた数値です。実ブラウザの数ではありません — ブラウザの UA を装う自動アクセスは除けません。人が見た回数は「実ブラウザ表示」を見てください。


この指標の限界(正直に書いておく)

万能ではない。使う前に知っておくべき制約がある。

1. これは「表示回数」ではなく「セッション」に近い

マニフェストはブラウザにキャッシュされる。同じ人が10ページ見ても、取得は初回の1回だけになりうる。だから GA の「表示回数」ではなく「セッション」と一致した。PV を知りたい用途には使えない。

2. ページ別に割れない

どのページを人間が見たかは分からない。マニフェストの取得はページに紐づかないからだ。「人気ページ」を人間ベースで出したい場合は別の方法が要る。

3. アプリのログに残る配信でないと使えない

CDN や nginx が静的ファイルとして直接返している場合、アプリのアクセスログには乗らない。実際このサイトでも、ビルド済みの CSS/JS はログに1件も残っていなかった(Webサーバーが静的配信していた)。マニフェストがフレームワークのルートを通っていたから使えた、という側面がある。

4. PWA対応が前提

マニフェストが無いサイトでは、代わりにアプリ経由で配信しているアイコンやフォントなど「ブラウザしか取らないファイル」を選ぶ必要がある。

5. GAの代替にはならない

これは「GAが無い環境で桁を間違えないための代理指標」だ。流入元・滞在時間・コンバージョンは分からない。GAがあるならGAを見るべきで、この指標の役割は自前の集計が嘘をついていないかの検算にある。


何が変わったか

事業判断に使う数字が変わった。

PVを前提に収益や成長を見積もっていたら、桁を2つ間違えることになる。「まだ立ち上がっていない」という現実を、正しい数字で認識できるようになったことが一番の収穫だった。

もうひとつ、負荷の実態も見えた。スクレイパーによるリクエストは量こそ多いが、ページを高速化した後ではサーバー負荷としてはごく軽い。慌てて遮断する必要はないという判断もできるようになった。数字が正しくなって初めて、優先順位を正しく決められる。


まとめ

  • サーバーログのPVとGAのPVが合わないとき、多くの場合サーバーログが多すぎる
  • UAキーワードによるbot除外は「名乗っているbot」しか落とせない。UAを偽装した分散スクレイパーには原理的に無力
  • ブラウザしか取りに来ないファイル(PWAマニフェスト等)の取得数は、実ブラウザの良い代理指標になる。今回はGAセッションと誤差6%
  • ただしそれはセッション寄りの指標であり、PVでもページ別でもない。限界を理解して使う
  • そして何より、自前の集計に「GAに近い」と書くなら、一度は実測で確かめるべきだった

もし自前のアクセス集計を持っているなら、一度 GA と突き合わせてみることをおすすめする。合わないなら、その差には理由がある。

コメント

タイトルとURLをコピーしました