Compare commits

..

4 Commits

Author SHA1 Message Date
rafa c5dcbdb997 TTS backlog: arreglar el gate de cuota y anadir el reporte diario (#188)
Gate: `int(v or 100)` trataba `five_h_pct = 0.0` como ausencia de dato y
devolvia 100, o sea que la tanda abortaba justo cuando la ventana de 5h estaba
entera libre. Cazado en la primera ventana real. Ahora se distingue 0.0 de None.

Reporte diario (F2): scripts/fea_tts_backlog_report.py, solo lectura. Cuenta
los audios de las ultimas 24h por autor (atribuidos por fea_audio_voice), lo que
queda por autor, la cuota de MiniMax y las ventanas ejecutadas/saltadas. Avisa
aparte si en 24h no hubo NI ventana ejecutada NI saltada, que es el sintoma de
que el cron no llego a correr (fue lo que paso el 2-ago con el bit +x).

Vive en el repo y ~/.hermes/scripts/ lo ve por symlink: sin copia duplicada que
se desincronice. Job: `hermes cron feadulta-tts-backlog-daily`, 07:30, no-agent,
deliver origin.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-02 18:53:26 -04:00
rafa 4bc86b9494 TTS backlog: marcar tts_backlog_cron.sh como ejecutable
Se subio sin el bit +x y cron fallaba en silencio (permission denied, sin log
y sin MTA que avise): 4 ventanas perdidas el 2-ago. La entrada de crontab pasa
a invocarlo con 'bash ...' para que no vuelva a depender del modo del fichero.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-02 18:50:16 -04:00
rafa 7c5330a528 TTS: cola de backlog por autor con gate de cuota MiniMax (#188)
Aprovechar la cuota ociosa de MiniMax para locutar el backlog historico,
empezando por Fray Marcos (2026 pendientes + 2025 = 86 articulos).

- fea_post_io.php: accion `listpending <autor> <desde> <hasta> <limite> [voz]`.
  Devuelve los posts ES publicados de un autor que no tienen audio, mas
  recientes primero. Si se pasa la voz clonada del autor, tambien salen los
  locutados con otra voz, para rehacerlos.
  Esa consulta ES la idempotencia: no hay fichero de estado que mantener, lo ya
  locutado deja de salir solo. Relanzar o solapar no duplica ni gasta cuota.

- tts_produce.py: flags --autor/--desde/--hasta/--max/--dry-run. Sin flags, el
  modo cartas de la carta semanal no cambia (retrocompatible).
  --max acota la tanda a la ventana de 5h.

- tts_backlog_cron.sh: wrapper de cron con flock y gate de cuota MiniMax
  (semanal >=85% o ventana de 5h >=55% -> se salta la ventana, para no comerle
  la cuota a la carta semanal). Cola configurable por entorno.
  Crontab: 0 */5 * * 1,5,6,0 (lunes, viernes, sabado y domingo).

SOLO LOCAL: no toca produccion. Publicar el backlog en prod
(sync_audio_to_prod.py) queda bloqueado hasta despues del cutover a
Hetzner (#180).

De paso entran al historial los cambios de tts_produce.py que estaban sin
commitear desde el 24-jul (argparse con --ids/--cartas y QUOTA_OR_RATE_ERRORS
para no reintentar ante rc 2056/1039).

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-01 07:56:16 -04:00
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
6 changed files with 985 additions and 8 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?
+56 -1
View File
@@ -3,11 +3,16 @@
* IO mínimo de posts WP para el reprocesador EN.
* get <id> -> escribe /tmp/fea_es.json {title, content, status}
* update <id> <titlef> <bodyf> -> actualiza post_title/post_content desde ficheros
* listpending <autor> ... -> cola de backlog TTS pendiente (ver abajo)
* Carga wp-load; portable (local docker o prod via FEA_WP_LOAD).
*/
$WP = getenv('FEA_WP_LOAD') ?: '/var/www/html/wp-load.php';
require $WP;
// Por debajo de esto el post_content no da para locutar (prefiltro barato en SQL;
// tts_produce.py vuelve a medir el texto ya extraído y marca fea_audio_skip).
const FEA_TTS_MIN_CONTENT = 400;
$action = $argv[1] ?? '';
if ($action === 'get') {
@@ -70,5 +75,55 @@ if ($action === 'unsetaudio') { // unsetaudio <id> (rollback: despublica el au
exit(0);
}
fwrite(STDERR, "uso: get|update|getmeta|setaudio|setflag|unsetaudio\n");
if ($action === 'listpending') { // listpending <autor> <desde> <hasta> <limite> [voz_esperada]
// Cola del backlog de TTS: posts ES publicados de un autor que todavía no tienen
// audio. La consulta ES la idempotencia — no hay fichero de estado que mantener:
// lo ya locutado deja de salir solo. Si se pasa la voz clonada del autor, también
// salen los que se locutaron en su día con otra voz (p. ej. Nico), para rehacerlos.
$autor = (int)($argv[2] ?? 0);
$desde = (int)($argv[3] ?? 0);
$hasta = (int)($argv[4] ?? 9999);
$limite = (int)($argv[5] ?? 50);
$voz = (string)($argv[6] ?? '');
if (!$autor) {
fwrite(STDERR, "uso: listpending <autor> <desde> <hasta> <limite> [voz_esperada]\n");
exit(2);
}
if ($limite <= 0) { $limite = 100000; }
global $wpdb;
$es = (int)$wpdb->get_var("
SELECT tt.term_taxonomy_id FROM {$wpdb->term_taxonomy} tt
JOIN {$wpdb->terms} t ON t.term_id = tt.term_id
WHERE tt.taxonomy = 'language' AND t.slug = 'es' LIMIT 1");
if (!$es) { fwrite(STDERR, "no encuentro el idioma 'es' de polylang\n"); exit(1); }
$ids = $wpdb->get_col($wpdb->prepare("
SELECT p.ID
FROM {$wpdb->posts} p
JOIN {$wpdb->term_relationships} tr
ON tr.object_id = p.ID AND tr.term_taxonomy_id = %d
LEFT JOIN {$wpdb->postmeta} done
ON done.post_id = p.ID AND done.meta_key = 'fea_audio_done'
LEFT JOIN {$wpdb->postmeta} voz
ON voz.post_id = p.ID AND voz.meta_key = 'fea_audio_voice'
LEFT JOIN {$wpdb->postmeta} skip
ON skip.post_id = p.ID AND skip.meta_key = 'fea_audio_skip'
WHERE p.post_author = %d
AND p.post_type = 'post'
AND p.post_status = 'publish'
AND YEAR(p.post_date) BETWEEN %d AND %d
AND CHAR_LENGTH(p.post_content) >= %d
AND (skip.meta_value IS NULL OR skip.meta_value <> '1')
AND (done.meta_value IS NULL
OR done.meta_value <> '1'
OR (%s <> '' AND COALESCE(voz.meta_value, '') <> %s))
ORDER BY p.post_date DESC
LIMIT %d", $es, $autor, $desde, $hasta, FEA_TTS_MIN_CONTENT, $voz, $voz, $limite));
foreach ($ids as $id) { echo (int)$id . "\n"; }
exit(0);
}
fwrite(STDERR, "uso: get|update|getmeta|setaudio|setflag|unsetaudio|listpending\n");
exit(2);
+168
View File
@@ -0,0 +1,168 @@
#!/usr/bin/env python3
"""Reporte diario del backlog de TTS de Fe Adulta (issue rafa/feadulta#188).
SOLO LECTURA: no genera audio ni toca la BD. Cuenta lo hecho en las últimas 24 h,
lo que queda por autor, la cuota de MiniMax y las ventanas que se saltaron.
Entregado por Hermes en modo no-agent (el stdout va directo a Rafa).
Silencio deliberado si no hay nada que contar y todo está en orden.
"""
from __future__ import annotations
import json
import re
import subprocess
import sys
import time
from datetime import datetime, timedelta
from pathlib import Path
REPO = Path("/home/rafa/joomla-migration")
TTS_DIR = REPO / "wordpress/wp-content/uploads/tts"
LOG_DIR = Path("/tmp/fea-tts-backlog")
QUOTA = Path("/home/rafa/ytsummaries/scripts/quota.py")
CONTAINER = "wordpress-web"
CRON = "/home/rafa/joomla-migration/scripts/tts_backlog_cron.sh"
# WP user_id -> (nombre, voz clonada). Mismo mapping que AUTHOR_VOICES en
# scripts/minimax_tts.py; si se clona una voz nueva, añadirla en los dos sitios.
AUTORES = {
382: ("Fray Marcos", "FrayMarcosFeadulta2026"),
383: ("Pagola", "PagolaFeadulta2026"),
774: ("Sicre", "SicreFeadulta2026"),
386: ("Arregi", "ArregiFeadulta2026"),
}
# Autor y rango que está locutando el cron ahora mismo (los valores por defecto
# de tts_backlog_cron.sh). Solo afecta a qué línea se marca como "en curso".
ACTIVO = (382, 2025, 2026)
def php(*args: str) -> str:
"""fea_post_io.php dentro del contenedor. stderr fuera: WP escupe warnings."""
r = subprocess.run(
["docker", "exec", CONTAINER, "php", "/tmp/fea_post_io.php", *args],
capture_output=True, text=True,
)
return r.stdout if r.returncode == 0 else ""
def pendientes(autor: int, desde: int, hasta: int, voz: str) -> int:
salida = php("listpending", str(autor), str(desde), str(hasta), "0", voz)
return len([x for x in salida.split() if x.strip().isdigit()])
def hechos_24h() -> dict[int, list[tuple[int, str]]]:
"""mp3 escritos en las últimas 24 h, agrupados por autor.
El mtime del fichero es la fuente: es lo que se acaba de escribir, sin
depender de metas que puedan venir de una sincronización antigua.
"""
corte = time.time() - 24 * 3600
por_autor: dict[int, list[tuple[int, str]]] = {}
if not TTS_DIR.is_dir():
return por_autor
recientes = [f for f in TTS_DIR.glob("*.mp3")
if f.stat().st_mtime >= corte and f.stem.isdigit()]
for f in sorted(recientes, key=lambda p: p.stat().st_mtime):
pid = int(f.stem)
# No hay meta de autor; la voz sí se guarda (fea_audio_voice) y basta
# para atribuirlo, porque cada autor clonado tiene la suya.
voz = php("getmeta", str(pid), "fea_audio_voice").strip()
aid = next((a for a, (_, v) in AUTORES.items() if v == voz), 0)
por_autor.setdefault(aid, []).append((pid, voz or "?"))
return por_autor
def cuota() -> tuple[int | None, int | None]:
try:
r = subprocess.run([sys.executable, str(QUOTA), "--json", "--no-local"],
capture_output=True, text=True, timeout=40)
d = json.loads(r.stdout)
m = next(p for p in d["providers"] if p["provider"] == "minimax" and p.get("ok"))
return m.get("five_h_pct"), m.get("week_pct")
except Exception: # noqa: BLE001
return None, None
def logs_24h() -> tuple[int, int, list[str]]:
"""(ventanas ejecutadas, ventanas saltadas por gate, líneas de fallo)."""
hoy = datetime.now()
ficheros = [LOG_DIR / f"cron-{(hoy - timedelta(days=d)).strftime('%Y-%m-%d')}.log"
for d in (0, 1)]
corridas = saltadas = 0
fallos: list[str] = []
corte = hoy - timedelta(hours=24)
for f in ficheros:
if not f.is_file():
continue
for linea in f.read_text(errors="replace").splitlines():
m = re.match(r"\[(\d{4}-\d\d-\d\d \d\d:\d\d:\d\d)\]", linea)
if not m:
continue
try:
if datetime.strptime(m.group(1), "%Y-%m-%d %H:%M:%S") < corte:
continue
except ValueError:
continue
if "cron TTS backlog start" in linea:
corridas += 1
elif "ABORT:" in linea:
saltadas += 1
fallos.append(linea.split("ABORT:", 1)[1].strip())
elif "FALLO rc=" in linea or "listpending falló" in linea:
fallos.append(linea.split("] ", 1)[-1].strip())
return corridas, saltadas, fallos
def main() -> int:
hechos = hechos_24h()
total = sum(len(v) for v in hechos.values())
corridas, saltadas, fallos = logs_24h()
p5, pw = cuota()
lineas = [f"Fe Adulta — backlog TTS (últimas 24 h): {total} audios"]
if hechos:
for aid, items in sorted(hechos.items(), key=lambda kv: -len(kv[1])):
nombre = AUTORES.get(aid, ("otros", ""))[0]
lineas.append(f" {nombre}: {len(items)} "
f"({', '.join('#%d' % p for p, _ in items[:8])}"
f"{'' if len(items) > 8 else ''})")
lineas.append("")
lineas.append("Pendientes:")
for aid, (nombre, voz) in AUTORES.items():
falta_todo = pendientes(aid, 0, 9999, voz)
marca = ""
if aid == ACTIVO[0]:
falta_lote = pendientes(aid, ACTIVO[1], ACTIVO[2], voz)
marca = f" ← en curso, {falta_lote} del lote {ACTIVO[1]}-{ACTIVO[2]}"
lineas.append(f" {nombre}: {falta_todo}{marca}")
lineas.append("")
if p5 is None:
lineas.append("Cuota MiniMax: no se pudo leer")
else:
lineas.append(f"Cuota MiniMax: 5h {p5:.0f}% · semana {pw:.0f}%")
lineas.append(f"Ventanas 24 h: {corridas} ejecutadas, {saltadas} saltadas por cuota")
# Una ventana que ni arranca ni se salta es un fallo mudo: el cron no llegó a
# correr (bit +x, WSL apagada...). Es exactamente lo que pasó el 2-ago.
if corridas == 0 and saltadas == 0:
lineas.append("")
lineas.append("⚠️ Ninguna ventana dejó rastro en 24 h. Si tocaba que corriera, "
f"comprobar: crontab -l | grep tts_backlog · ls -l {CRON}")
if fallos:
lineas.append("")
lineas.append("Avisos:")
for f in fallos[:10]:
lineas.append(f" {f}")
print("\n".join(lineas))
return 0
if __name__ == "__main__":
raise SystemExit(main())
+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())
+85
View File
@@ -0,0 +1,85 @@
#!/usr/bin/env bash
# Cron del backlog de TTS por autor (issue rafa/feadulta#188).
# Corre cada 5 h los lunes, viernes, sábados y domingos, aprovechando la cuota
# ociosa de MiniMax para locutar artículos antiguos. Por ventana:
# 1) Gate de cuota MiniMax: si la semana va alta, o si la ventana de 5 h ya la
# está usando alguien (la carta semanal, el backfill de summaraise...), se
# salta. El trabajo de fondo nunca le come la cuota al trabajo con dueño.
# 2) tts_produce.py --autor ... --max N: la cola sale de la BD, así que esto es
# idempotente por construcción — lo ya locutado no vuelve a salir.
# SOLO LOCAL: no toca producción. Publicar en prod es sync_audio_to_prod.py, que
# está bloqueado hasta después del cutover a Hetzner (#180).
# flock evita solapes si una ventana se alargara. Log por día.
set -uo pipefail
export PATH="/home/rafa/.local/bin:/usr/local/bin:/usr/bin:/bin:/usr/sbin:/sbin"
export HOME="/home/rafa"
REPO="/home/rafa/joomla-migration"
PY="/home/rafa/tts-local/xtts-venv/bin/python"
QUOTA="/home/rafa/ytsummaries/scripts/quota.py"
WORK="/tmp/fea-tts-backlog"
LOG="$WORK/cron-$(date +%F).log"
LOCK="$WORK/cron.lock"
# Cola: cambiar aquí (o por entorno) para pasar de un autor a otro.
# 382 Fray Marcos · 383 Pagola · 774 Sicre · 386 Arregi
AUTOR="${FEA_TTS_AUTOR:-382}"
DESDE="${FEA_TTS_DESDE:-2025}"
HASTA="${FEA_TTS_HASTA:-2026}"
BATCH="${FEA_TTS_BATCH:-10}"
# Gates de cuota (%). El semanal es el techo real del backlog. El de 5 h es
# "¿cabe esta tanda en lo que queda de ventana?": medido, cada audio gasta ~3-4 %
# de la ventana de 5 h, así que un batch de 10 pide ~40 %. Gate en 55 % deja sitio
# y frena solo cuando la ventana ya la está usando otro (la carta semanal son ~28
# audios de golpe) o cuando la está llenando este mismo cron. Si se sube BATCH,
# subir también este número o se saltarán ventanas.
MAX_SEMANA="${FEA_TTS_MAX_SEMANA:-85}"
MAX_5H="${FEA_TTS_MAX_5H:-55}"
mkdir -p "$WORK"
cd "$REPO" || exit 1
ts() { date +'%F %T'; }
exec 9>"$LOCK"
if ! flock -n 9; then
echo "[$(ts)] otra corrida en curso, salto." >> "$LOG"; exit 0
fi
echo "[$(ts)] === cron TTS backlog start (autor=$AUTOR $DESDE-$HASTA batch=$BATCH) ===" >> "$LOG"
# 1) Gate de cuota MiniMax (ventana de 5 h y semanal)
read -r PCT5 PCTW <<< "$(python3 "$QUOTA" --json --no-local 2>/dev/null | python3 -c '
import json, sys
def pct(v):
# OJO: 0.0 es un valor legítimo (ventana entera libre) y es falsy en Python.
# Un `v or 100` aquí aborta la tanda justo cuando hay toda la cuota disponible.
return int(v) if v is not None else 100
try:
d = json.load(sys.stdin)
m = next(p for p in d["providers"] if p["provider"] == "minimax" and p.get("ok"))
print(pct(m.get("five_h_pct")), pct(m.get("week_pct")))
except Exception:
print(100, 100) # sin lectura fiable de cuota, no se gasta
' 2>/dev/null || echo "100 100")"
echo "[$(ts)] MiniMax 5h=${PCT5}% semana=${PCTW}%" >> "$LOG"
if [ "${PCTW:-100}" -ge "$MAX_SEMANA" ]; then
echo "[$(ts)] ABORT: cuota semanal ${PCTW}% >= ${MAX_SEMANA}%, salto esta ventana." >> "$LOG"
exit 0
fi
if [ "${PCT5:-100}" -ge "$MAX_5H" ]; then
echo "[$(ts)] ABORT: ventana de 5h al ${PCT5}% >= ${MAX_5H}%, no cabe la tanda; salto." >> "$LOG"
exit 0
fi
# 2) Tanda. tts_produce.py ya para solo ante rc 2056/1039 (cuota/rate limit).
echo "[$(ts)] tts_produce --autor $AUTOR --desde $DESDE --hasta $HASTA --max $BATCH ..." >> "$LOG"
"$PY" scripts/tts_produce.py --autor "$AUTOR" --desde "$DESDE" --hasta "$HASTA" \
--max "$BATCH" >> "$LOG" 2>&1
# 3) Cuánto queda tras la tanda (recuento fresco de la BD, barato y sin cuota).
QUEDAN="$("$PY" scripts/tts_produce.py --autor "$AUTOR" --desde "$DESDE" --hasta "$HASTA" \
--dry-run 2>/dev/null | sed -n 's/.*Cola: \([0-9]*\) posts.*/\1/p' | tail -1)"
echo "[$(ts)] === cron TTS backlog done. Pendientes autor $AUTOR $DESDE-$HASTA: ${QUEDAN:-?} ===" >> "$LOG"
+71 -6
View File
@@ -6,9 +6,18 @@ Reanudable (meta fea_audio_done) y con freno ante la cuota (para tras N fallos
seguidos). NO toca el front; solo genera el mp3 y asocia la URL al post (meta
fea_audio_url).
Dos modos de cola:
- cartas (por defecto): FEA_TTS_CARTAS / --cartas / --ids. Es el flujo de la
carta semanal, que tiene prioridad y no cambia.
- backlog por autor: --autor 382 [--desde 2025] [--hasta 2026] [--max 15].
La cola sale de `listpending` en fea_post_io.php (posts ES publicados sin
audio, más recientes primero). Esa consulta ES la idempotencia: no hay
fichero de estado, lo ya locutado deja de salir solo.
Lanzar: nohup ~/tts-local/xtts-venv/bin/python scripts/tts_produce.py > /tmp/feadulta-tts-prod.out 2>&1 &
Log: /tmp/feadulta-tts-prod.log
"""
import argparse
import os
import shutil
import subprocess
@@ -26,8 +35,9 @@ CONTAINER = "wordpress-web"
PROD = Path(__file__).resolve().parent.parent / "wordpress/wp-content/uploads/tts"
LOG = Path("/tmp/feadulta-tts-prod.log")
INTERVAL = 180 # s entre cartas exitosas (reparte el ritmo)
BACKOFF = 1800 # s de espera ante fallo de cuota antes de reintentar
MAX_CONSEC_FAIL = 3 # fallos seguidos → parar (cuota probablemente agotada)
BACKOFF = 1800 # s de espera ante errores transitorios no clasificados
MAX_CONSEC_FAIL = 3 # fallos seguidos no clasificados → parar
QUOTA_OR_RATE_ERRORS = {2056, 1039} # MiniMax: no reintentar en este proceso
MIN_CHARS = 200 # por debajo, se considera sin contenido locutable
# Cola de cartas a locutar. Override por entorno (FEA_TTS_CARTAS) para priorizar
@@ -52,6 +62,18 @@ def meta(pid, key):
return php("getmeta", str(pid), key).stdout.strip()
def backlog_ids(autor, desde, hasta, limite):
"""Cola del backlog de un autor, delegada a la BD (ver listpending)."""
# Voz clonada del autor, si la tiene: los locutados con otra voz también
# cuentan como pendientes. Sin clon (""), pendiente = simplemente sin audio.
voz = mm.voice_for_author(autor, "")
r = php("listpending", str(autor), str(desde), str(hasta), str(limite), voz)
if r.returncode != 0:
log(f"listpending falló (rc={r.returncode}): {r.stderr.strip()[:200]}")
return []
return [int(x) for x in r.stdout.split() if x.strip().isdigit()]
def build_queue():
# Cola literal de IDs (ya filtrada/ordenada) para priorizar la carta nueva.
ids_override = os.environ.get("FEA_TTS_IDS", "").replace(",", " ").split()
@@ -67,11 +89,46 @@ def build_queue():
def main():
global CARTAS
parser = argparse.ArgumentParser(
description="Locuta posts ES de Fe Adulta con MiniMax; sin --ids conserva la cola programada."
)
parser.add_argument("--ids", help="CSV de IDs ES concretos, en el orden de locución deseado")
parser.add_argument("--cartas", help="CSV de cartas para construir la cola; sustituye FEA_TTS_CARTAS")
parser.add_argument("--autor", type=int,
help="WP user_id: cola del backlog de ese autor en vez de cartas")
parser.add_argument("--desde", type=int, default=0, help="año inicial del backlog (con --autor)")
parser.add_argument("--hasta", type=int, default=9999, help="año final del backlog (con --autor)")
parser.add_argument("--max", type=int, default=0,
help="para tras N audios OK en esta ejecución (0 = sin tope)")
parser.add_argument("--dry-run", action="store_true",
help="imprime la cola y sale, sin sintetizar ni gastar cuota")
args = parser.parse_args()
if args.ids:
os.environ["FEA_TTS_IDS"] = args.ids
if args.cartas:
os.environ["FEA_TTS_CARTAS"] = args.cartas
CARTAS = args.cartas.replace(",", " ").split()
PROD.mkdir(parents=True, exist_ok=True)
subprocess.run(["docker", "cp", "scripts/fea_post_io.php", f"{CONTAINER}:/tmp/fea_post_io.php"],
capture_output=True)
if args.autor:
# Pide holgura sobre --max: parte de la cola puede caerse por contenido corto.
limite = args.max * 3 if args.max else 0
queue = backlog_ids(args.autor, args.desde, args.hasta, limite)
origen = (f"backlog autor {args.autor} ({args.desde}-{args.hasta}), "
f"voz {mm.voice_for_author(args.autor, VOICE)}")
else:
queue = build_queue()
log(f"=== INICIO orquestador TTS. Cola: {len(queue)} posts ES del gap ===")
origen = "cartas"
tope = f", tope {args.max} esta tanda" if args.max else ""
log(f"=== INICIO orquestador TTS. Cola: {len(queue)} posts ES [{origen}]{tope} ===")
if args.dry_run:
log("--dry-run: no sintetizo. Cola = " + (",".join(str(x) for x in queue) or "(vacía)"))
return
i = consec = ok = 0
while i < len(queue):
@@ -104,16 +161,24 @@ def main():
voice_tag = f" [{voice}]" if voice != VOICE else ""
log(f"#{pid} OK «{title[:45]}»{voice_tag} → tts/{pid}.mp3 (total {ok})")
i += 1
if args.max and ok >= args.max:
log(f"Tope de la tanda alcanzado ({args.max}). PARO. "
"Reanudable: la próxima ventana recalcula la cola y sigue.")
break
time.sleep(INTERVAL)
else:
consec += 1
log(f"#{pid} FALLO rc={rc} (fallo seguido {consec}/{MAX_CONSEC_FAIL})")
php("setflag", str(pid), "fea_audio_error", str(rc))
if consec >= MAX_CONSEC_FAIL:
log("Demasiados fallos seguidos → cuota agotada probablemente. PARO. "
if rc in QUOTA_OR_RATE_ERRORS:
log(f"MiniMax rc={rc}: cuota/rate limit explícito. PARO sin reintentar. "
"Reanudable: relanzar el script más tarde (salta lo ya hecho).")
break
time.sleep(BACKOFF) # reintenta el mismo post tras esperar
if consec >= MAX_CONSEC_FAIL:
log("Demasiados fallos seguidos no clasificados. PARO. "
"Reanudable: relanzar el script más tarde (salta lo ya hecho).")
break
time.sleep(BACKOFF) # solo errores transitorios no clasificados
log(f"=== FIN tanda. {ok} audios generados esta ejecución. ===")