Files
feadulta/scripts/ga4_report.py
T
rafa 39256c0f9b GA4: traer el script de informes al repo bueno y filtrar por hostName
ga4_report.py vivia solo en el checkout separado feadulta-git, que apunta al Gitea
archivado y cuyo historial no tiene relacion con este repo. La copia canonica pasa a
estar aqui. El entorno de ejecucion (.venv/ y .secrets/ con el cliente OAuth y el token)
se queda en feadulta-git y nunca ha estado versionado -- por eso la doc sigue usando
rutas absolutas alli.

Ademas, el filtro que faltaba: la propiedad G-6RT9ZRS4LW recoge varios hostnames a la vez
-- el WordPress vivo (www.feadulta.com) y el archivo estatico del Joomla
(antiguo.feadulta.com, que lleva el mismo tag dentro del HTML capturado), mas restos
(wp-nuevo, bar). Cualquier informe sin filtro los sumaba en una cifra sin significado.

- preset "hosts": desglose de trafico por hostName.
- --host: filtro exacto, lista separada por comas, se combina con --page-path-regex en un
  andGroup.
- --host-not: negacion.

Refs #180, #187

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-07-31 15:05:50 -04:00

386 lines
14 KiB
Python
Executable File

#!/usr/bin/env python3
"""Query GA4 via the Google Analytics Data API and Admin API.
This script is intended for practical editorial analysis:
- resolve a GA4 property from a measurement ID
- run a few reusable reports
- export the result to CSV
"""
from __future__ import annotations
import argparse
import csv
import json
import os
import sys
from dataclasses import dataclass
from pathlib import Path
from typing import Any
import requests
from google.auth.transport.requests import Request
from google.oauth2.credentials import Credentials
from google_auth_oauthlib.flow import InstalledAppFlow
SCOPES = ["https://www.googleapis.com/auth/analytics.readonly"]
DATA_API_BASE = "https://analyticsdata.googleapis.com/v1beta"
ADMIN_API_BASE = "https://analyticsadmin.googleapis.com/v1beta"
@dataclass
class Config:
client_secrets_path: Path
token_path: Path
property_id: str | None
measurement_id: str | None
no_browser: bool
PRESETS: dict[str, dict[str, Any]] = {
"summary": {
"dimensions": [],
"metrics": ["screenPageViews", "totalUsers", "sessions", "engagedSessions", "engagementRate"],
"order_bys": [],
},
"traffic": {
"dimensions": ["date"],
"metrics": ["sessions", "totalUsers", "engagedSessions", "engagementRate", "screenPageViews"],
"order_bys": [{"dimension": {"dimensionName": "date"}}],
},
"content": {
"dimensions": ["pageTitle", "pagePath"],
"metrics": ["screenPageViews", "totalUsers", "engagedSessions", "engagementRate", "averageSessionDuration"],
"order_bys": [{"metric": {"metricName": "screenPageViews"}, "desc": True}],
},
"landing-pages": {
"dimensions": ["landingPagePlusQueryString"],
"metrics": ["sessions", "totalUsers", "engagedSessions", "engagementRate", "screenPageViews"],
"order_bys": [{"metric": {"metricName": "sessions"}, "desc": True}],
},
"source-medium": {
"dimensions": ["sessionSourceMedium"],
"metrics": ["sessions", "totalUsers", "engagedSessions", "engagementRate", "screenPageViews"],
"order_bys": [{"metric": {"metricName": "sessions"}, "desc": True}],
},
"device": {
"dimensions": ["deviceCategory"],
"metrics": ["sessions", "totalUsers", "engagedSessions", "engagementRate", "screenPageViews"],
"order_bys": [{"metric": {"metricName": "sessions"}, "desc": True}],
},
"hosts": {
"dimensions": ["hostName"],
"metrics": ["sessions", "totalUsers", "engagedSessions", "engagementRate", "screenPageViews"],
"order_bys": [{"metric": {"metricName": "sessions"}, "desc": True}],
},
}
def load_config(args: argparse.Namespace) -> Config:
client_secrets = args.client_secrets_path or os.getenv("GA4_CLIENT_SECRETS_PATH")
token_path = args.token_path or os.getenv("GA4_TOKEN_PATH") or ".secrets/ga4-token.json"
property_id = args.property_id or os.getenv("GA4_PROPERTY_ID")
measurement_id = args.measurement_id or os.getenv("GA4_MEASUREMENT_ID")
if not client_secrets:
raise SystemExit(
"Missing OAuth client secrets path. Set --client-secrets-path or GA4_CLIENT_SECRETS_PATH."
)
return Config(
client_secrets_path=Path(client_secrets),
token_path=Path(token_path),
property_id=property_id,
measurement_id=measurement_id,
no_browser=bool(args.no_browser),
)
def get_credentials(config: Config) -> Credentials:
creds: Credentials | None = None
if config.token_path.exists():
creds = Credentials.from_authorized_user_file(str(config.token_path), SCOPES)
if creds and creds.valid:
return creds
if creds and creds.expired and creds.refresh_token:
creds.refresh(Request())
config.token_path.parent.mkdir(parents=True, exist_ok=True)
config.token_path.write_text(creds.to_json(), encoding="utf-8")
return creds
if not config.client_secrets_path.exists():
raise SystemExit(f"Client secrets file not found: {config.client_secrets_path}")
flow = InstalledAppFlow.from_client_secrets_file(str(config.client_secrets_path), SCOPES)
prompt_message = "Please visit this URL to authorize this application: {url}"
creds = flow.run_local_server(
port=0,
open_browser=not config.no_browser,
authorization_prompt_message=prompt_message,
)
config.token_path.parent.mkdir(parents=True, exist_ok=True)
config.token_path.write_text(creds.to_json(), encoding="utf-8")
return creds
def auth_headers(creds: Credentials) -> dict[str, str]:
if not creds.valid:
creds.refresh(Request())
return {
"Authorization": f"Bearer {creds.token}",
"Content-Type": "application/json",
}
def admin_get(creds: Credentials, path: str, params: dict[str, Any] | None = None) -> dict[str, Any]:
url = f"{ADMIN_API_BASE}/{path.lstrip('/')}"
response = requests.get(url, headers=auth_headers(creds), params=params, timeout=60)
response.raise_for_status()
return response.json()
def data_post(creds: Credentials, path: str, payload: dict[str, Any]) -> dict[str, Any]:
url = f"{DATA_API_BASE}/{path.lstrip('/')}"
response = requests.post(url, headers=auth_headers(creds), json=payload, timeout=60)
response.raise_for_status()
return response.json()
def iterate_account_summaries(creds: Credentials) -> list[dict[str, Any]]:
results: list[dict[str, Any]] = []
page_token: str | None = None
while True:
params = {"pageSize": 200}
if page_token:
params["pageToken"] = page_token
payload = admin_get(creds, "accountSummaries", params=params)
results.extend(payload.get("accountSummaries", []))
page_token = payload.get("nextPageToken")
if not page_token:
return results
def resolve_property_id(creds: Credentials, measurement_id: str) -> dict[str, str]:
summaries = iterate_account_summaries(creds)
for summary in summaries:
for prop in summary.get("propertySummaries", []):
prop_resource = prop.get("property", "")
if not prop_resource.startswith("properties/"):
continue
prop_id = prop_resource.split("/", 1)[1]
streams = admin_get(creds, f"properties/{prop_id}/dataStreams")
for stream in streams.get("dataStreams", []):
web_stream = stream.get("webStreamData", {})
if web_stream.get("measurementId") == measurement_id:
return {
"property_id": prop_id,
"property_display_name": prop.get("displayName", ""),
"account_display_name": summary.get("displayName", ""),
"stream_display_name": stream.get("displayName", ""),
}
raise SystemExit(f"No accessible GA4 property matched measurement ID {measurement_id}.")
def build_report_payload(args: argparse.Namespace) -> dict[str, Any]:
preset = PRESETS[args.preset]
start_date = args.start_date or f"{args.days}daysAgo"
end_date = args.end_date or "yesterday"
payload: dict[str, Any] = {
"metrics": [{"name": m} for m in preset["metrics"]],
"dateRanges": [{"startDate": start_date, "endDate": end_date}],
"limit": str(args.limit),
"keepEmptyRows": False,
"returnPropertyQuota": True,
}
if preset["dimensions"]:
payload["dimensions"] = [{"name": d} for d in preset["dimensions"]]
if preset["order_bys"]:
payload["orderBys"] = preset["order_bys"]
filters: list[dict[str, Any]] = []
if args.page_path_regex:
expression: dict[str, Any] = {
"filter": {
"fieldName": "pagePath",
"stringFilter": {
"matchType": "FULL_REGEXP",
"value": args.page_path_regex,
},
}
}
if args.page_path_regex_not:
expression = {"notExpression": expression}
filters.append(expression)
# La propiedad G-6RT9ZRS4LW mide varios hostnames a la vez (www.feadulta.com
# vivo y antiguo.feadulta.com, el archivo estatico). Sin este filtro los
# informes los mezclan y no significan nada.
host_filter = getattr(args, "host", None)
if host_filter:
hosts = [h.strip() for h in host_filter.split(",") if h.strip()]
host_expression: dict[str, Any] = {
"filter": {
"fieldName": "hostName",
"inListFilter": {"values": hosts, "caseSensitive": False},
}
}
if getattr(args, "host_not", False):
host_expression = {"notExpression": host_expression}
filters.append(host_expression)
if len(filters) == 1:
payload["dimensionFilter"] = filters[0]
elif len(filters) > 1:
payload["dimensionFilter"] = {"andGroup": {"expressions": filters}}
return payload
def rows_from_response(response: dict[str, Any]) -> tuple[list[str], list[list[str]]]:
dimensions = [h["name"] for h in response.get("dimensionHeaders", [])]
metrics = [h["name"] for h in response.get("metricHeaders", [])]
headers = dimensions + metrics
rows: list[list[str]] = []
for row in response.get("rows", []):
dimension_values = [v.get("value", "") for v in row.get("dimensionValues", [])]
metric_values = [v.get("value", "") for v in row.get("metricValues", [])]
rows.append(dimension_values + metric_values)
return headers, rows
def write_csv(path: str, headers: list[str], rows: list[list[str]]) -> None:
out_path = Path(path)
out_path.parent.mkdir(parents=True, exist_ok=True)
with out_path.open("w", newline="", encoding="utf-8") as handle:
writer = csv.writer(handle)
writer.writerow(headers)
writer.writerows(rows)
def print_table(headers: list[str], rows: list[list[str]]) -> None:
widths = [len(h) for h in headers]
for row in rows:
for idx, value in enumerate(row):
widths[idx] = max(widths[idx], len(value))
fmt = " | ".join(f"{{:{w}}}" for w in widths)
print(fmt.format(*headers))
print("-+-".join("-" * w for w in widths))
for row in rows:
print(fmt.format(*row))
def cmd_resolve_property(args: argparse.Namespace) -> int:
config = load_config(args)
if not config.measurement_id:
raise SystemExit("Missing measurement ID. Set --measurement-id or GA4_MEASUREMENT_ID.")
creds = get_credentials(config)
result = resolve_property_id(creds, config.measurement_id)
print(json.dumps(result, indent=2, ensure_ascii=True))
return 0
def cmd_report(args: argparse.Namespace) -> int:
config = load_config(args)
creds = get_credentials(config)
property_id = config.property_id
if not property_id:
if not config.measurement_id:
raise SystemExit(
"Missing property ID. Set --property-id / GA4_PROPERTY_ID or provide --measurement-id / GA4_MEASUREMENT_ID."
)
resolved = resolve_property_id(creds, config.measurement_id)
property_id = resolved["property_id"]
print(
f"Resolved measurement ID {config.measurement_id} to property {property_id} "
f"({resolved['property_display_name']})",
file=sys.stderr,
)
payload = build_report_payload(args)
response = data_post(creds, f"properties/{property_id}:runReport", payload)
headers, rows = rows_from_response(response)
if args.csv:
write_csv(args.csv, headers, rows)
print(f"Wrote CSV to {args.csv}", file=sys.stderr)
print_table(headers, rows)
return 0
def build_parser() -> argparse.ArgumentParser:
parser = argparse.ArgumentParser(description="Query GA4 via OAuth.")
parser.add_argument("--client-secrets-path", help="Path to OAuth desktop client secrets JSON.")
parser.add_argument("--token-path", help="Path to cached OAuth token JSON.")
parser.add_argument("--property-id", help="GA4 property ID.")
parser.add_argument("--measurement-id", help="GA4 measurement ID (G-...).")
parser.add_argument(
"--no-browser",
action="store_true",
help="Print the OAuth URL instead of trying to open a browser automatically.",
)
subparsers = parser.add_subparsers(dest="command", required=True)
resolve_parser = subparsers.add_parser("resolve-property", help="Resolve GA4 property from measurement ID.")
resolve_parser.set_defaults(func=cmd_resolve_property)
report_parser = subparsers.add_parser("report", help="Run a preset GA4 report.")
report_parser.add_argument(
"--preset",
choices=sorted(PRESETS.keys()),
default="content",
help="Which report shape to run.",
)
report_parser.add_argument("--days", type=int, default=28, help="Lookback window in days.")
report_parser.add_argument("--start-date", help="Explicit GA4 start date, e.g. 2026-06-18.")
report_parser.add_argument("--end-date", help="Explicit GA4 end date, e.g. 2026-06-20.")
report_parser.add_argument("--limit", type=int, default=25, help="Max rows to request.")
report_parser.add_argument("--csv", help="Optional CSV output path.")
report_parser.add_argument(
"--page-path-regex",
help="Optional GA4 FULL_REGEXP filter applied to pagePath.",
)
report_parser.add_argument(
"--page-path-regex-not",
action="store_true",
help="Negate --page-path-regex.",
)
report_parser.add_argument(
"--host",
help=(
"Filtra por hostName (exacto, varios separados por coma). "
"Ej: www.feadulta.com o antiguo.feadulta.com. "
"Sin esto, la propiedad mezcla el sitio vivo y el archivo estatico."
),
)
report_parser.add_argument(
"--host-not",
action="store_true",
help="Negate --host (todo MENOS esos hostnames).",
)
report_parser.set_defaults(func=cmd_report)
return parser
def main() -> int:
parser = build_parser()
args = parser.parse_args()
return args.func(args)
if __name__ == "__main__":
raise SystemExit(main())