backfill.py

scripts/analytics/backfill.py(264 行)

backfill.py
"""過去分バックフィル: 任意サイト・任意期間の GA4 / Search Console データを取り込む。

日次バッチ(main.py)は鮮度ルールで取得窓が狭い(GA4 日次系=直近 ga4_reprocess_days 日 /
GSC=today-lag から page_rolling_days 日)ため、サイトを後から追加したり、バッチが
しばらく止まっていたりすると、その間のデータが DB に入らない。本スクリプトはその穴埋め用。

使い方:
    (プロジェクトのルートで実行する)
    python -X utf8 scripts/analytics/backfill.py --site site_a --start 2025-01-01
    python -X utf8 scripts/analytics/backfill.py --site site_a --start 2025-01-01 --sources gsc
    python -X utf8 scripts/analytics/backfill.py --site site_a --start 2025-01-01 --end 2025-02-28

設計方針:
- 日次バッチ側の取得・書込関数をそのまま呼び、日付範囲だけを差し替える
  (バックフィル専用の取得ロジックを持たないことで、日次バッチと結果がずれない)。
- 冪等: 日次系・GSC は delete→insert、page_metrics は INSERT OR REPLACE。
  同じ期間を2回流しても行は増えない。
- page_metrics は「実行日スナップショット(直近 page_rolling_days 日の合計)」という
  性質上、過去日ぶんは1日=1リクエストのループで再現する(--sources から page を外せば
  スキップできる)。GA4 のデータは後から再処理されるため、当時実行した場合の値と
  完全には一致しない。
- batch_runs には source を `*_backfill` として記録する(日次の行と衝突させない)。
- Search Console API で遡れる期間には上限がある(おおむね過去16か月)。

実行前に DB ファイルをコピーしてバックアップしておくことを推奨。
"""

from __future__ import annotations

import argparse
from datetime import date, datetime, timedelta

from google.analytics.data_v1beta.types import BatchRunReportsRequest

import common
import fetch_ga4
import fetch_gsc

# fetch_ga4.fetch の #2〜#7 と同じ並び(delete→insert の対象)
DAILY_TABLES = [
    "channel_metrics", "landing_metrics", "user_type_metrics",
    "device_geo_metrics", "hourly_metrics", "event_metrics",
]
MAX_BATCH = 5   # GA4 batchRunReports は1回5レポートまで


def _parse_ymd(s: str) -> date:
    return datetime.strptime(s, "%Y-%m-%d").date()


def _now() -> str:
    return datetime.now().isoformat(timespec="seconds")


# --------------------------------------------------------------------------- #
# GA4 日次系(#2〜#7)
# --------------------------------------------------------------------------- #

def backfill_ga4_daily(conn, client, site_cfg, cfg, start: date, end: date, logger) -> int:
    """[start, end] を batchRunReports で取り直す(delete→insert)。"""
    site = site_cfg["site"]
    domain = site_cfg["domain"]
    property_id = str(site_cfg["ga4_property_id"])

    # common.ga4_date_range(N, today) = (today-N, today-1) なので、
    # today=end+1 / N=期間日数 とすれば [start, end] にちょうど一致する。
    n_days = (end - start).days + 1
    pseudo_today = end + timedelta(days=1)
    cfg2 = dict(cfg, ga4_reprocess_days=n_days)
    d_start, d_end = common.ga4_date_range(n_days, pseudo_today)
    assert (d_start, d_end) == (common.ymd(start), common.ymd(end)), (d_start, d_end)

    for t in DAILY_TABLES:
        n_del = common.delete_date_range(conn, t, site, d_start, d_end)
        if n_del:
            logger.info("[%s][backfill] %s: 既存 %d 行を削除", site, t, n_del)

    writers = [
        ("channel_metrics", lambda r: fetch_ga4._write_channel(conn, site, r)),
        ("landing_metrics", lambda r: fetch_ga4._write_landing(conn, site, domain, r)),
        ("user_type_metrics", lambda r: fetch_ga4._write_user_type(conn, site, r)),
        ("device_geo_metrics", lambda r: fetch_ga4._write_device_geo(conn, site, r)),
        ("hourly_metrics", lambda r: fetch_ga4._write_hourly(conn, site, r)),
        ("event_metrics", lambda r: fetch_ga4._write_event(conn, site, r)),
    ]
    reqs = fetch_ga4._daily_requests(cfg2, pseudo_today)

    total = 0
    for i in range(0, len(reqs), MAX_BATCH):
        batch_req = BatchRunReportsRequest(
            property=f"properties/{property_id}",
            requests=reqs[i:i + MAX_BATCH],
        )
        resp = common.with_retry(
            client.batch_run_reports, batch_req,
            max_retries=cfg.get("retry_max", 3),
            base_delay=cfg.get("retry_base_delay", 2.0),
            logger=logger,
        )
        for (name, writer), report in zip(writers[i:i + MAX_BATCH], resp.reports):
            n = writer(report)
            total += n
            logger.info("[%s][backfill][ga4] %s: %d 行 (%s - %s)", site, name, n, d_start, d_end)
    conn.commit()
    return total


