Compare commits
4 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| c5dcbdb997 | |||
| 4bc86b9494 | |||
| 7c5330a528 | |||
| 39256c0f9b |
@@ -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
@@ -3,11 +3,16 @@
|
|||||||
* IO mínimo de posts WP para el reprocesador EN.
|
* IO mínimo de posts WP para el reprocesador EN.
|
||||||
* get <id> -> escribe /tmp/fea_es.json {title, content, status}
|
* get <id> -> escribe /tmp/fea_es.json {title, content, status}
|
||||||
* update <id> <titlef> <bodyf> -> actualiza post_title/post_content desde ficheros
|
* 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).
|
* Carga wp-load; portable (local docker o prod via FEA_WP_LOAD).
|
||||||
*/
|
*/
|
||||||
$WP = getenv('FEA_WP_LOAD') ?: '/var/www/html/wp-load.php';
|
$WP = getenv('FEA_WP_LOAD') ?: '/var/www/html/wp-load.php';
|
||||||
require $WP;
|
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] ?? '';
|
$action = $argv[1] ?? '';
|
||||||
|
|
||||||
if ($action === 'get') {
|
if ($action === 'get') {
|
||||||
@@ -70,5 +75,55 @@ if ($action === 'unsetaudio') { // unsetaudio <id> (rollback: despublica el au
|
|||||||
exit(0);
|
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);
|
exit(2);
|
||||||
|
|||||||
Executable
+168
@@ -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())
|
||||||
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())
|
||||||
Executable
+85
@@ -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"
|
||||||
+72
-7
@@ -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
|
seguidos). NO toca el front; solo genera el mp3 y asocia la URL al post (meta
|
||||||
fea_audio_url).
|
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 &
|
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
|
Log: /tmp/feadulta-tts-prod.log
|
||||||
"""
|
"""
|
||||||
|
import argparse
|
||||||
import os
|
import os
|
||||||
import shutil
|
import shutil
|
||||||
import subprocess
|
import subprocess
|
||||||
@@ -26,8 +35,9 @@ CONTAINER = "wordpress-web"
|
|||||||
PROD = Path(__file__).resolve().parent.parent / "wordpress/wp-content/uploads/tts"
|
PROD = Path(__file__).resolve().parent.parent / "wordpress/wp-content/uploads/tts"
|
||||||
LOG = Path("/tmp/feadulta-tts-prod.log")
|
LOG = Path("/tmp/feadulta-tts-prod.log")
|
||||||
INTERVAL = 180 # s entre cartas exitosas (reparte el ritmo)
|
INTERVAL = 180 # s entre cartas exitosas (reparte el ritmo)
|
||||||
BACKOFF = 1800 # s de espera ante fallo de cuota antes de reintentar
|
BACKOFF = 1800 # s de espera ante errores transitorios no clasificados
|
||||||
MAX_CONSEC_FAIL = 3 # fallos seguidos → parar (cuota probablemente agotada)
|
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
|
MIN_CHARS = 200 # por debajo, se considera sin contenido locutable
|
||||||
|
|
||||||
# Cola de cartas a locutar. Override por entorno (FEA_TTS_CARTAS) para priorizar
|
# 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()
|
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():
|
def build_queue():
|
||||||
# Cola literal de IDs (ya filtrada/ordenada) para priorizar la carta nueva.
|
# Cola literal de IDs (ya filtrada/ordenada) para priorizar la carta nueva.
|
||||||
ids_override = os.environ.get("FEA_TTS_IDS", "").replace(",", " ").split()
|
ids_override = os.environ.get("FEA_TTS_IDS", "").replace(",", " ").split()
|
||||||
@@ -67,11 +89,46 @@ def build_queue():
|
|||||||
|
|
||||||
|
|
||||||
def main():
|
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)
|
PROD.mkdir(parents=True, exist_ok=True)
|
||||||
subprocess.run(["docker", "cp", "scripts/fea_post_io.php", f"{CONTAINER}:/tmp/fea_post_io.php"],
|
subprocess.run(["docker", "cp", "scripts/fea_post_io.php", f"{CONTAINER}:/tmp/fea_post_io.php"],
|
||||||
capture_output=True)
|
capture_output=True)
|
||||||
queue = build_queue()
|
|
||||||
log(f"=== INICIO orquestador TTS. Cola: {len(queue)} posts ES del gap ===")
|
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()
|
||||||
|
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
|
i = consec = ok = 0
|
||||||
while i < len(queue):
|
while i < len(queue):
|
||||||
@@ -104,16 +161,24 @@ def main():
|
|||||||
voice_tag = f" [{voice}]" if voice != VOICE else ""
|
voice_tag = f" [{voice}]" if voice != VOICE else ""
|
||||||
log(f"#{pid} OK «{title[:45]}»{voice_tag} → tts/{pid}.mp3 (total {ok})")
|
log(f"#{pid} OK «{title[:45]}»{voice_tag} → tts/{pid}.mp3 (total {ok})")
|
||||||
i += 1
|
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)
|
time.sleep(INTERVAL)
|
||||||
else:
|
else:
|
||||||
consec += 1
|
consec += 1
|
||||||
log(f"#{pid} FALLO rc={rc} (fallo seguido {consec}/{MAX_CONSEC_FAIL})")
|
log(f"#{pid} FALLO rc={rc} (fallo seguido {consec}/{MAX_CONSEC_FAIL})")
|
||||||
php("setflag", str(pid), "fea_audio_error", str(rc))
|
php("setflag", str(pid), "fea_audio_error", str(rc))
|
||||||
if consec >= MAX_CONSEC_FAIL:
|
if rc in QUOTA_OR_RATE_ERRORS:
|
||||||
log("Demasiados fallos seguidos → cuota agotada probablemente. PARO. "
|
log(f"MiniMax rc={rc}: cuota/rate limit explícito. PARO sin reintentar. "
|
||||||
"Reanudable: relanzar el script más tarde (salta lo ya hecho).")
|
"Reanudable: relanzar el script más tarde (salta lo ya hecho).")
|
||||||
break
|
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. ===")
|
log(f"=== FIN tanda. {ok} audios generados esta ejecución. ===")
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user