Compare commits

..

13 Commits

Author SHA1 Message Date
rafa bda4a753c9 Report TTS WordPress read failures instead of false zero backlog 2026-08-28 04:20:15 -04:00
rafa de91485854 sync audio: el recuento final contaba las subidas buenas como saltadas
sync_one devuelve "ok prod#<id>" desde que el id de prod puede diferir del
local, pero el contador seguia comparando con la igualdad exacta "ok". Resultado:
el 8-ago la subida de los 90 audios de Fray Marcos, sin un solo fallo, cerro con
"ok=0 skip=90". El susto es lo de menos; lo malo es que un fallo real se camufla
en ese ruido.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-08 13:57:04 -04:00
rafa 2fa50a8600 TTS backlog: el cron deduce su repo, y corre desde un worktree fijo (#188)
El cron apuntaba al checkout principal, que cambia de rama a menudo. Al pasar a
docs/rescate-gap-194 (5-ago) los scripts del backlog dejaron de existir en el
working tree y el cron fallo en silencio: 7 ventanas perdidas hasta el 8-ago.
Ahora REPO se deduce de la ubicacion del script y el cron/informe apuntan a
~/worktrees/fea-tts-backlog, fijado a esta rama, inmune a los checkouts.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-08 07:39:24 -04:00
rafa df7bf98ef1 Apunta los scripts de sync prod al Hetzner nuevo y suma trabajo pendiente
- sync_translations_to_prod.py / sync_audio_to_prod.py / sync_carta_from_prod.py:
  migran de FEA_PROD_HOST/PASS (CDMON, password) a FEA_PROD_SSH_HOST/PASS +
  FEA_PROD_DOCKER_CONTAINER (Hetzner/Coolify, auth por clave), con wrapping
  docker exec y el fix del bug de redirecciones (wc -c/cat) resuelto en el host
  en vez de dentro del contenedor.
- fea_translate_helper.php: subcomando clone_new para clonar en ID local nuevo
  cuando el ID de prod ya está ocupado localmente.
- translate_post.py: --dry-run.
- tts_produce.py: --allow-default-voice (voz Nico solo si se permite
  explícitamente para autores sin voz clonada) + fix voice_for_author.
- minimax_tts.py: parámetro speed en t2a/_synth_chunk.
- mirror-antiguo/deploy/nginx-mirror.conf: redirects de URLs históricas de
  catálogo y vista imprimible EFFA.
- Importaciones humanas de Pagola (carta 738) + scripts/manifest de la
  fase A Enrique, y release_issue181_prod.sh / cdmon-retire-fewp1.sh.
- .gitignore: excluye docs/backups/ (dumps SQL, mismo motivo que backups/*).

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-08-05 07:09:29 -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
rafa 275aff1430 Mirror del Joomla antiguo: versionar los scripts y reponer los assets que faltaban
Los scripts del mirror (00-90) vivian solo en el disco. Van al repo; los datos que
generan no (16 GB entre crawl, snapshot del origen y Joomla restaurado) -> .gitignore.

Nuevo 91-repone-assets404.sh: repone los ficheros que el crawl no capturo porque se
referencian SOLO desde CSS y el crawler seguia enlaces HTML (system.css, los fondos de
fe_adulta_1, ratingstars.gif de K2). Salian como 404 en los logs de nginx del Hetzner.

Descarga por HTTP desde el Joomla local aislado, nunca del filesystem -- mismo principio
que el crawl, para no arrastrar los .php comprometidos del #183 -- y escanea PHP embebido
antes de copiar a site/.

Resultado sobre las 286 rutas unicas con 404 del log: 196 repuestas y verificadas en
produccion (196/196 en 200 tras el rsync), 82 que dan 301->404 tambien en el origen (ya
estaban rotas en la web original) y 8 rutas basura /%22/... de HTML mal formado.

Refs #180

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-07-31 14:04:31 -04:00
95 changed files with 4796 additions and 120 deletions
+12
View File
@@ -17,6 +17,10 @@ joomla-php83/
backups/* backups/*
!backups/README.md !backups/README.md
# Backups pesados (GBs) bajo docs/ — mismo motivo que backups/*, ruta distinta
docs/backups/*
!docs/backups/README.md
# Capturas de pantalla # Capturas de pantalla
capturas/ capturas/
@@ -57,3 +61,11 @@ tools/e2e/out/
# Akeeba Kickstart (tool de restauración, no es código del repo) # Akeeba Kickstart (tool de restauración, no es código del repo)
tools/akeeba-kickstart/ tools/akeeba-kickstart/
# Mirror del Joomla antiguo: los scripts SÍ van al repo, los datos NO (16 GB entre
# el crawl, el snapshot del origen y el Joomla restaurado)
mirror-antiguo/runs/
mirror-antiguo/source/
mirror-antiguo/restore/
mirror-antiguo/smoke/
mirror-antiguo/inventory/
+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?
@@ -0,0 +1,71 @@
# Release dry-run — Enrique Martínez Lozano / carta «Hacia el corazón»
**Estado:** preparado localmente; **no ejecutado en producción**.
## Validación local completada
- Comentario ES: `#55041``El tesoro está ya en nosotros`
- Categorías: `Comentarios al evangelio` + `Feadulta`.
- Audio: `tts/55041.mp3`, 2,221,101 bytes, voz `NicoFeadulta2026`.
- QA Haiku: aprobado tras correcciones objetivas EN/FR/IT.
- Traducciones del comentario: EN `#55047`, FR `#55048`, IT `#55049`, PT `#55050`.
- Carta local ES: `#55042``Hacia el corazón` (slug técnico de preview conservado).
- Traducciones: EN `#55051`, FR `#55052`, IT `#55053`, PT `#55054`.
- QA Haiku: contenido aprobado tras correcciones; 24 enlaces con destino traducido se repuntaron. Los 12 enlaces externos que quedan son fallback ES: no existe post destino traducido en local (verificado por `post_title` exacto y Polylang).
- Render comprobado en Tailscale:
- Comentario ES: título correcto, categorías correctas y reproductor HTML5 visible.
- Carta FR: título correcto; Enrique aparece tras Fray Marcos y antes de Pagola.
## Evidencia de producción (sólo lectura server-side)
- Carta real: `#54914`, título `Hacia el corazón`, estado `publish`.
- Hash actual de contenido prod: `5e7eb1b880bfc6311edb78ac8b6e81639f5b4502282a1b182c610b1d01308b69`.
- Hash candidato local: `6fdb4929fcb5b49b7f2dc1deb7c6f5be70983af866d44c5d5c27d2866c09418c`.
- La carta candidata lleva `fea_phase_a_source_prod_id=54914`; por tanto debe **actualizar #54914**, no crearse una carta nueva.
## Dry-runs que pasaron
```bash
# Crea solamente el grupo nuevo del comentario (ES + EN/FR/IT/PT): 5 posts draft
python3 scripts/sync_translations_to_prod.py --ids 55041 --dry-run
# Sube y asocia solamente el MP3 de Enrique
python3 scripts/sync_audio_to_prod.py --ids 55041 --dry-run
```
Ambos devolvieron `ok/error=1/0` (audio) y un grupo Polylang de cinco posts (comentario).
## Guardarraíl crítico
**NO ejecutar:**
```bash
python3 scripts/sync_translations_to_prod.py --ids 55041,55042
```
El dry-run demostró que clonaría `#55042` como una segunda carta ES. No actualiza la carta real `#54914`.
## Release que debe ejecutar Rafa (producción)
1. Backup de producción y snapshot server-side de `#54914`.
2. Crear el grupo nuevo del comentario desde `#55041` (ES + EN/FR/IT/PT), inicialmente en `draft`; anotar los IDs reales de producción.
3. Subir `tts/55041.mp3` y asociarlo al ID ES real creado en producción.
4. Actualizar **solamente** título/contenido de la carta real `#54914` con el candidato local `#55042`; mantener título `Hacia el corazón`, autor, fecha y estado existentes.
5. Crear EN/FR/IT/PT de la carta contra el origen real `#54914` y enlazarlas en Polylang. No clonar otra ES.
6. Releer por servidor los IDs creados y `#54914`; comprobar:
- Enrique sólo está en `Evangelio y comentarios al Evangelio`, tras Fray Marcos y antes de Pagola.
- No aparece en `Artículos seleccionados`.
- Cada carta enlaza a Enrique en su mismo idioma.
- El reproductor del ES carga `tts/55041.mp3`.
7. Sólo tras esas lecturas, promover los drafts de producción a `publish`.
## Rollback
- Restaurar el backup de producción previo.
- Alternativamente, restaurar el contenido/hash previo de `#54914` y despublicar el nuevo grupo + audio de Enrique.
## Backups locales relacionados
- `docs/backups/enrique-translations-20260725T004921Z/`
- `docs/backups/enrique-carta-fr-final-20260725T012649Z/`
- `docs/backups/enrique-pre-tts-20260725T013021Z/`
+50
View File
@@ -0,0 +1,50 @@
<?php
/**
* Issue #62 — Reapunta foto_perfil de cada autor a su avatar nuevo.
* Crea un attachment por autor y guarda el foto_perfil antiguo en _foto_perfil_pre62 (revertible).
* Uso: wp eval-file import_avatars_62.php [--apply]
* sin --apply => dry run (no escribe nada).
*/
require_once ABSPATH . 'wp-admin/includes/image.php';
$apply = getenv('APPLY') === '1';
$mfile = getenv('MANIFEST') ?: 'manifest_62.json';
$updir = wp_get_upload_dir();
$manifest = json_decode(file_get_contents($updir['basedir'] . '/avatares/' . $mfile), true);
$done = $skip = $err = 0;
foreach ($manifest as $row) {
list($uid, $name, $orig, $kind, $old_attach) = $row;
if (!in_array($kind, ['initials','trim','logo','qs'], true)) { $skip++; continue; }
$rel = "avatares/autores/autor-{$uid}.png";
$abs = $updir['basedir'] . '/' . $rel;
if (!file_exists($abs)) { echo "MISSING file uid=$uid\n"; $err++; continue; }
// si foto_perfil ya apunta a este fichero, solo se ha sobrescrito el PNG: no crear attachment nuevo
$cur = (int) get_user_meta($uid, 'foto_perfil', true);
if ($cur && get_post_meta($cur, '_wp_attached_file', true) === $rel) {
if ($apply) wp_update_attachment_metadata($cur, wp_generate_attachment_metadata($cur, $abs));
$skip++; continue;
}
if (!$apply) { $done++; continue; }
// guardar foto_perfil antiguo una sola vez (idempotente)
if (get_user_meta($uid, '_foto_perfil_pre62', true) === '') {
update_user_meta($uid, '_foto_perfil_pre62', $old_attach);
}
$attach = [
'post_mime_type' => 'image/png',
'post_title' => "Avatar {$name}",
'post_status' => 'inherit',
'guid' => $updir['baseurl'] . '/' . $rel,
];
$aid = wp_insert_attachment($attach, $abs, 0, true);
if (is_wp_error($aid)) { echo "ERR insert uid=$uid: ".$aid->get_error_message()."\n"; $err++; continue; }
wp_update_attachment_metadata($aid, wp_generate_attachment_metadata($aid, $abs));
update_user_meta($uid, 'foto_perfil', $aid);
$done++;
}
echo ($apply ? "APLICADO" : "DRY-RUN") . ": procesados=$done omitidos=$skip errores=$err\n";
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
+1
View File
File diff suppressed because one or more lines are too long
+1
View File
@@ -0,0 +1 @@
20260729T224708Z
+76
View File
@@ -0,0 +1,76 @@
# mirror-antiguo — mirror estático del Joomla legacy de feadulta
Construcción del mirror HTML read-only de `antiguo.feadulta.com` (issue
[rafa/feadulta#180](https://gitea.feadulta.com/rafa/feadulta/issues/180), remediación de #183).
**Regla de diseño:** el mirror se genera **solo por HTTP**, nunca copiando el filesystem. Así es
imposible arrastrar los `.php` comprometidos del incidente. Lo que se captura es lo que Joomla
*renderiza*.
## Origen del crawl
No es producción: es el **Joomla legacy restaurado en local**.
| | |
|---|---|
| Contenedor | `joomla-mirror-web` (`php:7.4-apache`), red `joomla-migration_joomla-net`, IP `172.20.0.5` |
| Acceso | `http://127.0.0.1:8086` o `http://antiguo.feadulta.com` (entrada en `/etc/hosts` → 172.20.0.5) |
| BD | `joomla_mirror` dentro del contenedor `joomla-mysql` |
| Snapshot fuente | `source/antiguo-20260729.tar.gz` + `source/fejoomla3-20260729.sql.gz`, hashes en `MANIFEST-source.sha256` |
⚠️ La imagen base necesitó `mysqli`, `pdo_mysql`, `gd`, `zip` y `mod_rewrite` instalados en caliente:
**se pierden si el contenedor se recrea**, no si solo se para/arranca. Para cambiar memoria o política
de reinicio usar `docker update`, nunca `docker rm` + `docker run`.
⚠️ WSL2 se apaga cuando no queda ninguna sesión abierta desde Windows, y al apagarse para los
contenedores. Antes de un proceso de horas, dejar un proceso ancla vivo en WSL.
## Datos del sitio
- SEF: `sef=1`, `sef_rewrite=1`, `sef_suffix=1` → las URLs terminan en `.html`.
- Un solo idioma de contenido publicado: `es-ES` (sef `es`); todo el contenido es `language='*'`.
El sitio hace **301 de `/x` → `/es/x`**, así que el inventario se genera ya con `/es/`.
- Solo 2 categorías K2 y un único ítem de menú de K2: `buscadoravanzado` (Itemid 138).
Las URLs de ítem son `/es/buscadoravanzado/item/<id>-<alias>.html`.
- 16.269 ítems K2 publicados · 9.079 artículos com_content · 216 ítems de menú · 68 categorías.
## Método
El inventario **no se reconstruye a mano**: lo genera el propio router de Joomla. `_genurls.php`
(en la raíz del sitio restaurado) arranca el framework por HTTP y llama a `JRoute::_()` y
`K2HelperRoute::getItemRoute()`, así que las URLs son idénticas a las que el sitio imprime.
Cuatro pases, todos acotados por listas — **nunca por recursión libre** (ver post-mortem del
2026-07-29 en #180 comment-497):
| Pase | Qué captura | Script |
|---|---|---|
| A | Las 25.437 URLs del inventario | `21-crawl-html.sh` |
| B | Recursos (css/js/img/mp3/pdf) referenciados por el HTML capturado | `30-extract-links.py` + `31-fetch-assets.sh` |
| C | Huecos: rutas de menú alternativas, páginas de autor de K2, `/anterior`, `/ediciones` | `43-pendientes.sh` |
| D | `/anterior`, la web estática anterior a Joomla (HTML plano, recursión finita) | `37-pase-d-anterior.sh` |
`raw/` es **inmutable**. `site/` es el derivado servible (`45-normalize-links.py`).
## Trampas encontradas (y cómo se resuelven)
1. **Paginación de K2**: los enlaces acumulan `&start=` en vez de reemplazarlo → espacio de URLs
infinito. Es lo que tumbó la VM el 29-jul. Se evita capturando por inventario, y el
`--reject-regex` incluye `start|limitstart|limit|print|tmpl|format|searchword|task|orderby|filter`.
2. **Alias con `?` literal**: ~198 artículos tienen el signo de interrogación dentro del alias
(`...-dios-nos-ama?.html`). Para cualquier cliente HTTP eso es el separador de query, así que la
ruta real es la parte anterior al `?` y wget guarda un fichero **sin extensión**. Es correcto para
servirlo estáticamente, pero nginx necesita `default_type text/html` en esa ubicación o el
navegador se lo descargará en vez de mostrarlo.
3. **Assets con cache-busting** (`core.js?a32fb…`): wget mete la query en el nombre del fichero. El
paso de normalización deja también una copia con el nombre limpio, que es la que pedirá el
servidor estático.
4. **Menús cuyo componente ya no existe** (`com_surveys`, `com_breezingforms`): `JRoute` no les
construye ruta y devuelven 404. Ya son 404 en producción; para el resto de ítems de menú la ruta
autoritativa es la columna `path` de `#__menu` (`41-menu-paths.sh`).
## Scripts
Numerados por orden de ejecución en `scripts/`. `23-estado.sh` da el estado en cualquier momento;
`22-monitor.sh` vigila el crawl y **lo aborta** si el servidor pasa de 100 respuestas 500 o si la RAM
libre baja de 800 MB. `60-restaurar-entorno.sh` rearranca los contenedores parados durante el crawl.
+87
View File
@@ -0,0 +1,87 @@
<!DOCTYPE html>
<html lang="es">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<meta name="robots" content="noindex, nofollow">
<title>Página no encontrada · Page not found — Archivo de feadulta.com</title>
<style>
:root { color-scheme: light dark; }
* { box-sizing: border-box; }
body {
margin: 0; padding: 2.5rem 1.25rem;
font-family: Georgia, "Times New Roman", serif;
line-height: 1.65; color: #2e2a25; background: #f7f4ef;
display: flex; justify-content: center;
}
.caja { max-width: 40rem; width: 100%; }
.codigo {
font-size: .8rem; letter-spacing: .18em; text-transform: uppercase;
color: #9a8f80; margin: 0 0 .5rem;
}
h1 { font-size: 1.7rem; line-height: 1.25; margin: 0 0 1rem; font-weight: normal; }
p { margin: 0 0 1rem; }
.acciones { margin: 1.75rem 0; display: flex; flex-wrap: wrap; gap: .75rem; }
a.boton {
display: inline-block; padding: .7rem 1.3rem; border-radius: 3px;
text-decoration: none; font-family: system-ui, sans-serif; font-size: .95rem;
}
a.principal { background: #7a1f1f; color: #fff; }
a.secundario { background: transparent; color: #7a1f1f; border: 1px solid #c9b8a8; }
a.boton:hover { opacity: .88; }
hr { border: 0; border-top: 1px solid #ddd3c6; margin: 2.25rem 0; }
.en { color: #5d564e; }
.nota { font-size: .88rem; color: #8a8078; }
@media (prefers-color-scheme: dark) {
body { background: #17150f; color: #e6ddd0; }
.codigo { color: #7d7365; }
a.principal { background: #a83232; }
a.secundario { color: #d8a7a7; border-color: #4a4238; }
hr { border-top-color: #383226; }
.en { color: #b3a89a; }
.nota { color: #857c70; }
}
</style>
</head>
<body>
<div class="caja">
<p class="codigo">Error 404</p>
<h1>Esta página no está en el archivo</h1>
<p>Estás en el <strong>archivo histórico de feadulta.com</strong>: una copia de solo lectura de
la web antigua, conservada tal y como estaba. No se actualiza y no admite búsquedas ni
formularios.</p>
<p>La dirección que has seguido no existe aquí. Puede que el enlace esté mal escrito, que la
página se retirase antes de hacer esta copia, o que su contenido viva ahora en la web nueva.</p>
<div class="acciones">
<a class="boton principal" href="/es/">Ir al inicio del archivo</a>
<a class="boton secundario" href="https://www.feadulta.com/">Ir a feadulta.com</a>
</div>
<hr>
<div class="en" lang="en">
<p class="codigo">Error 404</p>
<h1>This page is not in the archive</h1>
<p>You have reached the <strong>historical archive of feadulta.com</strong>: a read-only copy
of the old website, kept as it was. It is not updated, and search and forms do not work.</p>
<p>The address you followed does not exist here. The link may be mistyped, the page may have
been removed before this copy was made, or its content may now live on the new website.</p>
<div class="acciones">
<a class="boton principal" href="/es/">Go to the archive home</a>
<a class="boton secundario" href="https://www.feadulta.com/">Go to feadulta.com</a>
</div>
</div>
<hr>
<p class="nota">Archivo estático · Static archive — feadulta.com</p>
</div>
</body>
</html>
+42
View File
@@ -0,0 +1,42 @@
server {
listen 80;
server_name _;
root /usr/share/nginx/html;
charset utf-8;
index index.html;
add_header X-Robots-Tag "noindex, nofollow" always;
# Los alias de K2 que llevan un '?' literal se sirven desde ficheros SIN extension
# (el navegador pide la ruta hasta el '?'). Sin esto, nginx los manda como
# application/octet-stream y el navegador se los descarga en vez de mostrarlos.
default_type text/html;
# URLs históricas de catálogo: el catálogo vigente vive en Ediciones Fe Adulta.
# 302 primero: evita cachear un destino externo de forma irreversible durante el soak.
location ~ ^/(?:es/)?catalogo-de-libros-feadulta(?:/|\.html)?$ {
return 302 https://edicionesfeadulta.com/;
}
# La vista imprimible Joomla de esta página EFFA no existe en el archivo estático.
# Conserva la página normal del archivo; sólo la variante ?print=1 va al contenido WP vigente.
location = /es/effa/95-secc5cat/3304-seccion5col00.html {
if ($arg_print = 1) {
return 302 https://www.feadulta.com/seccion5col00-2/;
}
try_files $uri $uri.html $uri/index.html =404;
}
location / {
try_files $uri $uri.html $uri/index.html =404;
}
# Archivo historico: aqui no hay PHP. Cualquier .php es un fichero estatico inerte.
location ~ \.php {
default_type text/html;
try_files $uri $uri/index.html =404;
}
error_page 404 /404.html;
}
+278
View File
@@ -0,0 +1,278 @@
### Idiomas de contenido (ext_languages)
1 en-GB en -2
3 es-ES es 1
### K2 items por estado
0 0 1885
0 1 9
1 0 16269
1 1 54
### K2 items por idioma (publicados)
* 16269
### K2 categorias
2
### K2 categorias raiz (parent=0)
29 Feadulta feadulta *
30 Sin categoría sincategoria *
### com_content por estado
-2 59
0 38
1 9079
### com_content por idioma (publicados)
* 9079
### Menus
mainmenu Menú Principal
idioma Idioma
buscadores Buscadores
libros LIBROS
encuesta ENCUESTA
menuuser6 menu_user6
secciones Secciones
eucamenu eucamenu
comentmenu comentmenu
art1menu art1menu
art2menu art2menu
multimenu multimenu
resumeneslibros Resúmenes Libros
colaboradores Colaboradores
art3menu art3menu
multi2menu multi2menu
proyemenu proyemenu
sec2menu sec2menu
secc1menu secc1menu
sec3menu sec3menu
sec1-examen-menu sec1examen
sec4menu sec4menu
sec5menu sec5menu
sec2-examen1-menu sec2-examen1
contactar CONTACTAR
el-ano-de-la-biblia Año Biblia
listado A la fuente cada día (Fray Marcos)
### Items de menu publicados
1 Menu_Item_Root * 1 0
184 art1menu art1col1 art1col1 index.php?option=com_content&view=article&id=109 component * 1 0
182 art1menu art1col2 art1col2 index.php?option=com_content&view=article&id=67 component * 1 0
183 art1menu art1col3 art1col3 index.php?option=com_content&view=article&id=66 component * 1 0
357 art1menu art1col4 art1col4 index.php?option=com_content&view=article&id=2279 component * 1 0
185 art2menu art2col1 art2col1 index.php?option=com_content&view=article&id=69 component * 1 0
186 art2menu art2col2 art2col2 index.php?option=com_content&view=article&id=68 component * 1 0
221 art2menu art2col3 art2col3 index.php?option=com_content&view=article&id=125 component * 1 0
358 art2menu art2col4 art2col4 index.php?option=com_content&view=article&id=2280 component * 1 0
312 art3menu art3col1 art3col1 index.php?option=com_content&view=article&id=1449 component * 1 0
313 art3menu art3col2 art3col2 index.php?option=com_content&view=article&id=1450 component * 1 0
354 art3menu art3col3 art3col3 index.php?option=com_content&view=article&id=2131 component * 1 0
138 buscadores Buscador avanzado buscadoravanzado index.php?option=com_k2&view=itemlist&layout=category&task=category&id=29 component * 1 0
200 colaboradores Arregi arregi index.php?option=com_content&view=article&id=83 component * 1 0
555 colaboradores Inma Calvo inma index.php?option=com_content&view=article&id=5016 component * 1 0
554 colaboradores África de La Cruz africa index.php?option=com_content&view=article&id=5017 component * 1 0
201 colaboradores Pagola pagola index.php?option=com_content&view=article&id=86 component * 1 0
202 colaboradores Lozano lozano index.php?option=com_content&view=article&id=87 component * 1 0
203 colaboradores Marcos marcos index.php?option=com_content&view=article&id=85 component * 1 0
204 colaboradores Eloy eloy index.php?option=com_content&view=article&id=88 component * 1 0
205 colaboradores Dolores dolores index.php?option=com_content&view=article&id=84 component * 1 0
206 colaboradores Hojman hojman index.php?option=com_content&view=article&id=90 component * 1 0
207 colaboradores Koldo koldo index.php?option=com_content&view=article&id=92 component * 1 0
208 colaboradores Matilde Gastalver matilde-gastalver index.php?option=com_content&view=article&id=91 component * 1 0
209 colaboradores Ulibarri ulibarri index.php?option=com_content&view=article&id=93 component * 1 0
210 colaboradores Vicente vicente index.php?option=com_content&view=article&id=89 component * 1 0
211 colaboradores Rafael Calvo Beca rafael-calvo-beca index.php?option=com_content&view=article&id=94 component * 1 0
212 colaboradores Galarreta galarreta index.php?option=com_content&view=article&id=95 component * 1 0
213 colaboradores Mellado mellado index.php?option=com_content&view=article&id=96 component * 1 0
214 colaboradores Salazar salazar index.php?option=com_content&view=article&id=97 component * 1 0
243 colaboradores Mari patxi mari-patxi index.php?option=com_content&view=article&id=289 component * 1 0
245 colaboradores Mari Paz López Santos mari-paz-lopez-santos index.php?option=com_content&view=article&id=312 component * 1 0
247 colaboradores Mariangeles mariangeles index.php?option=com_content&view=article&id=341 component * 1 0
291 colaboradores Carmona carmona index.php?option=com_content&view=article&id=937 component * 1 0
292 colaboradores Rogelio rogelio index.php?option=com_content&view=article&id=938 component * 1 0
293 colaboradores Salome salome index.php?option=com_content&view=article&id=936 component * 1 0
294 colaboradores Victor blanco victor-blanco index.php?option=com_content&view=article&id=939 component * 1 0
306 colaboradores Viki viki index.php?option=com_content&view=article&id=1299 component * 1 0
311 colaboradores Lenin lenin index.php?option=com_content&view=article&id=1391 component * 1 0
315 colaboradores Vilabrille vilabrille index.php?option=com_content&view=article&id=1470 component * 1 0
319 colaboradores Luque luque index.php?option=com_content&view=article&id=1520 component * 1 0
360 colaboradores Yolanda yolanda index.php?option=com_content&view=article&id=2331 component * 1 0
369 colaboradores Gonzalo Haya gonzalo-haya index.php?option=com_content&view=article&id=2416 component * 1 0
370 colaboradores José Luis Sicre jose-luis-sicre index.php?option=com_content&view=article&id=2424 component * 1 0
371 colaboradores Andrés Torres Queiruga andres-torres-queiruga index.php?option=com_content&view=article&id=2425 component * 1 0
372 colaboradores Xabier Pikaza xabier-pikaza index.php?option=com_content&view=article&id=2426 component * 1 0
373 colaboradores Leandro Sequeiros leandro-sequeiros index.php?option=com_content&view=article&id=2423 component * 1 0
374 colaboradores José María Castillo jose-maria-castillo index.php?option=com_content&view=article&id=2427 component * 1 0
375 colaboradores Juan Antonio Estrada juan-antonio-estrada index.php?option=com_content&view=article&id=2428 component * 1 0
376 colaboradores Juan José Tamayo juan-jose-tamayo index.php?option=com_content&view=article&id=2429 component * 1 0
386 colaboradores Pope Godoy pope-godoy index.php?option=com_content&view=article&id=2666 component * 1 0
391 colaboradores anademiguel anademiguel index.php?option=com_content&view=article&id=2887 component * 1 0
392 colaboradores Suyapa Pérez Escapini suyapa-perez-escapini index.php?option=com_content&view=article&id=3103 component * 1 0
553 colaboradores Ramón Hernández Martín ramon index.php?option=com_content&view=article&id=5015 component * 1 0
174 comentmenu comentcol1 comentcol1 index.php?option=com_content&view=article&id=105 component * 1 0
179 comentmenu comentcol2 comentcol2 index.php?option=com_content&view=article&id=106 component * 1 0
180 comentmenu comentcol3 comentcol3 index.php?option=com_content&view=article&id=107 component * 1 0
181 comentmenu comentcol4 comentcol4 index.php?option=com_content&view=article&id=108 component * 1 0
537 contactar Elemento Menú Contactar elemento-menu-contactar index.php?option=com_breezingforms&view=form component * 1 0
547 contactar Para recibir carta de novedades para-recibir-carta-de-novedades index.php?option=com_breezingforms&view=form component * 1 0
572 el-ano-de-la-biblia Año de la Biblia biblia index.php?option=com_content&view=article&id=5335 component * 1 0
151 encuesta encuesta encuesta index.php?option=com_surveys&view=editsurvey component * 1 0
274 encuesta Resultados resultados index.php?option=com_surveys&view=indivsurveyresult component * 1 0
444 encuesta Ayúdanos a elegir la nueva imagen de Feadulta ayudanos-a-elegir-nueva-imagen index.php?option=com_content&view=article&id=3957 component * 1 0
450 encuesta Resultado resultado index.php?option=com_content&view=article&id=3990 component * 1 0
175 eucamenu eucacol1 eucol1 index.php?option=com_content&view=article&id=101 component * 1 0
176 eucamenu eucacol2 eucol2 index.php?option=com_content&view=article&id=102 component * 1 0
177 eucamenu eucacol3 eucol3 index.php?option=com_content&view=article&id=103 component * 1 0
178 eucamenu eucacol4 eucol4 index.php?option=com_content&view=article&id=104 component * 1 0
136 idioma Español espanol index.php?option=com_content&view=article&id=1 component * 1 0
137 idioma Ingles ingles index.php?option=com_content&view=article&id=1 component * 1 0
187 libros libroresumen1 libroresumen1 index.php?option=com_content&view=article&id=30 component * 1 0
188 libros libroresumen2 libroresumen2 index.php?option=com_content&view=article&id=31 component * 1 0
101 mainmenu PORTADA home index.php?option=com_content&view=featured component * 1 1
130 mainmenu QUIÉNES SOMOS quienessomos index.php?Itemid= alias * 1 0
131 mainmenu COLABORADORES quienessomos/colaboradores index.php?option=com_content&view=article&id=43 component * 1 0
170 mainmenu ESTE PORTAL quienessomos/portal index.php?option=com_content&view=article&id=59 component * 1 0
132 mainmenu PARA PONER AL DÍA LA FE quienessomos/poneraldialafe index.php?option=com_content&view=article&id=44 component * 1 0
134 mainmenu AYUDA ayuda index.php?option=com_content&view=article&id=45 component * 1 0
550 mainmenu ESTA SEMANA ayuda/esta-semana index.php?option=com_content&view=category&layout=blog&id=27 component * 1 0
551 mainmenu LA SEMANA PASADA ayuda/semana-pasada index.php?option=com_content&view=category&layout=blog&id=41 component * 1 0
552 mainmenu OTRAS SEMANAS ayuda/otras-semanas index.php?option=com_content&view=category&id=40 component * 1 0
240 mainmenu Acceso a web anterior Feadulta ayuda/2012-05-25-09-13-26 /anterior url * 1 0
241 mainmenu Para navegar en esta página ayuda/para-navegar-en-esta-pagina index.php?option=com_content&view=article&id=45 component * 1 0
244 mainmenu Vídeo tutorial ayuda/video-tutoriales index.php?option=com_content&view=article&id=293 component * 1 0
248 mainmenu CÓMO USAR EL BUSCADOR AVANZADO ayuda/como-usar-el-buscador-avanzado index.php?option=com_content&view=article&id=358 component * 1 0
359 mainmenu Para comprar un libro ayuda/para-comprar-un-libro index.php?option=com_content&view=article&id=2291 component * 1 0
523 mainmenu Catálogo de publicaciones 2018 ayuda/catalogo-de-publicaciones-2018 index.php?option=com_content&view=article&id=4717 component * 1 0
507 mainmenu NUEVA POLÍTICA DE DATOS ayuda/nueva-politica-de-datos index.php?option=com_content&view=article&id=4475 component * 1 0
536 mainmenu CONTACTAR contactar index.php?option=com_content&view=article&id=5266 component * 1 0
568 mainmenu Para contactar con nosotros contactar/para-contactar-con-nosotros index.php?option=com_content&view=article&id=5266 component * 1 0
567 mainmenu Para recibir la carta de novedades contactar/para-recibir-la-carta-de-novedades index.php?option=com_content&view=article&id=5265 component * 1 0
573 mainmenu Para inscribirse en la Escuela contactar/para-inscribirse-en-la-escuela index.php?option=com_content&view=article&id=5407 component * 1 0
362 mainmenu ESCUELA effa index.php?option=com_content&view=article&id=2408 component * 1 0
385 mainmenu LIBRERÍA 2015-03-30-15-34-35 https://edicionesfeadulta.com url * 1 0
569 mainmenu Buscar buscar index.php?option=com_search&view=search component * 1 0
161 menuuser6 Catálogo catalogolibros index.php?option=com_content&view=article&id=40 component * 1 0
324 multi2menu multi2col1 multi2col1 index.php?option=com_content&view=article&id=1657 component * 1 0
325 multi2menu multi2col2 multi2col2 index.php?option=com_content&view=article&id=1658 component * 1 0
326 multi2menu multi2col3 multi2col3 index.php?option=com_content&view=article&id=1659 component * 1 0
237 multimenu multicol1 multicol1 index.php?option=com_content&view=article&id=282 component * 1 0
238 multimenu multicol2 multicol2 index.php?option=com_content&view=article&id=283 component * 1 0
239 multimenu multicol3 multicol3 index.php?option=com_content&view=article&id=284 component * 1 0
363 proyemenu proyecol1 proyecol1 index.php?option=com_content&view=article&id=2410 component * 1 0
364 proyemenu proyecol2 proyecol2 index.php?option=com_content&view=article&id=2412 component * 1 0
365 proyemenu proyecol3 proyecol3 index.php?option=com_content&view=article&id=2411 component * 1 0
380 proyemenu proyecolumna4 proyecolumna4 index.php?option=com_content&view=article&id=2430 component * 1 0
190 resumeneslibros Resumen libro 1 resumenlibro1 index.php?option=com_content&view=article&id=30 component * 1 0
191 resumeneslibros Resumen libro 2 resumenlibro2 index.php?option=com_content&view=article&id=74 component * 1 0
192 resumeneslibros Resumen libro 10 resumenlibro10 index.php?option=com_content&view=article&id=82 component * 1 0
193 resumeneslibros Resumen libro 3 resumenlibro3 index.php?option=com_content&view=article&id=75 component * 1 0
194 resumeneslibros Resumen libro 4 resumenlibro4 index.php?option=com_content&view=article&id=76 component * 1 0
195 resumeneslibros Resumen libro 5 resumenlibro5 index.php?option=com_content&view=article&id=77 component * 1 0
196 resumeneslibros Resumen libro 6 resumenlibro6 index.php?option=com_content&view=article&id=78 component * 1 0
197 resumeneslibros Resumen libro 7 resumenlibro7 index.php?option=com_content&view=article&id=79 component * 1 0
198 resumeneslibros Resumen libro 8 resumenlibro8 index.php?option=com_content&view=article&id=80 component * 1 0
199 resumeneslibros Resumen libro 9 resumenlibro9 index.php?option=com_content&view=article&id=81 component * 1 0
215 resumeneslibros Resumen libro 11 resumenlibro11 index.php?option=com_content&view=article&id=99 component * 1 0
219 resumeneslibros Resumen libro 12 resumenlibro12 index.php?option=com_content&view=article&id=111 component * 1 0
249 resumeneslibros Resumen libro 13 resumen-libro-13 index.php?option=com_content&view=article&id=363 component * 1 0
251 resumeneslibros Resumen libro 14 libroresumen14 index.php?option=com_content&view=article&id=376 component * 1 0
275 resumeneslibros Resumen libro 15 resumen-libro-15 index.php?option=com_content&view=article&id=849 component * 1 0
277 resumeneslibros Resumen libro 16 resumen-libro-16 index.php?option=com_content&view=article&id=863 component * 1 0
295 resumeneslibros Resumen libro 17 resumen-libro-17 index.php?option=com_content&view=article&id=978 component * 1 0
301 resumeneslibros Resumen libro 18 resumen-libro-18 index.php?option=com_content&view=article&id=1098 component * 1 0
304 resumeneslibros Resumen libro 19 resumenlibro19 index.php?option=com_content&view=article&id=1197 component * 1 0
318 resumeneslibros Resumen libro 20 resumen-libro-20 index.php?option=com_content&view=article&id=1501 component * 1 0
321 resumeneslibros Resumen libro 21 resumen-libro-21 index.php?option=com_content&view=article&id=1611 component * 1 0
333 resumeneslibros Resumen libro 23 resumen-libro-23 index.php?option=com_content&view=article&id=1818 component * 1 0
334 resumeneslibros Resumen libro 24 resumen-libro-24 index.php?option=com_content&view=article&id=1862 component * 1 0
335 resumeneslibros Resumen libro 25 resumen-libro-25 index.php?option=com_content&view=article&id=1911 component * 1 0
350 resumeneslibros Resumen libro 26 resumen-libro-26 index.php?option=com_content&view=article&id=2019 component * 1 0
356 resumeneslibros Resumen libro 27 resumen-libro-27 index.php?option=com_content&view=article&id=2228 component * 1 0
384 resumeneslibros Resumen libro 30 resumen-libro-30 index.php?option=com_content&view=article&id=2514 component * 1 0
388 sec1-examen-menu Examen de Espiritualidad examen-de-espiritualidad index.php?option=com_surveys&view=editsurvey component * 1 0
394 sec2-examen1-menu Examen de Hermenéutica y Antiguo Testamento examen-de-hermeneutica-y-antiguo-testamento index.php?option=com_surveys&view=editsurvey component * 1 0
381 secc1menu secc1col1 secc1col1 index.php?option=com_content&view=article&id=2432 component * 1 0
382 secc1menu secc1col2 secc1col2 index.php?option=com_content&view=article&id=2433 component * 1 0
272 secciones Iniciación cristiana iniciacion-cristiana index.php?option=com_content&view=article&id=2222 component * 1 0
556 secciones Programa de las V Jornadas EFFA programa-de-las-v-jornadas-effa index.php?option=com_content&view=article&id=2222 component * 1 0
163 secciones El rincón del Viajero rinconviajero index.php?option=com_content&view=article&id=55 component * 1 0
164 secciones Último libro publicado ultimolibro index.php?option=com_content&view=article&id=56 component * 1 0
165 secciones Cartas que nos llegan cartas index.php?option=com_content&view=category&layout=blog&id=42 component * 1 0
189 secciones Catálogo de libros Feadulta catalogo-de-libros-feadulta index.php?option=com_content&view=article&id=40 component * 1 0
216 secciones Compras en España compras-en-espana index.php?option=com_content&view=article&id=25 component * 1 0
217 secciones Compras NO España compras-no-espana index.php?option=com_content&view=article&id=26 component * 1 0
218 secciones Prefacio 12 prefacio-12 index.php?option=com_content&view=article&id=110 component * 1 0
220 secciones Portales y revistas portales-y-revistas index.php?option=com_content&view=article&id=116 component * 1 0
223 secciones Canciones-plegarias canciones-plegarias index.php?option=com_content&view=category&id=44 component * 1 0
222 secciones Cantos de entrada cantos-de-entrada index.php?option=com_content&view=category&id=43 component * 1 0
224 secciones Cantos acción de gracias cantos-accion-de-gracias index.php?option=com_content&view=category&id=45 component * 1 0
225 secciones Otros cantos otros-cantos index.php?option=com_content&view=categories&id=46 component * 1 0
231 secciones Tablón de anuncios tablon-de-anuncios index.php?option=com_content&view=category&layout=blog&id=52 component * 1 0
232 secciones Blogs blogs index.php?option=com_content&view=article&id=157 component * 1 0
233 secciones ONGs ongs index.php?option=com_content&view=article&id=158 component * 1 0
234 secciones Otras comunidades otras-comunidades index.php?option=com_content&view=article&id=159 component * 1 0
235 secciones Registro personal registro-personal index.php?option=com_content&view=category&layout=blog&id=74 component * 1 0
236 secciones Comunidades comunidades index.php?option=com_content&view=article&id=160 component * 1 0
242 secciones Índice multimedia indice-multimedia index.php?option=com_content&view=category&id=54 component * 1 0
246 secciones CRISTIANISMO, MERCADO Y MOVIMIENTOS SOCIALES cristianismo-mercado-y-movimientos-sociales index.php?option=com_content&view=article&id=332 component * 1 0
250 secciones Índice cronológico indice-cronologico index.php?option=com_content&view=category&id=55 component * 1 0
252 secciones Evangelios y comentarios evangelios-y-comentarios index.php?option=com_content&view=category&id=56 component * 1 0
253 secciones Enlaces enlaces index.php?option=com_content&view=category&id=57 component * 1 0
254 secciones Cantoral cantoral index.php?option=com_content&view=categories&id=58 component * 1 0
255 secciones PELÍCULAS peliculas index.php?option=com_content&view=article&id=417 component * 1 0
256 secciones RESEÑAS DE LIBROS resenas-de-libros index.php?option=com_content&view=article&id=418 component * 1 0
257 secciones Pensamientos pensamientos index.php?option=com_content&view=article&id=419 component * 1 0
258 secciones ÍNDICE MULTIMEDIA WEB ANTERIOR indice-multimedia-web-anterior index.php?option=com_content&view=article&id=420 component * 1 0
259 secciones PAUSAS E INSTANTES pausas-e-instantes index.php?option=com_content&view=article&id=421 component * 1 0
260 secciones ORACIONES EUCARÍSTICAS oraciones-eucaristicas index.php?option=com_content&view=article&id=422 component * 1 0
261 secciones A MODO DE SALMO a-modo-de-salmos index.php?option=com_content&view=article&id=423 component * 1 0
262 secciones AUTORES autores index.php?option=com_content&view=categories&id=59 component * 1 0
263 secciones TEMAS temas index.php?option=com_content&view=category&id=60 component * 1 0
264 secciones Nuestros archivos nuestros-archivos index.php?option=com_content&view=article&id=439 component * 1 0
265 secciones PRECES Y ORACIONES VARIAS preces-y-oraciones-varias index.php?option=com_content&view=article&id=471 component * 1 0
266 secciones Donaciones donaciones index.php?option=com_content&view=article&id=494 component * 1 0
270 secciones Lista de autores habituales lista-de-autores-habituales index.php?option=com_content&view=category&id=62 component * 1 0
268 secciones Lista completa de autores por orden alfabético lista-completa-de-autores-por-orden-alfabetico index.php?option=com_content&view=category&id=61 component * 1 0
271 secciones La suma de todos la-suma-de-todos index.php?option=com_content&view=article&id=547 component * 1 0
276 secciones PLANUAL feadulta.com 2012-2013 planual-feadultacom-2012-2013 index.php?option=com_content&view=article&id=857 component * 1 0
278 secciones AMIGOS DE FEADULTA amigos-de-feadulta index.php?option=com_content&view=article&id=876 component * 1 0
296 secciones NOTICIAS DE ALCANCE noticias-de-alcance index.php?option=com_content&view=category&layout=blog&id=64 component * 1 0
297 secciones Cantoral de SALOMÉ ARRICIBITA cantoral-de-salome-arricibita index.php?option=com_content&view=category&id=65 component * 1 0
298 secciones Ambientación musical para BODAS ambientacion-musical-para-bodas index.php?option=com_content&view=category&id=66 component * 1 0
299 secciones Cantoral para COMUNIDADES cantoral-para-comunidades index.php?option=com_content&view=article&id=1088 component * 1 0
300 secciones OTRAS canciones otras-canciones index.php?option=com_content&view=article&id=1089 component * 1 0
226 secciones Himnos de gloria himnos-de-gloria index.php?option=com_content&view=category&id=47 component * 1 0
227 secciones Marianos marianos index.php?option=com_content&view=category&id=48 component * 1 0
228 secciones Adviento y Navidad adviento-y-navidad index.php?option=com_content&view=category&id=49 component * 1 0
229 secciones Otros cantos otroscantos index.php?option=com_content&view=category&id=50 component * 1 0
230 secciones Canciones populares canciones-populares index.php?option=com_content&view=category&id=51 component * 1 0
302 secciones Autores lista autores-lista index.php?option=com_content&view=article&id=1151 component * 1 0
303 secciones Indice cantoral indice-cantoral index.php?option=com_content&view=article&id=1152 component * 1 0
305 secciones Temas temassubtemas index.php?option=com_content&view=article&id=1213 component * 1 0
307 secciones Feadulta en Facebook feadulta-en-facebook index.php?option=com_content&view=category&layout=blog&id=75 component * 1 0
308 secciones PRIMERA JORNADA 'FEADULTA' primera-jornada-feadulta index.php?option=com_content&view=article&id=1373 component * 1 0
314 secciones 1jornada_feadulta 1jornadafeadulta index.php?option=com_content&view=article&id=1459 component * 1 0
316 secciones PLANUAL feadulta.com 2013-2014 planual-feadultacom-2013-2014 index.php?option=com_content&view=article&id=1492 component * 1 0
317 secciones Multimedia multimedia index.php?option=com_content&view=article&id=1493 component * 1 0
320 secciones Videos videos index.php?option=com_content&view=category&id=77 component * 1 0
322 secciones Libros y e-books libros-y-e-books index.php?option=com_content&view=article&id=1625 component * 1 0
323 secciones In memoriam in-memoriam index.php?option=com_content&view=article&id=1629 component * 1 0
329 secciones Reflexiones reflexiones index.php?option=com_content&view=article&id=1690 component * 1 0
330 secciones ENTREVISTA A JOSÉ MARÍA CASTILLO entrevista-a-jose-maria-castillo index.php?option=com_content&view=article&id=1694 component * 1 0
331 secciones PREGUNTA APREMIANTE: ¿RELIGIÓN O EVANGELIO? pregunta-apremiante-religion-o-evangelio index.php?option=com_content&view=article&id=1707 component * 1 0
332 secciones MÁS ALLÁ DE MÍ... mas-alla-de-mi index.php?option=com_content&view=article&id=1791 component * 1 0
351 secciones Cancioneros diversos cancioneros-diversos index.php?option=com_content&view=article&id=2090 component * 1 0
352 secciones Anáfora anafora index.php?option=com_content&view=category&id=80 component * 1 0
353 secciones Comunidades cristianas comunidades-cristianas index.php?option=com_content&view=category&layout=blog&id=81 component * 1 0
355 secciones Condolencias Conchita condolencias-conchita index.php?option=com_content&view=category&layout=blog&id=82 component * 1 0
361 secciones Jornadas con José Antonio Pagola jornadas-con-jose-antonio-pagola index.php?option=com_content&view=article&id=2330 component * 1 0
102 secciones CARTA DE NOVEDADES carta index.php?Itemid= alias * 1 0
127 secciones ESTA SEMANA carta/estasemana index.php?option=com_content&view=category&layout=blog&id=27 component * 1 0
128 secciones LA SEMANA PASADA carta/semanapasada index.php?option=com_content&view=category&layout=blog&id=41 component * 1 0
129 secciones OTRAS SEMANAS carta/otrassemanas index.php?option=com_content&view=category&id=40 component * 1 0
383 secciones LA INTELIGENCIA ESPIRITUAL la-inteligencia-espiritual index.php?option=com_content&view=article&id=2438 component * 1 0
587 secciones Evangelio diario evangelio-diario index.php?option=com_content&view=article&id=6269 component * 1 0
617 secciones A la fuente cada día alafuente index.php?option=com_content&view=category&id=97 component * 1 0
618 secciones Otro evangelio es posible otroevangelio index.php?option=com_content&view=category&id=98 component * 1 0
+16
View File
@@ -0,0 +1,16 @@
#!/bin/bash
# Sondeo del entorno restaurado
set -u
docker update --restart unless-stopped joomla-mirror-web >/dev/null
docker start joomla-mirror-web >/dev/null 2>&1
sleep 3
echo "=== version.php ==="
docker exec joomla-mirror-web grep -E "RELEASE|DEV_LEVEL|PRODUCT" /var/www/html/libraries/cms/version/version.php | head -6
echo "=== sef en configuration.php ==="
docker exec joomla-mirror-web grep -E 'sef|live_site|offline|dbprefix' /var/www/html/configuration.php
echo "=== componentes ==="
docker exec joomla-mirror-web ls /var/www/html/components/ | tr '\n' ' '
echo
echo "=== plugins system (sef/redirect) ==="
docker exec joomla-mirror-web ls /var/www/html/plugins/system/ | tr '\n' ' '
echo
@@ -0,0 +1,8 @@
#!/bin/bash
set -u
echo "=== estado ==="
docker inspect joomla-mirror-web --format 'Status={{.State.Status}} Exit={{.State.ExitCode}} OOM={{.State.OOMKilled}} Started={{.State.StartedAt}} Finished={{.State.FinishedAt}} RestartPolicy={{.HostConfig.RestartPolicy.Name}} Mem={{.HostConfig.Memory}}'
echo "=== ultimas lineas del log (sin access log) ==="
docker logs --tail 200 joomla-mirror-web 2>&1 | grep -v 'GET /' | tail -20
echo "=== docker events ultimos 30 min ==="
docker events --since 30m --until 0s --filter container=joomla-mirror-web --format '{{.Time}} {{.Action}}' 2>/dev/null | tail -20
+20
View File
@@ -0,0 +1,20 @@
#!/bin/bash
set -u
echo "=== crontab rafa ==="
crontab -l 2>/dev/null | grep -iE 'docker|joomla|mirror' || echo "(nada relevante)"
echo "=== crontab root ==="
sudo -n crontab -l 2>/dev/null | grep -iE 'docker|joomla|mirror' || echo "(no accesible o nada)"
echo "=== /etc/cron.d ==="
grep -rliE 'docker (stop|kill)|joomla' /etc/cron.d /etc/cron.daily 2>/dev/null || echo "(nada)"
echo "=== procesos sospechosos ==="
ps -eo pid,etimes,cmd | grep -iE 'docker stop|joomla|mirror|watch |while ' | grep -v grep || echo "(ninguno)"
echo "=== docker context/info ==="
docker version --format 'Server={{.Server.Version}} OS={{.Server.Os}}' 2>/dev/null
echo "=== eventos: arranco el contenedor y escucho 60s ==="
timeout 65 docker events --filter container=joomla-mirror-web --format '{{.Time}} {{.Action}} from={{index .Actor.Attributes "execID"}}' &
EVPID=$!
sleep 1
docker start joomla-mirror-web >/dev/null
wait $EVPID
echo "=== estado final ==="
docker inspect joomla-mirror-web --format 'Status={{.State.Status}} Exit={{.State.ExitCode}}'
+18
View File
@@ -0,0 +1,18 @@
#!/bin/bash
set -u
H='Host: antiguo.feadulta.com'
for u in \
"/" \
"/es/" \
"/carta/estasemana.html" \
"/es/carta/estasemana.html" \
"/buscadoravanzado/item/9-experiencia-pascual.html" \
"/es/buscadoravanzado/item/9-experiencia-pascual.html" \
"/20-sincategoria/10-domingo.html" \
"/es/20-sincategoria/10-domingo.html" \
; do
code=$(curl -s -o /dev/null -w '%{http_code}' -H "$H" "http://127.0.0.1:8086$u")
loc=$(curl -s -o /dev/null -w '%{redirect_url}' -H "$H" "http://127.0.0.1:8086$u")
size=$(curl -s -o /dev/null -w '%{size_download}' -H "$H" "http://127.0.0.1:8086$u")
printf '%-60s %s %8s %s\n' "$u" "$code" "$size" "$loc"
done
@@ -0,0 +1,23 @@
#!/bin/bash
# Fase 1 - Inventario de URLs generado por el propio router de Joomla (BD local joomla_mirror)
set -euo pipefail
BASE=/home/rafa/joomla-migration/mirror-antiguo
INV=$BASE/inventory
mkdir -p "$INV"
H='Host: antiguo.feadulta.com'
U='http://127.0.0.1:8086/_genurls.php'
for s in menu catcontent content k2; do
echo "-> $s"
curl -s --max-time 900 -H "$H" "$U?set=$s" > "$INV/urls-$s.txt"
wc -l < "$INV/urls-$s.txt"
done
cat "$INV"/urls-menu.txt "$INV"/urls-catcontent.txt "$INV"/urls-content.txt "$INV"/urls-k2.txt \
| grep -E '^http://antiguo\.feadulta\.com/' \
| sort -u > "$INV/urls-input.txt"
echo "=== TOTAL unico ==="
wc -l < "$INV/urls-input.txt"
echo "=== reparto por prefijo ==="
sed 's#^http://antiguo.feadulta.com/es/##' "$INV/urls-input.txt" | cut -d/ -f1 | sort | uniq -c | sort -rn | head -30
@@ -0,0 +1,27 @@
#!/bin/bash
# Valida una muestra aleatoria del inventario contra el Joomla local y mide tiempos
set -uo pipefail
BASE=/home/rafa/joomla-migration/mirror-antiguo
INV=$BASE/inventory
N=${1:-40}
H='Host: antiguo.feadulta.com'
tmp=$(mktemp)
shuf -n "$N" "$INV/urls-input.txt" > "$tmp"
ok=0; bad=0; tot=0
start=$(date +%s.%N)
while read -r u; do
path=${u#http://antiguo.feadulta.com}
read -r code t size < <(curl -s -o /dev/null -w '%{http_code} %{time_total} %{size_download}' -H "$H" "http://127.0.0.1:8086$path"; echo)
tot=$(echo "$tot + $t" | bc)
if [ "$code" = "200" ]; then ok=$((ok+1)); else bad=$((bad+1)); printf 'FALLO %s %s %s\n' "$code" "$t" "$path"; fi
done < "$tmp"
end=$(date +%s.%N)
echo "---"
echo "muestra=$N 200=$ok no200=$bad"
echo "tiempo medio por peticion: $(echo "scale=3; $tot / $N" | bc) s"
echo "wall: $(echo "scale=1; $end - $start" | bc) s"
echo "estimacion 25437 URLs a 1 hilo: $(echo "scale=1; $tot / $N * 25437 / 3600" | bc) h"
rm -f "$tmp"
+8
View File
@@ -0,0 +1,8 @@
#!/bin/bash
set -uo pipefail
IP=$(docker inspect joomla-mirror-web -f '{{range $k,$v := .NetworkSettings.Networks}}{{$v.IPAddress}}{{end}}')
echo "IP contenedor: $IP"
echo -n "acceso directo host->contenedor:80 "
curl -s -o /dev/null -w '%{http_code}\n' -H 'Host: antiguo.feadulta.com' "http://$IP/es/"
echo -n "wget nativo: "; which wget && wget --version | head -1
grep -q 'antiguo.feadulta.com' /etc/hosts && echo "hosts: ya presente" || echo "hosts: falta entrada"
+28
View File
@@ -0,0 +1,28 @@
#!/bin/bash
# Lote de prueba de 200 URLs para validar la invocacion de wget y el arbol resultante
set -uo pipefail
BASE=/home/rafa/joomla-migration/mirror-antiguo
SM=$BASE/smoke
rm -rf "$SM"; mkdir -p "$SM/raw"
shuf -n 200 "$BASE/inventory/urls-input.txt" > "$SM/urls.txt"
# aseguramos la portada y una carta
echo 'http://antiguo.feadulta.com/es/' >> "$SM/urls.txt"
t0=$(date +%s)
wget --input-file="$SM/urls.txt" \
--force-directories --directory-prefix="$SM/raw" \
--adjust-extension --no-verbose -e robots=off \
--user-agent='feadulta-archiver/1.0 (+incident-183; mirror local)' \
--wait=0.15 --tries=3 --timeout=45 --waitretry=5 \
--reject-regex='(\?|&)(start|limitstart|limit|print|tmpl|format|searchword|task|orderby|filter|catid|month|year)=' \
--output-file="$SM/wget.log"
t1=$(date +%s)
echo "=== tiempo: $((t1-t0))s para $(wc -l < "$SM/urls.txt") URLs ==="
echo "=== ficheros ==="; find "$SM/raw" -type f | wc -l
echo "=== arbol (muestra) ==="; find "$SM/raw" -type f | head -8
echo "=== errores en el log ==="; grep -icE 'error|failed' "$SM/wget.log" || true
grep -iE 'error|failed' "$SM/wget.log" | head -10
echo "=== tamano ==="; du -sh "$SM/raw"
echo "=== 500 en apache durante el lote ==="
docker logs --since "${t0}" joomla-mirror-web 2>&1 | grep -c '" 500 ' || true
+15
View File
@@ -0,0 +1,15 @@
#!/bin/bash
set -uo pipefail
SM=/home/rafa/joomla-migration/mirror-antiguo/smoke/raw
echo "=== titulos capturados (10) ==="
find "$SM" -name '*.html' | head -10 | while read -r f; do
t=$(grep -o '<title>[^<]*</title>' "$f" | head -1 | sed 's/<[^>]*>//g')
printf '%-70s %s\n' "$(basename "$f")" "$t"
done
echo
echo "=== paginas sospechosas (challenge/error) ==="
grep -rli 'Attention Required\|Just a moment\|Not Acceptable\|mod_security\|Error 500' "$SM" | wc -l
echo "=== ficheros con PHP embebido ==="
grep -rl '<?php' "$SM" | wc -l
echo "=== tamano minimo de fichero (posibles paginas vacias) ==="
find "$SM" -type f -printf '%s %p\n' | sort -n | head -5
+49
View File
@@ -0,0 +1,49 @@
#!/bin/bash
# Preparacion del crawl: hosts, liberacion de RAM, estructura de la corrida
set -uo pipefail
BASE=/home/rafa/joomla-migration/mirror-antiguo
INV=$BASE/inventory
WORKERS=${WORKERS:-4}
IP=$(docker inspect joomla-mirror-web -f '{{range $k,$v := .NetworkSettings.Networks}}{{$v.IPAddress}}{{end}}')
if ! grep -q 'antiguo.feadulta.com' /etc/hosts; then
echo "$IP antiguo.feadulta.com" | sudo -n tee -a /etc/hosts >/dev/null 2>&1 \
|| echo "AVISO: no pude escribir /etc/hosts (hazlo como root)"
fi
grep 'antiguo.feadulta.com' /etc/hosts || true
# --- parar contenedores no implicados (autorizado por Rafa) ---
KEEP='joomla-mirror-web|joomla-mysql|gitea|hub-proxy|beszel-agent'
STOPPED=$BASE/stopped-containers.txt
if [ ! -s "$STOPPED" ]; then
docker ps --format '{{.Names}}' | grep -vE "^($KEEP)$" > "$STOPPED"
echo "--- parando ---"; cat "$STOPPED"
xargs -r -a "$STOPPED" docker stop >/dev/null
fi
echo "--- en marcha ahora ---"
docker ps --format '{{.Names}}' | tr '\n' ' '; echo
# --- estructura de la corrida ---
RUN=$(date -u +%Y%m%dT%H%M%SZ)
DIR=$BASE/runs/$RUN
mkdir -p "$DIR/raw" "$DIR/chunks" "$DIR/logs"
echo "$RUN" > "$BASE/CURRENT_RUN"
split -n l/$WORKERS -d --additional-suffix=.txt "$INV/urls-input.txt" "$DIR/chunks/urls-"
wc -l "$DIR/chunks"/*.txt
cat > "$DIR/meta.json" <<EOF
{
"run": "$RUN",
"origen": "Joomla legacy restaurado en local (contenedor joomla-mirror-web, BD joomla_mirror)",
"origen_ip": "$IP",
"host_virtual": "antiguo.feadulta.com",
"inventario": "inventory/urls-input.txt",
"urls_inventario": $(wc -l < "$INV/urls-input.txt"),
"workers": $WORKERS,
"wget": "$(wget --version | head -1)",
"nota": "Crawl acotado por inventario de BD. Sin recursion. Ver issue rafa/feadulta#180 comment-499."
}
EOF
cat "$DIR/meta.json"
free -g | head -2
+34
View File
@@ -0,0 +1,34 @@
#!/bin/bash
# Pase A: captura de las paginas HTML del inventario. Sin recursion, sin page-requisites.
set -uo pipefail
BASE=/home/rafa/joomla-migration/mirror-antiguo
RUN=$(cat "$BASE/CURRENT_RUN")
DIR=$BASE/runs/$RUN
cd "$DIR"
UA='feadulta-archiver/1.0 (+incident-183; mirror local)'
REJECT='(\?|&)(start|limitstart|limit|print|tmpl|format|searchword|task|orderby|filter|catid|month|year)='
date -u +%Y-%m-%dT%H:%M:%SZ > "$DIR/logs/passA.start"
for c in chunks/urls-*.txt; do
n=$(basename "$c" .txt)
wget \
--input-file="$c" \
--force-directories --directory-prefix="$DIR/raw" \
--adjust-extension \
--no-verbose \
-e robots=off \
--user-agent="$UA" \
--wait=0.15 --tries=3 --timeout=45 --waitretry=5 \
--reject-regex="$REJECT" \
--no-check-certificate \
--output-file="$DIR/logs/wget-$n.log" &
echo "$!" >> "$DIR/logs/passA.pids"
done
wait
date -u +%Y-%m-%dT%H:%M:%SZ > "$DIR/logs/passA.end"
echo "PASE A TERMINADO"
find "$DIR/raw" -type f | wc -l
du -sh "$DIR/raw"
+40
View File
@@ -0,0 +1,40 @@
#!/bin/bash
# Vigilante del crawl: ficheros, 500 del servidor, RAM. Aborta si el servidor se degrada.
set -uo pipefail
BASE=/home/rafa/joomla-migration/mirror-antiguo
RUN=$(cat "$BASE/CURRENT_RUN")
DIR=$BASE/runs/$RUN
ST=$DIR/logs/monitor.log
MAX500=${MAX500:-100}
MINMEM_MB=${MINMEM_MB:-800}
T0=$(date +%s)
echo "monitor arrancado $(date -u +%FT%TZ) run=$RUN max500=$MAX500" > "$ST"
while true; do
sleep 60
pgrep -f 'wget --input-file=' >/dev/null || { echo "$(date -u +%FT%TZ) crawl terminado, monitor sale" >> "$ST"; break; }
files=$(find "$DIR/raw" -type f 2>/dev/null | wc -l)
size=$(du -sm "$DIR/raw" 2>/dev/null | cut -f1)
e500=$(docker logs --since "$T0" joomla-mirror-web 2>&1 | grep -c '" 500 ')
e408=$(docker logs --since "$T0" joomla-mirror-web 2>&1 | grep -c '" 40[38] ')
mem=$(free -m | awk '/^Mem:/{print $7}')
cmem=$(docker stats --no-stream --format '{{.MemUsage}}' joomla-mirror-web 2>/dev/null)
el=$(( $(date +%s) - T0 ))
pct=$(awk -v f="$files" 'BEGIN{printf "%.1f", f*100/25437}')
printf '%s t=%ss ficheros=%s (%s%%) %sMB 500=%s 40x=%s ram_libre=%sMB cont=%s\n' \
"$(date -u +%FT%TZ)" "$el" "$files" "$pct" "$size" "$e500" "$e408" "$mem" "$cmem" >> "$ST"
if [ "$e500" -gt "$MAX500" ]; then
echo "!!! ABORTO: $e500 respuestas 500 (umbral $MAX500)" >> "$ST"
pkill -f 'wget --input-file='
break
fi
if [ "$mem" -lt "$MINMEM_MB" ]; then
echo "!!! ABORTO: RAM libre ${mem}MB por debajo de ${MINMEM_MB}MB" >> "$ST"
pkill -f 'wget --input-file='
break
fi
done
+14
View File
@@ -0,0 +1,14 @@
#!/bin/bash
set -uo pipefail
BASE=/home/rafa/joomla-migration/mirror-antiguo
RUN=$(cat "$BASE/CURRENT_RUN")
DIR=$BASE/runs/$RUN
echo "ahora: $(date -u +%FT%TZ)"
echo "wget vivos: $(pgrep -cf 'wget --input-file=')"
echo "monitor vivo: $(pgrep -cf '22-monitor.sh')"
echo "ficheros: $(find "$DIR/raw" -type f 2>/dev/null | wc -l)"
du -sh "$DIR/raw" 2>/dev/null
echo "--- monitor.log ---"; tail -6 "$DIR/logs/monitor.log"
echo "--- ultimo log de wget activo ---"
ls -t "$DIR/logs"/wget-*.log | head -1 | xargs tail -2
free -m | head -2
@@ -0,0 +1,15 @@
#!/bin/bash
set -uo pipefail
BASE=/home/rafa/joomla-migration/mirror-antiguo
RUN=$(cat "$BASE/CURRENT_RUN")
DIR=$BASE/runs/$RUN
echo "=== inicio/fin ==="; cat "$DIR/logs/passA.start" "$DIR/logs/passA.end"
echo "=== codigos de error de wget ==="
grep -hoE 'ERROR [0-9]+[^.]*' "$DIR/logs"/wget-urls-*.log | sort | uniq -c
echo "=== lineas de fallo ==="
grep -hE 'ERROR [0-9]|unable to resolve|Giving up|failed:' "$DIR/logs"/wget-urls-*.log | head -20
echo "=== descargadas segun log ==="
grep -hc '^2026' "$DIR/logs"/wget-urls-*.log | paste -sd+ | bc
echo "=== 500/40x en apache durante el pase A ==="
docker logs --since 2026-07-29T22:47:00Z joomla-mirror-web 2>&1 | grep -c '" 500 '
docker logs --since 2026-07-29T22:47:00Z joomla-mirror-web 2>&1 | grep -c '" 40[0-9] '
@@ -0,0 +1,16 @@
#!/bin/bash
set -uo pipefail
cd /home/rafa/joomla-migration/mirror-antiguo/restore/web
for d in anterior ediciones music sport docs images media templates; do
if [ -d "$d" ]; then
printf '%-12s ficheros=%-8s php=%-6s html=%-7s %s\n' "$d" \
"$(find "$d" -type f | wc -l)" \
"$(find "$d" -iname '*.php' | wc -l)" \
"$(find "$d" -iname '*.htm*' | wc -l)" \
"$(du -sh "$d" | cut -f1)"
else
printf '%-12s (no existe)\n' "$d"
fi
done
echo "--- indice de /anterior ---"
ls anterior 2>/dev/null | head -20
@@ -0,0 +1,11 @@
#!/bin/bash
set -uo pipefail
H='Host: antiguo.feadulta.com'
for u in /anterior /anterior/ /anterior/index.html /anterior/index.htm /docs/ /music/; do
printf '%-26s ' "$u"
curl -s -o /dev/null -w 'code=%{http_code} size=%{size_download} loc=%{redirect_url}\n' -H "$H" "http://127.0.0.1:8086$u"
done
echo "--- ficheros indice en /anterior ---"
ls /home/rafa/joomla-migration/mirror-antiguo/restore/web/anterior/ | grep -iE '^(index|default|home)\.' | head
echo "--- php dentro de /anterior ---"
find /home/rafa/joomla-migration/mirror-antiguo/restore/web/anterior -iname '*.php'
@@ -0,0 +1,94 @@
#!/usr/bin/env python3
"""Pase B (1/2): extrae de las paginas capturadas los enlaces internos.
Salidas en el directorio de la corrida:
assets-input.txt URLs internas a recursos NO html (css, js, img, pdf, mp3, doc...)
missing-pages.txt paginas .html internas enlazadas que NO estan en el inventario
external-hosts.txt hosts externos referenciados, con recuento
"""
import os, re, sys, html
from collections import Counter
from urllib.parse import urljoin, urlsplit, urlunsplit
BASE = "/home/rafa/joomla-migration/mirror-antiguo"
RUN = open(os.path.join(BASE, "CURRENT_RUN")).read().strip()
DIR = os.path.join(BASE, "runs", RUN)
RAW = os.path.join(DIR, "raw", "antiguo.feadulta.com")
HOST = "antiguo.feadulta.com"
ATTR = re.compile(rb'(?:href|src|data-src|poster)\s*=\s*["\']([^"\'>]+)["\']', re.I)
CSSURL = re.compile(rb'url\(\s*["\']?([^"\')]+)["\']?\s*\)', re.I)
SKIP_SCHEMES = ("mailto:", "javascript:", "tel:", "data:", "#", "skype:", "whatsapp:")
inventory = set()
for line in open(os.path.join(BASE, "inventory", "urls-input.txt")):
inventory.add(line.strip())
assets, pages, ext = set(), set(), Counter()
nfiles = 0
def norm(u, page_url):
u = html.unescape(u.strip())
if not u or u.startswith(SKIP_SCHEMES):
return None
absu = urljoin(page_url, u)
p = urlsplit(absu)
if p.scheme not in ("http", "https"):
return None
if p.netloc.split(":")[0] != HOST:
ext[p.netloc] += 1
return None
# sin fragmento; conservamos query (rara en assets)
return urlunsplit(("http", HOST, p.path, p.query, ""))
for root, _dirs, files in os.walk(RAW):
for fn in files:
path = os.path.join(root, fn)
rel = os.path.relpath(path, RAW)
page_url = "http://%s/%s" % (HOST, rel.replace(os.sep, "/"))
if not fn.lower().endswith((".html", ".htm")):
continue
nfiles += 1
try:
data = open(path, "rb").read()
except OSError:
continue
found = ATTR.findall(data) + CSSURL.findall(data)
for raw in found:
try:
u = norm(raw.decode("utf-8", "replace"), page_url)
except ValueError:
continue
if not u:
continue
tail = urlsplit(u).path.lower()
if tail.endswith((".html", ".htm")) or tail.endswith("/"):
if u not in inventory:
pages.add(u)
else:
assets.add(u)
def dump(name, it):
p = os.path.join(DIR, name)
with open(p, "w") as f:
for x in sorted(it):
f.write(x + "\n")
return p, len(it)
print("paginas HTML analizadas:", nfiles)
for n, c in (dump("assets-input.txt", assets), dump("missing-pages.txt", pages)):
print(n, c)
with open(os.path.join(DIR, "external-hosts.txt"), "w") as f:
for h, c in ext.most_common():
f.write("%7d %s\n" % (c, h))
print("hosts externos distintos:", len(ext))
# reparto de assets por extension
c = Counter()
for u in assets:
e = os.path.splitext(urlsplit(u).path)[1].lower() or "(sin ext)"
c[e] += 1
print("--- assets por extension ---")
for e, n in c.most_common(25):
print("%7d %s" % (n, e))
+30
View File
@@ -0,0 +1,30 @@
#!/bin/bash
# Pase B (2/2): descarga los recursos (css/js/img/pdf/mp3...) referenciados por las paginas
set -uo pipefail
BASE=/home/rafa/joomla-migration/mirror-antiguo
RUN=$(cat "$BASE/CURRENT_RUN")
DIR=$BASE/runs/$RUN
W=${WORKERS:-3}
[ -s "$DIR/assets-input.txt" ] || { echo "no hay assets-input.txt"; exit 1; }
mkdir -p "$DIR/chunks-assets" "$DIR/logs"
rm -f "$DIR/chunks-assets"/*.txt
split -n l/$W -d --additional-suffix=.txt "$DIR/assets-input.txt" "$DIR/chunks-assets/a-"
date -u +%FT%TZ > "$DIR/logs/passB.start"
for c in "$DIR/chunks-assets"/a-*.txt; do
n=$(basename "$c" .txt)
wget --input-file="$c" \
--force-directories --directory-prefix="$DIR/raw" \
--no-verbose -e robots=off --no-clobber \
--user-agent='feadulta-archiver/1.0 (+incident-183; mirror local)' \
--wait=0.05 --tries=2 --timeout=45 --waitretry=3 \
--output-file="$DIR/logs/wget-$n.log" &
done
wait
date -u +%FT%TZ > "$DIR/logs/passB.end"
echo "PASE B TERMINADO"
find "$DIR/raw" -type f | wc -l
du -sh "$DIR/raw"
echo "=== errores ==="
grep -hoE 'ERROR [0-9]+' "$DIR/logs"/wget-a-*.log | sort | uniq -c
@@ -0,0 +1,18 @@
#!/bin/bash
set -uo pipefail
BASE=/home/rafa/joomla-migration/mirror-antiguo
RUN=$(cat "$BASE/CURRENT_RUN")
DIR=$BASE/runs/$RUN
M=$DIR/missing-pages.txt
echo "total: $(wc -l < "$M")"
echo
echo "=== con query string ==="
grep -c '?' "$M"
echo "=== primer segmento de ruta ==="
sed 's#^http://antiguo.feadulta.com/##' "$M" | cut -d/ -f1 | sort | uniq -c | sort -rn | head -20
echo
echo "=== segundo segmento bajo /es/ ==="
grep '^http://antiguo.feadulta.com/es/' "$M" | sed 's#^http://antiguo.feadulta.com/es/##' | cut -d/ -f1 | sort | uniq -c | sort -rn | head -25
echo
echo "=== muestra aleatoria de 25 ==="
shuf -n 25 "$M"
@@ -0,0 +1,14 @@
#!/bin/bash
set -uo pipefail
BASE=/home/rafa/joomla-migration/mirror-antiguo
RUN=$(cat "$BASE/CURRENT_RUN")
DIR=$BASE/runs/$RUN
grep -v '?' "$DIR/missing-pages.txt" > "$DIR/missing-pages-sinquery.txt"
M=$DIR/missing-pages-sinquery.txt
echo "sin query: $(wc -l < "$M")"
echo
echo "=== por prefijo (2 segmentos) ==="
sed 's#^http://antiguo.feadulta.com/##' "$M" | cut -d/ -f1,2 | sort | uniq -c | sort -rn | head -25
echo
echo "=== muestra de 30 (fuera de /anterior) ==="
grep -v '/anterior/' "$M" | shuf -n 30
@@ -0,0 +1,64 @@
#!/usr/bin/env python3
"""Clasifica missing-pages-sinquery.txt: separa lo que es ruido/codificacion de los huecos reales."""
import os, re
from collections import Counter
from urllib.parse import unquote, urlsplit
BASE = "/home/rafa/joomla-migration/mirror-antiguo"
RUN = open(os.path.join(BASE, "CURRENT_RUN")).read().strip()
DIR = os.path.join(BASE, "runs", RUN)
RAW = os.path.join(DIR, "raw", "antiguo.feadulta.com")
inv = set(l.strip() for l in open(os.path.join(BASE, "inventory", "urls-input.txt")))
inv_dec = set(unquote(u) for u in inv)
cats = Counter()
real = []
for line in open(os.path.join(DIR, "missing-pages-sinquery.txt")):
u = line.strip()
d = unquote(u)
if d in inv_dec:
cats["ya_en_inventario (solo difiere la codificacion %XX)"] += 1
continue
# ¿existe ya el fichero en disco?
p = urlsplit(d).path
fp = os.path.join(RAW, p.lstrip("/"))
if p.endswith("/"):
fp = os.path.join(fp, "index.html")
if os.path.exists(fp):
cats["ya_capturado en disco"] += 1
continue
if "/itemlist/user/" in d:
cats["K2 pagina de autor (itemlist/user)"] += 1; real.append(u)
elif "/itemlist/tag/" in d:
cats["K2 pagina de etiqueta (itemlist/tag)"] += 1; real.append(u)
elif "/itemlist/date/" in d or "/itemlist/category" in d:
cats["K2 listado (fecha/categoria)"] += 1; real.append(u)
elif d.startswith("http://antiguo.feadulta.com/anterior/"):
cats["/anterior (web estatica antigua)"] += 1; real.append(u)
elif d.startswith("http://antiguo.feadulta.com/ediciones/"):
cats["/ediciones"] += 1; real.append(u)
elif "/index.php/" in d:
cats["enlace no-SEF (index.php/...)"] += 1; real.append(u)
elif re.search(r"/ES/|/BUSCADORAVANZADO/", d):
cats["enlace roto por mayusculas"] += 1
else:
cats["OTROS - revisar"] += 1; real.append(u)
for k, v in cats.most_common():
print("%7d %s" % (v, k))
print()
out = os.path.join(DIR, "missing-real.txt")
with open(out, "w") as f:
for u in sorted(set(real)):
f.write(u + "\n")
print("candidatos reales ->", out, len(set(real)))
print("\n--- muestra de OTROS ---")
n = 0
for u in sorted(set(real)):
d = unquote(u)
if not any(s in d for s in ("/itemlist/", "/anterior/", "/ediciones/", "/index.php/")):
print(" ", u); n += 1
if n >= 20: break
@@ -0,0 +1,51 @@
#!/bin/bash
# Pase C: captura iterativa de las paginas internas enlazadas que no estaban en el inventario
# (rutas alternativas de menu, paginas de autor de K2, /anterior, ...).
# Itera hasta que no aparezcan URLs nuevas o hasta MAXIT vueltas.
set -uo pipefail
BASE=/home/rafa/joomla-migration/mirror-antiguo
RUN=$(cat "$BASE/CURRENT_RUN")
DIR=$BASE/runs/$RUN
MAXIT=${MAXIT:-6}
W=${WORKERS:-4}
REJECT='(\?|&)(start|limitstart|limit|print|tmpl|format|searchword|task|orderby|filter|catid|month|year)='
for it in $(seq 1 $MAXIT); do
IN=$DIR/passC-$it-input.txt
if [ "$it" = "1" ]; then
cp "$DIR/missing-real.txt" "$IN"
else
# recalcular huecos con lo capturado hasta ahora
python3 "$BASE/scripts/30-extract-links.py" > "$DIR/logs/extract-$it.log" 2>&1
grep -v '?' "$DIR/missing-pages.txt" > "$DIR/missing-pages-sinquery.txt"
python3 "$BASE/scripts/34-clasifica-missing.py" > "$DIR/logs/clasifica-$it.log" 2>&1
cp "$DIR/missing-real.txt" "$IN"
fi
n=$(wc -l < "$IN")
echo "=== iteracion $it: $n URLs candidatas ==="
[ "$n" -eq 0 ] && { echo "no quedan huecos"; break; }
mkdir -p "$DIR/chunks-c"
rm -f "$DIR/chunks-c"/*.txt
split -n l/$W -d --additional-suffix=.txt "$IN" "$DIR/chunks-c/c$it-"
for c in "$DIR/chunks-c"/c$it-*.txt; do
[ -s "$c" ] || continue
b=$(basename "$c" .txt)
wget --input-file="$c" \
--force-directories --directory-prefix="$DIR/raw" \
--adjust-extension --no-verbose --no-clobber -e robots=off \
--user-agent='feadulta-archiver/1.0 (+incident-183; mirror local)' \
--wait=0.1 --tries=2 --timeout=45 --waitretry=3 \
--reject-regex="$REJECT" \
--output-file="$DIR/logs/wget-$b.log" &
done
wait
echo " ficheros ahora: $(find "$DIR/raw" -type f | wc -l)"
echo " errores: $(grep -hoE 'ERROR [0-9]+' "$DIR/logs"/wget-c$it-*.log | sort | uniq -c | tr '\n' ' ')"
done
date -u +%FT%TZ > "$DIR/logs/passC.end"
echo "PASE C TERMINADO"
find "$DIR/raw" -type f | wc -l
du -sh "$DIR/raw"
+13
View File
@@ -0,0 +1,13 @@
#!/bin/bash
set -uo pipefail
BASE=/home/rafa/joomla-migration/mirror-antiguo
RUN=$(cat "$BASE/CURRENT_RUN")
D=$BASE/runs/$RUN
echo "inicio/fin pase B:"; cat "$D/logs/passB.start" "$D/logs/passB.end" 2>/dev/null || echo "(sin marcas)"
echo "assets pedidos: $(wc -l < "$D/assets-input.txt")"
echo "errores:"; grep -hoE 'ERROR [0-9]+' "$D/logs"/wget-a-*.log 2>/dev/null | sort | uniq -c
echo "descargados segun log: $(grep -hc '^2026' "$D/logs"/wget-a-*.log 2>/dev/null | paste -sd+ | bc)"
echo "ficheros totales: $(find "$D/raw" -type f | wc -l)"
du -sh "$D/raw"
echo "--- reparto por tipo en raw ---"
find "$D/raw" -type f | sed 's#.*\.##' | tr 'A-Z' 'a-z' | sort | uniq -c | sort -rn | head -15
@@ -0,0 +1,26 @@
#!/bin/bash
# Pase D: /anterior — la web estatica anterior a Joomla, enlazada desde el menu principal.
# Es HTML plano servido por Apache (solo 1 .php en 34.037 ficheros): la recursion aqui es finita
# y no pasa por PHP, asi que no reproduce la trampa de paginacion de K2.
set -uo pipefail
BASE=/home/rafa/joomla-migration/mirror-antiguo
RUN=$(cat "$BASE/CURRENT_RUN")
DIR=$BASE/runs/$RUN
date -u +%FT%TZ > "$DIR/logs/passD.start"
wget \
--recursive --level=inf --no-parent \
--force-directories --directory-prefix="$DIR/raw" \
--adjust-extension --no-verbose --no-clobber -e robots=off \
--domains=antiguo.feadulta.com --span-hosts=off \
--user-agent='feadulta-archiver/1.0 (+incident-183; mirror local)' \
--wait=0.05 --tries=2 --timeout=45 --waitretry=3 \
--reject-regex='(\?|&)(C|O|start|limitstart|limit|print|tmpl|format|searchword|task|orderby|filter)=' \
--output-file="$DIR/logs/wget-anterior.log" \
http://antiguo.feadulta.com/anterior/
date -u +%FT%TZ > "$DIR/logs/passD.end"
echo "PASE D TERMINADO"
find "$DIR/raw/antiguo.feadulta.com/anterior" -type f 2>/dev/null | wc -l
du -sh "$DIR/raw/antiguo.feadulta.com/anterior" 2>/dev/null
grep -hoE 'ERROR [0-9]+' "$DIR/logs/wget-anterior.log" | sort | uniq -c
+16
View File
@@ -0,0 +1,16 @@
#!/bin/bash
set -uo pipefail
BASE=/home/rafa/joomla-migration/mirror-antiguo
RUN=$(cat "$BASE/CURRENT_RUN")
D=$BASE/runs/$RUN
echo "=== procesos wget ==="
ps -eo pid,etimes,args | grep '[w]get --input-file' | sed 's/\(.\{160\}\).*/\1/'
echo
echo "=== ultimas 3 lineas de cada log del pase B ==="
for f in "$D/logs"/wget-a-*.log; do echo "--- $f"; tail -3 "$f"; done
echo
echo "=== 24 lineas mas recientes de apache ==="
docker logs --tail 8 joomla-mirror-web 2>&1 | sed 's/\(.\{150\}\).*/\1/'
echo
echo "=== conteo por chunk ==="
for f in "$D/chunks-assets"/a-*.txt; do echo "$f: $(wc -l < "$f")"; done
@@ -0,0 +1,25 @@
#!/bin/bash
# Cuantifica el problema de los alias que llevan '?' literal dentro de la URL
set -uo pipefail
BASE=/home/rafa/joomla-migration/mirror-antiguo
RUN=$(cat "$BASE/CURRENT_RUN")
D=$BASE/runs/$RUN
echo "=== URLs del inventario con '?' literal ==="
grep -c '?' "$BASE/inventory/urls-input.txt"
echo "=== muestra ==="
grep '?' "$BASE/inventory/urls-input.txt" | head -5
echo
echo "=== assets-input con '?' ==="
grep -c '?' "$D/assets-input.txt"
echo "=== assets-input SIN '?' (assets de verdad) ==="
grep -vc '?' "$D/assets-input.txt"
echo
echo "=== ficheros en raw sin extension ==="
find "$D/raw" -type f ! -name '*.*' | wc -l
echo "=== ficheros en raw con '?' en el nombre ==="
find "$D/raw" -type f -name '*[?]*' | wc -l
echo "=== muestra ==="
find "$D/raw" -type f -name '*[?]*' | head -3
echo
echo "=== items K2 con '?' en el alias (BD) ==="
grep -c 'buscadoravanzado' "$BASE/inventory/urls-k2.txt"
@@ -0,0 +1,40 @@
#!/bin/bash
# Fase 3: manifiestos sha256 + escaneo de seguridad del propio mirror (§4.3 del plan)
set -uo pipefail
BASE=/home/rafa/joomla-migration/mirror-antiguo
RUN=$(cat "$BASE/CURRENT_RUN")
DIR=$BASE/runs/$RUN
cd "$DIR"
echo "=== manifiesto raw ==="
( cd raw && find . -type f -print0 | sort -z | xargs -0 sha256sum ) > MANIFEST-raw.sha256
wc -l < MANIFEST-raw.sha256
du -sh raw
echo
echo "=== 1. ficheros con PHP ejecutable ==="
grep -rl '<?php' raw > scan-php.txt 2>/dev/null
wc -l < scan-php.txt
echo "=== 2. patrones tipicos de inyeccion ==="
grep -rlE 'eval\(|atob\(|document\.write\(unescape|fromCharCode' raw > scan-suspicious.txt 2>/dev/null
wc -l < scan-suspicious.txt
echo "=== 3. paginas de challenge/error congeladas ==="
grep -rli 'Attention Required\|Just a moment\|Not Acceptable\|mod_security\|Internal Server Error' raw > scan-garbage.txt 2>/dev/null
wc -l < scan-garbage.txt
echo "=== 4. hosts externos en script/iframe ==="
grep -rhoE '<(script|iframe)[^>]+src="https?://[^"/]+' raw \
| grep -oE 'https?://[^"/]+' | sort | uniq -c | sort -rn > scan-external-script-hosts.txt
head -25 scan-external-script-hosts.txt
echo
echo "=== 5. cobertura frente al inventario ==="
find raw/antiguo.feadulta.com -type f -name '*.html' \
| sed 's#^raw/antiguo.feadulta.com#http://antiguo.feadulta.com#' | sort -u > captured-pages.txt
comm -23 <(sort -u "$BASE/inventory/urls-input.txt" | sed 's#/es/$#/es/index.html#') captured-pages.txt > coverage-missing.txt
echo "inventario: $(wc -l < "$BASE/inventory/urls-input.txt")"
echo "capturadas: $(wc -l < captured-pages.txt)"
echo "sin capturar (aprox): $(wc -l < coverage-missing.txt)"
head -20 coverage-missing.txt
+13
View File
@@ -0,0 +1,13 @@
#!/bin/bash
set -uo pipefail
BASE=/home/rafa/joomla-migration/mirror-antiguo
INV=$BASE/inventory
curl -s -H 'Host: antiguo.feadulta.com' 'http://127.0.0.1:8086/_inv/menupaths.php' | sort -u > "$INV/urls-menu-path.txt"
echo "menu por path: $(wc -l < "$INV/urls-menu-path.txt")"
echo "menu via JRoute con ?Itemid=: $(grep -c 'Itemid=' "$INV/urls-menu.txt")"
echo
echo "=== comprobacion de 12 al azar ==="
shuf -n 12 "$INV/urls-menu-path.txt" | while read -r u; do
p=${u#http://antiguo.feadulta.com}
printf '%-60s %s\n' "$p" "$(curl -s -o /dev/null -w '%{http_code}' -H 'Host: antiguo.feadulta.com' "http://127.0.0.1:8086$p")"
done
+17
View File
@@ -0,0 +1,17 @@
#!/bin/bash
# El lote a-00 del pase B resulto ser casi todo basura: URLs de articulo cuyo alias lleva un '?'
# literal (mal clasificadas como assets) y sus vistas de impresion. Los assets de verdad estaban en
# a-01 y a-02, que ya terminaron. Se corta a-00 y se documenta.
set -uo pipefail
BASE=/home/rafa/joomla-migration/mirror-antiguo
RUN=$(cat "$BASE/CURRENT_RUN")
D=$BASE/runs/$RUN
echo "=== composicion de a-00 ==="
echo "total: $(wc -l < "$D/chunks-assets/a-00.txt")"
echo "bajo /es/ (articulos, no assets): $(grep -c '/es/' "$D/chunks-assets/a-00.txt")"
echo "assets reales: $(grep -vc '/es/' "$D/chunks-assets/a-00.txt")"
pkill -f 'chunks-assets/a-00.txt' && echo "a-00 detenido" || echo "a-00 ya no corria"
sleep 2
date -u +%FT%TZ > "$D/logs/passB.end"
echo "=== restos a limpiar (vistas de impresion) ==="
find "$D/raw" -type f -name '*print=1*' | wc -l
+46
View File
@@ -0,0 +1,46 @@
#!/bin/bash
# Pase C unificado: conjunto completo de URLs deseadas menos lo que ya esta en disco.
# inventario + rutas de menu por `path` + huecos detectados en el HTML + assets reales
# wget con --no-clobber se salta lo ya descargado, asi que el script es idempotente.
set -uo pipefail
BASE=/home/rafa/joomla-migration/mirror-antiguo
RUN=$(cat "$BASE/CURRENT_RUN")
D=$BASE/runs/$RUN
W=${WORKERS:-4}
REJECT='(\?|&)(start|limitstart|limit|print|tmpl|format|searchword|task|orderby|filter|catid|month|year)='
cat "$BASE/inventory/urls-input.txt" \
"$BASE/inventory/urls-menu-path.txt" \
"$D/missing-real.txt" \
<(grep -v 'antiguo.feadulta.com/es/' "$D/assets-input.txt") \
| grep -E '^http://antiguo\.feadulta\.com/' \
| grep -v 'print=1' \
| grep -v 'tmpl=component' \
| grep -v 'format=opensearch' \
| grep -v '/component/mailto/' \
| sort -u > "$D/passC-input.txt"
echo "conjunto deseado: $(wc -l < "$D/passC-input.txt")"
echo "ficheros en disco antes: $(find "$D/raw" -type f | wc -l)"
mkdir -p "$D/chunks-c"; rm -f "$D/chunks-c"/*.txt
split -n l/$W -d --additional-suffix=.txt "$D/passC-input.txt" "$D/chunks-c/c-"
date -u +%FT%TZ > "$D/logs/passC.start"
for c in "$D/chunks-c"/c-*.txt; do
b=$(basename "$c" .txt)
wget --input-file="$c" \
--force-directories --directory-prefix="$D/raw" \
--adjust-extension --no-verbose --no-clobber -e robots=off \
--user-agent='feadulta-archiver/1.0 (+incident-183; mirror local)' \
--wait=0.1 --tries=2 --timeout=45 --waitretry=3 \
--reject-regex="$REJECT" \
--output-file="$D/logs/wget-$b.log" &
done
wait
date -u +%FT%TZ > "$D/logs/passC.end"
echo "PASE C TERMINADO"
echo "ficheros en disco despues: $(find "$D/raw" -type f | wc -l)"
du -sh "$D/raw"
grep -hoE 'ERROR [0-9]+' "$D/logs"/wget-c-*.log | sort | uniq -c
@@ -0,0 +1,19 @@
#!/bin/bash
# Comprueba que los recursos de plantilla que pide la portada existen en el mirror
set -uo pipefail
BASE=/home/rafa/joomla-migration/mirror-antiguo
RUN=$(cat "$BASE/CURRENT_RUN")
R=$BASE/runs/$RUN/raw/antiguo.feadulta.com
P=$R/es/index.html
[ -f "$P" ] || { echo "no existe $P"; exit 1; }
grep -oE '(href|src)="[^"]+\.(css|js|png|jpg|gif|ico)[^"]*"' "$P" \
| sed 's/^[a-z]*="//; s/"$//' | sort -u > /tmp/portada-assets.txt
echo "recursos referenciados por la portada: $(wc -l < /tmp/portada-assets.txt)"
ok=0; miss=0
while read -r u; do
p=$(echo "$u" | sed 's#^https\?://antiguo.feadulta.com##; s#^/##; s#?.*##')
case "$u" in http*://*) case "$u" in *antiguo.feadulta.com*) ;; *) continue;; esac;; esac
if [ -f "$R/$p" ]; then ok=$((ok+1)); else miss=$((miss+1)); echo " FALTA: $p"; fi
done < /tmp/portada-assets.txt
echo "presentes=$ok ausentes=$miss"
@@ -0,0 +1,13 @@
#!/bin/bash
set -uo pipefail
BASE=/home/rafa/joomla-migration/mirror-antiguo
RUN=$(cat "$BASE/CURRENT_RUN")
R=$BASE/runs/$RUN/raw/antiguo.feadulta.com
for f in components/com_k2/css/k2.css media/com_jce/site/css/content.min.css \
media/jui/js/jquery-migrate.min.js media/jui/js/jquery-noconflict.js \
media/jui/js/jquery.min.js media/k2/assets/js/k2.frontend.js \
media/system/js/core.js media/system/js/html5fallback.js \
media/system/js/mootools-core.js media/system/js/mootools-more.js; do
hit=$(ls "$R/$f"* 2>/dev/null | head -1)
printf '%-50s %s\n' "$(basename "$f")" "${hit:-NO ENCONTRADO}"
done
@@ -0,0 +1,110 @@
#!/usr/bin/env python3
"""§3.5 del plan: deriva `site/` a partir de `raw/` (que queda intacto).
- Reescribe los enlaces absolutos a `antiguo.feadulta.com` como raiz-relativos, para que el mirror
funcione bajo cualquier hostname (p.ej. legacy.rafacalvo.nyc).
- Deja intactos los enlaces externos (incluido www.feadulta.com, que ahora es WordPress).
- Neutraliza los formularios que apuntan a endpoints PHP vivos: quedan inertes y con aviso.
- Descarta las vistas de impresion (`?tmpl=component&print=1`), que duplican paginas ya capturadas.
- Deja una copia sin la query en el nombre para los ficheros que wget guardo como `app.js?hash`
o `titulo?.html` (alias con '?' literal): un servidor estatico busca el nombre sin query.
Uso: 45-normalize-links.py [--no-copy] (--no-copy reaprovecha el site/ existente)
"""
import os, re, json, shutil, sys
from collections import Counter
BASE = "/home/rafa/joomla-migration/mirror-antiguo"
RUN = open(os.path.join(BASE, "CURRENT_RUN")).read().strip()
DIR = os.path.join(BASE, "runs", RUN)
RAW, SITE = os.path.join(DIR, "raw"), os.path.join(DIR, "site")
HOSTABS = re.compile(rb'(?:https?:)?//antiguo\.feadulta\.com', re.I)
FORM = re.compile(rb'<form\b[^>]*>', re.I)
ACTION = re.compile(rb'action\s*=\s*["\']([^"\']*)["\']', re.I)
AVISO = (b'<div style="background:#ffe9e9;border:1px solid #c00;padding:8px;margin:8px 0;'
b'font:13px sans-serif">Archivo hist\xc3\xb3rico: este formulario no est\xc3\xa1 '
b'operativo.</div>')
TEXTEXT = (".html", ".htm", ".css", ".js")
def basename_sin_query(fn):
"""`app.js?hash` -> `app.js`; `titulo?.html` -> `titulo`. Sin '?' devuelve el propio nombre."""
return fn.split("?", 1)[0]
def paso1_nombres_con_query(stats):
"""Se ejecuta ANTES de reescribir: descarta impresiones y crea las copias de nombre limpio."""
for root, _d, files in os.walk(SITE):
for fn in list(files):
if "?" not in fn:
continue
src = os.path.join(root, fn)
if "print=1" in fn:
os.remove(src)
stats["vistas_impresion_descartadas"] += 1
continue
base = basename_sin_query(fn)
if not base:
continue
dst = os.path.join(root, base)
if not os.path.exists(dst):
shutil.copy2(src, dst)
stats["copias_con_nombre_limpio"] += 1
def paso2_reescribe(stats):
for root, _d, files in os.walk(SITE):
for fn in files:
# la extension se mira sobre el nombre SIN query: `x.html?foo` sigue siendo HTML
base = basename_sin_query(fn).lower()
if not base.endswith(TEXTEXT):
continue
p = os.path.join(root, fn)
try:
data = open(p, "rb").read()
except OSError:
continue
orig = data
data, n = HOSTABS.subn(b"", data)
stats["enlaces_absolutos_reescritos"] += n
if base.endswith((".html", ".htm")):
def fix_form(m):
tag = m.group(0)
a = ACTION.search(tag)
if a and b".php" in a.group(1):
stats["formularios_neutralizados"] += 1
return ACTION.sub(b'action="#" onsubmit="return false"', tag) + AVISO
return tag
data = FORM.sub(fix_form, data)
if data != orig:
open(p, "wb").write(data)
stats["ficheros_modificados"] += 1
def main():
if "--no-copy" not in sys.argv:
if os.path.exists(SITE):
print("site/ ya existe, lo borro"); shutil.rmtree(SITE)
print("copiando raw/ -> site/ ...")
shutil.copytree(RAW, SITE)
else:
print("reaprovechando site/ existente")
stats = Counter()
paso1_nombres_con_query(stats)
paso2_reescribe(stats)
out = os.path.join(DIR, "link-rewrite.json")
json.dump(dict(stats), open(out, "w"), indent=2, ensure_ascii=False)
print(json.dumps(dict(stats), indent=2, ensure_ascii=False))
print("informe:", out)
if __name__ == "__main__":
main()
@@ -0,0 +1,20 @@
#!/bin/bash
set -uo pipefail
BASE=/home/rafa/joomla-migration/mirror-antiguo
RUN=$(cat "$BASE/CURRENT_RUN")
D=$BASE/runs/$RUN
grep -hB1 'ERROR 404' "$D/logs"/wget-c-*.log | grep '^http' | sed 's/:$//' | sort -u > "$D/404-passC.txt"
echo "URLs con 404: $(wc -l < "$D/404-passC.txt")"
echo
echo "=== por tipo ==="
printf 'itemlist/user %s\n' "$(grep -c '/itemlist/user/' "$D/404-passC.txt")"
printf 'item %s\n' "$(grep -c '/item/' "$D/404-passC.txt")"
printf '/anterior %s\n' "$(grep -c '/anterior/' "$D/404-passC.txt")"
printf 'resto %s\n' "$(grep -vcE '/itemlist/user/|/item/|/anterior/' "$D/404-passC.txt")"
echo
echo "=== muestra item ==="
grep '/item/' "$D/404-passC.txt" | head -5
echo "=== muestra itemlist/user ==="
grep '/itemlist/user/' "$D/404-passC.txt" | head -5
echo "=== muestra resto ==="
grep -vE '/itemlist/user/|/item/|/anterior/' "$D/404-passC.txt" | head -8
+18
View File
@@ -0,0 +1,18 @@
#!/bin/bash
set -uo pipefail
BASE=/home/rafa/joomla-migration/mirror-antiguo
RUN=$(cat "$BASE/CURRENT_RUN")
D=$BASE/runs/$RUN
curl -s -H 'Host: antiguo.feadulta.com' 'http://127.0.0.1:8086/_inv/k2ids.php' > "$D/k2-ids.tsv"
echo "items K2 en BD: $(wc -l < "$D/k2-ids.tsv")"
grep -oP '/item/\K\d+' "$D/404-passC.txt" | sort -u > "$D/404-ids.txt"
echo "ids distintos con 404: $(wc -l < "$D/404-ids.txt")"
awk -F'\t' 'NR==FNR{want[$1]=1;next} ($1 in want){print $2"\t"$3}' "$D/404-ids.txt" "$D/k2-ids.tsv" \
| sort | uniq -c | sed 's/^/ published,trash: /'
echo "ids que no existen en la BD: $(awk -F'\t' 'NR==FNR{have[$1]=1;next} !($1 in have)' "$D/k2-ids.tsv" "$D/404-ids.txt" | wc -l)"
echo
echo "=== los 172 'resto' ==="
grep -vE '/itemlist/user/|/item/|/anterior/' "$D/404-passC.txt" | sed 's#^http://antiguo.feadulta.com/es/##' | cut -d/ -f1 | sort | uniq -c | sort -rn | head -15
+14
View File
@@ -0,0 +1,14 @@
#!/bin/bash
set -uo pipefail
BASE=/home/rafa/joomla-migration/mirror-antiguo
RUN=$(cat "$BASE/CURRENT_RUN")
D=$BASE/runs/$RUN
L=$D/logs/wget-anterior.log
echo "wget vivo: $(pgrep -cf 'anterior/$')"
echo "descargados OK: $(grep -c '^2026.*URL:' "$L")"
echo "404: $(grep -c 'ERROR 404' "$L")"
echo "ficheros bajo /anterior: $(find "$D/raw/antiguo.feadulta.com/anterior" -type f 2>/dev/null | wc -l)"
echo "ficheros bajo /es/anterior (redirigidos, no deberia haber): $(find "$D/raw/antiguo.feadulta.com/es/anterior" -type f 2>/dev/null | wc -l)"
du -sh "$D/raw/antiguo.feadulta.com/anterior" 2>/dev/null
echo "--- ultimas 5 descargas OK ---"
grep '^2026.*URL:' "$L" | tail -5 | sed 's/\(.\{140\}\).*/\1/'
+83
View File
@@ -0,0 +1,83 @@
#!/usr/bin/env python3
"""Fase 5 (local): paridad entre el fichero capturado y lo que sirve ahora el Joomla local.
Detecta capturas truncadas, paginas de error congeladas y desfases de contenido.
No toca produccion.
"""
import os, re, sys, random, hashlib, subprocess, json
from urllib.parse import urlsplit
BASE = "/home/rafa/joomla-migration/mirror-antiguo"
RUN = open(os.path.join(BASE, "CURRENT_RUN")).read().strip()
DIR = os.path.join(BASE, "runs", RUN)
RAW = os.path.join(DIR, "raw", "antiguo.feadulta.com")
N = int(sys.argv[1]) if len(sys.argv) > 1 else 200
TITLE = re.compile(r"<title[^>]*>(.*?)</title>", re.I | re.S)
SCRIPTS = re.compile(r"<(script|style)[^>]*>.*?</\1>", re.I | re.S)
TAGS = re.compile(r"<[^>]+>")
WS = re.compile(r"\s+")
# bloques que cambian entre peticiones. El contador de visitas de K2 ("Read N times") se
# incrementa con nuestra propia peticion, asi que dos lecturas de la MISMA pagina nunca coinciden:
# se normaliza en vez de contarlo como diferencia.
VOLATILE = re.compile(r"[0-9a-f]{32}|csrf|token", re.I)
HITS = re.compile(r"(Read|Visto|Le[ií]do)\s+\d+\s+(times|veces)", re.I)
def texthash(s):
s = SCRIPTS.sub(" ", s)
s = TAGS.sub(" ", s)
s = WS.sub(" ", s).strip()
s = VOLATILE.sub("", s)
s = HITS.sub("HITS", s)
return hashlib.sha256(s.encode("utf-8", "replace")).hexdigest(), len(s)
def title(s):
m = TITLE.search(s)
return WS.sub(" ", m.group(1)).strip() if m else ""
urls = [l.strip() for l in open(os.path.join(BASE, "inventory", "urls-input.txt"))]
random.seed(20260729)
sample = random.sample(urls, min(N, len(urls)))
res = {"muestra": len(sample), "ok_status": 0, "falta_fichero": 0,
"titulo_igual": 0, "titulo_distinto": 0, "texto_igual": 0, "texto_distinto": 0,
"diffs": []}
for u in sample:
path = urlsplit(u).path
fp = os.path.join(RAW, path.lstrip("/"))
if path.endswith("/"):
fp = os.path.join(fp, "index.html")
if not os.path.exists(fp):
res["falta_fichero"] += 1
res["diffs"].append({"url": u, "motivo": "fichero ausente"})
continue
res["ok_status"] += 1
disk = open(fp, encoding="utf-8", errors="replace").read()
live = subprocess.run(
["curl", "-s", "--max-time", "60", "-H", "Host: antiguo.feadulta.com",
"http://127.0.0.1:8086" + path],
capture_output=True).stdout.decode("utf-8", "replace")
td, tl = title(disk), title(live)
if td == tl:
res["titulo_igual"] += 1
else:
res["titulo_distinto"] += 1
res["diffs"].append({"url": u, "motivo": "titulo", "mirror": td[:120], "vivo": tl[:120]})
hd, ld = texthash(disk)
hl, ll = texthash(live)
if hd == hl:
res["texto_igual"] += 1
else:
res["texto_distinto"] += 1
res["diffs"].append({"url": u, "motivo": "texto", "len_mirror": ld, "len_vivo": ll})
out = os.path.join(DIR, "parity-report.json")
json.dump(res, open(out, "w"), indent=2, ensure_ascii=False)
for k in ("muestra", "falta_fichero", "titulo_igual", "titulo_distinto", "texto_igual", "texto_distinto"):
print(k, "=", res[k])
print("informe:", out)
for d in res["diffs"][:15]:
print(" ", d)
+154
View File
@@ -0,0 +1,154 @@
#!/usr/bin/env python3
"""Paridad COMPLETA sobre el inventario entero, a fuego lento.
Igual que 50-parity.py pero (a) recorre las 25.437 URLs en vez de una muestra,
(b) mete una pausa entre peticiones para no ahogar al Joomla local ni a la WSL
(ver leccion del crawl que tumbo la VM), y (c) escribe JSONL incremental para
poder mirar el progreso y reanudar sin repetir trabajo.
Uso: python3 50b-parity-full.py [pausa_segundos] [workers] (por defecto 0.35 y 1)
Se registra ademas el codigo HTTP del Joomla local: sin eso, un 500 del contenedor se contaria
como "el mirror difiere" y ensuciaria el informe con diferencias que no lo son.
"""
import os, re, sys, time, json, hashlib, subprocess, threading, collections
import concurrent.futures
from urllib.parse import urlsplit
BASE = "/home/rafa/joomla-migration/mirror-antiguo"
RUN = open(os.path.join(BASE, "CURRENT_RUN")).read().strip()
DIR = os.path.join(BASE, "runs", RUN)
RAW = os.path.join(DIR, "raw", "antiguo.feadulta.com")
PAUSA = float(sys.argv[1]) if len(sys.argv) > 1 else 0.35
WORKERS = int(sys.argv[2]) if len(sys.argv) > 2 else 1
JSONL = os.path.join(DIR, "parity-full.jsonl")
PROG = os.path.join(DIR, "parity-full.progress")
OUT = os.path.join(DIR, "parity-report-full.json")
TITLE = re.compile(r"<title[^>]*>(.*?)</title>", re.I | re.S)
SCRIPTS = re.compile(r"<(script|style)[^>]*>.*?</\1>", re.I | re.S)
TAGS = re.compile(r"<[^>]+>")
WS = re.compile(r"\s+")
VOLATILE = re.compile(r"[0-9a-f]{32}|csrf|token", re.I)
HITS = re.compile(r"(Read|Visto|Le[ií]do)\s+\d+\s+(times|veces)", re.I)
def texthash(s):
s = SCRIPTS.sub(" ", s)
s = TAGS.sub(" ", s)
s = WS.sub(" ", s).strip()
s = VOLATILE.sub("", s)
s = HITS.sub("HITS", s)
return hashlib.sha256(s.encode("utf-8", "replace")).hexdigest(), len(s)
def title(s):
m = TITLE.search(s)
return WS.sub(" ", m.group(1)).strip() if m else ""
urls = [l.strip() for l in open(os.path.join(BASE, "inventory", "urls-input.txt")) if l.strip()]
# Reanudable: lo ya comprobado no se repite.
hechas = set()
if os.path.exists(JSONL):
for line in open(JSONL, encoding="utf-8"):
try:
hechas.add(json.loads(line)["url"])
except Exception:
pass
pendientes = [u for u in urls if u not in hechas]
print(f"inventario={len(urls)} ya_hechas={len(hechas)} pendientes={len(pendientes)} "
f"pausa={PAUSA}s workers={WORKERS}", flush=True)
t0 = time.time()
lock = threading.Lock()
contador = {"n": 0}
codigos = collections.Counter()
def comprueba(u):
path = urlsplit(u).path
fp = os.path.join(RAW, path.lstrip("/"))
if path.endswith("/"):
fp = os.path.join(fp, "index.html")
if not os.path.exists(fp):
return {"url": u, "estado": "falta_fichero"}
disk = open(fp, encoding="utf-8", errors="replace").read()
salida = subprocess.run(
["curl", "-s", "-w", "\n%{http_code}", "--max-time", "60",
"-H", "Host: antiguo.feadulta.com", "http://127.0.0.1:8086" + path],
capture_output=True).stdout.decode("utf-8", "replace")
live, _, code = salida.rpartition("\n")
code = code.strip() or "000"
td, tl = title(disk), title(live)
hd, ld = texthash(disk)
hl, ll = texthash(live)
if PAUSA:
time.sleep(PAUSA)
return {"url": u, "estado": "ok", "http_vivo": code,
"titulo_igual": td == tl, "texto_igual": hd == hl,
"titulo_mirror": td[:120], "titulo_vivo": tl[:120],
"len_mirror": ld, "len_vivo": ll}
with open(JSONL, "a", encoding="utf-8") as fh:
with concurrent.futures.ThreadPoolExecutor(max_workers=WORKERS) as ex:
for d in ex.map(comprueba, pendientes):
with lock:
fh.write(json.dumps(d, ensure_ascii=False) + "\n")
contador["n"] += 1
i = contador["n"]
codigos[d.get("http_vivo", "-")] += 1
if i % 100 == 0:
fh.flush()
hechas_tot = len(hechas) + i
ritmo = i / max(time.time() - t0, 1)
queda = (len(pendientes) - i) / max(ritmo, 0.001) / 60
open(PROG, "w").write(
f"{hechas_tot}/{len(urls)} ({100*hechas_tot/len(urls):.1f}%) "
f"ritmo={ritmo:.2f}/s ETA={queda:.0f}min "
f"http_vivo={dict(codigos)}\n")
# Resumen final a partir del JSONL completo.
res = {"inventario": len(urls), "comprobadas": 0, "falta_fichero": 0,
"vivo_no_200": 0, "http_vivo": {},
"titulo_igual": 0, "titulo_distinto": 0, "texto_igual": 0, "texto_distinto": 0,
"diffs": []}
for line in open(JSONL, encoding="utf-8"):
d = json.loads(line)
if d["estado"] == "falta_fichero":
res["falta_fichero"] += 1
res["diffs"].append({"url": d["url"], "motivo": "fichero ausente"})
continue
code = d.get("http_vivo", "?")
res["http_vivo"][code] = res["http_vivo"].get(code, 0) + 1
if code not in ("200", "?"):
# El Joomla local fallo en esta peticion: no es una diferencia del mirror.
res["vivo_no_200"] += 1
res["diffs"].append({"url": d["url"], "motivo": "joomla local " + code})
continue
res["comprobadas"] += 1
if d["titulo_igual"]:
res["titulo_igual"] += 1
else:
res["titulo_distinto"] += 1
res["diffs"].append({"url": d["url"], "motivo": "titulo",
"mirror": d["titulo_mirror"], "vivo": d["titulo_vivo"]})
if d["texto_igual"]:
res["texto_igual"] += 1
else:
res["texto_distinto"] += 1
res["diffs"].append({"url": d["url"], "motivo": "texto",
"len_mirror": d["len_mirror"], "len_vivo": d["len_vivo"]})
json.dump(res, open(OUT, "w"), indent=2, ensure_ascii=False)
for k in ("inventario", "comprobadas", "falta_fichero", "vivo_no_200", "http_vivo",
"titulo_igual", "titulo_distinto", "texto_igual", "texto_distinto"):
print(k, "=", res[k])
print("informe:", OUT)
+11
View File
@@ -0,0 +1,11 @@
#!/bin/bash
# Averigua QUE cambia entre el fichero capturado y lo que sirve ahora el Joomla local
set -uo pipefail
BASE=/home/rafa/joomla-migration/mirror-antiguo
RUN=$(cat "$BASE/CURRENT_RUN")
R=$BASE/runs/$RUN/raw/antiguo.feadulta.com
P=${1:-/es/buscadoravanzado/item/16690-el-dios-de-trump.html}
curl -s -H 'Host: antiguo.feadulta.com' "http://127.0.0.1:8086$P" > /tmp/vivo.html
diff <(sed 's/></>\n</g' "$R$P") <(sed 's/></>\n</g' /tmp/vivo.html) | head -30
echo "=== (fin del diff) ==="
@@ -0,0 +1,19 @@
#!/bin/bash
# Comprueba que los alias con '?' literal SI estan capturados, con el nombre truncado en el '?'
set -uo pipefail
BASE=/home/rafa/joomla-migration/mirror-antiguo
RUN=$(cat "$BASE/CURRENT_RUN")
R=$BASE/runs/$RUN/raw/antiguo.feadulta.com
INV=$BASE/inventory/urls-input.txt
grep '?' "$INV" | grep -v 'Itemid=' > /tmp/alias-q.txt
echo "URLs del inventario con '?' en el alias: $(wc -l < /tmp/alias-q.txt)"
ok=0; miss=0
while read -r u; do
p=${u#http://antiguo.feadulta.com}
p=${p%%\?*} # ruta real = hasta el primer '?'
if [ -f "$R$p" ]; then ok=$((ok+1)); else miss=$((miss+1)); echo " FALTA $p"; fi
done < /tmp/alias-q.txt
echo "presentes=$ok ausentes=$miss"
echo
echo "ejemplo:"; ls -la "$R/es/buscadoravanzado/item/715-"* 2>/dev/null | head -3
@@ -0,0 +1,45 @@
#!/bin/bash
# Pase D (corregido): /anterior por INVENTARIO, no por recursion.
#
# La recursion sobre /anterior funcionaba, pero se estaba comiendo el tiempo en 404: la web antigua
# esta llena de enlaces rotos (imagenes de los `_archivos/` de exportaciones de Word que ya no
# existen). Iban 3.050 aciertos por 2.744 fallos. Misma leccion del post-mortem: acotar por
# inventario. Aqui el inventario es el listado de ficheros del snapshot restaurado — solo la LISTA
# DE RUTAS, igual que se hace con la BD; el contenido se sigue capturando por HTTP.
set -uo pipefail
BASE=/home/rafa/joomla-migration/mirror-antiguo
RUN=$(cat "$BASE/CURRENT_RUN")
D=$BASE/runs/$RUN
SRC=$BASE/restore/web/anterior
W=${WORKERS:-4}
pkill -f 'antiguo.feadulta.com/anterior/$' && echo "recursion detenida" || echo "recursion ya parada"
sleep 2
cd "$BASE/restore/web"
find anterior -type f ! -iname '*.php' -printf '%p\n' \
| sed 's#^#http://antiguo.feadulta.com/#' \
| sort -u > "$D/anterior-input.txt"
echo "inventario de /anterior: $(wc -l < "$D/anterior-input.txt") ficheros (excluidos los .php)"
echo "php excluidos: $(find anterior -type f -iname '*.php' | wc -l)"
mkdir -p "$D/chunks-d"; rm -f "$D/chunks-d"/*.txt
split -n l/$W -d --additional-suffix=.txt "$D/anterior-input.txt" "$D/chunks-d/d-"
date -u +%FT%TZ > "$D/logs/passD2.start"
for c in "$D/chunks-d"/d-*.txt; do
b=$(basename "$c" .txt)
wget --input-file="$c" \
--force-directories --directory-prefix="$D/raw" \
--no-verbose --no-clobber -e robots=off \
--user-agent='feadulta-archiver/1.0 (+incident-183; mirror local)' \
--wait=0.02 --tries=2 --timeout=45 --waitretry=3 \
--output-file="$D/logs/wget-$b.log" &
done
wait
date -u +%FT%TZ > "$D/logs/passD2.end"
echo "PASE D TERMINADO"
echo "ficheros bajo /anterior: $(find "$D/raw/antiguo.feadulta.com/anterior" -type f | wc -l)"
du -sh "$D/raw/antiguo.feadulta.com/anterior"
grep -hoE 'ERROR [0-9]+' "$D/logs"/wget-d-*.log | sort | uniq -c
+47
View File
@@ -0,0 +1,47 @@
#!/usr/bin/env python3
"""Cobertura real: cada URL del inventario debe tener su fichero en raw/.
Contempla las tres formas en que wget nombra el fichero:
/es/x.html -> x.html
/es/ -> index.html
/es/x?.html (alias con '?' literal) -> "x?.html" o "x"
"""
import os, json
from urllib.parse import urlsplit, unquote
BASE = "/home/rafa/joomla-migration/mirror-antiguo"
RUN = open(os.path.join(BASE, "CURRENT_RUN")).read().strip()
DIR = os.path.join(BASE, "runs", RUN)
RAW = os.path.join(DIR, "raw", "antiguo.feadulta.com")
def candidates(url):
rest = url.split("antiguo.feadulta.com", 1)[1]
rest = unquote(rest)
yield rest.lstrip("/") # nombre literal, con '?' incluido
p = urlsplit(rest).path.lstrip("/")
yield p # truncado en el '?'
if rest.endswith("/") or p.endswith("/") or p == "":
yield (p + "index.html")
ok, missing = 0, []
urls = [l.strip() for l in open(os.path.join(BASE, "inventory", "urls-input.txt")) if l.strip()]
for u in urls:
if any(os.path.isfile(os.path.join(RAW, c)) for c in candidates(u) if c):
ok += 1
else:
missing.append(u)
print("inventario:", len(urls))
print("con fichero en raw/:", ok)
print("sin fichero:", len(missing))
print("cobertura: %.2f%%" % (ok * 100.0 / len(urls)))
with open(os.path.join(DIR, "coverage-missing.txt"), "w") as f:
for u in missing:
f.write(u + "\n")
for u in missing[:25]:
print(" ", u)
total = sum(len(fs) for _r, _d, fs in os.walk(os.path.join(DIR, "raw")))
json.dump({"inventario": len(urls), "capturadas": ok, "sin_fichero": len(missing),
"cobertura_pct": round(ok * 100.0 / len(urls), 2), "ficheros_totales_raw": total},
open(os.path.join(DIR, "coverage-report.json"), "w"), indent=2)
@@ -0,0 +1,13 @@
#!/bin/bash
set -uo pipefail
BASE=/home/rafa/joomla-migration/mirror-antiguo
RUN=$(cat "$BASE/CURRENT_RUN")
D=$BASE/runs/$RUN
cd "$D"
echo "=== ficheros marcados por el escaneo (§4.3.2) ==="
while read -r f; do
echo "----- $f"
du -h "$f" 2>/dev/null | cut -f1
head -c 200 "$f" | tr -d '\0'
echo; echo
done < "$D/scan-suspicious.txt"
@@ -0,0 +1,14 @@
#!/bin/bash
# Que los scripts auxiliares que metimos en la raiz del Joomla restaurado NO esten en el mirror
set -uo pipefail
BASE=/home/rafa/joomla-migration/mirror-antiguo
RUN=$(cat "$BASE/CURRENT_RUN")
D=$BASE/runs/$RUN
echo "=== _genurls.php / _inv/ dentro de raw ==="
find "$D/raw" \( -name '_genurls.php' -o -path '*_inv*' \) | wc -l
echo "=== cualquier .php en raw ==="
find "$D/raw" -iname '*.php' | head
echo "(total: $(find "$D/raw" -iname '*.php' | wc -l))"
echo
echo "=== auxiliares presentes en el Joomla restaurado (fuera del mirror) ==="
ls "$BASE/restore/web/_genurls.php" "$BASE/restore/web/_inv/" 2>/dev/null
+14
View File
@@ -0,0 +1,14 @@
#!/bin/bash
set -uo pipefail
BASE=/home/rafa/joomla-migration/mirror-antiguo
RUN=$(cat "$BASE/CURRENT_RUN")
D=$BASE/runs/$RUN
echo "=== coincidencias de '_inv' o '_genurls' en raw ==="
find "$D/raw" \( -name '_genurls.php' -o -path '*_inv*' \) | head -25
echo
echo "=== index.php capturado: que contiene ==="
f="$D/raw/antiguo.feadulta.com/index.php"
ls -la "$f" | sed 's/\(.\{120\}\).*/\1/'
head -c 200 "$f"
echo; echo
echo "contiene '<?php': $(grep -c '<?php' "$f" || true)"
@@ -0,0 +1,20 @@
#!/bin/bash
set -uo pipefail
BASE=/home/rafa/joomla-migration/mirror-antiguo
RUN=$(cat "$BASE/CURRENT_RUN")
D=$BASE/runs/$RUN
S=$D/site/antiguo.feadulta.com
echo "ficheros en site: $(find "$D/site" -type f | wc -l)"; du -sh "$D/site"
echo "con '?' en el nombre: $(find "$D/site" -type f -name '*[?]*' | wc -l)"
echo "con print=1: $(find "$D/site" -type f -name '*print=1*' | wc -l)"
echo
echo "=== el caso 715 ==="
ls "$S/es/buscadoravanzado/item/715-"* 2>/dev/null
echo
echo "=== assets cache-busted: existe la copia limpia? ==="
for f in media/system/js/core.js media/jui/js/jquery.min.js components/com_k2/css/k2.css; do
printf '%-45s %s\n' "$f" "$([ -f "$S/$f" ] && echo OK || echo FALTA)"
done
echo
echo "=== enlaces absolutos que queden a antiguo.feadulta.com ==="
grep -rl 'http://antiguo.feadulta.com' "$S/es" 2>/dev/null | wc -l
@@ -0,0 +1,30 @@
#!/usr/bin/env python3
"""Rehace las copias de nombre limpio DESPUES de la reescritura de enlaces.
El orden importaba: en `45-normalize-links.py` las copias se creaban antes de reescribir, asi que
`titulo?.html` quedaba reescrito pero su copia `titulo` (sin extension, la que pedira el navegador)
conservaba los enlaces absolutos. Aqui se rehacen desde el fichero ya reescrito.
"""
import os, shutil
from collections import Counter
BASE = "/home/rafa/joomla-migration/mirror-antiguo"
RUN = open(os.path.join(BASE, "CURRENT_RUN")).read().strip()
SITE = os.path.join(BASE, "runs", RUN, "site")
st = Counter()
for root, _d, files in os.walk(SITE):
for fn in list(files):
if "?" not in fn:
continue
base = fn.split("?", 1)[0]
if not base:
continue
src, dst = os.path.join(root, fn), os.path.join(root, base)
if os.path.exists(dst) and os.path.getmtime(dst) >= os.path.getmtime(src):
st["ya_al_dia"] += 1
continue
shutil.copy2(src, dst)
st["recopiados"] += 1
print(dict(st))
+74
View File
@@ -0,0 +1,74 @@
#!/usr/bin/env python3
"""Quita el tag de Google Analytics clasico (UA-32008163-1) del mirror servible.
UA dejo de procesar datos en julio de 2023: el snippet solo sirve para pedir un
ga.js muerto en cada carga. Se quita el bloque <script> ENTERO que lo contiene,
no solo la linea del ID, porque dejar el `_gaq.push` suelto no ahorra la peticion.
**GA4 (G-6RT9ZRS4LW) se queda**: es un bloque <script> distinto y Rafa quiere
seguir midiendo el archivo (#180 comment-504, decision 4).
Solo se toca `site/` (el arbol servible). `raw/` queda intacto como captura fiel
del original, igual que se hizo con los botones sociales.
Uso: python3 60-quitar-ua.py [--dry-run]
"""
import os, re, sys, hashlib
DRY = "--dry-run" in sys.argv
BASE = "/home/rafa/joomla-migration/mirror-antiguo"
RUN = open(os.path.join(BASE, "CURRENT_RUN")).read().strip()
SITE = os.path.join(BASE, "runs", RUN, "site", "antiguo.feadulta.com")
UA = "UA-32008163-1"
GA4 = "G-6RT9ZRS4LW"
# Bloque <script>…</script> que contiene el UA. El (?:(?!</script>).)*? impide
# que el .*? se coma varios bloques seguidos y se lleve por delante el GA4.
BLOQUE = re.compile(
r"[ \t]*<script[^>]*>(?:(?!</script>).)*?" + re.escape(UA) +
r"(?:(?!</script>).)*?</script>\s*", re.S)
tocados = errores = 0
sin_ga4 = []
bytes_antes = bytes_despues = 0
for raiz, _, ficheros in os.walk(SITE):
for f in ficheros:
if not f.lower().endswith((".html", ".htm")) and "." in f:
continue
ruta = os.path.join(raiz, f)
try:
txt = open(ruta, encoding="utf-8", errors="surrogateescape").read()
except (OSError, UnicodeDecodeError):
continue
if UA not in txt:
continue
tenia_ga4 = GA4 in txt
nuevo, n = BLOQUE.subn("\n", txt)
if UA in nuevo:
# El bloque no casó: no dejar el fichero a medias, mejor avisar.
errores += 1
continue
if tenia_ga4 and GA4 not in nuevo:
sin_ga4.append(ruta)
continue
bytes_antes += len(txt)
bytes_despues += len(nuevo)
tocados += 1
if not DRY:
with open(ruta, "w", encoding="utf-8", errors="surrogateescape") as fh:
fh.write(nuevo)
print(f"ficheros modificados : {tocados}")
print(f"no casó el patron : {errores}")
print(f"habrian perdido GA4 : {len(sin_ga4)}")
for r in sin_ga4[:5]:
print(" ", r)
if tocados:
print(f"bytes : {bytes_antes:,} -> {bytes_despues:,} "
f"({bytes_antes - bytes_despues:,} menos)")
print("(DRY RUN, no se ha escrito nada)" if DRY else "escrito")
@@ -0,0 +1,12 @@
#!/bin/bash
# Devuelve el entorno a como estaba: rearranca los contenedores parados durante el crawl
set -uo pipefail
BASE=/home/rafa/joomla-migration/mirror-antiguo
STOPPED=$BASE/stopped-containers.txt
[ -s "$STOPPED" ] || { echo "no hay lista de contenedores parados"; exit 0; }
while read -r c; do
[ -n "$c" ] && docker start "$c" >/dev/null && echo "arrancado $c"
done < "$STOPPED"
mv "$STOPPED" "$STOPPED.hecho-$(date -u +%Y%m%dT%H%M%SZ)"
sleep 5
docker ps --format '{{.Names}} {{.Status}}'
+38
View File
@@ -0,0 +1,38 @@
#!/bin/bash
# Prueba de humo del despliegue: sirve site/ con nginx y comprueba que las rutas criticas
# responden 200 con el Content-Type correcto. Local, en el puerto 8087, se borra al terminar.
set -uo pipefail
BASE=/home/rafa/joomla-migration/mirror-antiguo
RUN=$(cat "$BASE/CURRENT_RUN")
S=$BASE/runs/$RUN/site/antiguo.feadulta.com
docker rm -f mirror-nginx-test >/dev/null 2>&1
docker run -d --name mirror-nginx-test --memory 256m \
-p 127.0.0.1:8087:80 \
-v "$S":/usr/share/nginx/html:ro \
-v "$BASE/deploy/nginx-mirror.conf":/etc/nginx/conf.d/default.conf:ro \
nginx:alpine >/dev/null
sleep 3
probe() {
local u="$1" desc="$2"
read -r code ctype < <(curl -s -o /dev/null -w '%{http_code} %{content_type}' "http://127.0.0.1:8087$u"; echo)
printf '%-6s %-28s %-58s %s\n' "$code" "$ctype" "$u" "$desc"
}
echo "codigo content-type url"
probe "/es/" "portada"
probe "/es/carta/estasemana.html" "carta: esta semana"
probe "/es/buscadoravanzado/item/9-experiencia-pascual.html" "item K2"
probe "/es/buscadoravanzado/item/715-%C2%BFqui%C3%A9n-es-jes%C3%BAs" "item con '?' en el alias"
probe "/es/buscadoravanzado/itemlist/user/569-agust%C3%ADnud%C3%ADasvallina.html" "pagina de autor K2"
probe "/es/lista-completa-de-autores-por-orden-alfabetico.html" "listado de autores"
probe "/anterior/" "web anterior (indice)"
probe "/media/system/js/core.js" "js con cache-busting"
probe "/components/com_k2/css/k2.css" "css de K2"
probe "/es/no-existe-esta-pagina.html" "404 esperado"
echo
echo "=== la portada trae contenido de verdad? ==="
curl -s http://127.0.0.1:8087/es/ | grep -o '<title>[^<]*</title>' | head -1
curl -s http://127.0.0.1:8087/es/ | wc -c
@@ -0,0 +1,13 @@
#!/bin/bash
set -uo pipefail
BASE=/home/rafa/joomla-migration/mirror-antiguo
RUN=$(cat "$BASE/CURRENT_RUN")
D=$BASE/runs/$RUN
cd "$D/site" && find . -type f -print0 | sort -z | xargs -0 sha256sum > "$D/MANIFEST-site.sha256"
cd "$D"
echo "MANIFEST-raw: $(wc -l < MANIFEST-raw.sha256) ficheros"
echo "MANIFEST-site: $(wc -l < MANIFEST-site.sha256) ficheros"
du -sh raw site
echo
echo "=== contenido de la corrida ==="
ls -la "$D" | grep -vE '^d.*(raw|site|chunks|logs)$'
+22
View File
@@ -0,0 +1,22 @@
#!/bin/bash
# Sincroniza el arbol servible con el Hetzner. Solo lo que ha cambiado.
#
# --delete es intencionado: el servidor debe ser copia exacta de site/, ni un
# fichero de mas. Por eso se comprueba antes que el origen NO esta vacio: un
# origen vacio con --delete borraria el sitio entero.
set -euo pipefail
BASE=/home/rafa/joomla-migration/mirror-antiguo
RUN=$(cat "$BASE/CURRENT_RUN")
SRC="$BASE/runs/$RUN/site/antiguo.feadulta.com/"
DST=root@188.40.120.157:/data/feadulta-antiguo/site/antiguo.feadulta.com/
n=$(find "$SRC" -type f | wc -l)
echo "origen: $SRC"
echo "ficheros en origen: $n"
if [ "$n" -lt 60000 ]; then
echo "ABORTADO: el origen tiene menos ficheros de los esperados. No se sincroniza."
exit 1
fi
rsync -a --delete --stats --human-readable "$SRC" "$DST"
@@ -0,0 +1,17 @@
#!/bin/bash
# Fuente F3 del plan: inventario historico de URLs segun Internet Archive.
# No toca el origen ni produccion; es una consulta de solo lectura a web.archive.org.
set -uo pipefail
BASE=/home/rafa/joomla-migration/mirror-antiguo
INV=$BASE/inventory
mkdir -p "$INV"
for host in antiguo.feadulta.com feadulta.com; do
out="$INV/wayback-${host%%.*}.txt"
echo "-> $host"
curl -s --max-time 300 \
"https://web.archive.org/cdx/search/cdx?url=${host}*&output=text&fl=original&collapse=urlkey&limit=200000" \
> "$out"
echo " $(wc -l < "$out") URLs"
done
wc -l "$INV"/wayback-*.txt
@@ -0,0 +1,70 @@
#!/usr/bin/env python3
"""Cruza el inventario historico de Internet Archive contra el mirror.
Responde a la pregunta de aceptacion que de verdad importa: **de las URLs legacy que el mundo
exterior tiene enlazadas, cuantas resuelven en el mirror**. Sustituto parcial de la fuente F2 (GA4),
que sigue bloqueada porque requiere que Rafa abra el OAuth a mano.
"""
import os, json
from collections import Counter
from urllib.parse import urlsplit, unquote
BASE = "/home/rafa/joomla-migration/mirror-antiguo"
RUN = open(os.path.join(BASE, "CURRENT_RUN")).read().strip()
DIR = os.path.join(BASE, "runs", RUN)
SITE = os.path.join(DIR, "site", "antiguo.feadulta.com")
def existe(path):
p = unquote(path).lstrip("/")
for c in (p, p.split("?", 1)[0], os.path.join(p, "index.html")):
if c and os.path.isfile(os.path.join(SITE, c)):
return True
return False
paths, cats = set(), Counter()
for fn in ("wayback-antiguo.txt", "wayback-feadulta.txt"):
for line in open(os.path.join(BASE, "inventory", fn), errors="replace"):
u = line.strip()
if not u:
continue
p = urlsplit(u).path
q = urlsplit(u).query
if q: # las URLs con query no forman parte del mirror estatico
cats["con_query (fuera de alcance)"] += 1
continue
if not p or p == "/":
cats["raiz"] += 1
continue
paths.add(p)
ok, missing = 0, []
for p in sorted(paths):
if existe(p):
ok += 1
else:
missing.append(p)
print("URLs distintas de Wayback sin query:", len(paths))
print("presentes en el mirror:", ok, "(%.1f%%)" % (ok * 100.0 / max(len(paths), 1)))
print("ausentes:", len(missing))
for k, v in cats.most_common():
print(" %s: %s" % (k, v))
# clasificar las ausentes para ver si importan
tipo = Counter()
for p in missing:
seg = p.strip("/").split("/")[0] if p.strip("/") else "(raiz)"
tipo[seg] += 1
print("\n--- ausentes por primer segmento ---")
for k, v in tipo.most_common(20):
print("%7d %s" % (v, k))
with open(os.path.join(DIR, "wayback-missing.txt"), "w") as f:
for p in missing:
f.write(p + "\n")
json.dump({"wayback_paths": len(paths), "presentes": ok, "ausentes": len(missing),
"pct": round(ok * 100.0 / max(len(paths), 1), 2)},
open(os.path.join(DIR, "wayback-report.json"), "w"), indent=2)
print("\n--- muestra de ausentes ---")
for p in missing[:20]:
print(" ", p)
+63
View File
@@ -0,0 +1,63 @@
#!/usr/bin/env python3
"""Afina el cruce con Wayback: solo las URLs /es/ (el Joomla legacy), que es lo que el mirror cubre.
El 52 % global del script anterior mezcla peras con manzanas: Wayback conoce feadulta.com desde
antes de que existiera el Joomla (ficheros .htm sueltos en la raiz, que hoy viven bajo /anterior/) y
tambien el WordPress actual (/wp-content, /wp-json). Nada de eso forma parte del mirror del legacy.
"""
import os, json, re
from collections import Counter
from urllib.parse import urlsplit, unquote
BASE = "/home/rafa/joomla-migration/mirror-antiguo"
RUN = open(os.path.join(BASE, "CURRENT_RUN")).read().strip()
DIR = os.path.join(BASE, "runs", RUN)
SITE = os.path.join(DIR, "site", "antiguo.feadulta.com")
def existe(path):
p = unquote(path).lstrip("/")
for c in (p, p.split("?", 1)[0], os.path.join(p, "index.html")):
if c and os.path.isfile(os.path.join(SITE, c)):
return True
return False
paths = set()
for fn in ("wayback-antiguo.txt", "wayback-feadulta.txt"):
for line in open(os.path.join(BASE, "inventory", fn), errors="replace"):
u = line.strip()
if not u:
continue
s = urlsplit(u)
if s.query:
continue
if s.path.startswith("/es/"):
paths.add(s.path)
ok, missing = 0, []
for p in sorted(paths):
if existe(p):
ok += 1
else:
missing.append(p)
print("URLs /es/ conocidas por Wayback:", len(paths))
print("resuelven en el mirror:", ok, "(%.1f%%)" % (ok * 100.0 / max(len(paths), 1)))
print("no resuelven:", len(missing))
tipo = Counter()
for p in missing:
seg = p.split("/")
tipo["/".join(seg[:3])] += 1
print("\n--- las que faltan, por seccion ---")
for k, v in tipo.most_common(15):
print("%7d %s" % (v, k))
json.dump({"wayback_es_paths": len(paths), "presentes": ok, "ausentes": len(missing),
"pct": round(ok * 100.0 / max(len(paths), 1), 2)},
open(os.path.join(DIR, "wayback-es-report.json"), "w"), indent=2)
with open(os.path.join(DIR, "wayback-es-missing.txt"), "w") as f:
for p in missing:
f.write(p + "\n")
print("\n--- muestra ---")
for p in missing[:15]:
print(" ", p)
@@ -0,0 +1,77 @@
#!/usr/bin/env python3
"""§4.3, último punto del plan: contrastar páginas capturadas contra Internet Archive.
No compara el texto (una instantánea de hace años difiere por fuerza: fechas, barras laterales,
bloques rotativos). Compara lo que de verdad delata una inyección: **el conjunto de hosts externos
a los que la página carga scripts o iframes**. Si nuestra captura referencia hosts que la versión
histórica no tenía, hay que mirarlo.
"""
import os, re, json, sys, urllib.request, random
from collections import Counter
from urllib.parse import urlsplit, unquote
BASE = "/home/rafa/joomla-migration/mirror-antiguo"
RUN = open(os.path.join(BASE, "CURRENT_RUN")).read().strip()
DIR = os.path.join(BASE, "runs", RUN)
SITE = os.path.join(DIR, "site", "antiguo.feadulta.com")
N = int(sys.argv[1]) if len(sys.argv) > 1 else 10
SRC = re.compile(r'<(?:script|iframe)[^>]+src=["\']((?:https?:)?//[^"\'/]+)', re.I)
UA = {"User-Agent": "feadulta-archiver/1.0 (verificacion de integridad; incident-183)"}
def hosts(html):
out = set()
for m in SRC.findall(html):
h = m.split("//", 1)[-1].lower()
# web.archive.org reescribe los recursos: nos quedamos con el host original
if h.startswith("web.archive.org"):
continue
out.add(h)
return out
# candidatas: URLs /es/ que Wayback conoce Y que tenemos capturadas
cand = []
for fn in ("wayback-antiguo.txt", "wayback-feadulta.txt"):
for line in open(os.path.join(BASE, "inventory", fn), errors="replace"):
u = line.strip()
s = urlsplit(u)
if s.query or not s.path.startswith("/es/") or not s.path.endswith(".html"):
continue
p = unquote(s.path).lstrip("/")
if os.path.isfile(os.path.join(SITE, p)):
cand.append((u, p))
random.seed(20260730)
sample = random.sample(cand, min(N, len(cand)))
print("candidatas:", len(cand), "- muestra:", len(sample), "\n")
res, extra_total = [], Counter()
for url, rel in sample:
local = open(os.path.join(SITE, rel), encoding="utf-8", errors="replace").read()
hl = hosts(local)
try:
req = urllib.request.Request("https://web.archive.org/web/2id_/" + url, headers=UA)
arch = urllib.request.urlopen(req, timeout=90).read().decode("utf-8", "replace")
ha = hosts(arch)
estado = "ok"
except Exception as e:
ha, estado = set(), "sin snapshot (%s)" % type(e).__name__
extra = hl - ha
if estado == "ok":
for h in extra:
extra_total[h] += 1
print("%-70s %s" % (rel[-68:], estado))
if estado == "ok" and extra:
print(" hosts solo en nuestra captura:", ", ".join(sorted(extra)))
res.append({"url": url, "estado": estado, "hosts_mirror": sorted(hl),
"hosts_wayback": sorted(ha), "solo_en_mirror": sorted(extra)})
print("\n--- hosts presentes solo en nuestra captura (agregado) ---")
if extra_total:
for h, c in extra_total.most_common():
print("%4d %s" % (c, h))
else:
print("ninguno")
json.dump(res, open(os.path.join(DIR, "wayback-contraste.json"), "w"), indent=2, ensure_ascii=False)
print("\ninforme:", os.path.join(DIR, "wayback-contraste.json"))
@@ -0,0 +1,27 @@
#!/bin/bash
# Que codigo de terceros lleva realmente el mirror: GTM y botones sociales
set -uo pipefail
BASE=/home/rafa/joomla-migration/mirror-antiguo
RUN=$(cat "$BASE/CURRENT_RUN")
S=$BASE/runs/$RUN/site/antiguo.feadulta.com
P=$S/es/buscadoravanzado/item/9-experiencia-pascual.html
echo "=== IDs de contenedor GTM/GA que aparecen en el mirror ==="
grep -rhoE 'GTM-[A-Z0-9]+|UA-[0-9]+-[0-9]+|G-[A-Z0-9]+' "$S/es" 2>/dev/null | sort | uniq -c | sort -rn | head
echo
echo "=== bloque GTM en una pagina de ejemplo ==="
grep -o 'googletagmanager[^<]*' "$P" | head -3
grep -B2 -A6 'googletagmanager' "$P" | head -25
echo
echo "=== bloques sociales en esa misma pagina ==="
grep -oE '<script[^>]*(connect\.facebook\.net|platform\.twitter\.com)[^>]*>' "$P" | head
grep -oE '(fb-root|fb-like|twitter-share-button|fb:like|data-href="[^"]*")' "$P" | head -10
echo
echo "=== cuantas paginas llevan cada cosa ==="
printf 'googletagmanager : %s\n' "$(grep -rl 'googletagmanager' "$S" 2>/dev/null | wc -l)"
printf 'connect.facebook : %s\n' "$(grep -rl 'connect.facebook.net' "$S" 2>/dev/null | wc -l)"
printf 'platform.twitter : %s\n' "$(grep -rl 'platform.twitter.com' "$S" 2>/dev/null | wc -l)"
printf 'cdnjs.cloudflare : %s\n' "$(grep -rl 'cdnjs.cloudflare.com' "$S" 2>/dev/null | wc -l)"
+14
View File
@@ -0,0 +1,14 @@
#!/bin/bash
set -uo pipefail
BASE=/home/rafa/joomla-migration/mirror-antiguo
RUN=$(cat "$BASE/CURRENT_RUN")
S=$BASE/runs/$RUN/site/antiguo.feadulta.com
P=$S/es/buscadoravanzado/item/9-experiencia-pascual.html
echo "=== lineas con twitter / facebook / fb- ==="
grep -n -E 'platform\.twitter|connect\.facebook|fb-root|fb-like|twitter-share-button' "$P" \
| cut -c1-400
echo
echo "=== 12 lineas alrededor de la primera aparicion ==="
n=$(grep -n 'twitter-share-button\|platform.twitter' "$P" | head -1 | cut -d: -f1)
sed -n "$((n-6)),$((n+14))p" "$P" | cut -c1-300
@@ -0,0 +1,64 @@
#!/usr/bin/env python3
"""Antes de tocar nada: que hay realmente dentro de los bloques sociales del mirror."""
import os, re
from collections import Counter
BASE = "/home/rafa/joomla-migration/mirror-antiguo"
RUN = open(os.path.join(BASE, "CURRENT_RUN")).read().strip()
SITE = os.path.join(BASE, "runs", RUN, "site", "antiguo.feadulta.com")
OPEN = re.compile(r'<div\b[^>]*class="[^"]*itemSocialSharing[^"]*"[^>]*>', re.I)
DIV = re.compile(r'<div\b[^>]*>|</div>', re.I)
CLASS = re.compile(r'<div\b[^>]*class="([^"]+)"', re.I)
SCRIPTSRC = re.compile(r'<script[^>]+src="([^"]+)"', re.I)
def bloque(html, m):
"""Devuelve (inicio, fin) del div equilibrado que empieza en m."""
depth, pos = 0, m.start()
for d in DIV.finditer(html, m.start()):
if d.group(0).lower().startswith("</"):
depth -= 1
if depth == 0:
return m.start(), d.end()
else:
depth += 1
return None
clases, scripts, sin_bloque, con_bloque = Counter(), Counter(), 0, 0
n = 0
for root, _d, files in os.walk(SITE):
for fn in files:
if not fn.split("?", 1)[0].lower().endswith((".html", ".htm")):
continue
p = os.path.join(root, fn)
try:
html = open(p, encoding="utf-8", errors="replace").read()
except OSError:
continue
if "itemSocialSharing" not in html:
continue
n += 1
if n > 400:
break
for m in OPEN.finditer(html):
r = bloque(html, m)
if not r:
sin_bloque += 1
continue
con_bloque += 1
frag = html[r[0]:r[1]]
for c in CLASS.findall(frag):
clases[c.strip()] += 1
for s in SCRIPTSRC.findall(frag):
scripts[s.split("?")[0]] += 1
if n > 400:
break
print("paginas inspeccionadas con itemSocialSharing:", n)
print("bloques equilibrados:", con_bloque, " sin cerrar:", sin_bloque)
print("\n--- clases de div dentro del bloque ---")
for k, v in clases.most_common(15):
print("%7d %s" % (v, k))
print("\n--- scripts dentro del bloque ---")
for k, v in scripts.most_common(15):
print("%7d %s" % (v, k))
+108
View File
@@ -0,0 +1,108 @@
#!/usr/bin/env python3
"""Quita los botones sociales del derivado `site/`. `raw/` no se toca.
El bloque de K2 es uniforme en las 16.708 paginas que lo llevan: `<div class="itemSocialSharing">`
contiene el boton de Twitter, el de Facebook y un clearfix, nada mas (comprobado con
86-survey-social.py sobre 400 paginas: 0 variantes). Se elimina el bloque entero, asi no quedan
huecos ni botones rotos.
Red de seguridad: fuera del bloque tambien se eliminan los <script> a esos dos hosts y cualquier
`<div id="fb-root">` suelto.
"""
import os, re, json
from collections import Counter
BASE = "/home/rafa/joomla-migration/mirror-antiguo"
RUN = open(os.path.join(BASE, "CURRENT_RUN")).read().strip()
DIR = os.path.join(BASE, "runs", RUN)
SITE = os.path.join(DIR, "site", "antiguo.feadulta.com")
OPEN = re.compile(r'<div\b[^>]*class="[^"]*itemSocialSharing[^"]*"[^>]*>', re.I)
DIV = re.compile(r'<div\b[^>]*>|</div>', re.I)
SOCIAL_SCRIPT = re.compile(
r'<script[^>]+src="[^"]*(?:platform\.twitter\.com|connect\.facebook\.net)[^"]*"[^>]*>\s*</script>',
re.I)
FB_ROOT = re.compile(r'<div\s+id="fb-root"\s*>\s*</div>', re.I)
TW_ANCHOR = re.compile(r'<a\b[^>]*class="[^"]*twitter-share-button[^"]*"[^>]*>.*?</a>', re.I | re.S)
FB_LIKE = re.compile(r'<div\b[^>]*class="[^"]*fb-like[^"]*"[^>]*>\s*</div>', re.I)
AVISO = ('<!-- botones sociales retirados del archivo historico '
'(no procede compartir ni cargar SDK de terceros) -->')
def es_html(path, fn):
"""Decidir por extension no basta en este mirror: conviven `x.html?tmpl=…` (la extension esta
antes de la query) y `x?.html` con su copia `x` sin extension (alias de K2 con '?' literal).
Para esos casos se mira el contenido, que es lo unico fiable."""
base = fn.split("?", 1)[0].lower()
if base.endswith((".html", ".htm")) or fn.lower().endswith((".html", ".htm")):
return True
if "." in fn.split("/")[-1].split("?", 1)[0]:
return False # tiene otra extension (jpg, mp3, css…)
try:
with open(path, "rb") as f:
cabeza = f.read(512).lstrip().lower()
return cabeza.startswith(b"<!doctype html") or cabeza.startswith(b"<html")
except OSError:
return False
def quita_bloques(html, st):
out, pos = [], 0
while True:
m = OPEN.search(html, pos)
if not m:
out.append(html[pos:])
return "".join(out)
depth, fin = 0, None
for d in DIV.finditer(html, m.start()):
if d.group(0).lower().startswith("</"):
depth -= 1
if depth == 0:
fin = d.end()
break
else:
depth += 1
if fin is None: # bloque sin cerrar: no lo tocamos
st["bloques_sin_cerrar"] += 1
out.append(html[pos:m.end()])
pos = m.end()
continue
out.append(html[pos:m.start()])
out.append(AVISO)
st["bloques_retirados"] += 1
pos = fin
def main():
st = Counter()
for root, _d, files in os.walk(SITE):
for fn in files:
p = os.path.join(root, fn)
if not es_html(p, fn):
continue
try:
html = open(p, encoding="utf-8", errors="replace").read()
except OSError:
continue
if not any(k in html for k in
("itemSocialSharing", "platform.twitter.com", "connect.facebook.net", "fb-root")):
continue
orig = html
html = quita_bloques(html, st)
html, n = SOCIAL_SCRIPT.subn("", html); st["scripts_sueltos"] += n
html, n = TW_ANCHOR.subn("", html); st["botones_twitter_sueltos"] += n
html, n = FB_LIKE.subn("", html); st["botones_fb_sueltos"] += n
html, n = FB_ROOT.subn("", html); st["fb_root_sueltos"] += n
if html != orig:
open(p, "w", encoding="utf-8").write(html)
st["ficheros_modificados"] += 1
out = os.path.join(DIR, "social-removal.json")
json.dump(dict(st), open(out, "w"), indent=2, ensure_ascii=False)
print(json.dumps(dict(st), indent=2, ensure_ascii=False))
print("informe:", out)
if __name__ == "__main__":
main()
+19
View File
@@ -0,0 +1,19 @@
#!/bin/bash
# ¿El ID de medicion del mirror es el MISMO que usa el WordPress vivo?
# Si lo es, publicar el mirror contaminaria la analitica con la que se decide el cutover.
set -uo pipefail
echo "=== ID en el mirror ==="
echo "G-6RT9ZRS4LW (GA4) + UA-32008163-1 (Universal Analytics, dejo de recoger datos en jul-2023)"
echo
echo "=== ids de Google en el WordPress local de feadulta ==="
for d in /home/rafa/joomla-migration/wordpress /home/rafa/Feadulta; do
[ -d "$d" ] || continue
echo "--- $d"
grep -rhoE 'G-[A-Z0-9]{8,}|UA-[0-9]+-[0-9]+|GTM-[A-Z0-9]+' "$d" 2>/dev/null | sort | uniq -c | sort -rn | head -5
done
echo
echo "=== ids de Google en el repo feadulta (mu-plugins/scripts) ==="
for d in /home/rafa/feadulta /home/rafa/joomla-migration; do
[ -d "$d" ] || continue
grep -rhoE 'G-[A-Z0-9]{8,}|UA-[0-9]+-[0-9]+|GTM-[A-Z0-9]+' "$d" --include='*.php' --include='*.py' --include='*.md' 2>/dev/null | sort | uniq -c | sort -rn | head -5
done
@@ -0,0 +1,17 @@
#!/bin/bash
set -uo pipefail
BASE=/home/rafa/joomla-migration/mirror-antiguo
RUN=$(cat "$BASE/CURRENT_RUN")
S=$BASE/runs/$RUN/site/antiguo.feadulta.com
R=$BASE/runs/$RUN/raw/antiguo.feadulta.com
echo "=== site/ (derivado) ==="
printf 'connect.facebook.net : %s\n' "$(grep -rl 'connect.facebook.net' "$S" 2>/dev/null | wc -l)"
printf 'platform.twitter.com : %s\n' "$(grep -rl 'platform.twitter.com' "$S" 2>/dev/null | wc -l)"
printf 'itemSocialSharing : %s\n' "$(grep -rl 'itemSocialSharing' "$S" 2>/dev/null | wc -l)"
printf 'fb-root : %s\n' "$(grep -rl 'fb-root' "$S" 2>/dev/null | wc -l)"
echo
echo "=== raw/ (intacto, debe seguir teniendolos) ==="
printf 'connect.facebook.net : %s\n' "$(grep -rl 'connect.facebook.net' "$R" 2>/dev/null | wc -l)"
echo
echo "=== restos si los hay ==="
grep -rl 'connect.facebook.net\|platform.twitter.com' "$S" 2>/dev/null | head -5
+9
View File
@@ -0,0 +1,9 @@
#!/bin/bash
set -uo pipefail
BASE=/home/rafa/joomla-migration/mirror-antiguo
RUN=$(cat "$BASE/CURRENT_RUN")
S=$BASE/runs/$RUN/site/antiguo.feadulta.com
grep -rl 'itemSocialSharing' "$S" 2>/dev/null | while read -r f; do
echo "--- $f"
grep -o '.\{0,80\}itemSocialSharing.\{0,120\}' "$f" | head -3
done
+23
View File
@@ -0,0 +1,23 @@
#!/bin/bash
# Verificacion de cierre de la noche
set -uo pipefail
BASE=/home/rafa/joomla-migration/mirror-antiguo
RUN=$(cat "$BASE/CURRENT_RUN")
D=$BASE/runs/$RUN
echo "=== snapshot fuente (integridad) ==="
cd "$BASE/source" && sha256sum -c MANIFEST-source.sha256
echo
echo "=== corrida ==="
echo "run: $RUN"
du -sh "$D/raw" "$D/site"
echo "raw: $(find "$D/raw" -type f | wc -l) ficheros"
echo "site: $(find "$D/site" -type f | wc -l) ficheros"
echo
echo "=== contenedores ==="
docker ps --format '{{.Names}}\t{{.Status}}' | sort
echo
echo "=== mirror servido en 8087 ==="
curl -s -o /dev/null -w 'portada: %{http_code} %{content_type}\n' http://127.0.0.1:8087/es/
echo
echo "=== disco ==="
df -h /home | tail -1
@@ -0,0 +1,70 @@
#!/bin/bash
# Repone los assets que el crawl no capturo (404 en produccion, 200 en el origen).
#
# Entrada: lista de rutas absolutas (una por linea) sacada de los logs de nginx
# del Hetzner. Origen: el Joomla local restaurado y aislado (127.0.0.1:8086),
# por HTTP -- nunca copiando el filesystem, mismo principio que el crawl.
#
# Escribe en raw/ y en site/: raw/ es el archivo tal cual se capturo, site/ es
# el arbol servible que sincroniza 80-sync-hetzner.sh.
set -uo pipefail
BASE=/home/rafa/joomla-migration/mirror-antiguo
RUN=$(cat "$BASE/CURRENT_RUN")
DIR=$BASE/runs/$RUN
RAW=$DIR/raw/antiguo.feadulta.com
SITE=$DIR/site/antiguo.feadulta.com
ORIGEN=http://127.0.0.1:8086
IN=${1:-/tmp/assets404.txt}
OUT=$DIR/logs/repone-assets-$(date -u +%Y%m%dT%H%M%SZ)
[ -s "$IN" ] || { echo "no hay lista de entrada: $IN"; exit 1; }
[ -d "$SITE" ] || { echo "no existe $SITE"; exit 1; }
mkdir -p "$OUT"
ok=0; fail=0; skip=0; ya=0
while IFS= read -r p; do
[ -n "$p" ] || continue
case "$p" in
/%22*|*'"'*) echo "$p" >> "$OUT/descartados.txt"; skip=$((skip+1)); continue ;;
esac
dest="$RAW$p"
if [ -f "$dest" ]; then echo "$p" >> "$OUT/ya-estaban.txt"; ya=$((ya+1)); continue; fi
mkdir -p "$(dirname "$dest")" 2>/dev/null || { echo "$p" >> "$OUT/fallidos.txt"; fail=$((fail+1)); continue; }
code=$(curl -s --path-as-is -m 30 -o "$dest.part" -w '%{http_code}' "$ORIGEN$p")
if [ "$code" = "200" ] && [ -s "$dest.part" ]; then
mv "$dest.part" "$dest"
echo "$p" >> "$OUT/repuestos.txt"; ok=$((ok+1))
else
rm -f "$dest.part"
echo "$code $p" >> "$OUT/fallidos.txt"; fail=$((fail+1))
fi
done < "$IN"
echo "repuestos: $ok · fallidos: $fail · descartados: $skip · ya estaban: $ya"
# --- escaneo de seguridad antes de copiar a site/ ---
echo "== escaneo de PHP embebido en lo descargado =="
sospechosos=0
if [ -s "$OUT/repuestos.txt" ]; then
while IFS= read -r p; do
if head -c 4096 "$RAW$p" 2>/dev/null | grep -qa '<?php'; then
echo " SOSPECHOSO $p"; echo "$p" >> "$OUT/sospechosos.txt"; sospechosos=$((sospechosos+1))
fi
done < "$OUT/repuestos.txt"
fi
echo " sospechosos: $sospechosos"
if [ "$sospechosos" -gt 0 ]; then
echo "ABORTADO: hay ficheros con PHP embebido. No se copian a site/."
exit 1
fi
# --- copia a site/ ---
if [ -s "$OUT/repuestos.txt" ]; then
while IFS= read -r p; do
mkdir -p "$(dirname "$SITE$p")"
cp -p "$RAW$p" "$SITE$p"
done < "$OUT/repuestos.txt"
fi
echo "copiados a site/: $ok"
echo "detalle en: $OUT"
@@ -0,0 +1,10 @@
portfolio-tracker
yt-api-bridge
wordpress-web
joomla-web-php83
jellyfin
triptyk-local-wordpress-1
triptyk-local-db-1
yt-summaries
joomla-web
wordpress-mysql
+37
View File
@@ -0,0 +1,37 @@
#!/usr/bin/env bash
# Retira exclusivamente el esquema vacío fewp1 de CDMON.
# Por defecto solo hace dry-run. La eliminación real exige --apply y el token exacto.
set -euo pipefail
mode="dry-run"
if [[ "${1:-}" == "--apply" ]]; then
mode="apply"
fi
if [[ "$mode" == "apply" && "${CONFIRM_DROP_FEWP1:-}" != "DROP_FEWP1" ]]; then
echo "Refusing apply: export CONFIRM_DROP_FEWP1=DROP_FEWP1 first." >&2
exit 2
fi
set -a
source "$HOME/.hermes/profiles/feadulta/.env"
set +a
php='global $wpdb;
$count=(int)$wpdb->get_var("SELECT COUNT(*) FROM information_schema.tables WHERE table_schema=\"fewp1\"");
if ($count !== 0) { fwrite(STDERR, "ABORT: fewp1 has ".$count." tables; it is not empty.\n"); exit(3); }
echo "PASS: fewp1 exists and has 0 tables.\n";'
if [[ "$mode" == "apply" ]]; then
php+=' $ok=$wpdb->query("DROP DATABASE `fewp1`");
if ($ok === false) { fwrite(STDERR, "ABORT: DROP DATABASE failed.\n"); exit(4); }
$remaining=(int)$wpdb->get_var("SELECT COUNT(*) FROM information_schema.schemata WHERE schema_name=\"fewp1\"");
if ($remaining !== 0) { fwrite(STDERR, "ABORT: fewp1 remains after DROP.\n"); exit(5); }
echo "APPLIED: fewp1 removed; verified absent.\n";'
else
php+=' echo "DRY-RUN: would execute DROP DATABASE `fewp1`; no production change made.\n";'
fi
printf -v quoted_php '%q' "$php"
sshpass -p "$FEA_PROD_SSH_PASS" ssh -o StrictHostKeyChecking=accept-new "$FEA_PROD_SSH_HOST" \
"cd /web && wp eval $quoted_php"
+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);
+41
View File
@@ -262,6 +262,47 @@ switch ($cmd) {
break; break;
} }
case 'clone_new': {
// Clona en un ID local NUEVO. Se usa al importar desde prod cuando el
// mismo ID ya está ocupado por contenido local distinto.
$lang = (string) ($argv[2] ?? '');
$status = (string) ($argv[3] ?? '');
if ($lang === '') {
fwrite(STDERR, "uso: clone_new <lang> <status>\n"); exit(6);
}
$payload = json_decode(file_get_contents('php://stdin'), true);
if (!is_array($payload) || empty($payload['title'])) {
fwrite(STDERR, "payload inválido por stdin\n"); exit(4);
}
$postarr = [
'post_title' => wp_slash($payload['title']),
'post_content' => wp_slash($payload['content'] ?? ''),
'post_excerpt' => wp_slash($payload['excerpt'] ?? ''),
'post_status' => $status ?: ($payload['status'] ?? 'draft'),
'post_type' => $payload['type'] ?? 'post',
'post_author' => (int) ($payload['author'] ?? 1),
'post_date' => $payload['date'] ?? current_time('mysql'),
'post_date_gmt'=> $payload['date_gmt'] ?? current_time('mysql', true),
'post_name' => $payload['slug'] ?? '',
'to_ping' => '',
'pinged' => '',
];
$new_id = wp_insert_post($postarr, true);
if (is_wp_error($new_id)) { fwrite(STDERR, $new_id->get_error_message() . "\n"); exit(5); }
pll_set_post_language($new_id, $lang);
$cats = [];
foreach ((array) ($payload['cat_slugs'] ?? []) as $slug) {
$term = get_term_by('slug', (string) $slug, 'category');
if ($term && !is_wp_error($term)) $cats[] = (int) $term->term_id;
}
if (!$cats) $cats = array_values(array_unique(array_map('intval', (array) ($payload['cats'] ?? []))));
wp_set_post_categories($new_id, $cats);
set_meta_payload($new_id, normalize_meta_input($payload));
clean_post_cache($new_id);
echo $new_id;
break;
}
case 'carta_sections': { case 'carta_sections': {
// Descubre el cluster de una carta parseando sus propios enlaces internos // Descubre el cluster de una carta parseando sus propios enlaces internos
// (misma lógica que pinta la portada, fea-carta-portada.php). No depende // (misma lógica que pinta la portada, fea-carta-portada.php). No depende
+257
View File
@@ -0,0 +1,257 @@
#!/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. Debe seguir el crontab:
# solo afecta a qué línea se marca como «en curso» en el informe diario.
ACTIVO = (774, 2000, 2026)
def php(*args: str) -> str:
"""Llama al helper WP sin convertir un fallo de lectura en una cola vacía."""
r = subprocess.run(
["docker", "exec", CONTAINER, "php", "/tmp/fea_post_io.php", *args],
capture_output=True, text=True,
)
if r.returncode != 0:
detail = (r.stderr or r.stdout).strip().replace("\n", " ")[:300]
raise RuntimeError(f"consulta WP {' '.join(args)} falló (rc={r.returncode}): {detail}")
return r.stdout
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ían que haber corrido en las últimas 24 horas.
El backlog usa varias líneas de crontab (ritmo normal + sábado + domingo).
Se calcula la unión de sus instantes previstos, sin contar dos veces una
coincidencia entre líneas.
"""
try:
lineas = [
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
if not lineas:
return None
ahora = ahora or datetime.now()
inicio = ahora - timedelta(hours=24)
previstos: set[datetime] = set()
for linea in lineas:
campos = linea.split(None, 5)
if len(campos) < 5:
continue
try:
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))}
except (TypeError, ValueError):
continue
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 inicio < cand <= ahora and cand.hour in horas and (cand.weekday() + 1) % 7 in dows:
previstos.add(cand)
return len(previstos)
def main() -> int:
# Nunca informar «0 pendientes» cuando la lectura de WordPress ha fallado.
# El cron no-agent entrega stdout/errores tal cual: un aviso explícito permite
# arreglar Docker/helper sin que parezca que el backlog está terminado.
try:
hechos = hechos_24h()
faltantes = {
aid: (pendientes(aid, 0, 9999, voz),
pendientes(aid, ACTIVO[1], ACTIVO[2], voz) if aid == ACTIVO[0] else None)
for aid, (_nombre, voz) in AUTORES.items()
}
except RuntimeError as exc:
print("⚠️ Fe Adulta — informe TTS inválido: no se pudo leer WordPress.")
print(f" {exc}")
print(" Los ceros no son datos reales; revisar wordpress-web y /tmp/fea_post_io.php.")
return 1
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, falta_lote = faltantes[aid]
marca = ""
if aid == ACTIVO[0]:
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())
+135
View File
@@ -0,0 +1,135 @@
#!/usr/bin/env python3
"""Importa las traducciones editoriales humanas adjuntas a un issue de carta.
Por defecto solo valida y muestra el plan. --apply-local crea borradores en el
WordPress Docker local; nunca publica ni toca producción.
"""
from __future__ import annotations
import argparse
import hashlib
import html
import json
import re
import subprocess
import tempfile
import zipfile
from pathlib import Path
from xml.etree import ElementTree as ET
ROOT = Path(__file__).resolve().parent
HELPER = ROOT / "fea_translate_helper.php"
CONTAINER = "wordpress-web"
SOURCE_ID = 55465 # Pagola, Carta 738
LANG_BY_NAME = {"2_eng": "en", "3_fr": "fr", "4_it": "it", "5_pt": "pt"}
TRANSLATOR_LABELS = ("Translator:", "Traducteur:", "Traduzzione:", "Tradutor:")
def docx_lines(path: Path) -> list[str]:
ns = {"w": "http://schemas.openxmlformats.org/wordprocessingml/2006/main"}
with zipfile.ZipFile(path) as zf:
root = ET.fromstring(zf.read("word/document.xml"))
return [
"".join(t.text or "" for t in p.findall(".//w:t", ns)).strip()
for p in root.findall(".//w:p", ns)
if "".join(t.text or "" for t in p.findall(".//w:t", ns)).strip()
]
def value_after(lines: list[str], label: str) -> str:
try:
return lines[lines.index(label) + 1]
except (ValueError, IndexError) as exc:
raise ValueError(f"Falta {label!r}") from exc
def parse_doc(path: Path) -> dict:
lines = docx_lines(path)
title = value_after(lines, "Título:")
excerpt = value_after(lines, "Entradilla:")
author = value_after(lines, "Autor:")
start = lines.index("Cuerpo:") + 1
end = next((i for i in range(start, len(lines))
if any(lines[i].startswith(label) for label in TRANSLATOR_LABELS)), len(lines))
body = lines[start:end]
if not body:
raise ValueError("Cuerpo vacío")
translator = ""
if end < len(lines):
label = next(label for label in TRANSLATOR_LABELS if lines[end].startswith(label))
translator = lines[end][len(label):].strip()
if not translator and end + 1 < len(lines):
translator = lines[end + 1]
# La fuente española incluye el crédito/origen como último párrafo.
body.append('Publicado en: <a href="https://www.gruposdejesus.com">https://www.gruposdejesus.com</a>')
content = "\n".join(
f"<p>{line if line.startswith('Publicado en: <a ') else html.escape(line)}</p>" for line in body
)
return {
"title": title,
"excerpt": excerpt,
"author": author,
"translator": translator,
"content": content,
"source_sha256": hashlib.sha256(path.read_bytes()).hexdigest(),
}
def run(cmd: list[str], stdin: str | None = None) -> str:
result = subprocess.run(cmd, input=stdin, text=True, capture_output=True)
if result.returncode:
raise RuntimeError(f"rc={result.returncode}: {result.stderr.strip()} {result.stdout.strip()}")
return result.stdout.strip()
def helper(*args: str, stdin: str | None = None) -> str:
return run(["docker", "exec", "-i", CONTAINER, "php", "/tmp/fea_translate_helper.php", *args], stdin)
def lang_for(path: Path) -> str:
for marker, lang in LANG_BY_NAME.items():
if marker in path.name:
return lang
raise ValueError(f"No reconozco idioma en {path.name}")
def apply_meta(post_id: int, doc: Path, parsed: dict) -> None:
payload = json.dumps({"id": post_id, "doc": doc.name, "sha": parsed["source_sha256"],
"translator": parsed["translator"]}, ensure_ascii=False)
php = r'''$p=json_decode(base64_decode(getenv('PAYLOAD')),true); update_post_meta($p['id'],'traduccion_automatica','0'); update_post_meta($p['id'],'traduccion_modelo','editorial-humana'); update_post_meta($p['id'],'traduccion_fuente_doc',$p['doc']); update_post_meta($p['id'],'traduccion_fuente_sha256',$p['sha']); update_post_meta($p['id'],'traduccion_editor',$p['translator']); echo 'ok';'''
import base64
encoded = base64.b64encode(payload.encode()).decode()
run(["docker", "exec", "-i", "-e", f"PAYLOAD={encoded}", CONTAINER,
"wp", "--allow-root", "eval", php])
def main() -> int:
ap = argparse.ArgumentParser()
ap.add_argument("--dir", type=Path, required=True)
ap.add_argument("--apply-local", action="store_true")
args = ap.parse_args()
docs = sorted(args.dir.glob("*.docx"))
if len(docs) != 4:
raise SystemExit(f"Se esperaban 4 DOCX, encontrados {len(docs)}")
run(["docker", "cp", str(HELPER), f"{CONTAINER}:/tmp/fea_translate_helper.php"])
for doc in docs:
lang, parsed = lang_for(doc), parse_doc(doc)
existing = helper("exists", str(SOURCE_ID), lang)
plan = {"lang": lang, "doc": doc.name, "existing": int(existing or "0"),
"title": parsed["title"], "excerpt_chars": len(parsed["excerpt"]),
"content_chars": len(parsed["content"]), "translator": parsed["translator"]}
print(json.dumps(plan, ensure_ascii=False))
if not args.apply_local:
continue
if int(existing or "0"):
raise RuntimeError(f"{lang} ya existe como #{existing}; no sobrescribo")
payload = json.dumps({"title": parsed["title"], "excerpt": parsed["excerpt"],
"content": parsed["content"], "model": "editorial-humana"}, ensure_ascii=False)
post_id = int(helper("create", str(SOURCE_ID), lang, "draft", stdin=payload))
apply_meta(post_id, doc, parsed)
print(json.dumps({"created": post_id, "lang": lang, "status": "draft"}))
return 0
if __name__ == "__main__":
raise SystemExit(main())
+17 -7
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)
@@ -353,12 +361,12 @@ def _split_for_tts(text, limit=CHAR_LIMIT):
return chunks return chunks
def _synth_chunk(text, voice_id, model): def _synth_chunk(text, voice_id, model, speed=1.0):
"""Una petición t2a. Devuelve (audio_bytes|None, rc, usage_chars).""" """Una petición t2a. Devuelve (audio_bytes|None, rc, usage_chars)."""
body = { body = {
"model": model, "model": model,
"text": text, "text": text,
"voice_setting": {"voice_id": voice_id, "speed": 1.0, "vol": 1.0, "pitch": 0}, "voice_setting": {"voice_id": voice_id, "speed": speed, "vol": 1.0, "pitch": 0},
"audio_setting": {"sample_rate": 32000, "bitrate": 128000, "format": "mp3", "channel": 1}, "audio_setting": {"sample_rate": 32000, "bitrate": 128000, "format": "mp3", "channel": 1},
"language_boost": "Spanish", "language_boost": "Spanish",
} }
@@ -373,13 +381,15 @@ def _synth_chunk(text, voice_id, model):
return bytes.fromhex(audio_hex), 0, usage return bytes.fromhex(audio_hex), 0, usage
def t2a(text, voice_id, model, name): def t2a(text, voice_id, model, name, speed=1.0):
if not 0.5 <= float(speed) <= 2.0:
raise ValueError(f"speed fuera de rango: {speed} (permitido 0.52.0)")
chunks = _split_for_tts(text) chunks = _split_for_tts(text)
print(f"Sintetizando {len(text)} car con {model} / {voice_id} " print(f"Sintetizando {len(text)} car con {model} / {voice_id} a {float(speed):.2f}× "
f"({len(chunks)} petición/es)…", flush=True) f"({len(chunks)} petición/es)…", flush=True)
raw = OUT / f"{name}.raw.mp3" raw = OUT / f"{name}.raw.mp3"
if len(chunks) == 1: if len(chunks) == 1:
audio, rc, _ = _synth_chunk(chunks[0], voice_id, model) audio, rc, _ = _synth_chunk(chunks[0], voice_id, model, speed)
if audio is None: if audio is None:
return rc return rc
raw.write_bytes(audio) raw.write_bytes(audio)
@@ -390,7 +400,7 @@ def t2a(text, voice_id, model, name):
import os as _os, time as _t import os as _os, time as _t
_t.sleep(int(_os.environ.get("FEA_CHUNK_PAUSE", "35"))) # respetar TPM de MiniMax _t.sleep(int(_os.environ.get("FEA_CHUNK_PAUSE", "35"))) # respetar TPM de MiniMax
print(f" trozo {k + 1}/{len(chunks)} ({len(ch)} car)…", flush=True) print(f" trozo {k + 1}/{len(chunks)} ({len(ch)} car)…", flush=True)
audio, rc, _ = _synth_chunk(ch, voice_id, model) audio, rc, _ = _synth_chunk(ch, voice_id, model, speed)
if audio is None: if audio is None:
for p in parts: for p in parts:
p.unlink(missing_ok=True) p.unlink(missing_ok=True)
+151
View File
@@ -0,0 +1,151 @@
#!/usr/bin/env python3
"""Fase A local: borrador de Enrique + copia draft de la carta para revisión.
Por defecto no escribe WordPress. --apply-local sólo modifica el WordPress Docker local.
Nunca publica ni toca producción.
"""
import argparse
import base64
import hashlib
import html
import json
import os
import re
import subprocess
import sys
import zipfile
from pathlib import Path
from xml.etree import ElementTree as ET
ROOT = Path(__file__).resolve().parent.parent
DEFAULT_DOC = Path("/home/rafa/.hermes/cache/documents/doc_7909db597c74_7r_Enrique_Mart_nez_Lozano_tesoro_esta_ya_en_nosotros.docx")
CARTA_PROD_ID = 54914
AUTHOR_ID = 384 # Enrique Martínez Lozano, verificado localmente.
CATEGORIES = [1650, 71] # Artículos + Feadulta
SLUG = "el-tesoro-esta-ya-en-nosotros"
def docx_text(path: Path) -> list[str]:
ns = {"w": "http://schemas.openxmlformats.org/wordprocessingml/2006/main"}
with zipfile.ZipFile(path) as zf:
root = ET.fromstring(zf.read("word/document.xml"))
out = []
for p in root.findall(".//w:p", ns):
text = "".join(t.text or "" for t in p.findall(".//w:t", ns)).strip()
if text:
out.append(text)
return out
def parse_source(path: Path) -> dict:
lines = docx_text(path)
def after(label: str) -> str:
return lines[lines.index(label) + 1]
title = after("Título:")
author = after("Autor:")
assert title == "EL TESORO ESTÁ YA EN NOSOTROS", title
assert author == "ENRIQUE MARTÍNEZ LOZANO", author
body_start = lines.index("Cuerpo:") + 1
body = lines[body_start:]
# Última firma del boletín no es contenido del post: el autor va en WP.
if body[-1].startswith("ENRIQUE MARTÍNEZ LOZANO"):
body = body[:-1]
date_line, bible, *paragraphs = body
intro = lines[lines.index("Entradilla:") + 1]
content = "\n".join(
[f"<p><strong>{html.escape(date_line)}</strong></p>",
f"<p><strong>{html.escape(bible)}</strong></p>"]
+ [f"<p>{html.escape(p)}</p>" for p in paragraphs]
)
return {"title": title.lower().capitalize(), "author": author.title(), "intro": intro,
"content": content, "source_sha256": hashlib.sha256(path.read_bytes()).hexdigest()}
def prod_card() -> dict:
sys.path.insert(0, str(ROOT / "scripts"))
import sync_translations_to_prod as sync
return json.loads(sync.prod_helper("read_full", str(CARTA_PROD_ID)))
def carta_candidate(card: dict, anchor: str) -> str:
marker = '<p>&nbsp;</p>\n<p><span style="color: #ff0000;"><strong>Para unas eucaristías más participativas y actuales</strong></span></p>'
assert card["content"].count(marker) == 1, "Marcador de sección no único/no encontrado"
return card["content"].replace(marker, anchor + "\n" + marker, 1)
def run_wp(payload: dict, apply: bool) -> dict:
encoded = base64.b64encode(json.dumps(payload, ensure_ascii=False).encode()).decode()
php = r'''
$p=json_decode(base64_decode(getenv('FEA_PAYLOAD')), true);
$apply=getenv('FEA_APPLY') === '1';
$existing=get_page_by_path($p['slug'], OBJECT, 'post');
$author=get_user_by('id',(int)$p['author_id']);
$cats=array_map('intval',$p['categories']);
$cat_ok=count(array_filter($cats, fn($id)=>term_exists($id,'category')))===count($cats);
$preview=get_posts(['post_type'=>'post','post_status'=>'any','meta_key'=>'fea_phase_a_source_hash','meta_value'=>$p['source_sha256'],'numberposts'=>1]);
$out=['dry_run'=>!$apply,'existing_slug'=>$existing?['id'=>$existing->ID,'status'=>$existing->post_status]:null,
'author'=>$author?['id'=>$author->ID,'name'=>$author->display_name]:null,'categories_ok'=>$cat_ok,
'preview_existing'=>$preview?['id'=>$preview[0]->ID,'status'=>$preview[0]->post_status]:null,
'candidate_anchor'=>$p['anchor']];
if (!$author || !$cat_ok) { $out['error']='Autor o categorías no válidos'; echo wp_json_encode($out,JSON_UNESCAPED_UNICODE); exit(3); }
if ($existing || $preview) { $out['error']='Ya existe un borrador/slug para esta Fase A; no se duplica'; echo wp_json_encode($out,JSON_UNESCAPED_UNICODE); exit(4); }
if (!$apply) { echo wp_json_encode($out,JSON_UNESCAPED_UNICODE); exit; }
$article_id=wp_insert_post(['post_type'=>'post','post_status'=>'draft','post_author'=>(int)$p['author_id'],
'post_title'=>$p['article_title'],'post_name'=>$p['slug'],'post_excerpt'=>$p['intro'],
'post_content'=>$p['article_content'],'post_category'=>$cats],true);
if (is_wp_error($article_id)) { $out['error']=$article_id->get_error_message(); echo wp_json_encode($out,JSON_UNESCAPED_UNICODE); exit(5); }
if (function_exists('pll_set_post_language')) pll_set_post_language($article_id,'es');
update_post_meta($article_id,'fea_phase_a_source_hash',$p['source_sha256']);
update_post_meta($article_id,'fea_phase_a_source_doc','7r_Enrique Martínez Lozano_tesoro_esta_ya_en_nosotros.docx');
update_post_meta($article_id,'fea_phase_a_status','preview-only');
$preview_id=wp_insert_post(['post_type'=>'post','post_status'=>'draft','post_author'=>(int)$p['card_author_id'],
'post_title'=>'PREVIEW — Hacia el corazón (+ Enrique Martínez Lozano)','post_excerpt'=>'Copia local de validación; no publicar.',
'post_content'=>$p['candidate_card_content']],true);
if (is_wp_error($preview_id)) { wp_delete_post($article_id,true); $out['error']=$preview_id->get_error_message(); echo wp_json_encode($out,JSON_UNESCAPED_UNICODE); exit(6); }
if (function_exists('pll_set_post_language')) pll_set_post_language($preview_id,'es');
update_post_meta($preview_id,'fea_phase_a_source_hash',$p['source_sha256']);
update_post_meta($preview_id,'fea_phase_a_source_prod_id',(int)$p['carta_prod_id']);
update_post_meta($preview_id,'fea_phase_a_preview_article_id',$article_id);
update_post_meta($preview_id,'fea_phase_a_status','preview-only');
$out['article']=['id'=>$article_id,'status'=>'draft','slug'=>get_post_field('post_name',$article_id),'author'=>get_the_author_meta('display_name',(int)$p['author_id'])];
$out['preview_card']=['id'=>$preview_id,'status'=>'draft','source_prod_id'=>(int)$p['carta_prod_id']];
echo wp_json_encode($out,JSON_UNESCAPED_UNICODE);
'''
cmd = [
"docker", "exec", "-i", "-e", f"FEA_PAYLOAD={encoded}",
"-e", f"FEA_APPLY={'1' if apply else '0'}", "wordpress-web",
"wp", "--allow-root", "eval", php,
]
result = subprocess.run(cmd, text=True, capture_output=True, check=False)
if result.returncode:
raise RuntimeError(f"WP local rc={result.returncode}: {result.stderr}\n{result.stdout}")
return json.loads(result.stdout)
def main():
ap = argparse.ArgumentParser()
ap.add_argument("--doc", type=Path, default=DEFAULT_DOC)
ap.add_argument("--apply-local", action="store_true")
ap.add_argument("--output", type=Path, required=True)
args = ap.parse_args()
source = parse_source(args.doc)
card = prod_card() # lectura server-side únicamente.
article_url = "http://localhost:8080/" + SLUG + "/"
anchor = (f'<p><strong><a href="{article_url}">Enrique Martínez Lozano: '
f'{html.escape(source["title"])}.</a></strong> {html.escape(source["intro"])}</p>')
payload = {
"source_sha256": source["source_sha256"], "article_title": source["title"],
"article_content": source["content"], "intro": source["intro"], "slug": SLUG,
"author_id": AUTHOR_ID, "categories": CATEGORIES, "anchor": anchor,
"candidate_card_content": carta_candidate(card, anchor), "card_author_id": card["author"],
"carta_prod_id": CARTA_PROD_ID,
}
result = run_wp(payload, args.apply_local)
report = {"source": source, "prod_card": {"id": card["id"], "title": card["title"], "status": card["status"]},
"result": result, "mode": "apply-local" if args.apply_local else "dry-run"}
args.output.parent.mkdir(parents=True, exist_ok=True)
args.output.write_text(json.dumps(report, ensure_ascii=False, indent=2) + "\n", encoding="utf-8")
print(json.dumps(report, ensure_ascii=False, indent=2))
if __name__ == "__main__":
main()
+167
View File
@@ -0,0 +1,167 @@
#!/usr/bin/env bash
# Release Fase 1+2, issue #181: 68 traducciones publicadas + 16 MP3 TTS.
# Por defecto es sólo dry-run. --apply requiere confirmación explícita de Rafa.
set -euo pipefail
ROOT="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)"
cd "$ROOT"
MODE="dry-run"
if [[ "${1:-}" == "--apply" ]]; then
MODE="apply"
elif [[ "${1:-}" == "--verify" ]]; then
MODE="verify"
elif [[ "${1:-}" != "" && "${1:-}" != "--dry-run" ]]; then
echo "Uso: $0 [--dry-run|--verify|--apply]" >&2
exit 2
fi
# shellcheck disable=SC1091
source ~/.hermes/profiles/feadulta/.env
: "${FEA_PROD_WPLOAD:=/web/wp-load.php}"
if [[ "$FEA_PROD_WPLOAD" != "/web/wp-load.php" ]]; then
echo "ABORT: FEA_PROD_WPLOAD debe ser /web/wp-load.php (recibido: $FEA_PROD_WPLOAD)" >&2
exit 2
fi
RELEASE_DIR="logs/release-issue-181"
mkdir -p "$RELEASE_DIR/backups"
STAMP="$(date -u +%Y%m%dT%H%M%SZ)"
ORIGINS=(54875 54902 54903 54883 54884 54885 54886 54866 54867 54868 54869 54870 54871 54872 54873 54880 54914)
AUDIO_IDS=(54875 54902 54903 54883 54884 54885 54886 54866 54867 54868 54869 54870 54871 54872 54873 54880)
if [[ "$MODE" == "apply" && "${FEA_RELEASE_CONFIRM:-}" != "PUBLISH_ISSUE_181" ]]; then
echo "ABORT: para escribir en producción usa:" >&2
echo " FEA_RELEASE_CONFIRM=PUBLISH_ISSUE_181 $0 --apply" >&2
exit 2
fi
preflight() {
python3 - "${ORIGINS[@]}" <<'PY'
import json, sys
sys.path.insert(0, 'scripts')
import sync_translations_to_prod as sync
ids = [int(x) for x in sys.argv[1:]]
rows = []
for pid in ids:
d = json.loads(sync.prod_helper('read_full', str(pid)))
rows.append({'id': pid, 'status': d['status'], 'lang': d['lang'], 'translations': d.get('translations', {})})
assert len(rows) == 17
assert all(r['status'] == 'publish' and r['lang'] == 'es' for r in rows), rows
# Los 16 artículos no deben tener traducciones aún; la carta tampoco antes del primer release.
assert all(r['translations'] == {'es': r['id']} for r in rows), rows
print(json.dumps(rows, ensure_ascii=False, indent=2))
print('preflight=PASS sources=17')
PY
}
backup_prod_state() {
python3 - "$RELEASE_DIR/backups/prod-before-$STAMP.json" "${ORIGINS[@]}" -- "${AUDIO_IDS[@]}" <<'PY'
import json, sys
out = sys.argv[1]
sep = sys.argv.index('--')
origins = [int(x) for x in sys.argv[2:sep]]
audio_ids = [int(x) for x in sys.argv[sep+1:]]
sys.path.insert(0, 'scripts')
import sync_translations_to_prod as translations
import sync_audio_to_prod as audio
payload = {
'origins': {str(pid): json.loads(translations.prod_helper('read_full', str(pid))) for pid in origins},
'audio_before': {
str(pid): {
'url': audio.prod_helper('getmeta', str(pid), 'fea_audio_url').strip(),
'voice': audio.prod_helper('getmeta', str(pid), 'fea_audio_voice').strip(),
'done': audio.prod_helper('getmeta', str(pid), 'fea_audio_done').strip(),
} for pid in audio_ids
},
}
with open(out, 'w', encoding='utf-8') as fh:
json.dump(payload, fh, ensure_ascii=False, indent=2)
print(out)
PY
}
verify_release() {
python3 - "${ORIGINS[@]}" -- "${AUDIO_IDS[@]}" <<'PY'
import json, sys, time
sep = sys.argv.index('--')
origins = [int(x) for x in sys.argv[1:sep]]
audio_ids = [int(x) for x in sys.argv[sep+1:]]
sys.path.insert(0, 'scripts')
import sync_translations_to_prod as translations
import sync_audio_to_prod as audio
def read_post(pid):
"""Lectura server-side resiliente ante una respuesta SSH/PHP vacía transitoria."""
last = ''
for attempt in range(1, 4):
raw = translations.prod_helper('read_full', str(pid)).strip()
if raw:
try:
return json.loads(raw)
except json.JSONDecodeError as exc:
last = f'JSON inválido intento {attempt}: {exc}; prefijo={raw[:160]!r}'
else:
last = f'respuesta vacía intento {attempt}'
time.sleep(attempt)
raise AssertionError(f'No se pudo leer post #{pid}: {last}')
for pid in origins:
d = read_post(pid)
group = d.get('translations', {})
assert set(group) == {'es', 'en', 'fr', 'it', 'pt'}, (pid, group)
for lang, tid in group.items():
td = read_post(tid)
assert td['lang'] == lang and td['status'] == 'publish', (pid, lang, tid, td['lang'], td['status'])
for pid in audio_ids:
url = audio.prod_helper('getmeta', str(pid), 'fea_audio_url').strip()
done = audio.prod_helper('getmeta', str(pid), 'fea_audio_done').strip()
assert url.endswith(f'/wp-content/uploads/tts/{pid}.mp3') and done == '1', (pid, url, done)
print('verification=PASS groups=17 translations=68 audio=16')
PY
}
if [[ "$MODE" == "verify" ]]; then
echo "== Verificación server-side posterior (solo lectura) =="
verify_release | tee "$RELEASE_DIR/verification-$STAMP.txt"
echo "VERIFICACIÓN ISSUE #181 COMPLETADA"
exit 0
fi
echo "== Preflight server-side ($MODE): 17 fuentes ES =="
preflight | tee "$RELEASE_DIR/preflight-$MODE-$STAMP.json"
if [[ "$MODE" == "dry-run" ]]; then
: > "$RELEASE_DIR/translations-dry-run-$STAMP.log"
for origin in "${ORIGINS[@]}"; do
FEA_SYNC_STATUS=publish \
FEA_SYNC_LOG="$RELEASE_DIR/translations-dry-run-$STAMP.log" \
FEA_SYNC_STATE="$RELEASE_DIR/translations-$origin-state.json" \
python3 scripts/sync_translations_to_prod.py --origin "$origin" --dry-run
done
FEA_AUDIO_SYNC_LOG="$RELEASE_DIR/audio-dry-run-$STAMP.log" \
FEA_AUDIO_SYNC_STATE="$RELEASE_DIR/audio-state.json" \
python3 scripts/sync_audio_to_prod.py --ids "$(IFS=,; echo "${AUDIO_IDS[*]}")" --dry-run | tee "$RELEASE_DIR/audio-dry-run-$STAMP.log"
echo "DRY-RUN terminado: 68 traducciones publicables + 16 audios planificados."
exit 0
fi
echo "== Backup server-side previo =="
backup_prod_state | tee "$RELEASE_DIR/backup-path-$STAMP.txt"
echo "== Subiendo 68 traducciones como publish =="
for origin in "${ORIGINS[@]}"; do
FEA_SYNC_STATUS=publish \
FEA_SYNC_LOG="$RELEASE_DIR/translations-apply-$STAMP.log" \
FEA_SYNC_STATE="$RELEASE_DIR/translations-$origin-state.json" \
python3 scripts/sync_translations_to_prod.py --origin "$origin"
done
echo "== Subiendo 16 audios =="
FEA_AUDIO_SYNC_LOG="$RELEASE_DIR/audio-apply-$STAMP.log" \
FEA_AUDIO_SYNC_STATE="$RELEASE_DIR/audio-state.json" \
python3 scripts/sync_audio_to_prod.py --ids "$(IFS=,; echo "${AUDIO_IDS[*]}")"
echo "== Verificación server-side posterior =="
verify_release | tee "$RELEASE_DIR/verification-$STAMP.txt"
echo "RELEASE ISSUE #181 COMPLETADO"
+104 -42
View File
@@ -3,9 +3,10 @@
sync_audio_to_prod.py — Sube a PROD los mp3 de TTS ya generados/enlazados en sync_audio_to_prod.py — Sube a PROD los mp3 de TTS ya generados/enlazados en
local (fea_audio_done=1) y fija el meta fea_audio_url en prod. local (fea_audio_done=1) y fija el meta fea_audio_url en prod.
Prod (134.0.10.170) tiene glibc rota: scp/sftp NO funcionan (connection closed). Prod vive en Hetzner (Coolify/Docker) desde el cutover de agosto 2026. Subida
Workaround: subir el binario por stdin de ssh ("cat > ruta"), igual que el resto del binario por stdin de ssh ("cat > ruta" dentro del contenedor vía
de scripts que tocan ese servidor (ver feadulta-server-glibc-rota.md). `docker exec -i`), sin depender de scp/sftp — mismo patrón que
sync_translations_to_prod.py y sync_carta_from_prod.py.
Uso: Uso:
python3 sync_audio_to_prod.py --carta 54254 # sincroniza toda la cola de la carta python3 sync_audio_to_prod.py --carta 54254 # sincroniza toda la cola de la carta
@@ -19,8 +20,10 @@ Rollback (despublica en prod lo que este script publicó):
from __future__ import annotations from __future__ import annotations
import argparse import argparse
import base64
import json import json
import os import os
import shlex
import subprocess import subprocess
import time import time
from pathlib import Path from pathlib import Path
@@ -31,13 +34,20 @@ DB_NAME = os.environ.get("FEA_DB_NAME", "wordpress_db")
DB_USER = os.environ.get("FEA_DB_USER", "wordpress_user") DB_USER = os.environ.get("FEA_DB_USER", "wordpress_user")
DB_PASS = os.environ.get("FEA_DB_PASS", "wordpress_pass") DB_PASS = os.environ.get("FEA_DB_PASS", "wordpress_pass")
PROD_HOST = os.environ.get("FEA_PROD_HOST", "feadulta@134.0.10.170") PROD_HOST = os.environ.get("FEA_PROD_SSH_HOST", "")
PROD_PASS = os.environ.get("FEA_PROD_PASS", "C6c2A!mAl3Wj.BQF") PROD_PASS = os.environ.get("FEA_PROD_SSH_PASS", "")
PROD_WPLOAD = os.environ.get("FEA_PROD_WPLOAD", "/web/wp-load.php") PROD_WPLOAD = os.environ.get("FEA_PROD_WPLOAD", "/var/www/html/wp-load.php")
PROD_HELPER = "/tmp/fea_post_io.php" # Desde el cutover a Hetzner, WordPress vive dentro de Coolify/Docker. Si se
PROD_UPLOADS_TTS = "/web/wp-content/uploads/tts" # define, el helper y la subida de mp3 se ejecutan dentro del contenedor.
# Espejo de sync_translations_to_prod.py / sync_carta_from_prod.py.
PROD_DOCKER_CONTAINER = os.environ.get("FEA_PROD_DOCKER_CONTAINER", "")
PROD_UPLOADS_TTS = os.environ.get("FEA_PROD_UPLOADS_TTS", "/var/www/html/wp-content/uploads/tts")
HELPER_SRC = Path(__file__).resolve().parent / "fea_post_io.php" HELPER_SRC = Path(__file__).resolve().parent / "fea_post_io.php"
# Se evalúa en memoria con `php -r`: no se copia un helper temporal a prod.
HELPER_EVAL_CODE = "eval(base64_decode(" + repr(
base64.b64encode(HELPER_SRC.read_text(encoding="utf-8").removeprefix("<?php").encode("utf-8")).decode("ascii")
) + "));"
LOCAL_TTS_DIR = Path(__file__).resolve().parent.parent / "wordpress/wp-content/uploads/tts" LOCAL_TTS_DIR = Path(__file__).resolve().parent.parent / "wordpress/wp-content/uploads/tts"
LOG_FILE = Path(os.environ.get( LOG_FILE = Path(os.environ.get(
@@ -74,6 +84,17 @@ def local_meta(post_id: int, key: str) -> str:
return r.stdout.strip() return r.stdout.strip()
def prod_id_for_local(local_id: int) -> int:
"""Resolve a remapped local post to its original production ID.
``sync_carta_from_prod.py --remap-conflicts`` keeps this mapping in
``fea_prod_source_id``. Audio files remain named with the local ID, but
remote paths and production post meta must use the source ID.
"""
source_id = local_meta(local_id, "fea_prod_source_id")
return int(source_id) if source_id.isdigit() else local_id
def carta_article_ids(carta_id: int) -> list[int]: def carta_article_ids(carta_id: int) -> list[int]:
q = ("SELECT post_id FROM wp_postmeta " q = ("SELECT post_id FROM wp_postmeta "
f"WHERE meta_key='_carta_id' AND meta_value='{carta_id}' ORDER BY post_id;") f"WHERE meta_key='_carta_id' AND meta_value='{carta_id}' ORDER BY post_id;")
@@ -85,47 +106,80 @@ def carta_article_ids(carta_id: int) -> list[int]:
return [int(x) for x in r.stdout.split() if x.isdigit()] return [int(x) for x in r.stdout.split() if x.isdigit()]
# ── Prod (glibc rota: nada de scp/sftp, todo por ssh + cat) ──────────────────── # ── Prod (Hetzner/Docker: todo por ssh, mp3 por stdin de "cat") ────────────────
def _ssh_text(remote_cmd: str, *, stdin: str | None = None, timeout: int = 120) -> str: def _ssh_text(remote_cmd: str, *, stdin: str | None = None, timeout: int = 120) -> str:
cmd = ["sshpass", "-p", PROD_PASS, "ssh", "-o", "StrictHostKeyChecking=accept-new", # El Hetzner nuevo usa auth por clave (ed25519 claude-code@feadulta); sshpass
"-o", "ConnectTimeout=20", PROD_HOST, remote_cmd] # solo se antepone si hay contraseña configurada (servidor viejo CDMON).
if PROD_PASS:
cmd = ["sshpass", "-p", PROD_PASS, "ssh", "-o", "StrictHostKeyChecking=accept-new",
"-o", "ConnectTimeout=20", PROD_HOST, remote_cmd]
else:
cmd = ["ssh", "-o", "StrictHostKeyChecking=accept-new",
"-o", "ConnectTimeout=20", PROD_HOST, remote_cmd]
r = subprocess.run(cmd, input=stdin, capture_output=True, text=True, timeout=timeout) r = subprocess.run(cmd, input=stdin, capture_output=True, text=True, timeout=timeout)
if r.returncode != 0: if r.returncode != 0:
raise RuntimeError(f"ssh falló ({r.returncode}): {remote_cmd[:80]}\n{r.stderr.strip()[:400]}") raise RuntimeError(f"ssh falló ({r.returncode}): {remote_cmd[:80]}\n{r.stderr.strip()[:400]}")
return r.stdout return r.stdout
def _remote_wrap(inner_cmd: str) -> str:
"""Envuelve un comando para que corra dentro del contenedor Docker de prod
si FEA_PROD_DOCKER_CONTAINER está definido; si no, corre en el host tal cual.
Importante: el wrapping (incluidas redirecciones como `< ruta`) debe quedar
DENTRO de la shell del contenedor (`sh -c '...'`), porque `docker exec
CONTENEDOR cmd < ruta` resuelve esa redirección en el filesystem del HOST,
no dentro del contenedor.
"""
if PROD_DOCKER_CONTAINER:
return f"docker exec -i {shlex.quote(PROD_DOCKER_CONTAINER)} sh -c {shlex.quote(inner_cmd)}"
return inner_cmd
def _ssh_upload_bytes(data: bytes, remote_path: str, *, timeout: int = 180) -> None: def _ssh_upload_bytes(data: bytes, remote_path: str, *, timeout: int = 180) -> None:
cmd = ["sshpass", "-p", PROD_PASS, "ssh", "-o", "StrictHostKeyChecking=accept-new", remote_cmd = _remote_wrap(f"cat > {remote_path}")
"-o", "ConnectTimeout=20", PROD_HOST, f"cat > {remote_path}"] if PROD_PASS:
cmd = ["sshpass", "-p", PROD_PASS, "ssh", "-o", "StrictHostKeyChecking=accept-new",
"-o", "ConnectTimeout=20", PROD_HOST, remote_cmd]
else:
cmd = ["ssh", "-o", "StrictHostKeyChecking=accept-new",
"-o", "ConnectTimeout=20", PROD_HOST, remote_cmd]
sh(cmd, input_bytes=data, timeout=timeout) sh(cmd, input_bytes=data, timeout=timeout)
_prod_helper_ready = False
def prod_helper(subcmd: str, *args: str) -> str: def prod_helper(subcmd: str, *args: str) -> str:
global _prod_helper_ready """Ejecuta el helper PHP en memoria; no deja ficheros temporales en prod."""
if not _prod_helper_ready: argv = " ".join(shlex.quote(x) for x in (subcmd, *args))
_ssh_upload_bytes(HELPER_SRC.read_bytes(), PROD_HELPER) inner = (
_prod_helper_ready = True f"FEA_WP_LOAD={shlex.quote(PROD_WPLOAD)} "
inner = f"FEA_WP_LOAD={PROD_WPLOAD} php {PROD_HELPER} {subcmd} " + " ".join(args) f"php -r {shlex.quote(HELPER_EVAL_CODE)} {argv}"
return _ssh_text(inner, timeout=60) )
if PROD_DOCKER_CONTAINER:
remote_cmd = (
f"docker exec -i -e FEA_WP_LOAD={shlex.quote(PROD_WPLOAD)} "
f"{shlex.quote(PROD_DOCKER_CONTAINER)} php -r {shlex.quote(HELPER_EVAL_CODE)} {argv}"
)
else:
remote_cmd = inner
return _ssh_text(remote_cmd, timeout=60)
def prod_upload_mp3(post_id: int) -> None: def prod_upload_mp3(local_id: int, prod_id: int) -> None:
src = LOCAL_TTS_DIR / f"{post_id}.mp3" src = LOCAL_TTS_DIR / f"{local_id}.mp3"
data = src.read_bytes() data = src.read_bytes()
remote_path = f"{PROD_UPLOADS_TTS}/{post_id}.mp3" remote_path = f"{PROD_UPLOADS_TTS}/{prod_id}.mp3"
_ssh_upload_bytes(data, remote_path) _ssh_upload_bytes(data, remote_path)
# Verificación de tamaño (glibc rota => sin fiarse ciegamente del rc=0 de ssh) # Verificación de tamaño: no fiarse ciegamente del rc=0 de ssh. `wc -c` debe
remote_size = int(_ssh_text(f"wc -c < {remote_path}").strip()) # correr (y resolver la redirección) DENTRO del contenedor — ver _remote_wrap.
remote_size = int(_ssh_text(_remote_wrap(f"wc -c < {remote_path}")).strip())
if remote_size != len(data): if remote_size != len(data):
raise RuntimeError(f"tamaño no coincide tras subir #{post_id}: local={len(data)} remoto={remote_size}") raise RuntimeError(
f"tamaño no coincide tras subir prod#{prod_id}: local={len(data)} remoto={remote_size}"
)
def prod_remove_mp3(post_id: int) -> None: def prod_remove_mp3(post_id: int) -> None:
_ssh_text(f"rm -f {PROD_UPLOADS_TTS}/{post_id}.mp3") _ssh_text(_remote_wrap(f"rm -f {PROD_UPLOADS_TTS}/{post_id}.mp3"))
# ── Estado ─────────────────────────────────────────────────────────────────── # ── Estado ───────────────────────────────────────────────────────────────────
@@ -149,25 +203,27 @@ def sync_one(post_id: int, state: dict, *, dry_run: bool) -> str:
return "sin-audio-local" return "sin-audio-local"
if not (LOCAL_TTS_DIR / f"{post_id}.mp3").exists(): if not (LOCAL_TTS_DIR / f"{post_id}.mp3").exists():
return "mp3-local-ausente" return "mp3-local-ausente"
prod_id = prod_id_for_local(post_id)
if dry_run: if dry_run:
return "PLAN: subiría mp3 + setaudio" return f"PLAN: local#{post_id}.mp3 → prod#{prod_id}.mp3 + setaudio"
voice = local_meta(post_id, "fea_audio_voice") or "NicoFeadulta2026" voice = local_meta(post_id, "fea_audio_voice") or "NicoFeadulta2026"
prod_upload_mp3(post_id) prod_upload_mp3(post_id, prod_id)
prod_helper("setaudio", str(post_id), f"/wp-content/uploads/tts/{post_id}.mp3", voice) prod_helper("setaudio", str(prod_id), f"/wp-content/uploads/tts/{prod_id}.mp3", voice)
if post_id not in state["synced"]: if prod_id not in state["synced"]:
state["synced"].append(post_id) state["synced"].append(prod_id)
save_state(state) save_state(state)
return "ok" return f"ok prod#{prod_id}"
def rollback_one(post_id: int, state: dict) -> str: def rollback_one(post_id: int, state: dict) -> str:
prod_helper("unsetaudio", str(post_id)) prod_id = prod_id_for_local(post_id)
prod_remove_mp3(post_id) prod_helper("unsetaudio", str(prod_id))
if post_id in state["synced"]: prod_remove_mp3(prod_id)
state["synced"].remove(post_id) if prod_id in state["synced"]:
state["synced"].remove(prod_id)
save_state(state) save_state(state)
return "ok" return f"ok prod#{prod_id}"
def main() -> int: def main() -> int:
@@ -194,7 +250,7 @@ def main() -> int:
for pid in ids: for pid in ids:
try: try:
if args.rollback: if args.rollback:
if pid not in state["synced"] and not args.ids: if prod_id_for_local(pid) not in state["synced"] and not args.ids:
res = "no-estaba-sincronizado" res = "no-estaba-sincronizado"
skip += 1 skip += 1
else: else:
@@ -202,7 +258,13 @@ def main() -> int:
ok += 1 ok += 1
else: else:
res = sync_one(pid, state, dry_run=args.dry_run) res = sync_one(pid, state, dry_run=args.dry_run)
if res == "ok" or res.startswith("PLAN"): # `startswith`, no `==`: sync_one devuelve "ok prod#<id>" desde que
# el id de prod puede diferir del local. Con la igualdad exacta, una
# subida perfecta se contaba entera como saltada — el 8-ago-2026 el
# pie del log dijo "ok=0 skip=90" tras subir los 90 audios de Fray
# Marcos sin un solo fallo. Peor que el susto: así un fallo real se
# confunde con este ruido y pasa desapercibido.
if res.startswith(("ok", "PLAN")):
ok += 1 ok += 1
else: else:
skip += 1 skip += 1
+104 -37
View File
@@ -23,6 +23,8 @@ el mismo fea_translate_helper.php sin tocar su lógica, solo invertido
Uso: Uso:
python3 sync_carta_from_prod.py --carta 54495 --dry-run python3 sync_carta_from_prod.py --carta 54495 --dry-run
python3 sync_carta_from_prod.py --carta 54495 python3 sync_carta_from_prod.py --carta 54495
python3 sync_carta_from_prod.py --ids 54875,54902,54903 --dry-run
python3 sync_carta_from_prod.py --ids 54875,54902,54903
Tras esto, el resto del ciclo ya existente no cambia: Tras esto, el resto del ciclo ya existente no cambia:
translate_post.py --carta 54495 --langs en,fr,it,pt --status draft translate_post.py --carta 54495 --langs en,fr,it,pt --status draft
@@ -34,8 +36,10 @@ Tras esto, el resto del ciclo ya existente no cambia:
from __future__ import annotations from __future__ import annotations
import argparse import argparse
import base64
import json import json
import os import os
import shlex
import subprocess import subprocess
import time import time
from pathlib import Path from pathlib import Path
@@ -44,7 +48,11 @@ WP_CONTAINER = os.environ.get("FEA_WP_CONTAINER", "wordpress-web")
PROD_HOST = os.environ.get("FEA_PROD_SSH_HOST", "") PROD_HOST = os.environ.get("FEA_PROD_SSH_HOST", "")
PROD_PASS = os.environ.get("FEA_PROD_SSH_PASS", "") PROD_PASS = os.environ.get("FEA_PROD_SSH_PASS", "")
PROD_WPLOAD = os.environ.get("FEA_PROD_WPLOAD", "/web/wp-load.php") PROD_WPLOAD = os.environ.get("FEA_PROD_WPLOAD", "/var/www/html/wp-load.php")
# Desde el cutover a Hetzner, WordPress vive dentro de Coolify/Docker. Si se
# define, el helper se ejecuta en memoria dentro del contenedor; no se escribe
# ningún fichero temporal en prod.
PROD_DOCKER_CONTAINER = os.environ.get("FEA_PROD_DOCKER_CONTAINER", "")
PROD_HELPER = "/tmp/fea_translate_helper.php" PROD_HELPER = "/tmp/fea_translate_helper.php"
HELPER_SRC = Path(__file__).resolve().parent / "fea_translate_helper.php" HELPER_SRC = Path(__file__).resolve().parent / "fea_translate_helper.php"
@@ -74,22 +82,35 @@ def sh(cmd: list[str], *, stdin: str | None = None, timeout: int = 120) -> str:
# ── Prod (origen, solo lectura) ───────────────────────────────────────────── # ── Prod (origen, solo lectura) ─────────────────────────────────────────────
_prod_ready = False
def _ssh(remote_cmd: str, *, stdin: str | None = None, timeout: int = 120) -> str: def _ssh(remote_cmd: str, *, stdin: str | None = None, timeout: int = 120) -> str:
cmd = ["sshpass", "-p", PROD_PASS, "ssh", "-o", "StrictHostKeyChecking=accept-new", # El Hetzner nuevo usa auth por clave (ed25519 claude-code@feadulta); sshpass
"-o", "ConnectTimeout=20", PROD_HOST, remote_cmd] # solo se antepone si hay contraseña configurada (servidor viejo CDMON).
if PROD_PASS:
cmd = ["sshpass", "-p", PROD_PASS, "ssh", "-o", "StrictHostKeyChecking=accept-new",
"-o", "ConnectTimeout=20", PROD_HOST, remote_cmd]
else:
cmd = ["ssh", "-o", "StrictHostKeyChecking=accept-new",
"-o", "ConnectTimeout=20", PROD_HOST, remote_cmd]
return sh(cmd, stdin=stdin, timeout=timeout) return sh(cmd, stdin=stdin, timeout=timeout)
def prod_helper(subcmd: str, *args: str, stdin: str | None = None) -> str: def prod_helper(subcmd: str, *args: str) -> str:
global _prod_ready """Run the helper in prod memory; never upload a temporary file to prod."""
if not _prod_ready: helper_php = HELPER_SRC.read_text(encoding="utf-8").replace("<?php", "", 1)
_ssh(f"cat > {PROD_HELPER}", stdin=HELPER_SRC.read_text(encoding="utf-8")) encoded = base64.b64encode(helper_php.encode("utf-8")).decode("ascii")
_prod_ready = True code = f"eval(base64_decode('{encoded}'));"
inner = f"FEA_WP_LOAD={PROD_WPLOAD} php {PROD_HELPER} {subcmd} " + " ".join(args) if PROD_DOCKER_CONTAINER:
return _ssh(inner, stdin=stdin, timeout=180) remote = (
f"docker exec -i -e FEA_WP_LOAD={shlex.quote(PROD_WPLOAD)} "
f"{shlex.quote(PROD_DOCKER_CONTAINER)} php -r {shlex.quote(code)} -- "
+ " ".join(shlex.quote(part) for part in (subcmd, *args))
)
else:
remote = (
f"FEA_WP_LOAD={shlex.quote(PROD_WPLOAD)} php -r {shlex.quote(code)} -- "
+ " ".join(shlex.quote(part) for part in (subcmd, *args))
)
return _ssh(remote, timeout=180)
def prod_read_full(post_id: int) -> dict: def prod_read_full(post_id: int) -> dict:
@@ -110,6 +131,17 @@ def seed_ids_from_carta(carta_id: int) -> list[int]:
return sorted(ids) return sorted(ids)
def seed_ids_from_csv(raw_ids: str) -> list[int]:
"""Parse a deliberate prod→local subset for Mixbot --parte1 (#181)."""
try:
ids = sorted({int(part.strip()) for part in raw_ids.split(",") if part.strip()})
except ValueError as exc:
raise ValueError("--ids debe ser una lista CSV de IDs numéricos") from exc
if not ids or any(pid <= 0 for pid in ids):
raise ValueError("--ids debe contener al menos un ID positivo")
return ids
def collect_related_posts(seed_ids: list[int]) -> tuple[dict[int, dict], list[dict[str, int]]]: def collect_related_posts(seed_ids: list[int]) -> tuple[dict[int, dict], list[dict[str, int]]]:
posts: dict[int, dict] = {} posts: dict[int, dict] = {}
groups: dict[tuple[tuple[str, int], ...], dict[str, int]] = {} groups: dict[tuple[tuple[str, int], ...], dict[str, int]] = {}
@@ -153,7 +185,11 @@ def local_read_safe(post_id: int) -> dict | None:
return None return None
def local_clone(post: dict) -> int: def local_clone(post: dict, *, preserve_id: bool = True) -> int:
# Conserva el ID remoto como trazabilidad incluso cuando haya que crear un
# ID local nuevo por una colisión entre entornos.
meta = dict(post.get("meta", {}))
meta["fea_prod_source_id"] = [str(post["id"])]
payload = { payload = {
"title": post["title"], "title": post["title"],
"content": post.get("content", ""), "content": post.get("content", ""),
@@ -166,10 +202,14 @@ def local_clone(post: dict) -> int:
"status": post.get("status"), "status": post.get("status"),
"cats": post.get("cats", []), "cats": post.get("cats", []),
"cat_slugs": post.get("cat_slugs", []), "cat_slugs": post.get("cat_slugs", []),
"meta": post.get("meta", {}), "meta": meta,
} }
out = local_helper("clone", str(post["id"]), post.get("lang") or "es", STATUS, if preserve_id:
stdin=json.dumps(payload)).strip() out = local_helper("clone", str(post["id"]), post.get("lang") or "es", STATUS,
stdin=json.dumps(payload)).strip()
else:
out = local_helper("clone_new", post.get("lang") or "es", STATUS,
stdin=json.dumps(payload)).strip()
return int(out) return int(out)
@@ -179,13 +219,9 @@ def local_save_group(group: dict[str, int]) -> dict[str, int]:
# ── Main ───────────────────────────────────────────────────────────────────── # ── Main ─────────────────────────────────────────────────────────────────────
def sync_carta(carta_id: int, *, dry_run: bool) -> int: def sync_posts(seed_ids: list[int], *, dry_run: bool, source_label: str,
seed_ids = seed_ids_from_carta(carta_id) remap_conflicts: bool = False) -> int:
log(f"Cluster descubierto desde fea_parse_carta_sections(prod#{carta_id}): " log(f"Lote {source_label}: {len(seed_ids)} post(s) -> {seed_ids}")
f"{len(seed_ids)} post(s) -> {seed_ids}")
if not seed_ids or seed_ids == [carta_id]:
log(" ⚠️ El parser no resolvió ningún artículo enlazado (¿carta sin publicar aún, "
"o secciones sin encabezados reconocibles?). Revisa antes de continuar.")
posts, groups = collect_related_posts(seed_ids) posts, groups = collect_related_posts(seed_ids)
@@ -196,51 +232,82 @@ def sync_carta(carta_id: int, *, dry_run: bool) -> int:
conflicts.append((pid, existing.get("title", ""), p.get("title", ""))) conflicts.append((pid, existing.get("title", ""), p.get("title", "")))
if conflicts: if conflicts:
log(f" ⚠️ {len(conflicts)} CONFLICTO(S): el ID ya existe en local con OTRO contenido " log(f" ⚠️ {len(conflicts)} CONFLICTO(S): el ID ya existe en local con OTRO contenido:")
f"y va a ser SOBRESCRITO:")
for pid, old_title, new_title in conflicts: for pid, old_title, new_title in conflicts:
log(f" #{pid}: local actual «{old_title[:50]}» -> prod «{new_title[:50]}»") log(f" #{pid}: local actual «{old_title[:50]}» | prod «{new_title[:50]}»")
if not dry_run and not remap_conflicts:
raise RuntimeError(
"Importación cancelada: usar --remap-conflicts para crear IDs locales nuevos; "
"nunca se sobrescriben posts locales por un choque de IDs entre entornos."
)
if dry_run: if dry_run:
for pid, p in posts.items(): for pid, p in posts.items():
log(f" PULL prod#{pid} [{p.get('lang','?')}] status={p.get('status')} " action = "REMAPPING to new local ID" if pid in {c[0] for c in conflicts} else "PULL"
log(f" {action} prod#{pid} [{p.get('lang','?')}] status={p.get('status')} "
f"slug={p.get('slug','')} «{p.get('title','')[:50]}»") f"slug={p.get('slug','')} «{p.get('title','')[:50]}»")
for group in groups: for group in groups:
log(f" GROUP {group}") log(f" GROUP {group}")
log("DRY-RUN: nada escrito en local.") log("DRY-RUN: nada escrito en local.")
return 0 return 0
conflict_ids = {pid for pid, _, _ in conflicts}
id_map: dict[int, int] = {}
for pid, p in posts.items(): for pid, p in posts.items():
new_id = local_clone(p) new_id = local_clone(p, preserve_id=pid not in conflict_ids)
id_map[pid] = new_id
if new_id != pid: if new_id != pid:
log(f" ⚠️ prod#{pid} se clonó como local#{new_id} — ID NO preservado, revisar a mano.") log(f" remap prod#{pid} -> local#{new_id} [{p.get('lang','?')}] «{p['title'][:45]}»")
else: else:
log(f" clone prod#{pid} -> local#{new_id} [{p.get('lang','?')}] «{p['title'][:45]}»") log(f" clone prod#{pid} -> local#{new_id} [{p.get('lang','?')}] «{p['title'][:45]}»")
for group in groups: for group in groups:
if len(group) < 2: local_group = {lang: id_map.get(pid, pid) for lang, pid in group.items()}
if len(local_group) < 2:
continue continue
saved = local_save_group(group) saved = local_save_group(local_group)
log(f" group enlazado en local {saved}") log(f" group enlazado en local {saved}")
log(f"FIN sync prod->local. carta={carta_id} posts={len(posts)} conflictos_previos={len(conflicts)}") log(f"FIN sync prod->local. fuente={source_label} posts={len(posts)} conflictos_previos={len(conflicts)}")
return 0 return 0
def sync_carta(carta_id: int, *, dry_run: bool, remap_conflicts: bool = False) -> int:
seed_ids = seed_ids_from_carta(carta_id)
if not seed_ids or seed_ids == [carta_id]:
log(" ⚠️ El parser no resolvió ningún artículo enlazado (¿carta sin publicar aún, "
"o secciones sin encabezados reconocibles?). Revisa antes de continuar.")
return sync_posts(seed_ids, dry_run=dry_run, source_label=f"carta prod#{carta_id}",
remap_conflicts=remap_conflicts)
def sync_ids(raw_ids: str, *, dry_run: bool, remap_conflicts: bool = False) -> int:
return sync_posts(seed_ids_from_csv(raw_ids), dry_run=dry_run,
source_label="IDs explícitos de Mixbot --parte1",
remap_conflicts=remap_conflicts)
def main() -> int: def main() -> int:
ap = argparse.ArgumentParser( ap = argparse.ArgumentParser(
description="Copia una carta (y su cluster de artículos) de PROD a LOCAL preservando IDs.") description="Copia una carta (y su cluster de artículos) de PROD a LOCAL preservando IDs.")
ap.add_argument("--carta", type=int, required=True, help="ID del post ES de la carta en PROD.") group = ap.add_mutually_exclusive_group(required=True)
group.add_argument("--carta", type=int, help="ID del post ES de la carta en PROD.")
group.add_argument("--ids", help="Lista CSV explícita de posts ES para Mixbot --parte1 (#181).")
ap.add_argument("--dry-run", action="store_true", help="Solo muestra el plan; no escribe en local.") ap.add_argument("--dry-run", action="store_true", help="Solo muestra el plan; no escribe en local.")
ap.add_argument("--remap-conflicts", action="store_true",
help="Ante IDs ocupados localmente, crea IDs nuevos y guarda fea_prod_source_id; nunca sobrescribe.")
args = ap.parse_args() args = ap.parse_args()
if not PROD_HOST or not PROD_PASS: if not PROD_HOST:
raise SystemExit( raise SystemExit(
"Faltan FEA_PROD_SSH_HOST / FEA_PROD_SSH_PASS en el entorno.\n" "Falta FEA_PROD_SSH_HOST en el entorno (FEA_PROD_SSH_PASS es opcional: "
"el Hetzner nuevo usa auth por clave).\n"
"Antes de ejecutar: source ~/.hermes/profiles/feadulta/.env" "Antes de ejecutar: source ~/.hermes/profiles/feadulta/.env"
) )
return sync_carta(args.carta, dry_run=args.dry_run) if args.carta:
return sync_carta(args.carta, dry_run=args.dry_run, remap_conflicts=args.remap_conflicts)
return sync_ids(args.ids, dry_run=args.dry_run, remap_conflicts=args.remap_conflicts)
if __name__ == "__main__": if __name__ == "__main__":
+63 -20
View File
@@ -16,8 +16,10 @@ coincidencia local↔prod cuando prod va por detrás.
from __future__ import annotations from __future__ import annotations
import argparse import argparse
import base64
import json import json
import os import os
import shlex
import subprocess import subprocess
import time import time
from pathlib import Path from pathlib import Path
@@ -29,12 +31,20 @@ DB_NAME = os.environ.get("FEA_DB_NAME", "wordpress_db")
DB_USER = os.environ.get("FEA_DB_USER", "wordpress_user") DB_USER = os.environ.get("FEA_DB_USER", "wordpress_user")
DB_PASS = os.environ.get("FEA_DB_PASS", "wordpress_pass") DB_PASS = os.environ.get("FEA_DB_PASS", "wordpress_pass")
PROD_HOST = os.environ.get("FEA_PROD_HOST", "feadulta@134.0.10.170") PROD_HOST = os.environ.get("FEA_PROD_SSH_HOST", "")
PROD_PASS = os.environ.get("FEA_PROD_PASS", "C6c2A!mAl3Wj.BQF") PROD_PASS = os.environ.get("FEA_PROD_SSH_PASS", "")
PROD_WPLOAD = os.environ.get("FEA_PROD_WPLOAD", "/web/wp-load.php") PROD_WPLOAD = os.environ.get("FEA_PROD_WPLOAD", "/var/www/html/wp-load.php")
PROD_HELPER = "/tmp/fea_translate_helper.php" # Desde el cutover a Hetzner, WordPress vive dentro de Coolify/Docker. Si se
# define, el helper se ejecuta en memoria dentro del contenedor; no se escribe
# ningún fichero temporal en prod. Espejo de sync_carta_from_prod.py.
PROD_DOCKER_CONTAINER = os.environ.get("FEA_PROD_DOCKER_CONTAINER", "")
HELPER_SRC = Path(__file__).resolve().parent / "fea_translate_helper.php" HELPER_SRC = Path(__file__).resolve().parent / "fea_translate_helper.php"
# El helper se ejecuta en memoria con `php -r` en prod. Así no se copia ningún
# fichero a /tmp remoto y stdin queda disponible para el payload JSON.
HELPER_EVAL_CODE = "eval(base64_decode(" + repr(
base64.b64encode(HELPER_SRC.read_text(encoding="utf-8").removeprefix("<?php").encode("utf-8")).decode("ascii")
) + "));"
LOCAL_HELPER_DST = "/tmp/fea_translate_helper.php" LOCAL_HELPER_DST = "/tmp/fea_translate_helper.php"
STATE_FILE = Path(os.environ.get("FEA_SYNC_STATE", "/tmp/feadulta-sync-state.json")) STATE_FILE = Path(os.environ.get("FEA_SYNC_STATE", "/tmp/feadulta-sync-state.json"))
LOG_FILE = Path(os.environ.get("FEA_SYNC_LOG", "/tmp/feadulta-sync.log")) LOG_FILE = Path(os.environ.get("FEA_SYNC_LOG", "/tmp/feadulta-sync.log"))
@@ -107,8 +117,20 @@ def local_read_full(post_id: int) -> dict:
def local_translation_pairs() -> list[tuple[int, int]]: def local_translation_pairs() -> list[tuple[int, int]]:
q = ("SELECT post_id, meta_value FROM wp_postmeta " """Devuelve (traducción_local, origen_ES_en_prod).
"WHERE meta_key='traduccion_origen' ORDER BY CAST(meta_value AS UNSIGNED), post_id;")
Normalmente ambos entornos compartían IDs. Para importaciones remapeadas,
la carta ES local guarda `fea_prod_source_id`; esa referencia remota tiene
prioridad y evita enlazar una traducción con un post distinto de prod.
"""
q = (
"SELECT tr.post_id, COALESCE(NULLIF(src.meta_value,''), tr.meta_value) "
"FROM wp_postmeta tr "
"LEFT JOIN wp_postmeta src ON src.post_id=CAST(tr.meta_value AS UNSIGNED) "
"AND src.meta_key='fea_prod_source_id' "
"WHERE tr.meta_key='traduccion_origen' "
"ORDER BY CAST(COALESCE(NULLIF(src.meta_value,''), tr.meta_value) AS UNSIGNED), tr.post_id;"
)
out = sh(["docker", "exec", DB_CONTAINER, "mysql", f"-u{DB_USER}", f"-p{DB_PASS}", out = sh(["docker", "exec", DB_CONTAINER, "mysql", f"-u{DB_USER}", f"-p{DB_PASS}",
DB_NAME, "-N", "-e", q]) DB_NAME, "-N", "-e", q])
pairs = [] pairs = []
@@ -155,21 +177,31 @@ def collect_related_posts(seed_ids: list[int]) -> tuple[dict[int, dict], list[di
# ── Prod ───────────────────────────────────────────────────────────────────── # ── Prod ─────────────────────────────────────────────────────────────────────
_prod_ready = False
def _ssh(remote_cmd: str, *, stdin: str | None = None, timeout: int = 120) -> str: def _ssh(remote_cmd: str, *, stdin: str | None = None, timeout: int = 120) -> str:
cmd = ["sshpass", "-p", PROD_PASS, "ssh", "-o", "StrictHostKeyChecking=accept-new", # El Hetzner nuevo usa auth por clave (ed25519 claude-code@feadulta); sshpass
"-o", "ConnectTimeout=20", PROD_HOST, remote_cmd] # solo se antepone si hay contraseña configurada (servidor viejo CDMON).
if PROD_PASS:
cmd = ["sshpass", "-p", PROD_PASS, "ssh", "-o", "StrictHostKeyChecking=accept-new",
"-o", "ConnectTimeout=20", PROD_HOST, remote_cmd]
else:
cmd = ["ssh", "-o", "StrictHostKeyChecking=accept-new",
"-o", "ConnectTimeout=20", PROD_HOST, remote_cmd]
return sh(cmd, stdin=stdin, timeout=timeout) return sh(cmd, stdin=stdin, timeout=timeout)
def prod_helper(subcmd: str, *args: str, stdin: str | None = None) -> str: def prod_helper(subcmd: str, *args: str, stdin: str | None = None) -> str:
global _prod_ready """Ejecuta el helper PHP en memoria; no crea ficheros temporales en prod."""
if not _prod_ready: argv = " ".join(shlex.quote(x) for x in (subcmd, *args))
_ssh(f"cat > {PROD_HELPER}", stdin=HELPER_SRC.read_text(encoding="utf-8")) if PROD_DOCKER_CONTAINER:
_prod_ready = True inner = (
inner = f"FEA_WP_LOAD={PROD_WPLOAD} php {PROD_HELPER} {subcmd} " + " ".join(args) f"docker exec -i -e FEA_WP_LOAD={shlex.quote(PROD_WPLOAD)} "
f"{shlex.quote(PROD_DOCKER_CONTAINER)} php -r {shlex.quote(HELPER_EVAL_CODE)} {argv}"
)
else:
inner = (
f"FEA_WP_LOAD={shlex.quote(PROD_WPLOAD)} "
f"php -r {shlex.quote(HELPER_EVAL_CODE)} {argv}"
)
return _ssh(inner, stdin=stdin, timeout=180) return _ssh(inner, stdin=stdin, timeout=180)
@@ -256,7 +288,12 @@ def deploy_fixed_ids(seed_ids: list[int], *, keep_existing: set[int], dry_run: b
# ── Main legado ────────────────────────────────────────────────────────────── # ── Main legado ──────────────────────────────────────────────────────────────
def legacy_sync(limit: int, origin: int) -> int: def legacy_sync(limit: int, origin: int, *, dry_run: bool = False) -> int:
"""Sincroniza por origen ES+idioma, sin reutilizar IDs locales en prod.
Este es el modo seguro si producción ha avanzado y sus IDs ya pueden
colisionar con traducciones creadas en el entorno local.
"""
state = load_state() state = load_state()
pairs = local_translation_pairs() pairs = local_translation_pairs()
if origin: if origin:
@@ -280,6 +317,10 @@ def legacy_sync(limit: int, origin: int) -> int:
if key in state["done"]: if key in state["done"]:
n_skip += 1 n_skip += 1
continue continue
if dry_run:
log(f" PLAN {key}: crearía traducción [{lang}] «{t['title'][:45]}»")
n_ok += 1
continue
try: try:
new_id = prod_create(src_origin, lang, t["title"], t["content"]) new_id = prod_create(src_origin, lang, t["title"], t["content"])
state["done"][key] = new_id state["done"][key] = new_id
@@ -292,8 +333,10 @@ def legacy_sync(limit: int, origin: int) -> int:
n_err += 1 n_err += 1
log(f" {key} ERROR: {exc}") log(f" {key} ERROR: {exc}")
save_state(state) if not dry_run:
log(f"FIN sync legado. nuevos={n_ok} saltados={n_skip} errores={n_err}. Estado: {STATE_FILE}") save_state(state)
mode = "DRY-RUN" if dry_run else "SYNC"
log(f"FIN sync legado {mode}. nuevos/plan={n_ok} saltados={n_skip} errores={n_err}. Estado: {STATE_FILE}")
log("Recuerda en prod: ejecutar remap_translation_cats.php si alguna quedó sin categoría traducida.") log("Recuerda en prod: ejecutar remap_translation_cats.php si alguna quedó sin categoría traducida.")
return 0 return 0
@@ -318,7 +361,7 @@ def main() -> int:
keep_existing = set(parse_csv_ints(args.keep_existing)) keep_existing = set(parse_csv_ints(args.keep_existing))
return deploy_fixed_ids(seed_ids, keep_existing=keep_existing, dry_run=args.dry_run) return deploy_fixed_ids(seed_ids, keep_existing=keep_existing, dry_run=args.dry_run)
return legacy_sync(args.limit, args.origin) return legacy_sync(args.limit, args.origin, dry_run=args.dry_run)
if __name__ == "__main__": if __name__ == "__main__":
+14 -4
View File
@@ -271,7 +271,8 @@ def save_state(state: dict) -> None:
# ── Orquestación ───────────────────────────────────────────────────────────── # ── Orquestación ─────────────────────────────────────────────────────────────
def process_post(post_id: int, langs: list[str], status: str, force: bool, state: dict) -> None: def process_post(post_id: int, langs: list[str], status: str, force: bool, state: dict,
*, dry_run: bool = False) -> None:
src = read_post(post_id) src = read_post(post_id)
if src.get("lang") and src["lang"] != "es": if src.get("lang") and src["lang"] != "es":
log(f"#{post_id} no es ES (lang={src['lang']}) — saltado") log(f"#{post_id} no es ES (lang={src['lang']}) — saltado")
@@ -286,8 +287,14 @@ def process_post(post_id: int, langs: list[str], status: str, force: bool, state
state["done"][key] = existing state["done"][key] = existing
continue continue
if existing and force: if existing and force:
if dry_run:
log(f" {lang}: PLAN regenerar traducción previa #{existing}")
continue
php_helper("unlink", str(post_id), lang) php_helper("unlink", str(post_id), lang)
log(f" {lang}: --force, eliminada traducción previa #{existing}") log(f" {lang}: --force, eliminada traducción previa #{existing}")
if dry_run:
log(f" {lang}: PLAN traducir con {ENGINE} y crear como {status}")
continue
try: try:
t0 = time.time() t0 = time.time()
title = translate_text(src["title"], lang, is_title=True) title = translate_text(src["title"], lang, is_title=True)
@@ -312,6 +319,7 @@ def main() -> int:
ap.add_argument("--langs", default="en,fr,it,pt", help="Idiomas destino separados por coma.") ap.add_argument("--langs", default="en,fr,it,pt", help="Idiomas destino separados por coma.")
ap.add_argument("--status", default="draft", choices=["draft", "publish"], help="Estado de la traducción.") ap.add_argument("--status", default="draft", choices=["draft", "publish"], help="Estado de la traducción.")
ap.add_argument("--force", action="store_true", help="Regenera aunque ya exista la traducción.") ap.add_argument("--force", action="store_true", help="Regenera aunque ya exista la traducción.")
ap.add_argument("--dry-run", action="store_true", help="Muestra el plan sin llamar al LLM ni escribir en WordPress/estado.")
args = ap.parse_args() args = ap.parse_args()
langs = [l.strip() for l in args.langs.split(",") if l.strip() in LANG_NAMES] langs = [l.strip() for l in args.langs.split(",") if l.strip() in LANG_NAMES]
@@ -329,9 +337,11 @@ def main() -> int:
state = load_state() state = load_state()
for pid in ids: for pid in ids:
process_post(pid, langs, args.status, args.force, state) process_post(pid, langs, args.status, args.force, state, dry_run=args.dry_run)
save_state(state) if not args.dry_run:
log(f"FIN. {len(state['done'])} traducciones registradas, " save_state(state)
mode = "DRY-RUN" if args.dry_run else "FIN"
log(f"{mode}. {len(state['done'])} traducciones registradas, "
f"{len(state.get('errors', {}))} errores. Estado: {STATE_FILE}") f"{len(state.get('errors', {}))} errores. Estado: {STATE_FILE}")
return 0 return 0
+142
View File
@@ -0,0 +1,142 @@
#!/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"
# El repo NO se fija a mano: se deduce de dónde vive este script. El checkout
# principal cambia de rama a menudo, y en las ramas que no llevan estos scripts
# el cron se quedaba llamando a un fichero inexistente y fallaba en silencio
# (pasó del 5 al 8 de agosto de 2026: 7 ventanas perdidas). Por eso el cron
# apunta al worktree fijo ~/worktrees/fea-tts-backlog, y desde aquí se
# autodetecta: donde esté el script, ahí está su repo.
REPO="${FEA_TTS_REPO:-$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)}"
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"
+84 -9
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
@@ -20,14 +29,15 @@ sys.path.insert(0, os.path.dirname(os.path.abspath(__file__)))
import minimax_tts as mm # get_post_text, add_pauses, t2a, OUT import minimax_tts as mm # get_post_text, add_pauses, t2a, OUT
import translate_post as tp # carta_article_ids import translate_post as tp # carta_article_ids
VOICE = "NicoFeadulta2026" VOICE = os.environ.get("FEA_TTS_VOICE", "NicoFeadulta2026")
MODEL = "speech-2.8-hd" MODEL = "speech-2.8-hd"
CONTAINER = "wordpress-web" 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,48 @@ 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")
parser.add_argument("--allow-default-voice", action="store_true",
help="permite Nico para autores sin voz digitalizada (desactivado por defecto)")
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):
@@ -92,7 +151,15 @@ def main():
i += 1 i += 1
continue continue
voice = mm.voice_for_author(author, VOICE) cloned_voice = mm.voice_for_author(author, "")
if cloned_voice:
voice = cloned_voice
elif args.allow_default_voice:
voice = VOICE
else:
log(f"#{pid}: autor sin voz digitalizada (author_id={author}); omitido")
i += 1
continue
rc = mm.t2a(mm.add_pauses(text), voice, MODEL, f"prod-{pid}") rc = mm.t2a(mm.add_pauses(text), voice, MODEL, f"prod-{pid}")
if rc == 0: if rc == 0:
src = mm.OUT / f"prod-{pid}.mp3" src = mm.OUT / f"prod-{pid}.mp3"
@@ -104,16 +171,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. ===")