# --------------------------------------------------------------------------- #
# GA4 page_metrics(実行日スナップショット)
# --------------------------------------------------------------------------- #

def backfill_page_snapshots(conn, client, site_cfg, cfg, start: date, end: date, logger) -> int:
    """snapshot_date=d(窓 = d-page_rolling_days 〜 d-1)を d ごとに再現する。"""
    site = site_cfg["site"]
    domain = site_cfg["domain"]
    property_id = str(site_cfg["ga4_property_id"])

    total = 0
    days = 0
    d = start
    while d <= end:
        req = fetch_ga4._page_report_request(property_id, cfg, d)
        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,
        )
        n = fetch_ga4._write_page_metrics(conn, site, domain, common.ymd(d), resp)
        total += n
        days += 1
        if days % 10 == 0:
            conn.commit()
            logger.info("[%s][backfill][ga4] page_metrics: %s まで %d 行",
                        site, common.ymd(d), total)
        d += timedelta(days=1)
    conn.commit()
    logger.info("[%s][backfill][ga4] page_metrics: 合計 %d 行 / %d スナップショット",
                site, total, days)
    return total


# --------------------------------------------------------------------------- #
# Search Console
# --------------------------------------------------------------------------- #

def backfill_gsc(conn, service, site_cfg, cfg, start: date, end: date, logger) -> int:
    """fetch_gsc.fetch をそのまま使い、lag/rolling だけ [start, end] になるよう差し替える。"""
    today = date.today()
    lag = (today - end).days
    rolling = (end - start).days + 1
    if lag < 0:
        raise ValueError(f"end({end}) が未来です")
    cfg2 = dict(cfg, gsc_lag_days=lag, page_rolling_days=rolling)
    s, e = common.gsc_date_range(lag, rolling, today)
    assert (s, e) == (common.ymd(start), common.ymd(end)), (s, e)
    return fetch_gsc.fetch(conn, service, site_cfg, cfg2, today, logger)


# --------------------------------------------------------------------------- #

