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:
2026-07-31 15:05:50 -04:00
parent 275aff1430
commit 39256c0f9b
2 changed files with 604 additions and 0 deletions
+219
View File
@@ -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?
+385
View File
@@ -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())