GA4 の国別レポートで、シンガポールからの Direct 流入が急に増えることがあります。自分のサイトでは28日で210セッション、エンゲージは1件、滞在は0秒でした。
人ではなさそうだと分かっても、確かめる手段が意外と限られます。アクセス元のネットワーク(ASN)を見ればデータセンターかどうかは一発ですが、Cloudflare の無料プランでは API から ASN が取れません。
ここでは、ASN を使わずに「人かボットか」を判定するために使った2つのチェックを、そのまま動くコードつきでまとめます。
チェック1: GA4 で画面解像度を見る
一番効いたのは画面解像度でした。シンガポールの210セッションのうち201件が 1280x1200 を報告していました。
本物の訪問者なら解像度は何十通りにも散らばります。1つの国のほぼ全セッションが同じ珍しい値なら、それはツールの設定値です。
GA4 Data API(Python)で、国を絞って screenResolution 別に出します。
from google.analytics.data_v1beta import BetaAnalyticsDataClient
from google.analytics.data_v1beta.types import (
RunReportRequest, Dimension, Metric, DateRange, FilterExpression, Filter,
)
def by_resolution(client, property_id, country="Singapore", days=28):
req = RunReportRequest(
property=f"properties/{property_id}",
dimensions=[Dimension(name="screenResolution")],
metrics=[Metric(name="sessions"), Metric(name="engagedSessions")],
date_ranges=[DateRange(start_date=f"{days}daysAgo", end_date="today")],
dimension_filter=FilterExpression(filter=Filter(
field_name="country",
string_filter=Filter.StringFilter(value=country),
)),
)
for row in client.run_report(req).rows:
print(row.dimension_values[0].value,
[m.value for m in row.metric_values])
管理画面でやるなら、探索レポートで「国」と「画面の解像度」をディメンションに並べるだけで同じものが見られます。browser と operatingSystem も並べると、ボットは「Chrome / Windows」に固まって出ます。
1280x1200 という値は今回のボットの設定なので、別のサイトでは別の値かもしれません。見るべきは値そのものより「1つの値に偏っているか」です。
チェック2: Cloudflare のビーコンで Chrome のバージョンを見る
もう1つは、JavaScript を実行したクライアントが名乗る Chrome のバージョンです。
Cloudflare Web Analytics を入れているサイトでは、ページの JavaScript が /cdn-cgi/rum にビーコンを送ります。このパスを叩くのは JavaScript を実行したクライアントだけなので、GA4 のタグを発火させる相手と同じです。無料プランでも、このパスを User-Agent 別に数えるところまではできます。
import re, collections, datetime as dt, requests
GQL = "https://api.cloudflare.com/client/v4/graphql"
QUERY = """
query($zone: String!, $since: Time!, $until: Time!, $country: String!) {
viewer { zones(filter: { zoneTag: $zone }) {
httpRequestsAdaptiveGroups(
limit: 5000,
filter: {
datetime_geq: $since, datetime_lt: $until,
clientCountryName: $country,
clientRequestPath: "/cdn-cgi/rum"
}
) { count dimensions { userAgent } }
} }
}
"""
def chrome_versions(token, zone, country, days=7):
now = dt.datetime.now(dt.timezone.utc).replace(microsecond=0)
ver = collections.Counter()
for i in range(days): # 無料プランは1クエリ1日まで
until = now - dt.timedelta(days=i)
since = until - dt.timedelta(days=1)
r = requests.post(GQL, headers={"Authorization": f"Bearer {token}"}, json={
"query": QUERY,
"variables": {"zone": zone, "country": country,
"since": since.isoformat().replace("+00:00", "Z"),
"until": until.isoformat().replace("+00:00", "Z")},
}, timeout=60).json()
for g in r["data"]["viewer"]["zones"][0]["httpRequestsAdaptiveGroups"]:
m = re.search(r"Chrome/(\d+)", g["dimensions"]["userAgent"])
ver[m.group(1) if m else "other"] += g["count"]
return ver.most_common()
print("SG", chrome_versions(TOKEN, ZONE, "SG"))
print("JP", chrome_versions(TOKEN, ZONE, "JP")) # 比較対象
ポイントは、自分の読者がいる国を比較対象として一緒に流すことです。自分のサイトではこうなりました。
| シンガポール | 日本(比較対象) | |
|---|---|---|
| Chrome のバージョン | 103〜133、メジャー版で十数種類 | 153 と 154 |
| OS | すべて Windows | Windows |
| ビーコンが来た時間帯(UTC) | 24時間中23時間 | 数時間に固まる |
Chrome は自動更新されるので、本物の読者は最新版(2026年10月時点で153〜154)に揃います。同じ都市から古いバージョンが満遍なく散らばって来るのは、ツールが User-Agent をリストから選んで名乗っている形です。
時間帯も見ておくと確度が上がります。夜の落ち込みが無く、24時間ほぼ均等に来ているなら人ではありません。
ハマりどころ: 無料プランで clientAsn を指定するとエラー
最初は ASN を見ようとして、clientAsn を指定しました。無料プランのゾーンではこう返ります。
zone '...' does not have access to the field 'clientasndescription'
クエリ全体が失敗するので、ASN 系のフィールドは外してください。User-Agent・パス・ステータス・時間帯(datetimeHour)は無料プランでも取れます。
判定の目安
4つ揃えば、ASN が無くてもボットと言い切ってよいと思います。
| 観点 | ボット側 | 人間側 |
|---|---|---|
| エンゲージ | 0秒・1ページで離脱が大半 | スクロール・滞在がある |
| 画面解像度 | 1つの値に偏る | 何十通りに散らばる |
| Chrome バージョン | 古い版が満遍なく | 最新版に揃う |
| 時間帯 | 24時間ほぼ均等 | 生活時間に偏る |
GA4 は既知のボットを自動で除外しますが、そのリストは見ることも追加することもできません。普通のブラウザを名乗るヘッドレス Chrome はそこをすり抜けるので、除外は分析する側でやることになります。
ボットが実際にどのページを巡回していて、除外したら「よく読まれている記事」の顔ぶれがどう変わったか、国ごと遮断しなかった理由は Aulvem 本家にまとめました → Aulvem|GA4の国別1位はシンガポールのボットだった — ASNが見えないCloudflare無料プランで見分けた手順