Compare commits
17 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 78879aef68 | |||
| d98f99d0ab | |||
| 20528f0c5e | |||
| f6170b70b1 | |||
| 4f91d68996 | |||
| 585984cfe2 | |||
| 4a57ecfc56 | |||
| 9a5e8caebd | |||
| 65359338f8 | |||
| d528fe45ae | |||
| 603eb1ecb4 | |||
| da3dfae0a3 | |||
| 5ed2eaf3fe | |||
| 0ee9a2eef5 | |||
| 508a6b4178 | |||
| 5c5ea60348 | |||
| 91a212250d |
-12
@@ -17,10 +17,6 @@ joomla-php83/
|
||||
backups/*
|
||||
!backups/README.md
|
||||
|
||||
# Backups pesados (GBs) bajo docs/ — mismo motivo que backups/*, ruta distinta
|
||||
docs/backups/*
|
||||
!docs/backups/README.md
|
||||
|
||||
# Capturas de pantalla
|
||||
capturas/
|
||||
|
||||
@@ -61,11 +57,3 @@ tools/e2e/out/
|
||||
|
||||
# Akeeba Kickstart (tool de restauración, no es código del repo)
|
||||
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/
|
||||
|
||||
@@ -1,219 +0,0 @@
|
||||
# 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,78 @@
|
||||
# Los 500 de las cartas traducidas (`url_to_postid()`)
|
||||
|
||||
## ✅ RESUELTO — 4-ago-2026, 02:00 UTC
|
||||
|
||||
**36 URLs devolvían 500**: las mismas 9 «cartas de otras semanas» en los cuatro idiomas
|
||||
traducidos. En español las 9 iban bien.
|
||||
|
||||
```
|
||||
/fr/lumiere-et-phare/ /it/luce-e-faro/ /en/light-and-beacon/ /pt/luz-e-farol/ … ×9
|
||||
```
|
||||
|
||||
Era **una regresión del cutover, no algo heredado**: las mismas URLs contra CDMON respondían 200.
|
||||
|
||||
## La causa
|
||||
|
||||
`fea-homepage.php` reescribe los enlaces internos al idioma activo, y para eso llamaba a
|
||||
**`url_to_postid()`** una vez por enlace. Estas cartas traen unos 40.
|
||||
|
||||
`url_to_postid()` acaba construyendo una `WP_Query` a partir de las reglas de reescritura. Cuando
|
||||
la URL no encaja en ninguna, la query se queda **sin cláusula que la acote** y sale esto:
|
||||
|
||||
```sql
|
||||
SELECT wp_posts.* FROM wp_posts WHERE 1=1 AND wp_posts.post_type = 'post' ORDER BY post_date DESC
|
||||
```
|
||||
|
||||
**Las 32.311 entradas con su contenido entero.** Dos veces por petición. Los 256 MB de PHP se
|
||||
agotaban en `class-wpdb.php:2322` y Apache devolvía el 500.
|
||||
|
||||
Encaja con el patrón observado: el filtro se salta el español (`if (!$lang || $lang === 'es')
|
||||
return`), que es justo el idioma que no fallaba, y solo actúa en `is_singular()`.
|
||||
|
||||
## El arreglo
|
||||
|
||||
Se sustituye `url_to_postid()` por `fea_href_a_post_id()`: la estructura de enlaces es
|
||||
`/%postname%/`, así que basta el último segmento del path y **una consulta con `LIMIT 1`**, que no
|
||||
puede degenerar por rara que sea la URL. El resultado se cachea por petición.
|
||||
|
||||
El orden del `ORDER BY` no es cosmético, reproduce a quién sirve WordPress esa misma URL:
|
||||
|
||||
| caso | cuántos | criterio |
|
||||
|---|---|---|
|
||||
| página de primer nivel con el mismo slug que una entrada | 4 | gana la **página** (reglas verbosas de reescritura) |
|
||||
| dos entradas con el mismo slug (duplicados del import de Joomla) | 58 | gana la de **`post_date` más reciente** |
|
||||
|
||||
Ordenar por `ID ASC`, que fue el primer intento, devolvía la entrada vieja y habría traducido el
|
||||
enlace equivocado en esos 58 casos.
|
||||
|
||||
## Verificación
|
||||
|
||||
| | |
|
||||
|---|---|
|
||||
| Las 116 cartas traducidas (29 × 4 idiomas) | **116/116 en 200** |
|
||||
| Muestra de 200 entradas al azar (40 por idioma) | todas en 200 |
|
||||
| `fea_href_a_post_id()` vs `url_to_postid()`, 61 slugs repetidos + 400 al azar | **461/461 idénticos** |
|
||||
| La consulta gorda | desaparecida: la que más filas devuelve ahora son 640 de `wp_options` |
|
||||
| Suite E2E | **13/13 en 200** |
|
||||
| Errores PHP y 5xx tras el despliegue | 0 y 0 |
|
||||
|
||||
Los enlaces se siguen reescribiendo, y **algunos más que antes**: `url_to_postid()` fallaba en
|
||||
URLs que sí resuelven bien por slug. Comprobado contra CDMON que los nuevos pares son de verdad la
|
||||
misma entrada (mismo grupo de traducción de Polylang): `como-bendecir-la-mesa` →
|
||||
`comment-benir-la-table`, `felices-6` → `heureux`, `3-temario` → `programme`…
|
||||
|
||||
## Centinelas
|
||||
|
||||
`tools/e2e/sites/www.json` incorpora `carta-trad-it` y `carta-trad-fr`, dos de las que reventaban.
|
||||
Si esto vuelve, lo dice la suite.
|
||||
|
||||
## ⚠️ Para la próxima
|
||||
|
||||
**`url_to_postid()` no es seguro con URLs arbitrarias en un sitio grande.** El comentario que ya
|
||||
había en el fichero decía que en los listados agotaba memoria, y por eso se había acotado a
|
||||
`is_singular()`. La cura se quedó corta: el problema no era el listado, era la función.
|
||||
|
||||
Y una trampa de método: esto **no se reproduce con wp-cli**. Polylang no instancia su clase de
|
||||
frontend en CLI, así que el filtro sale antes de tiempo y no pasa nada. Se cazó mirando
|
||||
`performance_schema.events_statements_history_long` en MySQL mientras se pedía la página por HTTP,
|
||||
que muestra la secuencia real de sentencias de la petición.
|
||||
@@ -0,0 +1,279 @@
|
||||
# Runbook del cutover — WordPress feadulta.com: CDMON → Hetzner
|
||||
|
||||
**Fecha prevista: lunes 3 de agosto de 2026.** Plan maestro: gitea `rafa/feadulta`#180.
|
||||
Todos los tiempos de abajo están **medidos en el ensayo del 31-jul**, no estimados.
|
||||
|
||||
---
|
||||
|
||||
## ✅ EJECUTADO — 3-ago-2026, 10:50 UTC. Ventana real: 3 min 10 s
|
||||
|
||||
Dump 67 s · saneo 2 s · import 114 s · delta de uploads 6 s (0 ficheros, como estaba medido).
|
||||
|
||||
Cuadró al dígito con producción: **43.893 posts, 1.228 usuarios, 4.416 terms, 135.959 postmeta**
|
||||
(los options salen 958 frente a 975: son transients, hay 580 y caducan solos). Con tráfico real:
|
||||
**490×200, 66×301, 9×302, 0 respuestas 4xx/5xx y 0 errores PHP**, 177 IPs distintas atendidas en
|
||||
10 min tras la purga de caché. **E2E 11/11 en 200.** Audio TTS 200 (285.741 b). Login: `eqpyk` e
|
||||
`icalvotorre` entran con la contraseña rotada, las tres cuentas del #183 reciben *"This account is
|
||||
disabled"*.
|
||||
|
||||
### Lo que quedó abierto
|
||||
|
||||
| | |
|
||||
|---|---|
|
||||
| ✅ **Certificados LE de `www` y el apex** | **Emitidos el 3-ago a las 12:03 UTC**, válidos hasta el 1-nov. Detalle y las dos trampas del camino en `certificados-letsencrypt-cloudflare.md`. **Ya se puede subir la zona a `Full (strict)`.** |
|
||||
| ✅ **Application passwords** | Wordfence las desactiva por defecto y eso dejaba a los bots sin poder publicar (401 en la REST API). Reactivado y **añadido como paso 3e de `post-import-seguridad.sh`**, porque el 3d reinstala Wordfence y volvería a romperse. |
|
||||
| ✅ **Backups en Hetzner** | Ya existen (verificado por Rafa en otra sesión). |
|
||||
| 🟡 **CDMON** | Es el rollback. Inma lo está degradando a un plan barato con el correo. Caduca el **07/08**. |
|
||||
| 🟡 **`nuevo.feadulta.com`** | Ya no hace falta; retirar el registro cuando se quiera. |
|
||||
| 🟡 **Scripts que apuntan a `134.0.10.170` y `/web/`** | Cierra #158. |
|
||||
|
||||
**Purga de caché de Cloudflare: la hizo Inma.** Nuestro token no puede (`purge_cache` → 10000
|
||||
Authentication error): solo tiene `Zone → DNS → Edit`. Si se quiere automatizar, añadirle
|
||||
`Zone → Cache Purge → Purge`.
|
||||
|
||||
---
|
||||
|
||||
## Antes de empezar (verde obligatorio)
|
||||
|
||||
- [ ] **Freeze editorial** avisado a Inma. El cutover no puede caer el día de publicación de la carta.
|
||||
*(La carta 738 se compone el martes: el lunes está libre.)*
|
||||
- [ ] **CDMON sigue vivo** (§1). Es el rollback: si no está, no se ejecuta.
|
||||
- [x] ~~**Modo SSL de la zona**~~ — **CONFIRMADO POR INMA (2-ago): está en `Full`, no `Flexible`.**
|
||||
El riesgo de bucle de redirección queda descartado. (*Full (strict)* además valida el
|
||||
certificado del origen; es mejor y se puede subir después, no bloquea el cutover.)
|
||||
- [ ] 🔴 **`wp-nuevo.feadulta.com` declarado en Coolify — ver paso 0. SIN ESTO SE ROMPEN LOS BOTS.**
|
||||
- [ ] **Inventario del wildcard.** La zona tiene `*.feadulta.com` → apex, proxied. Al mover el A del
|
||||
apex, **todo subdominio no declarado se va a Hetzner** y Traefik devolverá su 404.
|
||||
`info.feadulta.com`: Rafa lo da por residual, se acepta que dé 404.
|
||||
|
||||
---
|
||||
|
||||
## 0. 🔴 `wp-nuevo.feadulta.com` — hacer ANTES del cutover
|
||||
|
||||
**Los bots de la carta (`teologasbot`, `escritoresbot`, `recordatoriobot`) publican por
|
||||
`wp-nuevo.feadulta.com`, no por `www`**, porque el WAF de Cloudflare los bloquea por `www` (regla
|
||||
"bloquear bots 1"). Aviso de Mixbot, 2-ago.
|
||||
|
||||
Ese hostname **no tiene registro propio**: lo cubre el wildcard. Al mover el A del apex viaja a
|
||||
Hetzner, y **Traefik solo tiene router para `nuevo.feadulta.com`** → devolvería su 404 y los bots
|
||||
dejarían de publicar. La carta 738 se compone el **martes**.
|
||||
|
||||
⚠️ **Hay que hacerlo en el PANEL de Coolify.** Por API no vale: `PATCH /services/{uuid}` con `fqdn`
|
||||
da `422 "This field is not allowed"`, y actualizar `SERVICE_FQDN_WORDPRESS` por API cambia la
|
||||
variable pero **no regenera los routers de Traefik** (comprobado el 2-ago).
|
||||
|
||||
Servicio `feadulta-wp` → campo de dominios:
|
||||
|
||||
```
|
||||
antes del cutover: https://nuevo.feadulta.com,https://wp-nuevo.feadulta.com
|
||||
después del cutover: https://www.feadulta.com,https://feadulta.com,https://wp-nuevo.feadulta.com
|
||||
```
|
||||
|
||||
**Plan B si el martes wp-nuevo falla:** los bots cambian una línea de su `.env` y publican por
|
||||
**`nuevo.feadulta.com`** — es grey cloud, así que **no pasa por Cloudflare y ninguna regla del WAF
|
||||
le aplica**, ya tiene certificado y ya está enrutado. Mixbot puede hacerlo al momento.
|
||||
|
||||
✅ **Estado a 2-ago: HECHO.** Verificado en el servidor: Traefik tiene los dos routers
|
||||
(`Host(nuevo.feadulta.com)` y `Host(wp-nuevo.feadulta.com)`), responde 302 a ambos y **404 a
|
||||
cualquier hostname no declarado**.
|
||||
|
||||
ℹ️ **No hace falta esperar al certificado de Let's Encrypt para que los bots funcionen.**
|
||||
`wp-nuevo` va **proxied** (naranja, por el wildcard): Cloudflare presenta *su* certificado a los
|
||||
bots, y hacia el origen —con el modo SSL en **`Full`, no `Full (strict)`**— acepta cualquier
|
||||
certificado, incluido el autofirmado por defecto de Traefik. Así que el hostname sirve desde el
|
||||
primer segundo y LE emitirá el suyo cuando le toque.
|
||||
⚠️ El reverso: **subir la zona a `Full (strict)` antes de que todos los certificados estén emitidos
|
||||
rompería justamente esto.** Hacerlo después, y comprobando.
|
||||
|
||||
Verificación tras el cutover (no vale hacerla desde la máquina de Rafa: **Cloudflare devuelve 403 a
|
||||
esa IP para cualquier hostname de feadulta**, así que un 403 desde ahí no significa nada):
|
||||
|
||||
```bash
|
||||
curl -sI https://wp-nuevo.feadulta.com/wp-json/ | head -3 # desde el Hetzner o desde los bots
|
||||
```
|
||||
|
||||
## Tiempos medidos (ensayo del 31-jul)
|
||||
|
||||
| Fase | Medido | ¿Se repite el lunes? |
|
||||
|---|---|---|
|
||||
| Dump CDMON → WSL (105 MB) | 25 s | **Sí** |
|
||||
| WSL → Hetzner | 74 s | **Sí** |
|
||||
| Saneo de collations + exclusión `*_bak` | 2 s | **Sí** |
|
||||
| Import en MySQL 8.4 | 107 s | **Sí** |
|
||||
| Instalar plugins + tema + 28 mu-plugins | 4 min 19 s | **No** (ya hecho) |
|
||||
| Pre-sync de `uploads` 5,9 GB | ver #180 | **No** (ya hecho; el lunes solo el delta) |
|
||||
| **Camino crítico de la ventana** | **≈ 3 min 30 s + delta de uploads** | |
|
||||
|
||||
> El corte real percibido por el visitante es **solo el cambio del A record**, que en Cloudflare es
|
||||
> instantáneo. Todo lo de arriba se hace **antes**, con el sitio viejo aún sirviendo.
|
||||
|
||||
---
|
||||
|
||||
## 1. Backup de última hora en el origen
|
||||
|
||||
No fiarse del cron de UpdraftPlus de las 05:09: forzar uno nuevo o confirmar que el del día existe.
|
||||
|
||||
```bash
|
||||
sshpass -p "$FEA_PROD_SSH_PASS" ssh feadulta@134.0.10.170 \
|
||||
'ls -lt /web/wp-content/updraft/*-db.gz | head -3'
|
||||
```
|
||||
|
||||
## 2. Traer el dump del día
|
||||
|
||||
```bash
|
||||
bash scripts/cutover/01-traer-dump.sh # CDMON → WSL → Hetzner, con sha256 en los 3 puntos
|
||||
```
|
||||
Dos saltos a propósito: `scp`/`sftp` no funcionan contra la jaula de CDMON, y no queremos dejar la
|
||||
contraseña de CDMON escrita en el Hetzner.
|
||||
|
||||
## 3. Sanear el dump ⚠️ el paso que no se puede saltar
|
||||
|
||||
El origen es **MariaDB 11.8.6** y el destino **MySQL 8.4.10**. El dump trae **20 tablas con
|
||||
collations `uca1400_*` que solo existen en MariaDB 11+**; MySQL las rechaza en el `CREATE TABLE` y
|
||||
el import aborta.
|
||||
|
||||
```
|
||||
utf8mb4_uca1400_ai_ci → utf8mb4_0900_ai_ci
|
||||
utf8mb3_uca1400_ai_ci → utf8mb4_0900_ai_ci (+ CHARSET=utf8mb3 → utf8mb4)
|
||||
utf8mb4_unicode_520_ci → sin tocar (válida en MySQL 8.4)
|
||||
```
|
||||
También excluye las 5 tablas de respaldo (`wp_options_bak`, `wp_posts_bak`, `wp_postmeta_bak`,
|
||||
`wp_usermeta_bak`, `wp_fea_date_slug_posts_backup`).
|
||||
|
||||
**Verificación antes de importar** (las tres deben dar 0):
|
||||
```bash
|
||||
grep -c 'uca1400' feadulta-saneado.sql
|
||||
grep -c 'utf8mb3' feadulta-saneado.sql
|
||||
grep -oE '^CREATE TABLE `[^`]+`' feadulta-saneado.sql | grep -cE '_bak|backup'
|
||||
```
|
||||
|
||||
## 4. Importar
|
||||
|
||||
**`--default-character-set=utf8mb4` en el CLIENTE es obligatorio.** Omitirlo es exactamente lo que
|
||||
produjo el mojibake `’` en summaraise.
|
||||
|
||||
```bash
|
||||
docker exec -i mysql-r2ssjifwj0r0ghyoqd528uaa \
|
||||
mysql --default-character-set=utf8mb4 -uroot -p"$RP" wordpress < feadulta-saneado.sql
|
||||
```
|
||||
|
||||
Verificación: 29 tablas, y las filas deben cuadrar con
|
||||
`(líneas que empiezan por " (") + (nº de sentencias INSERT)` de cada tabla — la primera fila de cada
|
||||
`INSERT` va pegada al `VALUES`. **Ignora el `# Approximate rows expected` del dump**: es la
|
||||
estimación de InnoDB y para `wp_posts` dice 64.160 cuando las filas reales son 43.941.
|
||||
|
||||
## 5. Delta de uploads
|
||||
|
||||
```bash
|
||||
rsync -a --info=progress2 --stats --exclude='*.php' \
|
||||
-e "sshpass -p ... ssh" feadulta@134.0.10.170:/web/wp-content/uploads/ <destino>/
|
||||
```
|
||||
Solo mueve lo publicado desde el pre-sync. **`--delete` únicamente tras revisar un `--dry-run`.**
|
||||
|
||||
## 6. Higiene de seguridad — SIEMPRE después de cada import
|
||||
|
||||
```bash
|
||||
bash scripts/cutover/post-import-seguridad.sh
|
||||
```
|
||||
El dump trae las cuentas de administrador con sus hashes, **incluidas las tres deshabilitadas en el
|
||||
incidente #183**: reimportar las revive. El script rota la contraseña de los 6 administradores
|
||||
(leyéndolas de `~/.hermes/profiles/feadulta/.env`), retira el rol a `pabloarias`/`josek`/`andrey`, y
|
||||
limpia los cron huérfanos de Yoast y WP Profile Builder.
|
||||
|
||||
Doble cinturón: además del rol retirado, el mu-plugin `fea-security-blocked-users.php` bloquea
|
||||
`wp_authenticate_user` y `allow_password_reset` para esos tres IDs. **Va en el manifiesto de
|
||||
`docs/cutover/mu-plugins-manifest.txt`, no en un glob `fea-*`** — un glob se lo dejaría fuera.
|
||||
|
||||
El script retira además la opción **`wordfence_core_options`**, que **no es de Wordfence**: es el
|
||||
payload de SEO spam que dejó el atacante (div oculto con enlaces a un casino, cifrado con un césar
|
||||
de una letra). Está inerte —quien lo leía era `wp-highlits`, en cuarentena— pero **viaja dentro del
|
||||
dump y cada import lo reintroduce**.
|
||||
|
||||
## 6b. Plugins de seguridad — Wordfence entra, LLAR sale
|
||||
|
||||
**Siguen siendo 7 plugins activos**, pero no los mismos: entra **Wordfence** (8.2.2, limpio desde
|
||||
wordpress.org) y sale **`limit-login-attempts-reloaded`** (decisión de Rafa, 2-ago).
|
||||
|
||||
Motivo de que entre Wordfence: CDMON tenía **ModSecurity** delante a nivel de hosting —fue lo que
|
||||
detectó las subidas de `wp-cl.php` y `health-check.php` en el #183— y **en Hetzner con Traefik no hay
|
||||
WAF ninguno**. Sin él, el servidor nuevo estrenaría con una capa defensiva menos que la que tenía el
|
||||
sitio comprometido.
|
||||
|
||||
Motivo de que salga LLAR: Wordfence hace lo mismo (límite de intentos, bloqueos) **y además**
|
||||
firewall de aplicación, escáner de malware y 2FA. Los dos a la vez son dos contadores compitiendo y
|
||||
bloqueos difíciles de diagnosticar.
|
||||
|
||||
**El dump trae LLAR en `active_plugins`, así que cada import lo revive** → lo retira
|
||||
`post-import-seguridad.sh` (paso 3c), junto con sus 12 opciones huérfanas. El mismo script comprueba
|
||||
que Wordfence sigue activo y lo reinstala si faltara.
|
||||
|
||||
⚠️ Wordfence queda con **protección básica** (a nivel de plugin). El *extended protection* exige
|
||||
configurar `auto_prepend_file` desde su asistente; se hace **después** del cutover y con calma, no
|
||||
en la ventana.
|
||||
|
||||
⚠️ `fea-cloudflare-realip.php` es obligatorio con Wordfence por el mismo motivo que lo era con LLAR:
|
||||
sin él ve todas las visitas como si vinieran de las IPs de Cloudflare. Está en el manifiesto.
|
||||
|
||||
## 7. Quitar el WP_HOME/WP_SITEURL del staging
|
||||
|
||||
```
|
||||
Coolify → servicio feadulta-wp → borrar la variable WORDPRESS_CONFIG_EXTRA → redeploy
|
||||
```
|
||||
**No hace falta ningún `search-replace`**: la base de datos ya trae `siteurl`/`home` =
|
||||
`https://www.feadulta.com`, que es la URL final. La constante era solo para el staging. Esto ahorra
|
||||
la pasada más lenta de todo el proceso.
|
||||
|
||||
## 8. Cambiar el A record en Cloudflare
|
||||
|
||||
```bash
|
||||
# feadulta.com y www.feadulta.com → 188.40.120.157, MANTENIENDO proxied=true (naranja)
|
||||
```
|
||||
El token **sí tiene permiso de escritura**, verificado el 31-jul creando `nuevo.feadulta.com`.
|
||||
Después: purgar la caché de Cloudflare.
|
||||
|
||||
## 9. Verificación en caliente
|
||||
|
||||
- [ ] Portada en los 5 idiomas (`/`, `/en/`, `/fr/`, `/it/`, `/pt/`) — `/es/` responde 301, es lo normal.
|
||||
- [ ] Carta de la semana, evangelios, EFFA, autores con avatar.
|
||||
- [ ] Buscador (índice FULLTEXT) devuelve resultados.
|
||||
- [ ] **Audio TTS** suena.
|
||||
- [ ] Imágenes cargan (depende del delta de uploads).
|
||||
- [ ] `wp-login.php` entra con la contraseña rotada, y **las 3 cuentas del incidente NO entran**.
|
||||
- [ ] Certificado Let's Encrypt para apex y `www`.
|
||||
- [ ] Cero errores PHP en la portada y en `docker logs`.
|
||||
- [ ] GA4 en tiempo real.
|
||||
|
||||
## 10. Application passwords — con Inma delante, no antes
|
||||
|
||||
**Solo existe una en todo el sitio**: `publicabot`, del usuario `icalvotorre` (ID 1048), creada el
|
||||
27-jun-2026, último uso el 29-jul-2026 desde `213.94.23.1`. Es la que usan los bots de la carta.
|
||||
|
||||
Rotarla sin coordinar **para la publicación de la carta semanal** y nadie sabrá por qué. Se rota con
|
||||
Inma delante, generando la nueva y actualizándola en el bot en el mismo momento.
|
||||
|
||||
*(Rotar la contraseña de un usuario **no** revoca sus application passwords: el paso 6 no la afecta.)*
|
||||
|
||||
---
|
||||
|
||||
## Rollback
|
||||
|
||||
**Revertir el A record en Cloudflare + purgar caché.** Segundos. El Joomla/WordPress de CDMON sigue
|
||||
intacto y sirviendo — comprobado con `curl --resolve` contra `134.0.10.170`.
|
||||
|
||||
**Esto solo funciona mientras CDMON siga vivo.** Es la razón por la que el §1 no es opcional.
|
||||
|
||||
## 🔴 El correo — no es del cutover, pero es lo que más puede doler
|
||||
|
||||
El cutover **no toca el correo**: el MX apunta a `mail.feadulta.com` → `134.0.13.123`, una IP
|
||||
distinta de la del web. Mover el A del apex no lo afecta.
|
||||
|
||||
**Pero la caducidad del 07/08 sí.** Los 17 buzones viven en CDMON, y `ediciones@` y `contenido@` son
|
||||
de donde los bots sacan el material de la carta: si cae el correo, se para la carta. Hay que
|
||||
resolverlo con CDMON **esta semana**, vaya como vaya el cutover.
|
||||
|
||||
## Después (no el mismo día)
|
||||
|
||||
- **Backups en Hetzner: hoy NO HAY NINGUNO.** La tabla `scheduled_database_backups` de Coolify está
|
||||
vacía y no hay cron. Summaraise lleva semanas así. Hay que darle dueño.
|
||||
- Actualizar los scripts y guías que apuntan a `134.0.10.170` y a `/web/` (cierra #158).
|
||||
- Retirar `nuevo.feadulta.com` de Cloudflare cuando ya no haga falta.
|
||||
@@ -0,0 +1,184 @@
|
||||
# Los certificados de Let's Encrypt de `www` y el apex
|
||||
|
||||
## ✅ RESUELTO — 3-ago-2026, 12:03 UTC
|
||||
|
||||
```
|
||||
www.feadulta.com Let's Encrypt YR2 3-ago-2026 -> 1-nov-2026
|
||||
feadulta.com Let's Encrypt YR2 3-ago-2026 -> 1-nov-2026
|
||||
wp-nuevo.feadulta.com Let's Encrypt YR1 3-ago-2026 -> 1-nov-2026
|
||||
```
|
||||
|
||||
**Lo que funcionó** (el "plan B" de Inma, porque la Configuration Rule que se propuso primero **no
|
||||
se puede montar en este plan de Cloudflare**):
|
||||
|
||||
1. **Custom rule del WAF con acción *Skip*** para `/.well-known/acme-challenge/`.
|
||||
2. **Apagar "Always Use HTTPS" a nivel de zona** durante unos minutos, y volver a encenderlo después.
|
||||
|
||||
Con eso el reto se completa por el puerto 80, donde lo atiende Traefik. **Ya se puede subir la zona
|
||||
a `Full (strict)`.**
|
||||
|
||||
### ⚠️ Dos avisos para la próxima, que costaron tiempo
|
||||
|
||||
**1. `acme.json` escribe `"main": "dominio"` CON espacio.** Un `grep '"main":"'` da vacío siempre y
|
||||
parece que no hay certificados. Los certificados de hoy llevaban **casi una hora emitidos** mientras
|
||||
un bucle de vigilancia mal escrito informaba de "sin novedad". **Parsear el JSON, no grepear:**
|
||||
|
||||
```bash
|
||||
docker exec coolify-proxy cat /traefik/acme.json > /tmp/a.json
|
||||
python3 -c "
|
||||
import json
|
||||
d=json.load(open('/tmp/a.json'))
|
||||
for r,v in d.items():
|
||||
if isinstance(v,dict):
|
||||
for c in (v.get('Certificates') or []): print(c.get('domain',{}).get('main'))
|
||||
"
|
||||
```
|
||||
|
||||
**2. Se descartó un riesgo que se había anotado aquí: el modo `Full` NO impide el reto.** Se temía
|
||||
que Cloudflare, al hablar con el origen por HTTPS, mandara la validación al 443 —donde el handler de
|
||||
ACME no existe— y que HTTP-01 fuera imposible con la zona en `Full`. **Es falso**, y se midió:
|
||||
con "Always Use HTTPS" apagado, una petición HTTP a la ruta del reto a través de Cloudflare devuelve
|
||||
**404 con 0 bytes y `server: cloudflare`**, es decir, Cloudflare **sí reenvía al puerto 80** y
|
||||
responde el handler de Traefik.
|
||||
|
||||
---
|
||||
|
||||
*Lo que sigue es el diagnóstico, que se conserva porque explica cómo se llegó hasta aquí y contiene
|
||||
un par de trampas que conviene no repetir.*
|
||||
|
||||
## Resumen del problema (histórico)
|
||||
|
||||
Traefik no conseguía el certificado de `www.feadulta.com` ni de `feadulta.com`. **No había nada roto
|
||||
para los visitantes**: Cloudflare presentaba su propio certificado, y hacia el origen —con el modo
|
||||
SSL de la zona en `Full`— aceptaba el certificado por defecto de Traefik. El único coste era que la
|
||||
zona no podía subir a `Full (strict)`.
|
||||
|
||||
La causa: el reto HTTP-01 acababa llegando por HTTPS y Cloudflare lo bloqueaba con un 403.
|
||||
|
||||
## Lo que dice Let's Encrypt, que es el único testigo que vale
|
||||
|
||||
```
|
||||
2026-08-03T11:46:54Z domains [www.feadulta.com]
|
||||
invalid authorization: acme: error: 403 :: urn:ietf:params:acme:error:unauthorized ::
|
||||
Invalid response from https://www.feadulta.com/.well-known/acme-challenge/YnmnUk3wAqkMGkL8Evv49DQrW1euPfnOgNFoekwLSUc: 403
|
||||
```
|
||||
|
||||
Ese intento es **posterior** al cambio de DNS, al arreglo del mu-plugin y a dos redeploys, y lleva un
|
||||
token real de 43 caracteres. Nótese el **`https`**: el reto empieza por el puerto 80 y termina en el
|
||||
443.
|
||||
|
||||
## Por qué las mediciones desde nuestras máquinas no sirven
|
||||
|
||||
⚠️ **Cloudflare devuelve 403 a nuestras IPs para cualquier hostname de feadulta** — la de Rafa y la
|
||||
del propio Hetzner. Eso contamina cualquier `curl`. Medido:
|
||||
|
||||
| petición desde la WSL | resultado |
|
||||
|---|---|
|
||||
| token real (43 ch), **sin** User-Agent de LE | 403 |
|
||||
| token real (43 ch), **con** User-Agent de LE | **301 a https** |
|
||||
| token de ejemplo corto | 403 |
|
||||
| directorio `/.well-known/acme-challenge/` a secas | 403 |
|
||||
|
||||
Idéntico en apex, `www` y `wp-nuevo`. **La forma del token no cambia nada; lo que cambia es el
|
||||
User-Agent.** Por eso dos personas midiendo lo mismo obtienen resultados distintos y ninguna de las
|
||||
dos tiene razón: hay que mirar lo que reporta Let's Encrypt.
|
||||
|
||||
*(Se barajó que Cloudflare exime de "Always Use HTTPS" las URLs de challenge que llevan token. En
|
||||
esta zona **no se está aplicando**: el intento de las 11:46, con token real, terminó en 403.)*
|
||||
|
||||
## La cadena, medida contra el origen
|
||||
|
||||
| punto | resultado | quién responde |
|
||||
|---|---|---|
|
||||
| Origen puerto 80, ruta del reto | 404, **0 bytes**, sin cabecera `server` | el handler de ACME de **Traefik** |
|
||||
| Origen puerto 80, cualquier otra ruta | 302 a HTTPS | Traefik |
|
||||
| Origen puerto 443, ruta del reto | 404, 99.802 bytes | WordPress |
|
||||
|
||||
O sea que **el lado del servidor está bien**: Traefik intercepta la ruta del reto en el entrypoint
|
||||
`http` y no la redirige. La configuración lo confirma:
|
||||
|
||||
```
|
||||
--certificatesresolvers.letsencrypt.acme.httpchallenge=true
|
||||
--certificatesresolvers.letsencrypt.acme.httpchallenge.entrypoint=http
|
||||
```
|
||||
|
||||
El problema está **delante**, en el borde de Cloudflare, no en Hetzner.
|
||||
|
||||
## El arreglo
|
||||
|
||||
⚠️ **Corrección (Inma, 3-ago): en este plan de Cloudflare las Configuration Rules NO ofrecen
|
||||
"Always Use HTTPS" ni "Security Level".** Los ajustes disponibles son Automatic HTTPS Rewrites,
|
||||
Browser Integrity Check, Disable RUM, Disable Zaraz, Email Obfuscation, Fonts, Hotlink Protection,
|
||||
I'm Under Attack, Opportunistic Encryption, Polish, Request/Response Body Buffering, Rocket Loader y
|
||||
SSL. **La regla no se puede montar así.**
|
||||
|
||||
### Lo que sí se puede hacer
|
||||
|
||||
1. **Custom rule del WAF con acción *Skip*** para `/.well-known/acme-challenge/` — quita el 403.
|
||||
2. **Apagar "Always Use HTTPS" a nivel de zona** durante el reintento, y volver a encenderlo
|
||||
después.
|
||||
|
||||
Es una excepción mínima y acotada: esa ruta no sirve contenido, solo tokens de un solo uso que el
|
||||
propio Traefik genera y valida.
|
||||
|
||||
### Sobre los visitantes durante la ventana
|
||||
|
||||
Apagar "Always Use HTTPS" unos minutos resultó inocuo: Cloudflare reenvía al puerto 80 del origen y
|
||||
**Traefik devuelve su propio 302 a HTTPS**, así que el visitante acaba en HTTPS igual. Medido antes
|
||||
y durante.
|
||||
|
||||
### Alternativa de fondo, para más adelante
|
||||
|
||||
Pasar Traefik a validación **DNS-01** con el token de Cloudflare que ya tenemos
|
||||
(`FEA_CF_API_TOKEN`, permiso `Zone → DNS → Edit`). Se salta el problema para siempre porque no
|
||||
depende del HTTP, y es lo recomendado cuando el origen vive detrás de un proxy. **Pero toca la
|
||||
configuración del proxy de Coolify entero, no solo feadulta**, así que no se hace en caliente ni sin
|
||||
ensayarlo.
|
||||
|
||||
## ⚠️ Límite de Let's Encrypt: no reintentar a lo loco
|
||||
|
||||
Let's Encrypt permite **5 validaciones fallidas por hostname y hora**. Fallos del 3-ago:
|
||||
|
||||
| hostname | fallos | horas (UTC) |
|
||||
|---|---|---|
|
||||
| `www.feadulta.com` | **5** | 10:44, 11:37, 11:38, 11:46, 11:47 |
|
||||
| `feadulta.com` | 3 | 10:44, 11:38, 11:46 |
|
||||
| `wp-nuevo.feadulta.com` | 4 | 10:44 ×2, 11:37, 11:46 |
|
||||
|
||||
`www` tocó el límite a las 11:47 UTC. **Reintentar antes de que pase la hora falla por rate limit
|
||||
aunque la regla esté bien puesta**, y hace creer que el arreglo no funciona.
|
||||
|
||||
### Procedimiento correcto
|
||||
|
||||
1. Inma pone la Configuration Rule.
|
||||
2. **Esperar a que pase una hora** desde el último fallo (para `www`, a partir de las **12:47 UTC**).
|
||||
3. Lanzar **un solo** reintento y mirar el resultado antes de tocar nada más.
|
||||
|
||||
El reintento obliga a Traefik a recargar configuración, y eso hoy se consigue redesplegando el
|
||||
servicio:
|
||||
|
||||
```bash
|
||||
curl -s -H "Authorization: Bearer $COOLIFY_API_TOKEN" \
|
||||
"http://localhost:8000/api/v1/deploy?uuid=r2ssjifwj0r0ghyoqd528uaa&force=false"
|
||||
```
|
||||
|
||||
*(El API de Coolify solo escucha en `localhost:8000` del servidor: la llamada va por SSH.)*
|
||||
Cuesta **unos 7 segundos** de corte: se ha medido dos veces, un único 500 y vuelta a 200.
|
||||
**Cada redeploy quema tres validaciones**, una por hostname.
|
||||
|
||||
### Comprobar si ya salió
|
||||
|
||||
⚠️ **No con `grep`** — ver el aviso del principio: el fichero escribe `"main": "dominio"` con
|
||||
espacio y un patrón sin espacio da vacío siempre. Parsear el JSON.
|
||||
|
||||
## Cabo suelto ya cerrado por el camino
|
||||
|
||||
`fea-legacy-redirect.php` mandaba **`/.well-known/` al archivo** con un 301, porque manda allí
|
||||
cualquier 404. No era la causa —el reto va por el 80 y ahí ni llega a WordPress— pero estaba mal y
|
||||
era una trampa para el futuro. Arreglado y desplegado: las rutas de protocolo quedan exentas y el
|
||||
resto del redirect sigue igual. Lo encontró el Claude de Inma.
|
||||
|
||||
## `Full (strict)`
|
||||
|
||||
Ya se puede: los tres certificados están emitidos. Sube la seguridad porque valida además el
|
||||
certificado del origen. **Comprobar el sitio justo después de cambiarlo.**
|
||||
@@ -0,0 +1,38 @@
|
||||
# Manifiesto de mu-plugins a desplegar en el servidor nuevo (cutover #180)
|
||||
# Generado: 2026-07-31T21:01:00Z
|
||||
# Fuente de verdad: este repo. Alcance: los .php VIVOS en produccion a 31-jul-2026.
|
||||
#
|
||||
# NO se despliega con un glob 'fea-*': carta-semana-plugin.php y stop-redirects.php
|
||||
# no empiezan por fea- y son necesarios. Se copia exactamente esta lista.
|
||||
#
|
||||
# Excluidos a proposito: los .bak-*, el .disabled, health-check.php.quarantined-incident-183
|
||||
# (backdoor del #183) y fea-support-campaign.php (esta en el repo pero nunca se desplego).
|
||||
|
||||
554ca4e775ae405df618916445cb04d3d5f6f8385040754027ddad394902a120 carta-semana-plugin.php
|
||||
a477bdd5a91d5925d451020f9f9bcb168e97e181f3522c8721ac12bb086d24fa fea-analytics.php
|
||||
94c496ea0bee37dfb49603af187f895e7dc6db85c8f61828ddf9740f55d03bb0 fea-audio-player.php
|
||||
8821197f28d78f43b57de0769c851645623f4ccf133b938d9d5e71c5946d378b fea-avatar-cachebust.php
|
||||
62f937bebb7ebe884fc4f7de243745e7e7e6d4b0cf8de027dcc8fa8fbe203bfd fea-beta-feedback.php
|
||||
9ec7bb43d3758973b495cb3f615bfe4b8e6e569d669db62f748ef248982d58ea fea-carta-id-api.php
|
||||
a4c426f4d36fd12c83474536ffc3c2938b795d0dc5f2a3a1d743bd4f09c0c9bb fea-carta-portada.php
|
||||
ccc1c26bdd1117d5a5cf7c6ed88c6e1f1a889e1ce098335c5b27a6af788dce87 fea-cloudflare-realip.php
|
||||
41e83f9c475b25bcaa18bd80eec32a71680caac9f1db6d1b18c9f0f122b7ef9c fea-compact-entry-spacing.php
|
||||
d3d8bfaf176f14cf80dd4b94e0c65682cdc19ff2cfd6226d14fb07bd4914448e fea-cookie-consent.php
|
||||
32df7762d33b37fa3465a201f681c2b590ee5a8c5ea8292bfe077da5341a7a59 fea-crear-autor-api.php
|
||||
eee49e2bc543f6bf03ce6083ee276a968532e38b915373317f15b49629c805e5 fea-disable-comments.php
|
||||
721c52fdeab85e6bbc512b65792ca3a7d18c895d79d92c03714ff93136211a63 fea-gsc-verification.php
|
||||
351734516531857c92cfec40c78faa5417866694e1c9e6976f1080ec29bbc77b fea-hide-bad-tag.php
|
||||
ccac15d46eae66f4782c7a859d22d6553c80be2324cd498309e6304e0a953455 fea-homepage.php
|
||||
302da523f8c77ea2d2488fcd946d4e1207d49fbe39f26940115b7264aa268227 fea-legacy-redirect.php
|
||||
ecced1dc780edaabce659047860092428904b9a79688e908227f7ce086aa86c6 fea-menu-i18n.php
|
||||
cef207908964319efc0fb3a5929200f8fb9a4dbcce67e5b6bd2712b5cb83289b fea-pensamientos.php
|
||||
c952727a40b4ed018086d1f054d2d18bb08b6c6df5c1fd8cf4eadbc9183aa560 fea-recopilatorios.php
|
||||
8dc64763baa5fe79e408ad7aead7b3f6d660e16b7ec927677f7b2dc1682b5a42 fea-search-advanced.php
|
||||
32f4281b073a77e72d0cd917b1495722893333245056493dd758a7b62ba49cfa fea-search-fulltext.php
|
||||
dcb17d30f5728d13062b7b36d47011463405ed9e55b7ccb9b026d1a7f6620642 fea-search.php
|
||||
541583067fb8c85faa7d4ea995711ba42c629efb7ab6b9e17adf6ed1c433e5f9 fea-security-blocked-users.php
|
||||
3cd8d4e6b096a07371846d9e3afcdb70ca0358b644cac1554b4524a612327d73 fea-share.php
|
||||
534c3b112555518b1750e1322e1649f8a4721bc17699729c0bcc45f66693a4bc fea-slider-sync.php
|
||||
db82ef8b671601a2befb15a498db324f034ab86583a423b037a1d466d9900a5e fea-subir-avatar-api.php
|
||||
dbb602d4ba58dd1451d128a2a2ec8d3f6dcd03db7e2a340c6772422b68045c78 fea-ui.php
|
||||
eb255ea189aed781120efe1482676ce6f49665a8cf99a9be5c80db9295bba7d7 stop-redirects.php
|
||||
@@ -1,71 +0,0 @@
|
||||
# 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/`
|
||||
@@ -1,50 +0,0 @@
|
||||
<?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.
File diff suppressed because one or more lines are too long
@@ -1 +0,0 @@
|
||||
20260729T224708Z
|
||||
@@ -1,76 +0,0 @@
|
||||
# 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.
|
||||
@@ -1,87 +0,0 @@
|
||||
<!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>
|
||||
@@ -1,42 +0,0 @@
|
||||
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;
|
||||
}
|
||||
@@ -1,278 +0,0 @@
|
||||
### 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
|
||||
|
||||
@@ -1,16 +0,0 @@
|
||||
#!/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
|
||||
@@ -1,8 +0,0 @@
|
||||
#!/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
|
||||
@@ -1,20 +0,0 @@
|
||||
#!/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}}'
|
||||
@@ -1,18 +0,0 @@
|
||||
#!/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
|
||||
@@ -1,23 +0,0 @@
|
||||
#!/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
|
||||
@@ -1,27 +0,0 @@
|
||||
#!/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"
|
||||
@@ -1,8 +0,0 @@
|
||||
#!/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"
|
||||
@@ -1,28 +0,0 @@
|
||||
#!/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
|
||||
@@ -1,15 +0,0 @@
|
||||
#!/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
|
||||
@@ -1,49 +0,0 @@
|
||||
#!/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
|
||||
@@ -1,34 +0,0 @@
|
||||
#!/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"
|
||||
@@ -1,40 +0,0 @@
|
||||
#!/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
|
||||
@@ -1,14 +0,0 @@
|
||||
#!/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
|
||||
@@ -1,15 +0,0 @@
|
||||
#!/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] '
|
||||
@@ -1,16 +0,0 @@
|
||||
#!/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
|
||||
@@ -1,11 +0,0 @@
|
||||
#!/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'
|
||||
@@ -1,94 +0,0 @@
|
||||
#!/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))
|
||||
@@ -1,30 +0,0 @@
|
||||
#!/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
|
||||
@@ -1,18 +0,0 @@
|
||||
#!/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"
|
||||
@@ -1,14 +0,0 @@
|
||||
#!/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
|
||||
@@ -1,64 +0,0 @@
|
||||
#!/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
|
||||
@@ -1,51 +0,0 @@
|
||||
#!/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"
|
||||
@@ -1,13 +0,0 @@
|
||||
#!/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
|
||||
@@ -1,26 +0,0 @@
|
||||
#!/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
|
||||
@@ -1,16 +0,0 @@
|
||||
#!/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
|
||||
@@ -1,25 +0,0 @@
|
||||
#!/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"
|
||||
@@ -1,40 +0,0 @@
|
||||
#!/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
|
||||
@@ -1,13 +0,0 @@
|
||||
#!/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
|
||||
@@ -1,17 +0,0 @@
|
||||
#!/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
|
||||
@@ -1,46 +0,0 @@
|
||||
#!/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
|
||||
@@ -1,19 +0,0 @@
|
||||
#!/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"
|
||||
@@ -1,13 +0,0 @@
|
||||
#!/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
|
||||
@@ -1,110 +0,0 @@
|
||||
#!/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()
|
||||
@@ -1,20 +0,0 @@
|
||||
#!/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
|
||||
@@ -1,18 +0,0 @@
|
||||
#!/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
|
||||
@@ -1,14 +0,0 @@
|
||||
#!/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/'
|
||||
@@ -1,83 +0,0 @@
|
||||
#!/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)
|
||||
@@ -1,154 +0,0 @@
|
||||
#!/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)
|
||||
@@ -1,11 +0,0 @@
|
||||
#!/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) ==="
|
||||
@@ -1,19 +0,0 @@
|
||||
#!/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
|
||||
@@ -1,45 +0,0 @@
|
||||
#!/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
|
||||
@@ -1,47 +0,0 @@
|
||||
#!/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)
|
||||
@@ -1,13 +0,0 @@
|
||||
#!/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"
|
||||
@@ -1,14 +0,0 @@
|
||||
#!/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
|
||||
@@ -1,14 +0,0 @@
|
||||
#!/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)"
|
||||
@@ -1,20 +0,0 @@
|
||||
#!/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
|
||||
@@ -1,30 +0,0 @@
|
||||
#!/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))
|
||||
@@ -1,74 +0,0 @@
|
||||
#!/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")
|
||||
@@ -1,12 +0,0 @@
|
||||
#!/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}}'
|
||||
@@ -1,38 +0,0 @@
|
||||
#!/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
|
||||
@@ -1,13 +0,0 @@
|
||||
#!/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)$'
|
||||
@@ -1,22 +0,0 @@
|
||||
#!/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"
|
||||
@@ -1,17 +0,0 @@
|
||||
#!/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
|
||||
@@ -1,70 +0,0 @@
|
||||
#!/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)
|
||||
@@ -1,63 +0,0 @@
|
||||
#!/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)
|
||||
@@ -1,77 +0,0 @@
|
||||
#!/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"))
|
||||
@@ -1,27 +0,0 @@
|
||||
#!/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)"
|
||||
@@ -1,14 +0,0 @@
|
||||
#!/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
|
||||
@@ -1,64 +0,0 @@
|
||||
#!/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))
|
||||
@@ -1,108 +0,0 @@
|
||||
#!/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()
|
||||
@@ -1,19 +0,0 @@
|
||||
#!/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
|
||||
@@ -1,17 +0,0 @@
|
||||
#!/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
|
||||
@@ -1,9 +0,0 @@
|
||||
#!/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
|
||||
@@ -1,23 +0,0 @@
|
||||
#!/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
|
||||
@@ -1,70 +0,0 @@
|
||||
#!/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"
|
||||
@@ -1,10 +0,0 @@
|
||||
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
|
||||
@@ -1,37 +0,0 @@
|
||||
#!/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"
|
||||
Executable
+54
@@ -0,0 +1,54 @@
|
||||
#!/bin/bash
|
||||
#
|
||||
# Paso 1-2 del cutover: traer el dump de BD del dia. CDMON -> WSL -> Hetzner.
|
||||
#
|
||||
# Dos saltos a proposito: scp/sftp no funcionan contra la jaula de CDMON, y no
|
||||
# queremos dejar la contrasena de CDMON escrita en el Hetzner. Se verifica el
|
||||
# sha256 en los tres puntos y la integridad del gzip en dos.
|
||||
#
|
||||
# Uso: bash 01-traer-dump.sh [nombre-del-dump]
|
||||
# sin argumento coge el mas reciente de /web/wp-content/updraft/
|
||||
#
|
||||
set -euo pipefail
|
||||
set -a; . ~/.hermes/profiles/feadulta/.env 2>/dev/null; set +a
|
||||
|
||||
WORK=/home/rafa/migracion-wp
|
||||
SRV=root@188.40.120.157
|
||||
ORIG=feadulta@134.0.10.170
|
||||
mkdir -p "$WORK"
|
||||
|
||||
s() { sshpass -p "$FEA_PROD_SSH_PASS" ssh -o StrictHostKeyChecking=accept-new "$ORIG" "$@"; }
|
||||
|
||||
DUMP="${1:-}"
|
||||
if [ -z "$DUMP" ]; then
|
||||
# sin xargs ni basename: la jaula de CDMON tiene un PATH minimo
|
||||
DUMP=$(s 'ls -t /web/wp-content/updraft/*-db.gz | head -1 | sed "s|.*/||"')
|
||||
fi
|
||||
echo "== dump elegido: $DUMP"
|
||||
|
||||
echo "== en origen:"
|
||||
s "php -r \"\\\$p='/web/wp-content/updraft/$DUMP'; echo ' ',filesize(\\\$p),' bytes sha256=',hash_file('sha256',\\\$p),\\\"\n\\\";\""
|
||||
|
||||
echo "== descargando a la WSL:"
|
||||
T0=$(date +%s)
|
||||
s "cat /web/wp-content/updraft/$DUMP" > "$WORK/$DUMP"
|
||||
T1=$(date +%s); echo " descarga: $((T1-T0))s"
|
||||
|
||||
echo "== integridad en la WSL:"
|
||||
ls -l "$WORK/$DUMP" | awk '{print " ", $5, "bytes"}'
|
||||
sha256sum "$WORK/$DUMP" | awk '{print " sha256=", $1}'
|
||||
gzip -t "$WORK/$DUMP" && echo " gzip: integro"
|
||||
|
||||
echo "== subiendo al Hetzner:"
|
||||
ssh -o BatchMode=yes "$SRV" 'mkdir -p /data/feadulta-migracion'
|
||||
T2=$(date +%s)
|
||||
scp -q "$WORK/$DUMP" "$SRV:/data/feadulta-migracion/"
|
||||
T3=$(date +%s); echo " subida: $((T3-T2))s"
|
||||
|
||||
echo "== verificacion en destino:"
|
||||
ssh -o BatchMode=yes "$SRV" "cd /data/feadulta-migracion && ls -l $DUMP | awk '{print \" \", \$5, \" bytes\"}' && sha256sum $DUMP | awk '{print \" sha256=\", \$1}' && gzip -t $DUMP && echo ' gzip: integro'"
|
||||
|
||||
echo ""
|
||||
echo "== total: $(( $(date +%s) - T0 ))s"
|
||||
echo "$DUMP" > "$WORK/DUMP-DEL-DIA"
|
||||
echo " (nombre guardado en $WORK/DUMP-DEL-DIA para el paso siguiente)"
|
||||
Executable
+57
@@ -0,0 +1,57 @@
|
||||
#!/bin/bash
|
||||
#
|
||||
# Paso 3 del cutover: sanear el dump. NO importa: eso es el paso 4.
|
||||
#
|
||||
# Dos cosas a la vez sobre el mismo streaming:
|
||||
# - excluir las 5 tablas de respaldo (*_bak y wp_fea_date_slug_posts_backup)
|
||||
# - mapear las collations de MariaDB 11 que MySQL 8.4 rechaza en el CREATE TABLE
|
||||
#
|
||||
# El troceado por tablas se apoya en los comentarios "# Table: `nombre`" que escribe
|
||||
# UpdraftPlus. Si algun dia el dump lo genera mysqldump, esto hay que rehacerlo.
|
||||
#
|
||||
set -euo pipefail
|
||||
DUMP=$(cat /home/rafa/migracion-wp/DUMP-DEL-DIA)
|
||||
echo "== dump: $DUMP"
|
||||
ssh -o BatchMode=yes root@188.40.120.157 "D='$DUMP' bash -s" <<'EOF'
|
||||
set -euo pipefail
|
||||
cd /data/feadulta-migracion
|
||||
OUT=feadulta-saneado.sql
|
||||
T0=$(date +%s)
|
||||
|
||||
zcat "$D" \
|
||||
| awk '
|
||||
/^# Table: `/ {
|
||||
name = $0; sub(/^# Table: `/, "", name); sub(/`.*$/, "", name)
|
||||
skip = (name ~ /_bak$/ || name == "wp_fea_date_slug_posts_backup") ? 1 : 0
|
||||
if (skip) print " [excluida] " name > "/dev/stderr"
|
||||
}
|
||||
!skip { print }
|
||||
' \
|
||||
| sed -E \
|
||||
-e 's/utf8mb4_uca1400_ai_ci/utf8mb4_0900_ai_ci/g' \
|
||||
-e 's/utf8mb3_uca1400_ai_ci/utf8mb4_0900_ai_ci/g' \
|
||||
-e 's/CHARSET=utf8mb3/CHARSET=utf8mb4/g' \
|
||||
-e 's/CHARACTER SET utf8mb3/CHARACTER SET utf8mb4/g' \
|
||||
-e 's/utf8mb3_general_ci/utf8mb4_general_ci/g' \
|
||||
> "$OUT"
|
||||
|
||||
echo ""
|
||||
echo "== saneo en $(( $(date +%s) - T0 ))s"
|
||||
ls -l "$OUT" | awk '{printf " %.1f MB sin comprimir\n", $5/1048576}'
|
||||
|
||||
echo ""
|
||||
echo "== VERIFICACION (las tres primeras deben dar 0)"
|
||||
u=$(grep -c 'uca1400' "$OUT" || true); echo " restos de uca1400 : $u"
|
||||
m=$(grep -c 'utf8mb3' "$OUT" || true); echo " restos de utf8mb3 : $m"
|
||||
b=$(grep -oE '^CREATE TABLE `[^`]+`' "$OUT" | grep -cE '_bak|backup' || true); echo " tablas de respaldo: $b"
|
||||
echo " tablas en el saneado: $(grep -cE '^CREATE TABLE ' "$OUT")"
|
||||
echo " DROP TABLE IF EXISTS: $(grep -c '^DROP TABLE IF EXISTS' "$OUT" || true) <- si es 0, hay que vaciar la BD antes"
|
||||
echo " collations resultantes:"
|
||||
grep -oE 'COLLATE=[a-z0-9_]+' "$OUT" | LC_ALL=C sort | uniq -c | sed 's/^/ /'
|
||||
echo " epilogo (debe verse el cierre del dump):"
|
||||
tail -3 "$OUT" | sed 's/^/ /'
|
||||
|
||||
[ "$u" = "0" ] && [ "$m" = "0" ] && [ "$b" = "0" ] || { echo ""; echo "!! VERIFICACION FALLIDA, no importar"; exit 1; }
|
||||
echo ""
|
||||
echo "== saneado OK"
|
||||
EOF
|
||||
Executable
+54
@@ -0,0 +1,54 @@
|
||||
#!/bin/bash
|
||||
#
|
||||
# Paso 4 del cutover: importar el dump saneado en MySQL 8.4.
|
||||
#
|
||||
# --default-character-set=utf8mb4 EN EL CLIENTE es obligatorio: omitirlo es lo que
|
||||
# produjo el mojibake de summaraise. El dump trae DROP TABLE IF EXISTS en las 29
|
||||
# tablas, asi que no hace falta vaciar la BD antes.
|
||||
#
|
||||
set -euo pipefail
|
||||
ssh -o BatchMode=yes root@188.40.120.157 'bash -s' <<'EOF'
|
||||
set -euo pipefail
|
||||
U=r2ssjifwj0r0ghyoqd528uaa
|
||||
cd /data/feadulta-migracion
|
||||
RP=$(docker inspect mysql-$U --format '{{range .Config.Env}}{{println .}}{{end}}' | sed -n 's/^MYSQL_ROOT_PASSWORD=//p')
|
||||
# OJO: sin -i y con </dev/null. Este script llega por el stdin del ssh, y un
|
||||
# "docker exec -i" sin redirigir se come el resto del script.
|
||||
m() { docker exec mysql-$U mysql --default-character-set=utf8mb4 -uroot -p"$RP" -N -B -e "$1" wordpress 2>/dev/null </dev/null; }
|
||||
|
||||
echo "== antes del import:"
|
||||
m "SELECT CONCAT(' tablas: ', COUNT(*)) FROM information_schema.tables WHERE table_schema='wordpress';"
|
||||
|
||||
echo "== importando..."
|
||||
T0=$(date +%s)
|
||||
docker exec -i mysql-$U mysql --default-character-set=utf8mb4 -uroot -p"$RP" wordpress < feadulta-saneado.sql
|
||||
T1=$(date +%s)
|
||||
echo " import: $((T1-T0))s"
|
||||
|
||||
echo ""
|
||||
echo "== recuentos en destino (comparar con produccion):"
|
||||
m "SELECT 'tablas', COUNT(*) FROM information_schema.tables WHERE table_schema='wordpress'
|
||||
UNION ALL SELECT 'posts', COUNT(*) FROM wp_posts
|
||||
UNION ALL SELECT 'users', COUNT(*) FROM wp_users
|
||||
UNION ALL SELECT 'options', COUNT(*) FROM wp_options
|
||||
UNION ALL SELECT 'terms', COUNT(*) FROM wp_terms
|
||||
UNION ALL SELECT 'postmeta', COUNT(*) FROM wp_postmeta;" | sed 's/^/ /'
|
||||
|
||||
echo ""
|
||||
echo "== charset/collation de las tablas (no debe quedar nada raro):"
|
||||
m "SELECT table_collation, COUNT(*) FROM information_schema.tables WHERE table_schema='wordpress' GROUP BY table_collation;" | sed 's/^/ /'
|
||||
|
||||
echo ""
|
||||
echo "== indice FULLTEXT del buscador:"
|
||||
m "SELECT index_name, COUNT(*) FROM information_schema.statistics WHERE table_schema='wordpress' AND index_type='FULLTEXT' GROUP BY index_name;" | sed 's/^/ /'
|
||||
|
||||
echo ""
|
||||
echo "== acentos y tipografia (mojibake = 0):"
|
||||
m "SELECT 'con  raro', COUNT(*) FROM wp_posts WHERE post_title COLLATE utf8mb4_bin LIKE '%Ã%' AND post_title COLLATE utf8mb4_bin NOT LIKE '%ÃÂ%'
|
||||
UNION ALL SELECT '’ (mojibake real)', COUNT(*) FROM wp_posts WHERE post_content COLLATE utf8mb4_bin LIKE '%’%'
|
||||
UNION ALL SELECT '© mojibake', COUNT(*) FROM wp_posts WHERE post_content COLLATE utf8mb4_bin LIKE '%Â%';" | sed 's/^/ /'
|
||||
|
||||
echo ""
|
||||
echo "== siteurl / home que trae el dump:"
|
||||
m "SELECT option_name, option_value FROM wp_options WHERE option_name IN ('siteurl','home');" | sed 's/^/ /'
|
||||
EOF
|
||||
Executable
+59
@@ -0,0 +1,59 @@
|
||||
#!/bin/bash
|
||||
#
|
||||
# Paso 5 del cutover: delta de uploads.
|
||||
#
|
||||
# El grueso (5,9 GB / 47.920 ficheros) se pre-sincronizo el 31-jul. Aqui solo viaja
|
||||
# lo publicado desde entonces. Dos saltos igual que el dump: CDMON -> WSL -> Hetzner,
|
||||
# para no dejar la contrasena de CDMON escrita en el servidor.
|
||||
#
|
||||
# --partial y ServerAliveInterval porque un rsync largo contra este enlace se corta
|
||||
# EN SILENCIO (la primera pasada del 31-jul murio a 36.289 de 47.920 sin error en el
|
||||
# log). Por eso al final se comparan los recuentos de los dos lados: no vale con que
|
||||
# rsync diga que termino.
|
||||
#
|
||||
# Los .php se excluyen a proposito: en uploads solo hay 2 y son importadores de un
|
||||
# solo uso, no tienen que viajar.
|
||||
#
|
||||
set -euo pipefail
|
||||
set -a; . ~/.hermes/profiles/feadulta/.env 2>/dev/null; set +a
|
||||
|
||||
STAGE=/tmp/fea-uploads-staging
|
||||
SRV=root@188.40.120.157
|
||||
DEST=/var/lib/docker/volumes/r2ssjifwj0r0ghyoqd528uaa_wordpress-files/_data/wp-content/uploads
|
||||
|
||||
echo "== antes:"
|
||||
printf " WSL : "; find "$STAGE" -type f | wc -l
|
||||
printf " Hetzner : "; ssh -o BatchMode=yes "$SRV" "find $DEST -type f | wc -l"
|
||||
printf " CDMON : "; sshpass -p "$FEA_PROD_SSH_PASS" ssh -o StrictHostKeyChecking=no feadulta@134.0.10.170 'find /web/wp-content/uploads -type f | wc -l'
|
||||
|
||||
echo ""
|
||||
echo "== delta CDMON -> WSL:"
|
||||
T0=$(date +%s)
|
||||
sshpass -p "$FEA_PROD_SSH_PASS" rsync -a --partial --info=stats2 --exclude='*.php' \
|
||||
-e "ssh -o StrictHostKeyChecking=accept-new -o ServerAliveInterval=20 -o ServerAliveCountMax=6" \
|
||||
feadulta@134.0.10.170:/web/wp-content/uploads/ "$STAGE/" \
|
||||
| grep -E 'Number of (regular files transferred|deleted)|Total transferred file size' | sed 's/^/ /'
|
||||
echo " $(( $(date +%s) - T0 ))s"
|
||||
|
||||
echo ""
|
||||
echo "== delta WSL -> Hetzner (como www-data, uid 33):"
|
||||
T1=$(date +%s)
|
||||
rsync -a --partial --info=stats2 --exclude='*.php' --chown=33:33 \
|
||||
-e "ssh -o BatchMode=yes -o ServerAliveInterval=20 -o ServerAliveCountMax=6" \
|
||||
"$STAGE/" "$SRV:$DEST/" \
|
||||
| grep -E 'Number of (regular files transferred|deleted)|Total transferred file size' | sed 's/^/ /'
|
||||
echo " $(( $(date +%s) - T1 ))s"
|
||||
|
||||
echo ""
|
||||
echo "== VERIFICACION por recuento (no fiarse de que rsync diga que termino):"
|
||||
a=$(sshpass -p "$FEA_PROD_SSH_PASS" ssh -o StrictHostKeyChecking=no feadulta@134.0.10.170 'find /web/wp-content/uploads -type f ! -name "*.php" | wc -l')
|
||||
b=$(find "$STAGE" -type f | wc -l)
|
||||
c=$(ssh -o BatchMode=yes "$SRV" "find $DEST -type f | wc -l")
|
||||
echo " CDMON (sin .php): $a"
|
||||
echo " WSL : $b"
|
||||
echo " Hetzner : $c"
|
||||
[ "$a" = "$c" ] && echo " OK: origen y destino cuadran" || echo " !! NO CUADRAN, revisar antes de seguir"
|
||||
|
||||
echo ""
|
||||
echo "== propietario en destino (no debe haber nada que no sea www-data):"
|
||||
ssh -o BatchMode=yes "$SRV" "find $DEST ! -user 33 | head -5 | sed 's/^/ /'; echo ' ficheros con otro dueno: '\$(find $DEST ! -user 33 | wc -l)"
|
||||
Executable
+59
@@ -0,0 +1,59 @@
|
||||
#!/bin/bash
|
||||
#
|
||||
# Paso 8 del cutover: mover el A record del apex y de www a Hetzner.
|
||||
#
|
||||
# Se cambia SOLO el content. proxied=true se mantiene (la web va por Cloudflare) y
|
||||
# no se toca nada mas de la zona: el MX, el wildcard, los NS de cdmon y los TXT
|
||||
# (SPF y brevo) se quedan igual, asi que el correo no se ve afectado.
|
||||
#
|
||||
# ROLLBACK: volver a poner 134.0.10.170 en estos dos mismos ids.
|
||||
#
|
||||
set -uo pipefail
|
||||
set -a; . ~/.hermes/profiles/feadulta/.env 2>/dev/null; set +a
|
||||
|
||||
ZONE=5aa15f0f9cf7d45f3710177bb4a66aba
|
||||
NUEVA_IP=188.40.120.157
|
||||
APEX=807be63daf5a2ce9cfc710dcc1ef0a21
|
||||
WWW=0ce31fc2fbf5316434ace0c944909c59
|
||||
|
||||
mover() {
|
||||
local id=$1 name=$2
|
||||
curl -s -X PATCH \
|
||||
-H "Authorization: Bearer $FEA_CF_API_TOKEN" -H "Content-Type: application/json" \
|
||||
"https://api.cloudflare.com/client/v4/zones/$ZONE/dns_records/$id" \
|
||||
--data "{\"content\":\"$NUEVA_IP\"}" \
|
||||
| python3 -c "
|
||||
import sys,json
|
||||
d=json.load(sys.stdin)
|
||||
if d.get('success'):
|
||||
r=d['result']; print(' OK %-20s -> %s proxied=%s' % (r['name'], r['content'], r['proxied']))
|
||||
else:
|
||||
print(' FALLO %s: %s' % ('$name', d.get('errors')))
|
||||
"
|
||||
}
|
||||
|
||||
echo "== $(date '+%F %T %Z') moviendo los A records:"
|
||||
mover "$APEX" feadulta.com
|
||||
mover "$WWW" www.feadulta.com
|
||||
|
||||
echo ""
|
||||
echo "== estado en la zona tras el cambio:"
|
||||
curl -s -H "Authorization: Bearer $FEA_CF_API_TOKEN" \
|
||||
"https://api.cloudflare.com/client/v4/zones/$ZONE/dns_records?type=A&per_page=100" \
|
||||
| python3 -c '
|
||||
import sys,json
|
||||
for r in json.load(sys.stdin)["result"]:
|
||||
print(" %-22s %-16s proxied=%s" % (r["name"], r["content"], r["proxied"]))
|
||||
'
|
||||
|
||||
echo ""
|
||||
echo "== purgando la cache de Cloudflare:"
|
||||
curl -s -X POST \
|
||||
-H "Authorization: Bearer $FEA_CF_API_TOKEN" -H "Content-Type: application/json" \
|
||||
"https://api.cloudflare.com/client/v4/zones/$ZONE/purge_cache" --data '{"purge_everything":true}' \
|
||||
| python3 -c '
|
||||
import sys,json
|
||||
d=json.load(sys.stdin)
|
||||
print(" purga OK" if d.get("success") else " NO se pudo purgar: %s" % d.get("errors"))
|
||||
print(" (si falla es que el token solo tiene permiso de DNS: hay que purgar desde el panel)")
|
||||
'
|
||||
@@ -0,0 +1,181 @@
|
||||
#!/bin/bash
|
||||
#
|
||||
# Higiene de seguridad posterior a importar la base de datos en el servidor nuevo.
|
||||
# Parte del cutover CDMON -> Hetzner (gitea rafa/feadulta #180, criterio del comment-516).
|
||||
#
|
||||
# SE EJECUTA DESPUES DE CADA IMPORT. El dump trae las cuentas de administrador de
|
||||
# produccion con sus hashes, incluidas las tres deshabilitadas durante el incidente #183,
|
||||
# asi que reimportar las revive. Por eso esto es un script y no una tarea manual.
|
||||
#
|
||||
# Lee las contrasenas de ~/.hermes/profiles/feadulta/.env (claves FEA_WP_PASS_*).
|
||||
# Nunca las imprime ni las pasa por argv: viajan por variable de entorno del proceso.
|
||||
#
|
||||
# Uso: bash post-import-seguridad.sh [--dry-run]
|
||||
#
|
||||
set -euo pipefail
|
||||
|
||||
DRY=0
|
||||
[ "${1:-}" = "--dry-run" ] && DRY=1
|
||||
|
||||
ENVF=~/.hermes/profiles/feadulta/.env
|
||||
[ -f "$ENVF" ] || { echo "Falta $ENVF"; exit 1; }
|
||||
set -a; . "$ENVF"; set +a
|
||||
|
||||
SRV=root@188.40.120.157
|
||||
U=r2ssjifwj0r0ghyoqd528uaa # servicio feadulta-wp en Coolify
|
||||
WPC=wordpress-$U
|
||||
|
||||
# usuario:ID:variable_de_password
|
||||
ADMINS_OK="eqpyk:1:FEA_WP_PASS_EQPYK icalvotorre:1048:FEA_WP_PASS_ICALVOTORRE calvo:396:FEA_WP_PASS_CALVO"
|
||||
ADMINS_KO="pabloarias:1047:FEA_WP_PASS_PABLOARIAS josek:1049:FEA_WP_PASS_JOSEK andrey:1087:FEA_WP_PASS_ANDREY"
|
||||
|
||||
wp() { ssh -o BatchMode=yes "$SRV" "docker exec -u www-data $WPC php wp-content/wp-cli.phar --path=/var/www/html --skip-plugins --skip-themes $*" 2>/dev/null; }
|
||||
|
||||
# Variante que SI carga los plugins. Hace falta para el paso 3e: sin ella la clase
|
||||
# wfConfig de Wordfence no existe y el eval falla en silencio.
|
||||
wpp() { ssh -o BatchMode=yes "$SRV" "docker exec -u www-data $WPC php wp-content/wp-cli.phar --path=/var/www/html --skip-themes $*" 2>/dev/null; }
|
||||
|
||||
echo "=== 1. Rotar la contrasena de los 6 administradores ==="
|
||||
for entry in $ADMINS_OK $ADMINS_KO; do
|
||||
login=${entry%%:*}; rest=${entry#*:}; id=${rest%%:*}; var=${rest##*:}
|
||||
pass="${!var:-}"
|
||||
[ -n "$pass" ] || { echo " $login: falta $var en el .env"; exit 1; }
|
||||
if [ $DRY -eq 1 ]; then
|
||||
echo " [dry-run] rotaria la contrasena de $login (ID $id)"
|
||||
else
|
||||
ssh -o BatchMode=yes "$SRV" \
|
||||
"docker exec -e NEWPASS=\"$pass\" -u www-data $WPC php wp-content/wp-cli.phar --path=/var/www/html --skip-plugins --skip-themes eval 'wp_set_password(getenv(\"NEWPASS\"), $id);'" 2>/dev/null
|
||||
echo " $login (ID $id): contrasena rotada"
|
||||
fi
|
||||
done
|
||||
|
||||
echo ""
|
||||
echo "=== 2. Deshabilitar las tres cuentas del incidente #183 ==="
|
||||
echo " (doble cinturon: se les quita el rol Y el mu-plugin fea-security-blocked-users.php"
|
||||
echo " bloquea wp_authenticate_user y allow_password_reset para estos mismos IDs)"
|
||||
for entry in $ADMINS_KO; do
|
||||
login=${entry%%:*}; rest=${entry#*:}; id=${rest%%:*}
|
||||
if [ $DRY -eq 1 ]; then
|
||||
echo " [dry-run] quitaria el rol administrator a $login (ID $id)"
|
||||
else
|
||||
wp user remove-role "$id" administrator >/dev/null || true
|
||||
echo " $login (ID $id): rol administrator retirado"
|
||||
fi
|
||||
done
|
||||
|
||||
echo ""
|
||||
echo "=== 3. Limpiar los cron huerfanos de plugins ya desinstalados ==="
|
||||
for hook in wpseo-reindex wpseo_permalink_structure_check wppb_review_check cozmos_wppb_plugin_optin_sync; do
|
||||
if [ $DRY -eq 1 ]; then
|
||||
echo " [dry-run] borraria el evento $hook"
|
||||
else
|
||||
out=$(wp cron event delete "$hook" 2>&1 || true)
|
||||
echo " $hook: ${out:-sin eventos}"
|
||||
fi
|
||||
done
|
||||
|
||||
echo ""
|
||||
echo "=== 3b. Retirar la inyeccion de SEO spam del incidente #183 ==="
|
||||
# La opcion 'wordfence_core_options' NO es de Wordfence (que ni siquiera esta instalado):
|
||||
# es el payload que dejo el atacante, cifrado con un cesar de una letra, con un div oculto
|
||||
# (left:-7585px) y enlaces a un casino japones, enganchado a wp_footer. Hoy esta inerte
|
||||
# porque quien lo leia era el plugin wp-highlits, en cuarentena. Pero VIAJA EN EL DUMP,
|
||||
# asi que cada import lo reintroduce. Se preserva antes de borrar (evidencia del #183).
|
||||
SPAMOPT=wordfence_core_options
|
||||
if [ $DRY -eq 1 ]; then
|
||||
echo " [dry-run] preservaria y borraria la opcion $SPAMOPT"
|
||||
else
|
||||
ssh -o BatchMode=yes "$SRV" "bash -s" <<REMOTE
|
||||
RP=\$(docker inspect mysql-$U --format '{{range .Config.Env}}{{println .}}{{end}}' | sed -n 's/^MYSQL_ROOT_PASSWORD=//p')
|
||||
m() { docker exec mysql-$U mysql --default-character-set=utf8mb4 -uroot -p"\$RP" -N -e "\$1" wordpress 2>/dev/null; }
|
||||
EV=/data/feadulta-migracion/evidencia-183; mkdir -p "\$EV"
|
||||
m "SELECT option_value FROM wp_options WHERE option_name='$SPAMOPT';" > "\$EV/${SPAMOPT}.\$(date +%Y%m%d%H%M%S).txt"
|
||||
m "DELETE FROM wp_options WHERE option_name='$SPAMOPT';"
|
||||
n=\$(m "SELECT COUNT(*) FROM wp_options WHERE option_name='$SPAMOPT';")
|
||||
echo " $SPAMOPT: preservada y borrada (quedan \$n)"
|
||||
REMOTE
|
||||
fi
|
||||
|
||||
echo ""
|
||||
echo "=== 3c. Retirar limit-login-attempts-reloaded ==="
|
||||
# Decision de Rafa (2-ago): el bloqueo de login lo lleva Wordfence, que hace lo mismo
|
||||
# y ademas firewall de aplicacion, escaner de malware y 2FA. Tener los dos = dos
|
||||
# contadores compitiendo. El dump trae LLAR en active_plugins, asi que cada import
|
||||
# lo revive: por eso se retira aqui y no una sola vez a mano.
|
||||
if [ $DRY -eq 1 ]; then
|
||||
echo " [dry-run] desactivaria y borraria limit-login-attempts-reloaded"
|
||||
echo " [dry-run] limpiaria las opciones limit_login_*"
|
||||
else
|
||||
wp plugin deactivate limit-login-attempts-reloaded >/dev/null 2>&1 || true
|
||||
wp plugin delete limit-login-attempts-reloaded >/dev/null 2>&1 || true
|
||||
echo " limit-login-attempts-reloaded: retirado"
|
||||
ssh -o BatchMode=yes "$SRV" "bash -s" <<REMOTE
|
||||
RP=\$(docker inspect mysql-$U --format '{{range .Config.Env}}{{println .}}{{end}}' | sed -n 's/^MYSQL_ROOT_PASSWORD=//p')
|
||||
docker exec mysql-$U mysql --default-character-set=utf8mb4 -uroot -p"\$RP" -N -e \
|
||||
"DELETE FROM wp_options WHERE option_name LIKE 'limit_login%';" wordpress 2>/dev/null
|
||||
n=\$(docker exec mysql-$U mysql -uroot -p"\$RP" -N -e "SELECT COUNT(*) FROM wp_options WHERE option_name LIKE 'limit_login%';" wordpress 2>/dev/null)
|
||||
echo " opciones limit_login_* restantes: \$n"
|
||||
REMOTE
|
||||
fi
|
||||
|
||||
echo ""
|
||||
echo "=== 3d. Wordfence presente? ==="
|
||||
if [ $DRY -eq 1 ]; then
|
||||
echo " [dry-run] comprobaria Wordfence"
|
||||
else
|
||||
if wp plugin is-installed wordfence 2>/dev/null; then
|
||||
wp plugin activate wordfence >/dev/null 2>&1 || true
|
||||
echo " wordfence: instalado y activo"
|
||||
else
|
||||
echo " wordfence: NO instalado -> instalando desde wordpress.org"
|
||||
wp plugin install wordfence --activate >/dev/null 2>&1 && echo " wordfence: instalado" || echo " wordfence: FALLO la instalacion, revisar a mano"
|
||||
fi
|
||||
fi
|
||||
|
||||
echo ""
|
||||
echo "=== 3e. Wordfence NO debe bloquear las application passwords ==="
|
||||
# Wordfence trae loginSec_disableApplicationPasswords=1 POR DEFECTO, y con eso
|
||||
# wp_is_application_passwords_available() devuelve false: la seccion desaparece del
|
||||
# perfil y, lo que importa, la autenticacion por REST falla con 401. Los bots de la
|
||||
# carta publican asi (publicabot, de icalvotorre), o sea que con esto en 1 no publican.
|
||||
#
|
||||
# Detectado el 3-ago despues del cutover, con un 401 real de icalvotorre contra
|
||||
# /wp-json/wp/v2/users/me. En produccion nunca paso porque alli Wordfence no estaba
|
||||
# instalado; lo introdujimos nosotros al anadirlo.
|
||||
#
|
||||
# Va aqui y no una sola vez a mano porque el paso 3d reinstala Wordfence si falta, y
|
||||
# una instalacion nueva vuelve a ponerlo a 1.
|
||||
if [ $DRY -eq 1 ]; then
|
||||
echo " [dry-run] pondria loginSec_disableApplicationPasswords a 0"
|
||||
else
|
||||
# Por heredoc y no por la funcion wp(): esa expande "$*" y se come las comillas
|
||||
# del codigo PHP. Y SIN --skip-plugins, o la clase wfConfig no existe.
|
||||
ssh -o BatchMode=yes "$SRV" "WPC=$WPC bash -s" <<'REMOTE'
|
||||
W="docker exec -u www-data $WPC php wp-content/wp-cli.phar --path=/var/www/html --skip-themes"
|
||||
$W eval 'wfConfig::set("loginSec_disableApplicationPasswords", 0);' >/dev/null 2>&1
|
||||
v=$($W eval 'echo var_export(wfConfig::get("loginSec_disableApplicationPasswords"), true);' 2>/dev/null)
|
||||
echo " loginSec_disableApplicationPasswords = $v (tiene que ser '0')"
|
||||
echo " disponibles?: $($W eval '$_SERVER["HTTPS"]="on"; echo wp_is_application_passwords_available() ? "SI" : "NO -- los bots no podran publicar";' 2>/dev/null)"
|
||||
echo " application passwords que existen (no se tocan, se coordinan con Inma):"
|
||||
$W eval '
|
||||
foreach (get_users(["fields" => "ID"]) as $id) {
|
||||
foreach (WP_Application_Passwords::get_user_application_passwords($id) as $p) {
|
||||
$u = get_user_by("id", $id);
|
||||
echo " ", $u->user_login, " / ", $p["name"], " ultimo uso ",
|
||||
($p["last_used"] ? date("Y-m-d", $p["last_used"]) : "nunca"), PHP_EOL;
|
||||
}
|
||||
}' 2>/dev/null
|
||||
REMOTE
|
||||
fi
|
||||
|
||||
echo ""
|
||||
echo "=== 4. Verificacion ==="
|
||||
echo " administradores que quedan:"
|
||||
wp user list --role=administrator --fields=ID,user_login,user_email --format=csv | sed 's/^/ /'
|
||||
echo " el mu-plugin de bloqueo esta presente?:"
|
||||
ssh -o BatchMode=yes "$SRV" "docker exec $WPC test -f /var/www/html/wp-content/mu-plugins/fea-security-blocked-users.php && echo ' SI' || echo ' NO -- FALTA, las cuentas del incidente quedarian solo con el rol retirado'"
|
||||
echo ""
|
||||
echo " application passwords existentes (NO se tocan: las usan los bots de la carta;"
|
||||
echo " su rotacion se coordina con Inma):"
|
||||
wp db query "SELECT u.user_login, LENGTH(m.meta_value) AS bytes FROM wp_usermeta m JOIN wp_users u ON u.ID=m.user_id WHERE m.meta_key='_application_passwords';" 2>/dev/null | sed 's/^/ /' || \
|
||||
echo " (wp db query no disponible; consultar por SQL directo)"
|
||||
+1
-58
@@ -3,16 +3,11 @@
|
||||
* IO mínimo de posts WP para el reprocesador EN.
|
||||
* get <id> -> escribe /tmp/fea_es.json {title, content, status}
|
||||
* update <id> <titlef> <bodyf> -> actualiza post_title/post_content desde ficheros
|
||||
* listpending <autor> ... -> cola de backlog TTS pendiente (ver abajo)
|
||||
* Carga wp-load; portable (local docker o prod via FEA_WP_LOAD).
|
||||
*/
|
||||
$WP = getenv('FEA_WP_LOAD') ?: '/var/www/html/wp-load.php';
|
||||
require $WP;
|
||||
|
||||
// Por debajo de esto el post_content no da para locutar (prefiltro barato en SQL;
|
||||
// tts_produce.py vuelve a medir el texto ya extraído y marca fea_audio_skip).
|
||||
const FEA_TTS_MIN_CONTENT = 400;
|
||||
|
||||
$action = $argv[1] ?? '';
|
||||
|
||||
if ($action === 'get') {
|
||||
@@ -24,8 +19,6 @@ if ($action === 'get') {
|
||||
'title' => $p->post_title,
|
||||
'content' => $p->post_content,
|
||||
'status' => $p->post_status,
|
||||
'post_type' => $p->post_type,
|
||||
'post_name' => $p->post_name,
|
||||
'author' => (int)$p->post_author,
|
||||
], JSON_UNESCAPED_UNICODE));
|
||||
exit(0);
|
||||
@@ -77,55 +70,5 @@ if ($action === 'unsetaudio') { // unsetaudio <id> (rollback: despublica el au
|
||||
exit(0);
|
||||
}
|
||||
|
||||
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");
|
||||
fwrite(STDERR, "uso: get|update|getmeta|setaudio|setflag|unsetaudio\n");
|
||||
exit(2);
|
||||
|
||||
@@ -262,47 +262,6 @@ switch ($cmd) {
|
||||
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': {
|
||||
// Descubre el cluster de una carta parseando sus propios enlaces internos
|
||||
// (misma lógica que pinta la portada, fea-carta-portada.php). No depende
|
||||
|
||||
@@ -1,257 +0,0 @@
|
||||
#!/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())
|
||||
@@ -1,385 +0,0 @@
|
||||
#!/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())
|
||||
@@ -1,135 +0,0 @@
|
||||
#!/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())
|
||||
+7
-17
@@ -93,15 +93,7 @@ def get_post_text(pid):
|
||||
check=True, capture_output=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"))
|
||||
# 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"(?i)</p>|<br\s*/?>|</h[1-6]>", "\n", d["content"])
|
||||
raw = re.sub(r"<[^>]+>", "", raw)
|
||||
raw = re.sub(r"\[[^\]]+\]", "", raw)
|
||||
raw = html.unescape(raw)
|
||||
@@ -361,12 +353,12 @@ def _split_for_tts(text, limit=CHAR_LIMIT):
|
||||
return chunks
|
||||
|
||||
|
||||
def _synth_chunk(text, voice_id, model, speed=1.0):
|
||||
def _synth_chunk(text, voice_id, model):
|
||||
"""Una petición t2a. Devuelve (audio_bytes|None, rc, usage_chars)."""
|
||||
body = {
|
||||
"model": model,
|
||||
"text": text,
|
||||
"voice_setting": {"voice_id": voice_id, "speed": speed, "vol": 1.0, "pitch": 0},
|
||||
"voice_setting": {"voice_id": voice_id, "speed": 1.0, "vol": 1.0, "pitch": 0},
|
||||
"audio_setting": {"sample_rate": 32000, "bitrate": 128000, "format": "mp3", "channel": 1},
|
||||
"language_boost": "Spanish",
|
||||
}
|
||||
@@ -381,15 +373,13 @@ def _synth_chunk(text, voice_id, model, speed=1.0):
|
||||
return bytes.fromhex(audio_hex), 0, usage
|
||||
|
||||
|
||||
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.5–2.0)")
|
||||
def t2a(text, voice_id, model, name):
|
||||
chunks = _split_for_tts(text)
|
||||
print(f"Sintetizando {len(text)} car con {model} / {voice_id} a {float(speed):.2f}× "
|
||||
print(f"Sintetizando {len(text)} car con {model} / {voice_id} "
|
||||
f"({len(chunks)} petición/es)…", flush=True)
|
||||
raw = OUT / f"{name}.raw.mp3"
|
||||
if len(chunks) == 1:
|
||||
audio, rc, _ = _synth_chunk(chunks[0], voice_id, model, speed)
|
||||
audio, rc, _ = _synth_chunk(chunks[0], voice_id, model)
|
||||
if audio is None:
|
||||
return rc
|
||||
raw.write_bytes(audio)
|
||||
@@ -400,7 +390,7 @@ def t2a(text, voice_id, model, name, speed=1.0):
|
||||
import os as _os, time as _t
|
||||
_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)
|
||||
audio, rc, _ = _synth_chunk(ch, voice_id, model, speed)
|
||||
audio, rc, _ = _synth_chunk(ch, voice_id, model)
|
||||
if audio is None:
|
||||
for p in parts:
|
||||
p.unlink(missing_ok=True)
|
||||
|
||||
@@ -1,151 +0,0 @@
|
||||
#!/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> </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()
|
||||
@@ -1,167 +0,0 @@
|
||||
#!/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"
|
||||
+42
-104
@@ -3,10 +3,9 @@
|
||||
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.
|
||||
|
||||
Prod vive en Hetzner (Coolify/Docker) desde el cutover de agosto 2026. Subida
|
||||
del binario por stdin de ssh ("cat > ruta" dentro del contenedor vía
|
||||
`docker exec -i`), sin depender de scp/sftp — mismo patrón que
|
||||
sync_translations_to_prod.py y sync_carta_from_prod.py.
|
||||
Prod (134.0.10.170) tiene glibc rota: scp/sftp NO funcionan (connection closed).
|
||||
Workaround: subir el binario por stdin de ssh ("cat > ruta"), igual que el resto
|
||||
de scripts que tocan ese servidor (ver feadulta-server-glibc-rota.md).
|
||||
|
||||
Uso:
|
||||
python3 sync_audio_to_prod.py --carta 54254 # sincroniza toda la cola de la carta
|
||||
@@ -20,10 +19,8 @@ Rollback (despublica en prod lo que este script publicó):
|
||||
from __future__ import annotations
|
||||
|
||||
import argparse
|
||||
import base64
|
||||
import json
|
||||
import os
|
||||
import shlex
|
||||
import subprocess
|
||||
import time
|
||||
from pathlib import Path
|
||||
@@ -34,20 +31,13 @@ DB_NAME = os.environ.get("FEA_DB_NAME", "wordpress_db")
|
||||
DB_USER = os.environ.get("FEA_DB_USER", "wordpress_user")
|
||||
DB_PASS = os.environ.get("FEA_DB_PASS", "wordpress_pass")
|
||||
|
||||
PROD_HOST = os.environ.get("FEA_PROD_SSH_HOST", "")
|
||||
PROD_PASS = os.environ.get("FEA_PROD_SSH_PASS", "")
|
||||
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 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")
|
||||
PROD_HOST = os.environ.get("FEA_PROD_HOST", "feadulta@134.0.10.170")
|
||||
PROD_PASS = os.environ.get("FEA_PROD_PASS", "C6c2A!mAl3Wj.BQF")
|
||||
PROD_WPLOAD = os.environ.get("FEA_PROD_WPLOAD", "/web/wp-load.php")
|
||||
PROD_HELPER = "/tmp/fea_post_io.php"
|
||||
PROD_UPLOADS_TTS = "/web/wp-content/uploads/tts"
|
||||
|
||||
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"
|
||||
|
||||
LOG_FILE = Path(os.environ.get(
|
||||
@@ -84,17 +74,6 @@ def local_meta(post_id: int, key: str) -> str:
|
||||
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]:
|
||||
q = ("SELECT post_id FROM wp_postmeta "
|
||||
f"WHERE meta_key='_carta_id' AND meta_value='{carta_id}' ORDER BY post_id;")
|
||||
@@ -106,80 +85,47 @@ def carta_article_ids(carta_id: int) -> list[int]:
|
||||
return [int(x) for x in r.stdout.split() if x.isdigit()]
|
||||
|
||||
|
||||
# ── Prod (Hetzner/Docker: todo por ssh, mp3 por stdin de "cat") ────────────────
|
||||
# ── Prod (glibc rota: nada de scp/sftp, todo por ssh + cat) ────────────────────
|
||||
def _ssh_text(remote_cmd: str, *, stdin: str | None = None, timeout: int = 120) -> str:
|
||||
# El Hetzner nuevo usa auth por clave (ed25519 claude-code@feadulta); sshpass
|
||||
# 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]
|
||||
cmd = ["sshpass", "-p", PROD_PASS, "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)
|
||||
if r.returncode != 0:
|
||||
raise RuntimeError(f"ssh falló ({r.returncode}): {remote_cmd[:80]}…\n{r.stderr.strip()[:400]}")
|
||||
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:
|
||||
remote_cmd = _remote_wrap(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]
|
||||
cmd = ["sshpass", "-p", PROD_PASS, "ssh", "-o", "StrictHostKeyChecking=accept-new",
|
||||
"-o", "ConnectTimeout=20", PROD_HOST, f"cat > {remote_path}"]
|
||||
sh(cmd, input_bytes=data, timeout=timeout)
|
||||
|
||||
|
||||
_prod_helper_ready = False
|
||||
|
||||
|
||||
def prod_helper(subcmd: str, *args: str) -> str:
|
||||
"""Ejecuta el helper PHP en memoria; no deja ficheros temporales en prod."""
|
||||
argv = " ".join(shlex.quote(x) for x in (subcmd, *args))
|
||||
inner = (
|
||||
f"FEA_WP_LOAD={shlex.quote(PROD_WPLOAD)} "
|
||||
f"php -r {shlex.quote(HELPER_EVAL_CODE)} {argv}"
|
||||
)
|
||||
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)
|
||||
global _prod_helper_ready
|
||||
if not _prod_helper_ready:
|
||||
_ssh_upload_bytes(HELPER_SRC.read_bytes(), PROD_HELPER)
|
||||
_prod_helper_ready = True
|
||||
inner = f"FEA_WP_LOAD={PROD_WPLOAD} php {PROD_HELPER} {subcmd} " + " ".join(args)
|
||||
return _ssh_text(inner, timeout=60)
|
||||
|
||||
|
||||
def prod_upload_mp3(local_id: int, prod_id: int) -> None:
|
||||
src = LOCAL_TTS_DIR / f"{local_id}.mp3"
|
||||
def prod_upload_mp3(post_id: int) -> None:
|
||||
src = LOCAL_TTS_DIR / f"{post_id}.mp3"
|
||||
data = src.read_bytes()
|
||||
remote_path = f"{PROD_UPLOADS_TTS}/{prod_id}.mp3"
|
||||
remote_path = f"{PROD_UPLOADS_TTS}/{post_id}.mp3"
|
||||
_ssh_upload_bytes(data, remote_path)
|
||||
# Verificación de tamaño: no fiarse ciegamente del rc=0 de ssh. `wc -c` debe
|
||||
# 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())
|
||||
# Verificación de tamaño (glibc rota => sin fiarse ciegamente del rc=0 de ssh)
|
||||
remote_size = int(_ssh_text(f"wc -c < {remote_path}").strip())
|
||||
if remote_size != len(data):
|
||||
raise RuntimeError(
|
||||
f"tamaño no coincide tras subir prod#{prod_id}: local={len(data)} remoto={remote_size}"
|
||||
)
|
||||
raise RuntimeError(f"tamaño no coincide tras subir #{post_id}: local={len(data)} remoto={remote_size}")
|
||||
|
||||
|
||||
def prod_remove_mp3(post_id: int) -> None:
|
||||
_ssh_text(_remote_wrap(f"rm -f {PROD_UPLOADS_TTS}/{post_id}.mp3"))
|
||||
_ssh_text(f"rm -f {PROD_UPLOADS_TTS}/{post_id}.mp3")
|
||||
|
||||
|
||||
# ── Estado ───────────────────────────────────────────────────────────────────
|
||||
@@ -203,27 +149,25 @@ def sync_one(post_id: int, state: dict, *, dry_run: bool) -> str:
|
||||
return "sin-audio-local"
|
||||
if not (LOCAL_TTS_DIR / f"{post_id}.mp3").exists():
|
||||
return "mp3-local-ausente"
|
||||
prod_id = prod_id_for_local(post_id)
|
||||
if dry_run:
|
||||
return f"PLAN: local#{post_id}.mp3 → prod#{prod_id}.mp3 + setaudio"
|
||||
return "PLAN: subiría mp3 + setaudio"
|
||||
|
||||
voice = local_meta(post_id, "fea_audio_voice") or "NicoFeadulta2026"
|
||||
prod_upload_mp3(post_id, prod_id)
|
||||
prod_helper("setaudio", str(prod_id), f"/wp-content/uploads/tts/{prod_id}.mp3", voice)
|
||||
if prod_id not in state["synced"]:
|
||||
state["synced"].append(prod_id)
|
||||
prod_upload_mp3(post_id)
|
||||
prod_helper("setaudio", str(post_id), f"/wp-content/uploads/tts/{post_id}.mp3", voice)
|
||||
if post_id not in state["synced"]:
|
||||
state["synced"].append(post_id)
|
||||
save_state(state)
|
||||
return f"ok prod#{prod_id}"
|
||||
return "ok"
|
||||
|
||||
|
||||
def rollback_one(post_id: int, state: dict) -> str:
|
||||
prod_id = prod_id_for_local(post_id)
|
||||
prod_helper("unsetaudio", str(prod_id))
|
||||
prod_remove_mp3(prod_id)
|
||||
if prod_id in state["synced"]:
|
||||
state["synced"].remove(prod_id)
|
||||
prod_helper("unsetaudio", str(post_id))
|
||||
prod_remove_mp3(post_id)
|
||||
if post_id in state["synced"]:
|
||||
state["synced"].remove(post_id)
|
||||
save_state(state)
|
||||
return f"ok prod#{prod_id}"
|
||||
return "ok"
|
||||
|
||||
|
||||
def main() -> int:
|
||||
@@ -250,7 +194,7 @@ def main() -> int:
|
||||
for pid in ids:
|
||||
try:
|
||||
if args.rollback:
|
||||
if prod_id_for_local(pid) not in state["synced"] and not args.ids:
|
||||
if pid not in state["synced"] and not args.ids:
|
||||
res = "no-estaba-sincronizado"
|
||||
skip += 1
|
||||
else:
|
||||
@@ -258,13 +202,7 @@ def main() -> int:
|
||||
ok += 1
|
||||
else:
|
||||
res = sync_one(pid, state, dry_run=args.dry_run)
|
||||
# `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")):
|
||||
if res == "ok" or res.startswith("PLAN"):
|
||||
ok += 1
|
||||
else:
|
||||
skip += 1
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user