fetch_ga4.py

scripts/analytics/fetch_ga4.py(470 行)

fetch_ga4.py
"""GA4 Data API v1beta からデータを取得して SQLite に upsert する。

- ページ別(#1)は ローリング期間の合計・上位N の単発レポート(snapshot_date = 実行日)。
- 日次系(#2〜#7)は batchRunReports でまとめて取得(リクエスト数の節約)。
- 鮮度ルール: 日次系は直近 reprocess_days 日を毎回取り直す(GA4 の再処理対策)。
- アフィリエイトクリック(#8)は任意。サイトに ga4_affiliate_event があるときだけ取得。
"""

from __future__ import annotations

import logging
import sqlite3
from datetime import date, timedelta
from typing import Any

from google.analytics.data_v1beta import BetaAnalyticsDataClient
from google.analytics.data_v1beta.types import (
    BatchRunReportsRequest,
    DateRange,
    Dimension,
    Filter,
    FilterExpression,
    Metric,
    OrderBy,
    RunReportRequest,
)

import common


def build_client(credentials) -> BetaAnalyticsDataClient:
    """OAuth / サービスアカウントいずれのクレデンシャルでも GA4 クライアントを構築する。"""
    return BetaAnalyticsDataClient(credentials=credentials)


def _fmt_ga4_date(v: str) -> str:
    """GA4 の date ディメンション 'YYYYMMDD' を 'YYYY-MM-DD' に変換。"""
    if v and len(v) == 8 and v.isdigit():
        return f"{v[0:4]}-{v[4:6]}-{v[6:8]}"
    return v


def _rows(resp: Any) -> list[tuple[list[str], list[str]]]:
    """RunReportResponse から (dim値リスト, metric値リスト) のリストを返す。"""
    out = []
    for row in resp.rows:
        dims = [d.value for d in row.dimension_values]
        mets = [m.value for m in row.metric_values]
        out.append((dims, mets))
    return out


# --------------------------------------------------------------------------- #
# #1 ページ別(単発: ローリング期間の合計・上位N)
# --------------------------------------------------------------------------- #

def _page_report_request(property_id: str, cfg: dict, today: date) -> RunReportRequest:
    # 「ローリング期間ぶんの合計」: 終端=昨日、開始=page_rolling_days 日前。
    start = common.ymd(today - timedelta(days=cfg["page_rolling_days"]))
    end = common.ymd(today - timedelta(days=1))
    return RunReportRequest(
        property=f"properties/{property_id}",
        dimensions=[Dimension(name="pagePath"), Dimension(name="pageTitle")],
        metrics=[
            Metric(name="screenPageViews"),
            Metric(name="activeUsers"),
            Metric(name="userEngagementDuration"),
            Metric(name="engagementRate"),
            Metric(name="bounceRate"),
        ],
        date_ranges=[DateRange(start_date=start, end_date=end)],
        order_bys=[
            OrderBy(
                metric=OrderBy.MetricOrderBy(metric_name="screenPageViews"),
                desc=True,
            )
        ],
        limit=cfg["page_top_n"],
    )


def _write_page_metrics(
    conn: sqlite3.Connection, site: str, domain: str, snapshot_date: str, resp: Any
) -> int:
    # GA4 は (pagePath, pageTitle) の組でグループ化するため、ページのタイトルを変更すると
    # 同じパスが新旧タイトルの2行に分かれて返る。主キーは (site, snapshot_date, page_path)
    # の INSERT OR REPLACE なので、そのまま書くと後勝ちで片方の PV が消える。
    # → 正規化パス単位に合算してから upsert する。
    agg: dict[str, list] = {}
    for dims, mets in _rows(resp):
        page_path = common.normalize_path(dims[0], domain)
        pv = common.to_int(mets[0])
        au = common.to_int(mets[1])
        dur = common.to_float(mets[2])
        er = common.to_float(mets[3])
        br = common.to_float(mets[4])
        cur = agg.get(page_path)
        if cur is None:
            # [title, pv, au, dur, er加重和, br加重和, title側pv]
            agg[page_path] = [dims[1], pv, au, dur, er * pv, br * pv, pv]
        else:
            cur[1] += pv
            cur[2] += au          # activeUsers は行間で重複しうるので近似の合算
            cur[3] += dur
            cur[4] += er * pv     # 率は PV 加重平均で合成
            cur[5] += br * pv
            if pv > cur[6]:       # タイトルは PV 最大の行(=現行タイトル想定)を代表にする
                cur[0] = dims[1]
                cur[6] = pv
    rows = []
    for page_path, (title, pv, au, dur, er_w, br_w, _tpv) in agg.items():
        rows.append([
            site, snapshot_date, page_path, title,
            pv, au, dur,
            (er_w / pv) if pv else 0.0,
            (br_w / pv) if pv else 0.0,
        ])
    return common.upsert(
        conn, "page_metrics",
        ["site", "snapshot_date", "page_path", "page_title", "page_views",
         "active_users", "engagement_duration", "engagement_rate", "bounce_rate"],
        rows,
    )


