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>
This commit is contained in:
@@ -0,0 +1,219 @@
|
||||
# GA4 API setup for feadulta
|
||||
|
||||
This document describes the simplest practical path for querying Google Analytics 4 from this repo.
|
||||
|
||||
> **Where the code lives vs where it runs (2026-07-31).** This script used to live only in the
|
||||
> separate `feadulta-git` checkout, which points at the *archived* Gitea and never made it into
|
||||
> this repo. The canonical copy is now here, in `rafa/feadulta` on `gitea.feadulta.com`.
|
||||
> The **runtime environment stays in `/mnt/c/Users/Chia/feadulta-git`**: `.venv/` and, above all,
|
||||
> `.secrets/` (OAuth client + cached token) are gitignored and were never versioned anywhere.
|
||||
> That is why the commands below still use absolute paths into `feadulta-git` — the paths are
|
||||
> correct, the code is just no longer only there.
|
||||
|
||||
## Current known identifier
|
||||
|
||||
The site is tagged with GA4 measurement ID:
|
||||
|
||||
- `G-6RT9ZRS4LW`
|
||||
|
||||
Important:
|
||||
|
||||
- the GA4 **measurement ID** (`G-...`) is **not** the same as the GA4 **property ID**
|
||||
- the Data API `runReport` endpoint needs the **property ID**
|
||||
- the script added in this repo can resolve the property automatically if the authenticated Google user has access to the property
|
||||
|
||||
Official references:
|
||||
|
||||
- Data API `runReport`: https://developers.google.com/analytics/devguides/reporting/data/v1/rest/v1beta/properties/runReport
|
||||
- Admin API overview: https://developers.google.com/analytics/devguides/config/admin/v1
|
||||
- Where to find the measurement ID in GA4: https://support.google.com/analytics/answer/9304153
|
||||
|
||||
## Recommended auth model
|
||||
|
||||
Use **OAuth desktop app credentials** for a Google user that already has access to the GA4 property.
|
||||
|
||||
Why this is the easiest first step:
|
||||
|
||||
- no need to create a service account and grant property access separately
|
||||
- no need to know the property ID upfront
|
||||
- the script can authenticate as you and search the accessible properties for the matching `G-...`
|
||||
|
||||
## One-time Google Cloud setup
|
||||
|
||||
1. Open Google Cloud Console.
|
||||
2. Create or reuse a project.
|
||||
3. Enable:
|
||||
- Google Analytics Data API
|
||||
- Google Analytics Admin API
|
||||
4. Create an OAuth client of type `Desktop app`.
|
||||
5. Download the client secrets JSON file.
|
||||
|
||||
Suggested local path:
|
||||
|
||||
- `/mnt/c/Users/Chia/feadulta-git/.secrets/ga4-oauth-client.json`
|
||||
|
||||
Do not commit it.
|
||||
|
||||
## Local Python environment
|
||||
|
||||
This repo is set up to use a local virtualenv so the host Python installation does not need to be modified.
|
||||
|
||||
Create it once:
|
||||
|
||||
```bash
|
||||
python3 -m venv /mnt/c/Users/Chia/feadulta-git/.venv
|
||||
```
|
||||
|
||||
Install the required packages inside that environment:
|
||||
|
||||
```bash
|
||||
/mnt/c/Users/Chia/feadulta-git/.venv/bin/python -m pip install google-auth google-auth-oauthlib requests
|
||||
```
|
||||
|
||||
## Environment variables
|
||||
|
||||
You can configure the script with environment variables:
|
||||
|
||||
```bash
|
||||
export GA4_CLIENT_SECRETS_PATH=/mnt/c/Users/Chia/feadulta-git/.secrets/ga4-oauth-client.json
|
||||
export GA4_TOKEN_PATH=/mnt/c/Users/Chia/feadulta-git/.secrets/ga4-token.json
|
||||
export GA4_MEASUREMENT_ID=G-6RT9ZRS4LW
|
||||
export GA4_PROPERTY_ID=508378818
|
||||
```
|
||||
|
||||
If `GA4_PROPERTY_ID` is omitted, the script can try to resolve it from `GA4_MEASUREMENT_ID`.
|
||||
|
||||
## First run
|
||||
|
||||
Authenticate and resolve the property:
|
||||
|
||||
```bash
|
||||
/mnt/c/Users/Chia/feadulta-git/.venv/bin/python scripts/ga4_report.py --measurement-id G-6RT9ZRS4LW resolve-property
|
||||
```
|
||||
|
||||
If the local environment cannot open a browser directly, use manual mode:
|
||||
|
||||
```bash
|
||||
/mnt/c/Users/Chia/feadulta-git/.venv/bin/python scripts/ga4_report.py --measurement-id G-6RT9ZRS4LW --no-browser resolve-property
|
||||
```
|
||||
|
||||
This prints a Google authorization URL. Open it in the browser, sign in with a Google user that has access to the GA4 property, and complete the redirect back to the `localhost` callback URL shown in the command output.
|
||||
|
||||
On successful first run, the script stores a reusable token locally at:
|
||||
|
||||
- `/mnt/c/Users/Chia/feadulta-git/.secrets/ga4-token.json`
|
||||
|
||||
Current known resolved property:
|
||||
|
||||
- measurement ID: `G-6RT9ZRS4LW`
|
||||
- property ID: `508378818`
|
||||
- property name: `https://feadulta.com`
|
||||
- account name: `Portal feadulta.com`
|
||||
- stream name: `https://www.feadulta.com/`
|
||||
|
||||
## Example reports
|
||||
|
||||
Traffic overview:
|
||||
|
||||
```bash
|
||||
/mnt/c/Users/Chia/feadulta-git/.venv/bin/python scripts/ga4_report.py --property-id 508378818 report --preset traffic --days 28
|
||||
```
|
||||
|
||||
Top content:
|
||||
|
||||
```bash
|
||||
/mnt/c/Users/Chia/feadulta-git/.venv/bin/python scripts/ga4_report.py --property-id 508378818 report --preset content --days 28 --limit 25
|
||||
```
|
||||
|
||||
Landing pages:
|
||||
|
||||
```bash
|
||||
/mnt/c/Users/Chia/feadulta-git/.venv/bin/python scripts/ga4_report.py --property-id 508378818 report --preset landing-pages --days 28 --limit 25
|
||||
```
|
||||
|
||||
Traffic by source / medium:
|
||||
|
||||
```bash
|
||||
/mnt/c/Users/Chia/feadulta-git/.venv/bin/python scripts/ga4_report.py --property-id 508378818 report --preset source-medium --days 28 --limit 25
|
||||
```
|
||||
|
||||
Device mix:
|
||||
|
||||
```bash
|
||||
/mnt/c/Users/Chia/feadulta-git/.venv/bin/python scripts/ga4_report.py --property-id 508378818 report --preset device --days 28 --limit 25
|
||||
```
|
||||
|
||||
Export to CSV:
|
||||
|
||||
```bash
|
||||
/mnt/c/Users/Chia/feadulta-git/.venv/bin/python scripts/ga4_report.py --property-id 508378818 report --preset content --days 28 --csv /tmp/ga4-content.csv
|
||||
```
|
||||
|
||||
## Splitting the live site from the static archive (`--host`)
|
||||
|
||||
This single property (`G-6RT9ZRS4LW`) collects several hostnames at once: the live
|
||||
WordPress (`www.feadulta.com`), the frozen Joomla archive (`antiguo.feadulta.com`,
|
||||
which carries the same GA tag inside its captured HTML), plus leftovers like
|
||||
`wp-nuevo.feadulta.com`. **Any report without a host filter mixes them and means
|
||||
nothing.**
|
||||
|
||||
Which hostnames are actually reporting:
|
||||
|
||||
```bash
|
||||
/mnt/c/Users/Chia/feadulta-git/.venv/bin/python scripts/ga4_report.py --property-id 508378818 report --preset hosts --days 28
|
||||
```
|
||||
|
||||
Only the live site:
|
||||
|
||||
```bash
|
||||
/mnt/c/Users/Chia/feadulta-git/.venv/bin/python scripts/ga4_report.py --property-id 508378818 report --preset content --host www.feadulta.com --days 28 --limit 25
|
||||
```
|
||||
|
||||
Only the archive:
|
||||
|
||||
```bash
|
||||
/mnt/c/Users/Chia/feadulta-git/.venv/bin/python scripts/ga4_report.py --property-id 508378818 report --preset content --host antiguo.feadulta.com --days 28 --limit 25
|
||||
```
|
||||
|
||||
`--host` takes a comma-separated list (exact match, case-insensitive) and combines
|
||||
with `--page-path-regex` as an AND group. `--host-not` negates it.
|
||||
|
||||
## Practical future access
|
||||
|
||||
For future use, the shortest path is:
|
||||
|
||||
1. Confirm these files still exist locally:
|
||||
- `/mnt/c/Users/Chia/feadulta-git/.secrets/ga4-oauth-client.json`
|
||||
- `/mnt/c/Users/Chia/feadulta-git/.secrets/ga4-token.json`
|
||||
- `/mnt/c/Users/Chia/feadulta-git/.venv/`
|
||||
2. Run reports directly with `--property-id 508378818`.
|
||||
3. Only rerun `resolve-property` if the token was deleted or the Google access changed.
|
||||
4. If the token expires, the script should refresh it automatically when possible.
|
||||
|
||||
## About WordPress logs
|
||||
|
||||
If the question is “what content is being seen?”, GA4 is usually the better first tool because it gives:
|
||||
|
||||
- page-level views
|
||||
- landing pages
|
||||
- traffic sources
|
||||
- device mix
|
||||
- trends over time
|
||||
|
||||
WordPress itself does **not** log page views by default in a way that is useful for editorial analysis.
|
||||
|
||||
If GA4 turns out to be incomplete or unreliable, the next fallback is usually:
|
||||
|
||||
1. web server access logs
|
||||
2. reverse proxy logs
|
||||
3. plugin-specific event logging if the site has a dedicated analytics plugin
|
||||
|
||||
In this repo, there is no obvious WordPress analytics plugin configuration under `wordpress/wp-content/mu-plugins/`, so GA4 or server logs are the most likely useful sources.
|
||||
|
||||
## Useful questions this script should answer
|
||||
|
||||
- Which pages got the most views in the last 28 days?
|
||||
- Which landing pages attract the most traffic?
|
||||
- Which sources or source/medium pairs bring traffic?
|
||||
- Is mobile traffic increasing or decreasing?
|
||||
- Did traffic fall because fewer users arrived, or because fewer pages were viewed per session?
|
||||
Executable
+385
@@ -0,0 +1,385 @@
|
||||
#!/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())
|
||||
Reference in New Issue
Block a user