ブログ

GA4の探索レポートはロングテールが切り捨てられる?Pythonで全ランディングページを取得・集計する方法
この記事を書いた人
大森友貴
この記事で伝えたいこと
「GA4の集計、また手でCSV落として、Excelでキーワードごとに手動集計してませんか?」
コンテンツマーケティングやSEO施策では、ランディングページのURL中にキーワード(施策名や記事テーマ)を埋め込んで運用しているケースが多くあります。この単位でセッション数や表示回数を集計しようとすると、GA4の標準機能だけでは地味に骨が折れる作業になります。
そしてもう一つ、見落とされがちな問題があります。その集計結果、本当に全ページ・全ディレクトリの数字ですか?
GA4の探索レポートやレポート画面のCSVエクスポートには、表示・出力できる行数に上限があります。対象のページ数・ディレクトリ数が多いサイトほど、アクセス数が少ない施策ページやロングテールのキーワードが一覧から静かにこぼれ落ちている可能性があります。
今回、GA4 Data APIを直接叩いて全量データをディレクトリ(キーワード)単位で集計するPythonスクリプトを作ったので、標準エクスポートで何が起きているのか、そしてこのスクリプトで作業の自動化と全量取得の両方がどう実現できるのかを紹介します。
GA4標準のCSVエクスポートで困っていたこと
GA4の探索レポート(自由形式)やレポート画面からCSVをダウンロードする方法は手軽ですが、実際に運用してみると以下のような壁にぶつかります。
1. 行数上限によって「ロングテールのページ」が集計から漏れる
探索レポートやレポート画面には、表示・エクスポートできる行数に上限があります。ページ数やキーワード数が多いサイトで長期間のデータを一括で見ようとすると、アクセス数が多い上位のものしか出てこず、アクセス数が少ない施策ページやニッチなキーワードは一覧そのものに現れないことがあります。「集計結果に出てこない=アクセスがない」わけではなく、単に「上限に引っかかって切り捨てられた」だけ、というケースが起こり得ます。特にロングテールで多数のページを運用しているサイトほど、この影響を受けやすくなります。
2. 「URLのディレクトリ名だけ」を集計単位にできない
GA4の標準ディメンションは「ランディングページ」単位(=URLそのまま)です。例えば以下のようなURLが並んでいても、
/dir/aaa/
/dir/aaa/index.html
/dir/aaa/detail.html
/dir/aaa/2/
/dir/aaa/?sort=1&area=tokyo
GA4はこれを別々の行として出してくるだけで、「aaaというキーワード(ディレクトリ)単位で合算する」機能は持っていません。結局CSVをダウンロードしたあと、Excelの関数やピボットテーブルで文字列を切り出して集計し直す作業が毎回発生します。しかもこの手動集計は、上記の行数上限によって欠けたデータの上に成り立ってしまうため、集計の土台そのものが不確かになりがちです。
3. 「セッション数」と「表示回数」を両方見たい時にひと手間増える
指標を追加するたびにレポートを作り直したり、エクスポートのたびに列を選び直したりと、地味に手間がかかります。
4. 毎月・毎週の定例集計が「作業」になる
これを月次・週次でやろうとすると、同じ手順(ダウンロード→整形→集計)を毎回人力で繰り返すことになります。しかも上記の理由で対象データが多い時期ほど数字がズレる可能性があり、「先月と比べて数字が合わない」といった余計な確認作業まで発生しがちです。
このプログラムだと何が変わるのか
上記の課題に対して、今回作成したスクリプトでは「GA4 Data APIを日次で直接叩き、ローカルに全件を蓄積してから集計する」というアプローチを取りました。
課題 | GA4標準エクスポート | 今回のスクリプト |
|---|---|---|
行数上限によるロングテールの欠落 | 上限に収まらないページ・キーワードは一覧に出てこない | 日次× |
キーワード(ディレクトリ)単位の集計 | 手動でURLを分解して集計し直す必要あり | URLのプレフィックスを指定するだけで自動的にディレクトリ単位に集計 |
集計期間の柔軟さ | 期間ごとに毎回手動でエクスポート |
|
指標の切り替え | レポートを作り直す |
|
再実行・エラー対応 | 途中で失敗したら最初からやり直し | 進捗ファイルにより中断した日から自動再開 |
定例集計の運用 | 毎回手作業 | コマンド1つ(cronやバッチにも組み込み可能) |
つまりこのスクリプトの価値は2つあります。1つは「作業の自動化・工数削減」という分かりやすいメリット。もう1つは、それだけにとどまらずGA4の画面上では見えていなかったロングテールのデータまで含めて全量を集計し直せるという点です。「決まった単位・決まった形で、繰り返し集計する」ことを自動化した上で、その集計結果が確かな全量データに基づいている、というのがこのスクリプトの核となる価値です。
なぜ「日次取得」が行数上限の回避につながるのか
種明かしをすると、このスクリプトが全量データを取れる仕組みは非常に単純です。
1回のAPIリクエストの対象期間を「1日分」に絞る
limit=100000を指定し、1日あたりの取得件数の上限を大きく確保するディメンションを
landingPage、メトリクスをsessions(必要に応じてscreenPageViews)のみに絞る
という、リクエスト自体を小さく分割することで、1回あたりのデータ量を上限に収まる範囲に抑えています。これを対象期間分(例えば1年なら365回)繰り返し、出てきたCSVをローカルで合算することで、「探索レポートで一括表示しようとすると上限に引っかかって切り捨てられてしまうデータ」を、切り捨てられる前の形で積み上げ直しているわけです。
言ってしまえば、探索レポートの画面が持っている行数制約を、「画面を使わずAPIから日次で小分けに取得する」ことで構造的に回避している、というのがこのスクリプトの仕組みです。
なお、GA4 Data APIには「データ閾値(しきい値)によるマスク」という別の仕組みもあります。これはユーザー数が少ないデータについてプライバシー保護の観点から値が伏せられるもので、行数上限とは異なり、日次取得に分割しても回避できるものではありません。この点は本スクリプトの対象外の制約として、念のため触れておきます。
URL構造とキーワード抽出の関係 〜「ページ単位」ではなく「ディレクトリ単位」〜
全量データを取得できても、それが「ページ単位」のままではExcelでの手動集計が残ってしまいます。そこでこのスクリプトでは、集計の単位を「ページ単位」から「ディレクトリ単位」に変換する処理も組み込んでいます。
GA4標準の「ランディングページ」ディメンションはURL(ページ)そのものが単位ですが、本スクリプトはURLを階層構造として解釈し、指定したprefixの直後にある1階層分のディレクトリ名だけを取り出してキーワードとします。
/dir/【ここがキーワード=ディレクトリ名】/以降は全部まとめて捨てる
ロジックは非常にシンプルで、「prefixを取り除いた残り文字列を、最初の / で区切って先頭だけ取る」というものです。
rest = path[len(prefix):]
keyword = rest.split("/", 1)[0]
検証結果:どこまで同じキーワードに集約されるか
-prefixを/dir/とした場合、実際には以下のように集約されます。
入力URL | 抽出されるキーワード | ディレクトリ単位で集約される? |
|---|---|---|
|
| - |
|
| ✅ 同じディレクトリとして集約 |
|
| ✅ 同じディレクトリとして集約 |
|
| ✅ 同じディレクトリとして集約(2ページ目) |
|
| ✅ 同じディレクトリとして集約(末尾スラッシュ+パラメータ) |
|
| ❌ 別キーワード扱いになってしまう |
つまり、詳細ページ・2ページ目・末尾スラッシュ付きのパラメータURLなど、同じディレクトリ配下にぶら下がっているものはすべて自動的に1つのキーワードへ丸められます。GA4標準エクスポートで「ページごとにバラバラの行」として出てきていたものが、ディレクトリという意味のある単位に再構成されるイメージです。
一点だけ注意が必要なのは、末尾スラッシュなしでクエリパラメータが直結しているURLです。この場合はクエリ文字列がスラッシュを挟まずキーワードにくっついてしまい、split("/", 1)[0] では切り離せないため、別のキーワードとして扱われてしまいます。運用しているサイトで末尾スラッシュなしURLにパラメータが付くケースが多い場合は、この挙動を踏まえておく必要があります。
-prefix の深さで集計範囲が変わる
-prefixは「どこからをキーワードの起点にするか」だけでなく、「サイト内のどの範囲を集計対象にするか」というフィルタも兼ねています。例えば-prefix "/dir/sample/"のように深くすれば、集計したい施策ディレクトリだけに絞り込めます。実際のURL構成を見ながら、意図した範囲だけを拾えるprefixの深さを選ぶのがポイントです。
具体的に何ができるのか
指定したディレクトリ(例:
/dir/)配下のランディングページについて、URL中のキーワード部分(ディレクトリ名)ごとにセッション数を集計するオプションでページ+スクリーンの表示回数(
screenPageViews)も同時に取得・集計する集計期間は以下のいずれかの形式で柔軟に指定できる
直近1年間(
-last-year)直近N日間(
-days)開始日〜終了日を直接指定(
-start-date/-end-date)
取得済みの日別データはCSVとしてローカルに保存されるため、途中でエラーになっても再実行時に続きから取得できる(進捗管理ファイルによる再開機能)
集計対象のプロパティID・ディレクトリ・出力先などはすべてコマンドライン引数で指定でき、コードを直接編集する必要がない
こんなチームに向いている
URLにキーワードや施策名を埋め込んで運用しているオウンドメディア・LP施策のチーム
毎月同じ集計作業を手作業で繰り返しているチーム
ページ数・ディレクトリ数が多く、GA4の探索レポートの行数上限に引っかかっている可能性があるチーム
集計結果をBIツールやスプレッドシートに定期的に流し込みたいチーム
逆に、対象ページ数が少なく、月に数回ざっと数字を見るだけで十分な場合は、GA4の標準レポートで事足りることも多いです。「対象が多く、集計の型が決まっていて、繰り返し実行するもの」に対して威力を発揮するツールだと捉えてください。
事前準備
1. Python環境
Python 3.10以上を推奨(型ヒントに
str | Noneを使用しているため)
2. 必要なライブラリのインストール
pip install google-analytics-data
3. GA4 APIを利用するための認証設定
本スクリプトは Google Analytics Data API(GA4)を使用します。事前に以下の設定が必要です。
Google Cloud Consoleで対象プロジェクトの「Google Analytics Data API」を有効化する
サービスアカウントを作成し、JSON形式の認証キーを発行する
発行したサービスアカウントのメールアドレスを、対象のGA4プロパティに「閲覧者」以上の権限で追加する(GA4管理画面 → プロパティのアクセス管理)
環境変数
GOOGLE_APPLICATION_CREDENTIALSにJSONキーファイルのパスを設定する
export GOOGLE_APPLICATION_CREDENTIALS="/path/to/service-account-key.json"
4. GA4のプロパティIDの確認
GA4管理画面の「管理」→「プロパティ設定」の一番上に表示される数字を使用します(ストリームIDとは異なるので注意)。
実行例
# セッション数のみ、直近1年間(デフォルト設定)
python ga4_export.py --last-year
# セッション数+表示回数、直近90日間
python ga4_export.py --days 90 --include-views
# 期間・プロパティ・ディレクトリ・出力先をすべて個別指定
python ga4_export.py --property-id XXXXXXXXX \
--start-date 2025-04-01 --end-date 2026-03-31 \
--prefix "/dir/" \
--output-dir ./output_campaign \
--include-views
出力ファイル
ファイル | 内容 |
|---|---|
| 日次データ(landingPage, sessions, [views]) |
| 集計結果(landingPage, annual_sessions, [annual_views]) |
| 再実行用の進捗管理ファイル |
プログラム全文
※ --property-id のデフォルト値、および --prefix のデフォルト値は社内固有の情報のため、それぞれ XXXXXXXXX および /dir/sample/ に置き換えています。実際に使う場合は各自の環境の値を指定してください。
import argparse
import os
import csv
import time
from datetime import datetime, timedelta
from collections import defaultdict
from google.analytics.data_v1beta import BetaAnalyticsDataClient
from google.analytics.data_v1beta.types import DateRange, Dimension, Metric, RunReportRequest
# =========================
# 引数定義
# =========================
def parse_args():
parser = argparse.ArgumentParser(
description="GA4のランディングページ別セッション数・表示回数をキーワード単位で集計するスクリプト"
)
parser.add_argument(
"--property-id",
default="XXXXXXXXX",
help="GA4のプロパティID(デフォルト: XXXXXXXXX)",
)
date_group = parser.add_mutually_exclusive_group(required=True)
date_group.add_argument(
"--last-year",
action="store_true",
help="直近1年間(昨日から365日前〜昨日)を集計期間にする",
)
date_group.add_argument(
"--days",
type=int,
help="直近N日間(昨日からN日前〜昨日)を集計期間にする",
)
date_group.add_argument(
"--start-date",
help="集計開始日(YYYY-MM-DD)。--end-date とセットで指定",
)
parser.add_argument(
"--end-date",
help="集計終了日(YYYY-MM-DD)。--start-date とセットで指定",
)
parser.add_argument(
"--prefix",
default="/dir/",
help="削除・抽出対象とする landingPage の先頭ディレクトリ(デフォルト: /dir/sample/)",
)
parser.add_argument(
"--output-dir",
default="./output",
help="出力先ディレクトリ(デフォルト: ./output)",
)
parser.add_argument(
"--include-views",
action=argparse.BooleanOptionalAction,
default=False,
help="ページ+スクリーンの表示回数(views)も取得・出力するか(デフォルト: 無効。有効にする場合は --include-views)",
)
args = parser.parse_args()
if args.start_date and not args.end_date:
parser.error("--start-date を指定する場合は --end-date も必要です")
if args.end_date and not args.start_date:
parser.error("--end-date を指定する場合は --start-date も必要です")
today = datetime.today().date()
yesterday = today - timedelta(days=1)
if args.last_year:
args.start_date = (yesterday - timedelta(days=365)).strftime("%Y-%m-%d")
args.end_date = yesterday.strftime("%Y-%m-%d")
elif args.days:
args.start_date = (yesterday - timedelta(days=args.days - 1)).strftime("%Y-%m-%d")
args.end_date = yesterday.strftime("%Y-%m-%d")
# --start-date/--end-date が指定されている場合はそのまま使う
return args
# =========================
# ユーティリティ
# =========================
def daterange(start_date, end_date):
current = start_date
while current <= end_date:
yield current
current += timedelta(days=1)
def normalize_landing_page(path: str, prefix: str) -> str | None:
if not path:
return None
# prefix がなければ除外
if not path.startswith(prefix):
return None
# prefix を除去
rest = path[len(prefix):]
# prefix直後〜次のスラッシュまで取得
keyword = rest.split("/", 1)[0]
# %E3%80%80 を全角スペースに置換
keyword = keyword.replace("%E3%80%80", " ")
# keyword に % が含まれる場合は除外
if "%" in keyword:
return None
# 空は除外
if not keyword:
return None
# html は除外
if keyword.endswith(".html"):
return None
return keyword
def load_progress(progress_file):
if not os.path.exists(progress_file):
return None
with open(progress_file, "r") as f:
return f.read().strip()
def save_progress(progress_file, date_str):
with open(progress_file, "w") as f:
f.write(date_str)
# =========================
# メイン処理
# =========================
def main():
args = parse_args()
output_dir = args.output_dir
daily_dir = os.path.join(output_dir, "daily")
final_tsv = os.path.join(output_dir, "annual_landing_page.tsv")
progress_file = os.path.join(output_dir, "progress.txt")
os.makedirs(daily_dir, exist_ok=True)
client = BetaAnalyticsDataClient()
start = datetime.strptime(args.start_date, "%Y-%m-%d").date()
end = datetime.strptime(args.end_date, "%Y-%m-%d").date()
print(f"[INFO] 集計期間: {args.start_date} 〜 {args.end_date}")
print(f"[INFO] プロパティID: {args.property_id}")
print(f"[INFO] 対象ディレクトリ: {args.prefix}")
print(f"[INFO] 表示回数(views)の取得: {'有効' if args.include_views else '無効'}")
last_done = load_progress(progress_file)
skip = True if last_done else False
for day in daterange(start, end):
day_str = day.strftime("%Y-%m-%d")
if skip:
if day_str == last_done:
skip = False
continue
daily_csv = os.path.join(daily_dir, f"{day_str}.csv")
if os.path.exists(daily_csv):
save_progress(progress_file, day_str)
continue
print(f"[INFO] Fetching {day_str}")
metrics = [Metric(name="sessions")]
if args.include_views:
metrics.append(Metric(name="screenPageViews")) # ページ+スクリーンの表示回数
request = RunReportRequest(
property=f"properties/{args.property_id}",
date_ranges=[DateRange(start_date=day_str, end_date=day_str)],
dimensions=[
Dimension(name="landingPage"),
],
metrics=metrics,
limit=100000,
)
response = client.run_report(request)
daily_header = ["landingPage", "sessions"] + (["views"] if args.include_views else [])
with open(daily_csv, "w", newline="", encoding="utf-8") as f:
writer = csv.writer(f)
writer.writerow(daily_header)
for row in response.rows:
lp = row.dimension_values[0].value
sessions = int(row.metric_values[0].value)
if args.include_views:
views = int(row.metric_values[1].value)
writer.writerow([lp, sessions, views])
else:
writer.writerow([lp, sessions])
save_progress(progress_file, day_str)
time.sleep(1) # レート制御
# =========================
# 年間集計処理
# =========================
print("[INFO] Aggregating annual data")
annual_sessions = defaultdict(int)
annual_views = defaultdict(int)
for filename in os.listdir(daily_dir):
if not filename.endswith(".csv"):
continue
with open(os.path.join(daily_dir, filename), encoding="utf-8") as f:
reader = csv.DictReader(f)
for row in reader:
normalized = normalize_landing_page(row["landingPage"], args.prefix)
if not normalized:
continue
annual_sessions[normalized] += int(row["sessions"])
if args.include_views and "views" in row and row["views"] != "":
annual_views[normalized] += int(row["views"])
# =========================
# 最終TSV出力
# =========================
final_header = ["landingPage", "annual_sessions"] + (["annual_views"] if args.include_views else [])
with open(final_tsv, "w", newline="", encoding="utf-8") as f:
writer = csv.writer(f, delimiter="\t")
writer.writerow(final_header)
for lp, sessions in sorted(annual_sessions.items(), key=lambda x: x[1], reverse=True):
if args.include_views:
writer.writerow([lp, sessions, annual_views[lp]])
else:
writer.writerow([lp, sessions])
print(f"[DONE] Output: {final_tsv}")
if __name__ == "__main__":
main()
まとめ
GA4標準のエクスポート機能は「ちょっと見る」には十分便利ですが、「対象が多く、決まった単位・決まった形で、繰り返し集計する」用途には向いていません。今回のスクリプトは、その繰り返し作業をAPI経由で自動化しつつ、行数上限で切り捨てられていたロングテールのデータまで含めて全量を取得し直し、URLをページ単位ではなくディレクトリ単位のキーワードへ変換するところまでを1コマンドに集約したものです。
同じような「URLにキーワードを埋め込んで運用している」「対象ページが多い」「毎月同じ集計をしている」というチームがあれば、ぜひ流用してみてください。
補足
末尾スラッシュなしでクエリパラメータが直結したURL(
/dir/sample/aaa?xxx)は現状のロジックでは別キーワード扱いになるため、該当するURL構成のサイトで使う場合は注意してくださいデータ閾値(しきい値)によるマスクはGA4のプライバシー保護の仕組みであり、本スクリプトでも回避できません。ユーザー数が極端に少ないディレクトリでは値が伏せられる可能性がある点は留意してください
サービスアカウントのJSONキーは機密情報のため、リポジトリには含めず環境変数や秘密情報管理サービス経由で渡してください
大森友貴
10年以上にわたる開発ディレクター経験と深いSEO知見で、「LPクラフト」の価値を進化させているキーパーソン。チームの困りごとに徹底して寄り添うマネジメントでメンバーの成長を支え、プロダクト品質の要を担う。育児と仕事を両立しながら柔軟かつ力強くチームを引っ張る、開発リードディレクター。