# --------------------------------------------------------------------------- #
# #2〜#7 日次系(batchRunReports に集約)
# --------------------------------------------------------------------------- #

def _daily_requests(cfg: dict, today: date) -> list[RunReportRequest]:
    start, end = common.ga4_date_range(cfg["ga4_reprocess_days"], today)
    dr = [DateRange(start_date=start, end_date=end)]
    # 1レポートの行数上限。API の既定値のままだと長期間のバックフィルで行が切り捨てられる。
    daily_limit = cfg.get("ga4_daily_row_limit", 100000)

    def req(dims, mets, order_metric=None, limit=None):
        kwargs = dict(
            dimensions=[Dimension(name=d) for d in dims],
            metrics=[Metric(name=m) for m in mets],
            date_ranges=dr,
            limit=limit or daily_limit,
        )
        if order_metric:
            kwargs["order_bys"] = [
                OrderBy(metric=OrderBy.MetricOrderBy(metric_name=order_metric), desc=True)
            ]
        return RunReportRequest(**kwargs)

    return [
        # 2. 流入チャネル
        req(["date", "sessionDefaultChannelGroup", "sessionSource", "sessionMedium"],
            ["sessions", "activeUsers", "engagedSessions"]),
        # 3. ランディングページ
        # 注意: ここに「上位N」の limit を付けないこと。limit はレポート全体に掛かるので、
        #       複数日のレンジに付けると「日×ページ」の上位N組しか残らず、1日あたりの
        #       行が大きく欠ける。
        req(["date", "landingPagePlusQueryString"],
            ["sessions", "bounceRate", "engagementRate"],
            order_metric="sessions"),
        # 4. 新規 vs リピーター
        req(["date", "newVsReturning"], ["activeUsers", "sessions"]),
        # 5. デバイス・地域
        req(["date", "deviceCategory", "country", "region"],
            ["activeUsers", "sessions"]),
        # 6. 時間別
        req(["date", "dayOfWeek", "hour"],
            ["sessions", "activeUsers", "screenPageViews"]),
        # 7. イベント / キーイベント
        req(["date", "eventName"], ["keyEvents", "eventCount", "totalRevenue"]),
    ]


def _write_channel(conn, site, resp) -> int:
    rows = []
    for dims, mets in _rows(resp):
        rows.append([
            site, _fmt_ga4_date(dims[0]), dims[1], dims[2], dims[3],
            common.to_int(mets[0]), common.to_int(mets[1]), common.to_int(mets[2]),
        ])
    return common.upsert(
        conn, "channel_metrics",
        ["site", "date", "channel_group", "source", "medium",
         "sessions", "active_users", "engaged_sessions"],
        rows,
    )


def _write_landing(conn, site, domain, resp) -> int:
    # landingPagePlusQueryString はクエリ文字列違いで同じパスが複数行になる。
    # 正規化後にそのまま INSERT OR REPLACE すると後勝ちでセッションが消えるので、
    # 正規化パス単位で合算してから upsert する(sessions=合算、率=セッション加重平均)。
    agg: dict = {}
    for dims, mets in _rows(resp):
        key = (_fmt_ga4_date(dims[0]), common.normalize_path(dims[1], domain))
        sess = common.to_int(mets[0])
        rec = agg.setdefault(key, {"sessions": 0, "br_w": 0.0, "er_w": 0.0})
        rec["sessions"] += sess
        rec["br_w"] += common.to_float(mets[1]) * sess
        rec["er_w"] += common.to_float(mets[2]) * sess
    rows = [
        [site, d, path, r["sessions"],
         (r["br_w"] / r["sessions"]) if r["sessions"] else 0.0,
         (r["er_w"] / r["sessions"]) if r["sessions"] else 0.0]
        for (d, path), r in agg.items()
    ]
    return common.upsert(
        conn, "landing_metrics",
        ["site", "date", "landing_page", "sessions", "bounce_rate", "engagement_rate"],
        rows,
    )


def _write_user_type(conn, site, resp) -> int:
    rows = []
    for dims, mets in _rows(resp):
        rows.append([
            site, _fmt_ga4_date(dims[0]), (dims[1] or "unknown"),
            common.to_int(mets[0]), common.to_int(mets[1]),
        ])
    return common.upsert(
        conn, "user_type_metrics",
        ["site", "date", "user_type", "active_users", "sessions"],
        rows,
    )