def main() -> int:
    p = argparse.ArgumentParser(description="GA4 / Search Console の過去分バックフィル")
    p.add_argument("--site", required=True, help="設定ファイルの site 識別子(例: site_a)")
    p.add_argument("--start", required=True, help="取得開始日 YYYY-MM-DD")
    p.add_argument("--end", default=None,
                   help="取得終了日 YYYY-MM-DD(既定: GA4=昨日 / GSC=today-gsc_lag_days)")
    p.add_argument("--sources", default="ga4,page,gsc,affiliate",
                   help="カンマ区切り: ga4(日次系) / page(page_metrics) / gsc / "
                        "affiliate(ga4_affiliate_event を設定したサイトのみ)")
    p.add_argument("--config", default=None, help="設定ファイルのパス(既定: プロジェクトのルートの config.yaml。読むのは analytics: 節)")
    args = p.parse_args()

    cfg = common.load_config(args.config)
    logger = common.setup_logging(cfg["log_file"])
    sources = {s.strip() for s in args.sources.split(",") if s.strip()}

    site_cfg = next((s for s in cfg.get("sites", []) or [] if s["site"] == args.site), None)
    if site_cfg is None:
        logger.error("site=%s が設定ファイルにありません", args.site)
        return 2

    today = date.today()
    start = _parse_ymd(args.start)
    end_arg = _parse_ymd(args.end) if args.end else None

    logger.info("=" * 60)
    logger.info("バックフィル開始: site=%s start=%s end=%s sources=%s",
                args.site, args.start, args.end or "auto", sorted(sources))

    credentials = common.load_credentials(cfg, logger)
    common.init_db(cfg["database"])
    conn = common.connect(cfg["database"])
    run_date = common.ymd(today)
    total = 0
    failures = 0

    if sources & {"ga4", "page", "affiliate"}:
        client = fetch_ga4.build_client(credentials)
        ga4_end = end_arg or (today - timedelta(days=1))

        if "ga4" in sources:
            try:
                n = backfill_ga4_daily(conn, client, site_cfg, cfg, start, ga4_end, logger)
                total += n
                common.record_batch_run(conn, args.site, run_date, "ga4_backfill",
                                        "success", n, f"{start} - {ga4_end}", _now())
            except Exception as exc:  # noqa: BLE001
                failures += 1
                logger.exception("GA4日次系のバックフィル失敗: %s", exc)
                conn.rollback()
                common.record_batch_run(conn, args.site, run_date, "ga4_backfill",
                                        "failed", 0, str(exc)[:500], _now())

        if "page" in sources:
            # snapshot_date は実行日なので終端は today(窓は昨日まで)
            page_end = end_arg or today
            try:
                n = backfill_page_snapshots(conn, client, site_cfg, cfg, start, page_end, logger)
                total += n
                common.record_batch_run(conn, args.site, run_date, "ga4_page_backfill",
                                        "success", n, f"{start} - {page_end}", _now())
            except Exception as exc:  # noqa: BLE001
                failures += 1
                logger.exception("page_metrics のバックフィル失敗: %s", exc)
                common.record_batch_run(conn, args.site, run_date, "ga4_page_backfill",
                                        "failed", 0, str(exc)[:500], _now())

        if "affiliate" in sources and site_cfg.get("ga4_affiliate_event"):
            try:
                n = fetch_ga4.fetch_affiliate(conn, client, site_cfg, cfg, today, logger,
                                              start=common.ymd(start),
                                              end=common.ymd(ga4_end))
                total += n
                common.record_batch_run(conn, args.site, run_date, "ga4_affiliate_backfill",
                                        "success", n, f"{start} - {ga4_end}", _now())
            except Exception as exc:  # noqa: BLE001
                failures += 1
                logger.exception("アフィリエイトクリックのバックフィル失敗: %s", exc)
                common.record_batch_run(conn, args.site, run_date, "ga4_affiliate_backfill",
                                        "failed", 0, str(exc)[:500], _now())

    if "gsc" in sources:
        service = fetch_gsc.build_service(credentials)
        gsc_end = end_arg or (today - timedelta(days=cfg["gsc_lag_days"]))
        try:
            n = backfill_gsc(conn, service, site_cfg, cfg, start, gsc_end, logger)
            total += n
            common.record_batch_run(conn, args.site, run_date, "gsc_backfill",
                                    "success", n, f"{start} - {gsc_end}", _now())
        except Exception as exc:  # noqa: BLE001
            failures += 1
            logger.exception("GSCのバックフィル失敗: %s", exc)
            common.record_batch_run(conn, args.site, run_date, "gsc_backfill",
                                    "failed", 0, str(exc)[:500], _now())

    conn.close()
    logger.info("バックフィル完了: 書込総行数=%d, 失敗数=%d", total, failures)
    logger.info("=" * 60)
    return 1 if failures else 0


if __name__ == "__main__":
    raise SystemExit(main())

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