自作の管理画面が表示していた月間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 と突き合わせてみることをおすすめする。合わないなら、その差には理由がある。


コメント