def _write_device_geo(conn, site, resp) -> int:
    rows = []
    for dims, mets in _rows(resp):
        rows.append([
            site, _fmt_ga4_date(dims[0]), dims[1], dims[2], dims[3],
            common.to_int(mets[0]), common.to_int(mets[1]),
        ])
    return common.upsert(
        conn, "device_geo_metrics",
        ["site", "date", "device_category", "country", "region",
         "active_users", "sessions"],
        rows,
    )


def _write_hourly(conn, site, resp) -> int:
    rows = []
    for dims, mets in _rows(resp):
        rows.append([
            site, _fmt_ga4_date(dims[0]),
            common.to_int(dims[2]),   # hour
            common.to_int(dims[1]),   # day_of_week
            common.to_int(mets[0]), common.to_int(mets[1]), common.to_int(mets[2]),
        ])
    return common.upsert(
        conn, "hourly_metrics",
        ["site", "date", "hour", "day_of_week", "sessions", "active_users", "page_views"],
        rows,
    )


def _write_event(conn, site, resp) -> int:
    rows = []
    for dims, mets in _rows(resp):
        rows.append([
            site, _fmt_ga4_date(dims[0]), dims[1],
            common.to_float(mets[0]), common.to_int(mets[1]), common.to_float(mets[2]),
        ])
    return common.upsert(
        conn, "event_metrics",
        ["site", "date", "event_name", "key_events", "event_count", "total_revenue"],
        rows,
    )


# --------------------------------------------------------------------------- #
# #8 アフィリエイトクリック(任意・ページ別×遷移先ホスト別)
# --------------------------------------------------------------------------- #
# サイト側の計測タグが「広告リンクのクリック」を GA4 イベントとして送っている場合に、
# そのイベントをページ別・遷移先ホスト別に日次集計する。
#
# 前提(サイト側で用意するもの):
#   - イベント名: config の各サイトの ga4_affiliate_event に書く(例: affiliate_click)。
#     未設定のサイトはこのレポートを実行しない。
#   - イベントパラメータ article_path(クリックされたページのパス)と
#     link_host(遷移先のホスト名)を送り、GA4 管理画面で同名のイベントスコープの
#     カスタムディメンションとして登録しておく。
# 注意: カスタムディメンションが未登録のプロパティでは API が
#       "not a valid dimension" エラーを返す。その場合は warning を出してスキップする
#       (バッチ全体は止めない)。また GA4 は登録前のイベントに遡って値を付けないので、
#       登録日より前は "(not set)" になる。

def _is_missing_custom_dimension(exc: Exception) -> bool:
    """GA4 の「customEvent:* が未登録」エラーか判定する。

    設定不備なのでリトライしても直らない。with_retry(give_up_if=...) に渡して
    即あきらめさせる。
    """
    msg = str(exc)
    return "not a valid dimension" in msg and "customEvent:" in msg


_AFF_DIMS = [
    "date",
    "customEvent:article_path",
    "customEvent:link_host",
]


def _affiliate_request(
    property_id: str, cfg: dict, start: str, end: str, event_name: str
) -> RunReportRequest:
    return RunReportRequest(
        property=f"properties/{property_id}",
        dimensions=[Dimension(name=d) for d in _AFF_DIMS],
        metrics=[Metric(name="eventCount")],
        date_ranges=[DateRange(start_date=start, end_date=end)],
        dimension_filter=FilterExpression(
            filter=Filter(
                field_name="eventName",
                string_filter=Filter.StringFilter(value=event_name),
            )
        ),
        limit=cfg.get("ga4_daily_row_limit", 100000),
    )


def _write_affiliate(conn, site, domain, resp) -> int:
    # article_path は他テーブルと JOIN できるよう正規化する。"(not set)" はそのまま保持。
    # 正規化で別の行が同じキーに畳まれることがあるので、upsert 前に合算する。
    agg: dict[tuple[str, str, str], int] = {}
    for dims, mets in _rows(resp):
        path = dims[1]
        if path and path != "(not set)":
            path = common.normalize_path(path, domain)
        else:
            path = path or "(not set)"
        key = (_fmt_ga4_date(dims[0]), path, dims[2] or "(not set)")
        agg[key] = agg.get(key, 0) + common.to_int(mets[0])
    rows = [[site, d, p, h, c] for (d, p, h), c in agg.items()]
    return common.upsert(
        conn, "affiliate_click_metrics",
        ["site", "date", "article_path", "link_host", "event_count"],
        rows,
    )


