Compare commits

...

9 Commits

Author SHA1 Message Date
rafa c5704d7b4a fix(feedback-beta): no mostrar éxito si el POST del formulario falla
El handler de envío llamaba a fetch() sin await y sin comprobar
response.ok, con .catch() vacío: el UI mostraba "Gracias" aunque la
petición fallara, así que el feedback público podía no persistir sin
que nadie se enterase. Ahora se encadena la promesa, se exige
response.ok + {ok:true} antes de mostrar éxito y, si falla, se
mantiene la tarjeta abierta con mensaje de error (5 idiomas) y el
botón reactivado para reintentar.

Validado en local con Playwright: caso OK crea exactamente 1 CPT
fea_feedback; caso de fallo de red no crea ninguno y no muestra
éxito.

Refs #192
2026-08-04 09:42:30 -04:00
rafa 159080f0c8 TTS backlog: no dar falsa alarma los dias sin cron (#188)
El informe avisaba de "ninguna ventana dejo rastro en 24 h" los miercoles y
jueves, que son dias de carta y no tienen cron por diseno. Ahora lee el crontab,
cuenta cuantas ventanas TENIAN que correr y solo avisa si de verdad tocaba.
Si Rafa cambia los dias del cron, el informe le sigue solo.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-03 22:10:15 -04:00
rafa e4d2073eeb fix(tts): exclude donations and accounting entries 2026-08-03 09:40:18 -04:00
rafa 6dc847a151 TTS backlog: nota sobre el wrapper de Hermes (#188)
Hermes bloquea los scripts cuyo path resuelve fuera de ~/.hermes/scripts y
resuelve los symlinks antes de comprobarlo, asi que el enlace que habia puesto
al script del repo hacia fallar el job diario. Sustituido por un wrapper fino
que lo llama por subproceso, igual que feadulta_ga4_daily.py.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-03 07:36:26 -04:00
rafa e1a14ec3fc TTS backlog: tamano de tanda adaptativo y reparto de la cuota semanal (#188)
Un tamano fijo de tanda no maximiza: deja la ventana de 5h a medias cuando esta
libre y no cabe cuando esta medio usada. Ahora cada corrida mide la cuota y
calcula cuantos audios caben.

Coste medido (2 tandas de 10 el 2-ago): 4,4 puntos de la ventana de 5h y 0,4 de
la semanal por audio. Con la ventana limpia salen 20, no 10.

Pero la semanal NO se gasta a tope en cada ventana: se REPARTE entre las que
quedan hasta su reset. Llenar cada ventana de 5h al 90% son ~8 puntos de
semanal, y hay 20 ventanas activas por semana = 160 puntos para un presupuesto
de 85. Sin reparto, domingo y lunes se lo comen y el fin de semana se queda a
cero (simulado: se seca en la ventana 12 de 20). Con reparto salen ~10 por
ventana, 204 audios/semana, y la semanal acaba justo en el 85% al llegar el
reset. En la ultima ventana el reparto vale todo lo que sobre, asi que tampoco
queda cuota sin usar: eso sustituye al apano anterior de "apurar en las ultimas
12h", que se retira.

El gate binario tambien desaparece: si otro esta usando MiniMax, en vez de
saltarse la ventana entera se hace una tanda pequena con lo que quede.

Validado en vivo: "reset semanal en 166h, 20 ventanas por delante · Caben: 10
por la de 5h, 10 por el reparto semanal".

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-02 21:15:41 -04:00
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
8 changed files with 1148 additions and 20 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?
+58 -1
View File
@@ -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') {
@@ -19,6 +24,8 @@ if ($action === 'get') {
'title' => $p->post_title, 'title' => $p->post_title,
'content' => $p->post_content, 'content' => $p->post_content,
'status' => $p->post_status, 'status' => $p->post_status,
'post_type' => $p->post_type,
'post_name' => $p->post_name,
'author' => (int)$p->post_author, 'author' => (int)$p->post_author,
], JSON_UNESCAPED_UNICODE)); ], JSON_UNESCAPED_UNICODE));
exit(0); exit(0);
@@ -70,5 +77,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);
+234
View File
@@ -0,0 +1,234 @@
#!/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.
Hermes solo ejecuta scripts que resuelvan DENTRO de ~/.hermes/scripts, y resuelve
los symlinks antes de comprobarlo: un enlace a este fichero se bloquea. Por eso
~/.hermes/scripts/fea_tts_backlog_report.py es un wrapper que lo llama por
subproceso (mismo patrón que feadulta_ga4_daily.py). Este de aquí es el único
sitio donde se edita la lógica.
"""
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 _campo_cron(campo: str, valores: range) -> set[int]:
"""Expande un campo de crontab ('*', '*/5', '1,5,6,0', '0-4') a un set."""
if campo == "*":
return set(valores)
out: set[int] = set()
for trozo in campo.split(","):
paso = 1
if "/" in trozo:
trozo, p = trozo.split("/", 1)
paso = int(p)
if trozo == "*":
base = list(valores)
elif "-" in trozo:
a, b = (int(x) for x in trozo.split("-", 1))
base = list(range(a, b + 1))
else:
base = [int(trozo)]
out.update(base[::paso] if paso > 1 else base)
return out
def previstas_24h(ahora: datetime | None = None) -> int | None:
"""Cuántas ventanas TENÍA que haber corrido el cron en las últimas 24 h.
Sin esto, los martes/miércoles/jueves (días de carta, sin cron) el informe
daba la alarma de 'ninguna ventana dejó rastro' estando todo correcto. La
verdad está en el crontab, no aquí: si Rafa cambia los días, esto le sigue.
"""
try:
linea = next(
l for l in subprocess.run(["crontab", "-l"], text=True, capture_output=True,
check=True).stdout.splitlines()
if "tts_backlog_cron.sh" in l and not l.lstrip().startswith("#"))
except Exception: # noqa: BLE001
return None
campos = linea.split(None, 5)
if len(campos) < 5:
return None
minutos = _campo_cron(campos[0], range(60))
horas = _campo_cron(campos[1], range(24))
dows = {d % 7 for d in _campo_cron(campos[4], range(7))} # cron: 0 y 7 = domingo
ahora = ahora or datetime.now()
n = 0
for h in range(25):
t = (ahora - timedelta(hours=h)).replace(second=0, microsecond=0)
for m in minutos:
cand = t.replace(minute=m)
if not (ahora - timedelta(hours=24) < cand <= ahora):
continue
if cand.hour in horas and (cand.weekday() + 1) % 7 in dows:
n += 1
return n
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}%")
previstas = previstas_24h()
de = "" if previstas is None else f" de {previstas} previstas"
lineas.append(f"Ventanas 24 h: {corridas} ejecutadas{de}, {saltadas} saltadas por cuota")
if previstas == 0:
lineas.append(" (sin ventanas previstas: día sin cron, toca carta)")
# 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.
# Solo es alarma si de verdad tocaba correr; si no, es martes.
if corridas == 0 and saltadas == 0 and previstas != 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())
+9 -1
View File
@@ -93,7 +93,15 @@ def get_post_text(pid):
check=True, capture_output=True) check=True, capture_output=True)
subprocess.run(["docker", "cp", f"{CONTAINER}:/tmp/fea_es.json", "/tmp/fea_es.json"], check=True) subprocess.run(["docker", "cp", f"{CONTAINER}:/tmp/fea_es.json", "/tmp/fea_es.json"], check=True)
d = json.load(open("/tmp/fea_es.json")) d = json.load(open("/tmp/fea_es.json"))
raw = re.sub(r"(?i)</p>|<br\s*/?>|</h[1-6]>", "\n", d["content"]) # Hard gate: pages and operational/accounting entries are never TTS input.
# This also protects explicit --ids queues, which bypass author-backlog SQL.
raw_content = d.get("content", "")
blocked_markers = ("fea-don-wrap", "fea-ledger", "Haz tu donación")
if d.get("post_type") != "post":
raise ValueError(f"post #{pid} no es un artículo (post_type={d.get('post_type')!r}); TTS excluido")
if d.get("post_name") == "numeros" or any(marker in raw_content for marker in blocked_markers):
raise ValueError(f"post #{pid} es contenido de cuentas/donaciones; TTS excluido")
raw = re.sub(r"(?i)</p>|<br\s*/?>|</h[1-6]>", "\n", raw_content)
raw = re.sub(r"<[^>]+>", "", raw) raw = re.sub(r"<[^>]+>", "", raw)
raw = re.sub(r"\[[^\]]+\]", "", raw) raw = re.sub(r"\[[^\]]+\]", "", raw)
raw = html.unescape(raw) raw = html.unescape(raw)
+136
View File
@@ -0,0 +1,136 @@
#!/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) Mide la cuota y CALCULA el tamaño de la tanda para llenar la ventana hasta
# el objetivo. Un tamaño fijo desaprovecha: deja la ventana a medias cuando
# está libre, y no cabe cuando está medio usada. Al dimensionar por hueco
# libre, además, deja de importar dónde caiga el cron respecto a la ventana.
# 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}"
# Coste medido de un audio, en DÉCIMAS de punto porcentual (aritmética entera en
# bash). Dos tandas de 10 el 2026-08-02: la ventana de 5 h fue 0→45→88 (~4,4 pts
# por audio) y la semanal 10→14→18 (~0,4 pts). Artículos de Fray Marcos de
# 4.000-5.000 caracteres; si se locuta a otro autor con textos mucho más largos,
# revisar estos números con un par de tandas.
COSTE_5H="${FEA_TTS_COSTE_5H:-44}"
COSTE_SEM="${FEA_TTS_COSTE_SEM:-4}"
# Objetivos de llenado (%). Dejar cuota sin usar al llegar el reset es tirarla.
OBJ_5H="${FEA_TTS_OBJ_5H:-90}"
OBJ_SEM="${FEA_TTS_OBJ_SEM:-85}"
# La semanal NO se gasta a tope en cada ventana: se REPARTE entre las ventanas
# que quedan hasta su reset. Llenar cada ventana de 5 h al 90 % son ~8 puntos de
# semanal, y hay 20 ventanas activas por semana: 160 puntos para un presupuesto
# de 85. Sin reparto, domingo y lunes se lo comen y el fin de semana se queda a
# cero. Con reparto sale ~10 audios por ventana, y en la última ventana de la
# semana el reparto vale todo lo que sobre, así que tampoco queda cuota sin usar.
# Tope de seguridad por tanda y override manual (FEA_TTS_BATCH fija el tamaño y
# se salta el cálculo).
MAX_BATCH="${FEA_TTS_MAX_BATCH:-25}"
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) ===" >> "$LOG"
# 1) Medir la cuota, y contar cuántas ventanas del cron quedan hasta que se
# reinicie la semanal — es el denominador del reparto.
read -r PCT5 PCTW HSEM VENTANAS <<< "$(python3 "$QUOTA" --json --no-local 2>/dev/null | python3 -c '
import json, sys
from datetime import datetime, timedelta, timezone
# DEBEN COINCIDIR CON EL CRONTAB: 0 */5 * * 1,5,6,0
HORAS = {0, 5, 10, 15, 20}
DIAS = {0, 4, 5, 6} # lun, vie, sab, dom en datetime.weekday()
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"))
horas, ventanas = 999, 1
try:
fin = datetime.fromisoformat(m["week_reset"]).astimezone()
ahora = datetime.now().astimezone()
horas = max(int((fin - ahora).total_seconds() // 3600), 0)
# Esta corrida cuenta como una; se suman las que quedan programadas.
t = (ahora + timedelta(hours=1)).replace(minute=0, second=0, microsecond=0)
while t < fin:
if t.hour in HORAS and t.weekday() in DIAS:
ventanas += 1
t += timedelta(hours=1)
except Exception:
pass
print(pct(m.get("five_h_pct")), pct(m.get("week_pct")), horas, ventanas)
except Exception:
print(100, 100, 999, 1) # sin lectura fiable de cuota, no se gasta
' 2>/dev/null || echo "100 100 999 1")"
[ "${VENTANAS:-0}" -lt 1 ] && VENTANAS=1
# 2) Dimensionar la tanda. Dos límites, manda el más restrictivo:
# - la ventana de 5 h: se llena hasta OBJ_5H aquí y ahora;
# - la semanal: solo la parte que le toca a esta ventana de lo que queda.
CABE_5H=$(( ((OBJ_5H - PCT5) * 10) / COSTE_5H ))
CABE_SEM=$(( (((OBJ_SEM - PCTW) * 10) / VENTANAS) / COSTE_SEM ))
[ "$CABE_5H" -lt 0 ] && CABE_5H=0
[ "$CABE_SEM" -lt 0 ] && CABE_SEM=0
BATCH=$CABE_5H
[ "$CABE_SEM" -lt "$BATCH" ] && BATCH=$CABE_SEM
[ "$BATCH" -gt "$MAX_BATCH" ] && BATCH=$MAX_BATCH
# Override manual: fija el tamaño y se salta todo el cálculo.
[ -n "${FEA_TTS_BATCH:-}" ] && BATCH="$FEA_TTS_BATCH"
echo "[$(ts)] MiniMax 5h=${PCT5}% semana=${PCTW}% · reset semanal en ${HSEM}h, ${VENTANAS} ventanas por delante" >> "$LOG"
echo "[$(ts)] Caben: ${CABE_5H} por la de 5h, ${CABE_SEM} por el reparto semanal → tanda de ${BATCH}" >> "$LOG"
if [ "$BATCH" -lt 1 ]; then
echo "[$(ts)] ABORT: no cabe ni un audio sin pasarse del objetivo; salto esta ventana." >> "$LOG"
exit 0
fi
# 3) Tanda. tts_produce.py ya para solo ante rc 2056/1039 (cuota/rate limit).
# Una tanda larga puede desbordar el reset de 5 h (~2,6 min por audio): no pasa
# nada, lo que sobra lo absorbe la ventana siguiente y la próxima corrida la
# mide y se redimensiona sola. Cortar por tiempo dejaría cuota sin gastar.
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
# 4) 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 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)
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() 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 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. ===")
@@ -151,23 +151,28 @@ function fea_beta_labels(): array {
'es' => ['region'=>'Aviso Beta','intro'=>'Estamos en','help'=>'¿Nos ayudas a mejorar FeAdulta?', 'es' => ['region'=>'Aviso Beta','intro'=>'Estamos en','help'=>'¿Nos ayudas a mejorar FeAdulta?',
'opinion'=>'Dar mi opinión','collab'=>'Colaborar','dismiss'=>'Cerrar aviso','fbregion'=>'Feedback de la página', 'opinion'=>'Dar mi opinión','collab'=>'Colaborar','dismiss'=>'Cerrar aviso','fbregion'=>'Feedback de la página',
'close'=>'Cerrar','q'=>'¿Se ve bien esta página?','up'=>'Sí, se ve bien','down'=>'No, hay algo mal', 'close'=>'Cerrar','q'=>'¿Se ve bien esta página?','up'=>'Sí, se ve bien','down'=>'No, hay algo mal',
'ph'=>'¿Algo falla o se ve mal? Cuéntanoslo (opcional)','send'=>'Enviar','thanks'=>'¡Gracias por ayudar! 🙏'], 'ph'=>'¿Algo falla o se ve mal? Cuéntanoslo (opcional)','send'=>'Enviar','thanks'=>'¡Gracias por ayudar! 🙏',
'error'=>'No se pudo enviar. Comprueba tu conexión e inténtalo de nuevo.'],
'en' => ['region'=>'Beta notice','intro'=>'We are in','help'=>'Will you help us improve FeAdulta?', 'en' => ['region'=>'Beta notice','intro'=>'We are in','help'=>'Will you help us improve FeAdulta?',
'opinion'=>'Give feedback','collab'=>'Collaborate','dismiss'=>'Close notice','fbregion'=>'Page feedback', 'opinion'=>'Give feedback','collab'=>'Collaborate','dismiss'=>'Close notice','fbregion'=>'Page feedback',
'close'=>'Close','q'=>'Does this page look right?','up'=>'Yes, looks good','down'=>'No, something is wrong', 'close'=>'Close','q'=>'Does this page look right?','up'=>'Yes, looks good','down'=>'No, something is wrong',
'ph'=>'Something broken or off? Tell us (optional)','send'=>'Send','thanks'=>'Thanks for helping! 🙏'], 'ph'=>'Something broken or off? Tell us (optional)','send'=>'Send','thanks'=>'Thanks for helping! 🙏',
'error'=>'Could not send. Check your connection and try again.'],
'fr' => ['region'=>'Avis Bêta','intro'=>'Nous sommes en','help'=>'Voulez-vous nous aider à améliorer FeAdulta ?', 'fr' => ['region'=>'Avis Bêta','intro'=>'Nous sommes en','help'=>'Voulez-vous nous aider à améliorer FeAdulta ?',
'opinion'=>'Donner mon avis','collab'=>'Collaborer','dismiss'=>'Fermer lavis','fbregion'=>'Retour sur la page', 'opinion'=>'Donner mon avis','collab'=>'Collaborer','dismiss'=>'Fermer lavis','fbregion'=>'Retour sur la page',
'close'=>'Fermer','q'=>'Cette page saffiche-t-elle bien ?','up'=>'Oui, cest bien','down'=>'Non, il y a un problème', 'close'=>'Fermer','q'=>'Cette page saffiche-t-elle bien ?','up'=>'Oui, cest bien','down'=>'Non, il y a un problème',
'ph'=>'Un souci ou un affichage incorrect ? Dites-le-nous (facultatif)','send'=>'Envoyer','thanks'=>'Merci de votre aide ! 🙏'], 'ph'=>'Un souci ou un affichage incorrect ? Dites-le-nous (facultatif)','send'=>'Envoyer','thanks'=>'Merci de votre aide ! 🙏',
'error'=>'Échec de lenvoi. Vérifiez votre connexion et réessayez.'],
'it' => ['region'=>'Avviso Beta','intro'=>'Siamo in','help'=>'Ci aiuti a migliorare FeAdulta?', 'it' => ['region'=>'Avviso Beta','intro'=>'Siamo in','help'=>'Ci aiuti a migliorare FeAdulta?',
'opinion'=>'Dai la tua opinione','collab'=>'Collabora','dismiss'=>'Chiudi avviso','fbregion'=>'Feedback della pagina', 'opinion'=>'Dai la tua opinione','collab'=>'Collabora','dismiss'=>'Chiudi avviso','fbregion'=>'Feedback della pagina',
'close'=>'Chiudi','q'=>'Questa pagina si vede bene?','up'=>'Sì, si vede bene','down'=>'No, c’è qualcosa che non va', 'close'=>'Chiudi','q'=>'Questa pagina si vede bene?','up'=>'Sì, si vede bene','down'=>'No, c’è qualcosa che non va',
'ph'=>'Qualcosa non va o si vede male? Faccelo sapere (facoltativo)','send'=>'Invia','thanks'=>'Grazie per laiuto! 🙏'], 'ph'=>'Qualcosa non va o si vede male? Faccelo sapere (facoltativo)','send'=>'Invia','thanks'=>'Grazie per laiuto! 🙏',
'error'=>'Invio non riuscito. Controlla la connessione e riprova.'],
'pt' => ['region'=>'Aviso Beta','intro'=>'Estamos em','help'=>'Ajuda-nos a melhorar a FeAdulta?', 'pt' => ['region'=>'Aviso Beta','intro'=>'Estamos em','help'=>'Ajuda-nos a melhorar a FeAdulta?',
'opinion'=>'Dar a minha opinião','collab'=>'Colaborar','dismiss'=>'Fechar aviso','fbregion'=>'Feedback da página', 'opinion'=>'Dar a minha opinião','collab'=>'Colaborar','dismiss'=>'Fechar aviso','fbregion'=>'Feedback da página',
'close'=>'Fechar','q'=>'Esta página vê-se bem?','up'=>'Sim, vê-se bem','down'=>'Não, há algo errado', 'close'=>'Fechar','q'=>'Esta página vê-se bem?','up'=>'Sim, vê-se bem','down'=>'Não, há algo errado',
'ph'=>'Algo falha ou vê-se mal? Conta-nos (opcional)','send'=>'Enviar','thanks'=>'Obrigado por ajudar! 🙏'], 'ph'=>'Algo falha ou vê-se mal? Conta-nos (opcional)','send'=>'Enviar','thanks'=>'Obrigado por ajudar! 🙏',
'error'=>'Não foi possível enviar. Verifica a ligação e tenta novamente.'],
]; ];
$lang = function_exists('pll_current_language') ? (string) pll_current_language() : 'es'; $lang = function_exists('pll_current_language') ? (string) pll_current_language() : 'es';
return $all[$lang] ?? $all['es']; return $all[$lang] ?? $all['es'];
@@ -211,6 +216,8 @@ add_action('wp_footer', function () {
font:inherit; font-size:.85rem; resize:vertical; min-height:58px; box-sizing:border-box; } font:inherit; font-size:.85rem; resize:vertical; min-height:58px; box-sizing:border-box; }
#fea-fb .fea-fb-send { background:#8b1a2e; color:#fff; border:1px solid #8b1a2e; border-radius:8px; #fea-fb .fea-fb-send { background:#8b1a2e; color:#fff; border:1px solid #8b1a2e; border-radius:8px;
padding:6px 12px; font-size:.85rem; width:100%; cursor:pointer; } padding:6px 12px; font-size:.85rem; width:100%; cursor:pointer; }
#fea-fb .fea-fb-send:disabled { opacity:.6; cursor:default; }
#fea-fb .fea-fb-error { color:#8b1a2e; font-size:.82rem; margin:0 0 8px; }
#fea-fb .fea-fb-hp { position:absolute; left:-9999px; } #fea-fb .fea-fb-hp { position:absolute; left:-9999px; }
#fea-fb .fea-fb-close { position:absolute; top:4px; right:8px; border:0; background:none; font-size:1rem; cursor:pointer; padding:2px 4px; line-height:1; } #fea-fb .fea-fb-close { position:absolute; top:4px; right:8px; border:0; background:none; font-size:1rem; cursor:pointer; padding:2px 4px; line-height:1; }
@media (max-width:600px){ #fea-fb{ right:10px; left:10px; max-width:none; } #fea-beta-bar{ font-size:.8rem; } } @media (max-width:600px){ #fea-fb{ right:10px; left:10px; max-width:none; } #fea-beta-bar{ font-size:.8rem; } }
@@ -234,6 +241,7 @@ add_action('wp_footer', function () {
<div class="fea-fb-more" hidden> <div class="fea-fb-more" hidden>
<input type="text" class="fea-fb-hp" tabindex="-1" autocomplete="off" aria-hidden="true" placeholder="No rellenar"> <input type="text" class="fea-fb-hp" tabindex="-1" autocomplete="off" aria-hidden="true" placeholder="No rellenar">
<textarea placeholder="<?php echo esc_attr($t['ph']); ?>"></textarea> <textarea placeholder="<?php echo esc_attr($t['ph']); ?>"></textarea>
<p class="fea-fb-error" hidden role="alert"><?php echo esc_html($t['error']); ?></p>
<button type="button" class="fea-fb-send"><?php echo esc_html($t['send']); ?></button> <button type="button" class="fea-fb-send"><?php echo esc_html($t['send']); ?></button>
</div> </div>
<div class="fea-fb-thanks" hidden><?php echo esc_html($t['thanks']); ?></div> <div class="fea-fb-thanks" hidden><?php echo esc_html($t['thanks']); ?></div>
@@ -252,6 +260,8 @@ add_action('wp_footer', function () {
var moreEl = box.querySelector('.fea-fb-more'); var moreEl = box.querySelector('.fea-fb-more');
var votes = box.querySelectorAll('.fea-fb-vote'); var votes = box.querySelectorAll('.fea-fb-vote');
var thanks = box.querySelector('.fea-fb-thanks'); var thanks = box.querySelector('.fea-fb-thanks');
var errorEl = box.querySelector('.fea-fb-error');
var sendBtn = box.querySelector('.fea-fb-send');
// Mostrar la barra salvo que el usuario la haya descartado antes. // Mostrar la barra salvo que el usuario la haya descartado antes.
try { if (!localStorage.getItem('fea_beta_bar_off')) bar.classList.remove('hidden'); } try { if (!localStorage.getItem('fea_beta_bar_off')) bar.classList.remove('hidden'); }
@@ -273,17 +283,31 @@ add_action('wp_footer', function () {
moreEl.hidden = false; moreEl.hidden = false;
});}); });});
box.querySelector('.fea-fb-send').addEventListener('click', function(){ sendBtn.addEventListener('click', function(){
if(!chosen) return; if(!chosen) return;
var hp = box.querySelector('.fea-fb-hp').value; var hp = box.querySelector('.fea-fb-hp').value;
var comment = box.querySelector('textarea').value; var comment = box.querySelector('textarea').value;
sendBtn.disabled = true;
errorEl.hidden = true;
fetch(REST, { method:'POST', headers:{'Content-Type':'application/json'}, fetch(REST, { method:'POST', headers:{'Content-Type':'application/json'},
body: JSON.stringify({ vote:chosen, comment:comment, url:location.href, post_id:pid, body: JSON.stringify({ vote:chosen, comment:comment, url:location.href, post_id:pid,
lang:lang, title:document.title, website:hp }) }).catch(function(){}); lang:lang, title:document.title, website:hp }) })
.then(function(res){
if(!res.ok) throw new Error('http_' + res.status);
return res.json();
})
.then(function(data){
if(!data || data.ok !== true) throw new Error('bad_response');
box.querySelector('.fea-fb-btns').hidden = true; box.querySelector('.fea-fb-btns').hidden = true;
box.querySelector('.fea-fb-q').hidden = true; box.querySelector('.fea-fb-q').hidden = true;
moreEl.hidden = true; thanks.hidden = false; moreEl.hidden = true; thanks.hidden = false;
setTimeout(closeCard, 2200); setTimeout(closeCard, 2200);
})
.catch(function(err){
if (window.console && console.warn) console.warn('fea-fb submit failed:', err && err.message);
errorEl.hidden = false;
sendBtn.disabled = false;
});
}); });
})(); })();
</script> </script>