← back to Ga Allsites

scripts/batch_create_properties.py

173 lines

#!/usr/bin/env python3
"""
Batch-create GA4 properties + web data streams for domains in
cache/_ga4_inject_targets.json. Resilient: one failure never aborts the
batch; rate-limit errors get exponential back-off. Writes results to
cache/_ga4_inject_ids.json as {domain: measurement_id}.

Cost: $0 — Analytics Admin API is free.
"""
from __future__ import annotations

import json
import os
import sys
import time
from pathlib import Path

# ---------------------------------------------------------------------------
# Paths
# ---------------------------------------------------------------------------
PROJECT_ROOT = Path("/Users/macstudio3/Projects/ga-allsites")
TARGETS_FILE = PROJECT_ROOT / "cache" / "_ga4_inject_targets.json"
OUTPUT_FILE  = PROJECT_ROOT / "cache" / "_ga4_inject_ids.json"

# Re-use auth helpers from the analytics skill
SKILL_SCRIPTS = Path.home() / ".claude/skills/analytics/scripts"
sys.path.insert(0, str(SKILL_SCRIPTS))
from _auth import ensure_credentials, get_account_id  # noqa: E402

# ---------------------------------------------------------------------------
# Config
# ---------------------------------------------------------------------------
TIMEZONE = "America/Los_Angeles"
CURRENCY = "USD"
INDUSTRY = "ONLINE_COMMUNITIES"

MAX_RETRIES    = 4
BASE_BACKOFF_S = 5    # seconds; doubles each retry


# ---------------------------------------------------------------------------
# Core helpers
# ---------------------------------------------------------------------------

def create_property_and_stream(client, account_id: str, domain: str):
    """Create property + web stream; return (property_id, measurement_id)."""
    from google.analytics.admin_v1alpha.types import Property, DataStream

    prop = Property(
        parent=account_id,
        display_name=domain,
        time_zone=TIMEZONE,
        currency_code=CURRENCY,
        industry_category=INDUSTRY,
    )
    created = client.create_property(property=prop)
    prop_name = created.name
    prop_id   = prop_name.split("/")[-1]

    clean_domain = domain.replace("https://", "").replace("http://", "").rstrip("/")
    stream = DataStream(
        type_=DataStream.DataStreamType.WEB_DATA_STREAM,
        display_name=domain,
        web_stream_data=DataStream.WebStreamData(
            default_uri=f"https://{clean_domain}",
        ),
    )
    created_stream = client.create_data_stream(parent=prop_name, data_stream=stream)
    measurement_id = created_stream.web_stream_data.measurement_id

    return prop_id, measurement_id


def create_with_retry(client, account_id: str, domain: str):
    """Wrap create_property_and_stream with exponential back-off."""
    import grpc

    last_err = None
    backoff = BASE_BACKOFF_S
    for attempt in range(1, MAX_RETRIES + 1):
        try:
            return create_property_and_stream(client, account_id, domain)
        except Exception as e:
            last_err = e
            err_str = str(e).lower()
            is_rate_limit = (
                "quota" in err_str
                or "resource_exhausted" in err_str
                or "rate" in err_str
                or "429" in err_str
            )
            if is_rate_limit and attempt < MAX_RETRIES:
                print(f"  [rate-limit] attempt {attempt}/{MAX_RETRIES}, sleeping {backoff}s …")
                time.sleep(backoff)
                backoff *= 2
                continue
            # Non-rate-limit or final attempt — give up on this domain
            break

    raise RuntimeError(f"Failed after {MAX_RETRIES} attempts: {last_err}") from last_err


# ---------------------------------------------------------------------------
# Main
# ---------------------------------------------------------------------------

def main() -> int:
    ensure_credentials()
    account_id = get_account_id()
    print(f"Account: {account_id}")

    domains: list[str] = json.loads(TARGETS_FILE.read_text())
    print(f"Targets: {len(domains)} domains\n")

    # Load any previously written results so a re-run is idempotent
    existing: dict[str, str] = {}
    if OUTPUT_FILE.exists():
        try:
            existing = json.loads(OUTPUT_FILE.read_text())
            if existing:
                print(f"Resuming: {len(existing)} already done → {list(existing.keys())[:3]} …\n")
        except json.JSONDecodeError:
            pass

    from google.analytics.admin import AnalyticsAdminServiceClient
    client = AnalyticsAdminServiceClient()

    results: dict[str, str] = dict(existing)
    succeeded = list(existing.keys())
    failed: list[tuple[str, str]] = []

    for i, domain in enumerate(domains, 1):
        if domain in existing:
            print(f"[{i:02d}/{len(domains)}] {domain:<35}  SKIP (already done: {existing[domain]})")
            continue

        print(f"[{i:02d}/{len(domains)}] {domain:<35}  creating …", end="", flush=True)
        try:
            prop_id, mid = create_with_retry(client, account_id, domain)
            results[domain] = mid
            succeeded.append(domain)
            print(f"  OK  property={prop_id}  measurement_id={mid}")

            # Persist after every success so a crash mid-batch doesn't lose work
            OUTPUT_FILE.write_text(json.dumps(results, indent=2))

            # Polite inter-request gap (avoid burst rate limit)
            if i < len(domains):
                time.sleep(1.2)

        except Exception as exc:
            failed.append((domain, str(exc)))
            print(f"  FAIL  {exc}")

    # Final write
    OUTPUT_FILE.write_text(json.dumps(results, indent=2))

    # Summary
    print("\n" + "=" * 72)
    print(f"SUCCEEDED: {len(succeeded)}   FAILED: {len(failed)}")
    if failed:
        print("\nFailed domains:")
        for d, err in failed:
            print(f"  {d}: {err}")
    print(f"\nOutput: {OUTPUT_FILE}")
    print("Cost: $0 (Admin API free)")

    return 0 if not failed else 1


if __name__ == "__main__":
    sys.exit(main())