def fetch_affiliate(
    conn: sqlite3.Connection,
    client: BetaAnalyticsDataClient,
    site_cfg: dict,
    cfg: dict,
    today: date,
    logger: logging.Logger,
    start: str | None = None,
    end: str | None = None,
) -> int:
    """アフィリエイトクリックのページ別・遷移先ホスト別の件数を取得→upsert。

    サイトに ga4_affiliate_event が無ければ API を呼ばず 0 行で返す。
    通常バッチでは日次系と同じ鮮度ルール(直近 reprocess_days 日を delete→insert)。
    start/end を指定するとその期間を取り直す(バックフィル用)。
    カスタムディメンション未登録のプロパティは warning を出して 0 行でスキップする。
    """
    site = site_cfg["site"]
    event_name = site_cfg.get("ga4_affiliate_event")
    if not event_name:
        logger.info("[%s][ga4_affiliate] ga4_affiliate_event 未設定のためスキップ", site)
        return 0

    domain = site_cfg["domain"]
    property_id = str(site_cfg["ga4_property_id"])
    if start is None or end is None:
        start, end = common.ga4_date_range(cfg["ga4_reprocess_days"], today)

    req = _affiliate_request(property_id, cfg, start, end, str(event_name))
    try:
        resp = common.with_retry(
            client.run_report, req,
            max_retries=cfg.get("retry_max", 3),
            base_delay=cfg.get("retry_base_delay", 2.0),
            logger=logger,
            give_up_if=_is_missing_custom_dimension,
        )
    except Exception as exc:  # noqa: BLE001
        msg = str(exc)
        if "not a valid dimension" in msg or "customEvent:" in msg:
            logger.warning(
                "[%s][ga4_affiliate] カスタムディメンション未登録のためスキップ: %s",
                site, msg[:200],
            )
            return 0
        raise

    common.delete_date_range(conn, "affiliate_click_metrics", site, start, end)
    n = _write_affiliate(conn, site, domain, resp)
    conn.commit()
    logger.info(
        "[%s][ga4_affiliate] affiliate_click_metrics: %d 行 (%s〜%s)",
        site, n, start, end,
    )
    return n


# --------------------------------------------------------------------------- #
# エントリ
# --------------------------------------------------------------------------- #

def fetch(
    conn: sqlite3.Connection,
    client: BetaAnalyticsDataClient,
    site_cfg: dict,
    cfg: dict,
    today: date,
    logger: logging.Logger,
) -> int:
    """1サイト分のGA4データを取得→upsert。書き込んだ総行数を返す。失敗時は例外送出。"""
    site = site_cfg["site"]
    domain = site_cfg["domain"]
    property_id = str(site_cfg["ga4_property_id"])
    snapshot_date = common.ymd(today)

    max_retries = cfg.get("retry_max", 3)
    base_delay = cfg.get("retry_base_delay", 2.0)
    total = 0

    # --- #1 ページ別(単発レポート) ---
    page_req = _page_report_request(property_id, cfg, today)
    page_resp = common.with_retry(
        client.run_report, page_req,
        max_retries=max_retries, base_delay=base_delay, logger=logger,
    )
    n = _write_page_metrics(conn, site, domain, snapshot_date, page_resp)
    total += n
    logger.info("[%s][ga4] page_metrics: %d 行", site, n)

    # --- #2〜#7 日次系(batchRunReports) ---
    # 再取得対象期間を delete→insert にする(GA4 が返さなくなった古い行を取り除く)。
    daily_tables = [
        "channel_metrics", "landing_metrics", "user_type_metrics",
        "device_geo_metrics", "hourly_metrics", "event_metrics",
    ]
    d_start, d_end = common.ga4_date_range(cfg["ga4_reprocess_days"], today)
    for t in daily_tables:
        common.delete_date_range(conn, t, site, d_start, d_end)

    # batchRunReports は1回あたり5レポートまでなので、5件ずつに分割して投げる。
    daily_reqs = _daily_requests(cfg, today)
    writers = [
        ("channel_metrics", lambda r: _write_channel(conn, site, r)),
        ("landing_metrics", lambda r: _write_landing(conn, site, domain, r)),
        ("user_type_metrics", lambda r: _write_user_type(conn, site, r)),
        ("device_geo_metrics", lambda r: _write_device_geo(conn, site, r)),
        ("hourly_metrics", lambda r: _write_hourly(conn, site, r)),
        ("event_metrics", lambda r: _write_event(conn, site, r)),
    ]
    MAX_BATCH = 5
    for i in range(0, len(daily_reqs), MAX_BATCH):
        chunk = daily_reqs[i:i + MAX_BATCH]
        batch_req = BatchRunReportsRequest(
            property=f"properties/{property_id}",
            requests=chunk,
        )
        batch_resp = common.with_retry(
            client.batch_run_reports, batch_req,
            max_retries=max_retries, base_delay=base_delay, logger=logger,
        )
        for (name, writer), report in zip(writers[i:i + MAX_BATCH], batch_resp.reports):
            n = writer(report)
            total += n
            logger.info("[%s][ga4] %s: %d 行", site, name, n)

    conn.commit()
    return total

MIT ライセンスで配布しています。