Apunta los scripts de sync prod al Hetzner nuevo y suma trabajo pendiente
- sync_translations_to_prod.py / sync_audio_to_prod.py / sync_carta_from_prod.py: migran de FEA_PROD_HOST/PASS (CDMON, password) a FEA_PROD_SSH_HOST/PASS + FEA_PROD_DOCKER_CONTAINER (Hetzner/Coolify, auth por clave), con wrapping docker exec y el fix del bug de redirecciones (wc -c/cat) resuelto en el host en vez de dentro del contenedor. - fea_translate_helper.php: subcomando clone_new para clonar en ID local nuevo cuando el ID de prod ya está ocupado localmente. - translate_post.py: --dry-run. - tts_produce.py: --allow-default-voice (voz Nico solo si se permite explícitamente para autores sin voz clonada) + fix voice_for_author. - minimax_tts.py: parámetro speed en t2a/_synth_chunk. - mirror-antiguo/deploy/nginx-mirror.conf: redirects de URLs históricas de catálogo y vista imprimible EFFA. - Importaciones humanas de Pagola (carta 738) + scripts/manifest de la fase A Enrique, y release_issue181_prod.sh / cdmon-retire-fewp1.sh. - .gitignore: excluye docs/backups/ (dumps SQL, mismo motivo que backups/*). Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
This commit is contained in:
@@ -17,6 +17,10 @@ joomla-php83/
|
|||||||
backups/*
|
backups/*
|
||||||
!backups/README.md
|
!backups/README.md
|
||||||
|
|
||||||
|
# Backups pesados (GBs) bajo docs/ — mismo motivo que backups/*, ruta distinta
|
||||||
|
docs/backups/*
|
||||||
|
!docs/backups/README.md
|
||||||
|
|
||||||
# Capturas de pantalla
|
# Capturas de pantalla
|
||||||
capturas/
|
capturas/
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,71 @@
|
|||||||
|
# Release dry-run — Enrique Martínez Lozano / carta «Hacia el corazón»
|
||||||
|
|
||||||
|
**Estado:** preparado localmente; **no ejecutado en producción**.
|
||||||
|
|
||||||
|
## Validación local completada
|
||||||
|
|
||||||
|
- Comentario ES: `#55041` — `El tesoro está ya en nosotros`
|
||||||
|
- Categorías: `Comentarios al evangelio` + `Feadulta`.
|
||||||
|
- Audio: `tts/55041.mp3`, 2,221,101 bytes, voz `NicoFeadulta2026`.
|
||||||
|
- QA Haiku: aprobado tras correcciones objetivas EN/FR/IT.
|
||||||
|
- Traducciones del comentario: EN `#55047`, FR `#55048`, IT `#55049`, PT `#55050`.
|
||||||
|
- Carta local ES: `#55042` — `Hacia el corazón` (slug técnico de preview conservado).
|
||||||
|
- Traducciones: EN `#55051`, FR `#55052`, IT `#55053`, PT `#55054`.
|
||||||
|
- QA Haiku: contenido aprobado tras correcciones; 24 enlaces con destino traducido se repuntaron. Los 12 enlaces externos que quedan son fallback ES: no existe post destino traducido en local (verificado por `post_title` exacto y Polylang).
|
||||||
|
- Render comprobado en Tailscale:
|
||||||
|
- Comentario ES: título correcto, categorías correctas y reproductor HTML5 visible.
|
||||||
|
- Carta FR: título correcto; Enrique aparece tras Fray Marcos y antes de Pagola.
|
||||||
|
|
||||||
|
## Evidencia de producción (sólo lectura server-side)
|
||||||
|
|
||||||
|
- Carta real: `#54914`, título `Hacia el corazón`, estado `publish`.
|
||||||
|
- Hash actual de contenido prod: `5e7eb1b880bfc6311edb78ac8b6e81639f5b4502282a1b182c610b1d01308b69`.
|
||||||
|
- Hash candidato local: `6fdb4929fcb5b49b7f2dc1deb7c6f5be70983af866d44c5d5c27d2866c09418c`.
|
||||||
|
- La carta candidata lleva `fea_phase_a_source_prod_id=54914`; por tanto debe **actualizar #54914**, no crearse una carta nueva.
|
||||||
|
|
||||||
|
## Dry-runs que pasaron
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# Crea solamente el grupo nuevo del comentario (ES + EN/FR/IT/PT): 5 posts draft
|
||||||
|
python3 scripts/sync_translations_to_prod.py --ids 55041 --dry-run
|
||||||
|
|
||||||
|
# Sube y asocia solamente el MP3 de Enrique
|
||||||
|
python3 scripts/sync_audio_to_prod.py --ids 55041 --dry-run
|
||||||
|
```
|
||||||
|
|
||||||
|
Ambos devolvieron `ok/error=1/0` (audio) y un grupo Polylang de cinco posts (comentario).
|
||||||
|
|
||||||
|
## Guardarraíl crítico
|
||||||
|
|
||||||
|
**NO ejecutar:**
|
||||||
|
|
||||||
|
```bash
|
||||||
|
python3 scripts/sync_translations_to_prod.py --ids 55041,55042
|
||||||
|
```
|
||||||
|
|
||||||
|
El dry-run demostró que clonaría `#55042` como una segunda carta ES. No actualiza la carta real `#54914`.
|
||||||
|
|
||||||
|
## Release que debe ejecutar Rafa (producción)
|
||||||
|
|
||||||
|
1. Backup de producción y snapshot server-side de `#54914`.
|
||||||
|
2. Crear el grupo nuevo del comentario desde `#55041` (ES + EN/FR/IT/PT), inicialmente en `draft`; anotar los IDs reales de producción.
|
||||||
|
3. Subir `tts/55041.mp3` y asociarlo al ID ES real creado en producción.
|
||||||
|
4. Actualizar **solamente** título/contenido de la carta real `#54914` con el candidato local `#55042`; mantener título `Hacia el corazón`, autor, fecha y estado existentes.
|
||||||
|
5. Crear EN/FR/IT/PT de la carta contra el origen real `#54914` y enlazarlas en Polylang. No clonar otra ES.
|
||||||
|
6. Releer por servidor los IDs creados y `#54914`; comprobar:
|
||||||
|
- Enrique sólo está en `Evangelio y comentarios al Evangelio`, tras Fray Marcos y antes de Pagola.
|
||||||
|
- No aparece en `Artículos seleccionados`.
|
||||||
|
- Cada carta enlaza a Enrique en su mismo idioma.
|
||||||
|
- El reproductor del ES carga `tts/55041.mp3`.
|
||||||
|
7. Sólo tras esas lecturas, promover los drafts de producción a `publish`.
|
||||||
|
|
||||||
|
## Rollback
|
||||||
|
|
||||||
|
- Restaurar el backup de producción previo.
|
||||||
|
- Alternativamente, restaurar el contenido/hash previo de `#54914` y despublicar el nuevo grupo + audio de Enrique.
|
||||||
|
|
||||||
|
## Backups locales relacionados
|
||||||
|
|
||||||
|
- `docs/backups/enrique-translations-20260725T004921Z/`
|
||||||
|
- `docs/backups/enrique-carta-fr-final-20260725T012649Z/`
|
||||||
|
- `docs/backups/enrique-pre-tts-20260725T013021Z/`
|
||||||
@@ -0,0 +1,50 @@
|
|||||||
|
<?php
|
||||||
|
/**
|
||||||
|
* Issue #62 — Reapunta foto_perfil de cada autor a su avatar nuevo.
|
||||||
|
* Crea un attachment por autor y guarda el foto_perfil antiguo en _foto_perfil_pre62 (revertible).
|
||||||
|
* Uso: wp eval-file import_avatars_62.php [--apply]
|
||||||
|
* sin --apply => dry run (no escribe nada).
|
||||||
|
*/
|
||||||
|
require_once ABSPATH . 'wp-admin/includes/image.php';
|
||||||
|
|
||||||
|
$apply = getenv('APPLY') === '1';
|
||||||
|
$mfile = getenv('MANIFEST') ?: 'manifest_62.json';
|
||||||
|
$updir = wp_get_upload_dir();
|
||||||
|
$manifest = json_decode(file_get_contents($updir['basedir'] . '/avatares/' . $mfile), true);
|
||||||
|
|
||||||
|
$done = $skip = $err = 0;
|
||||||
|
foreach ($manifest as $row) {
|
||||||
|
list($uid, $name, $orig, $kind, $old_attach) = $row;
|
||||||
|
if (!in_array($kind, ['initials','trim','logo','qs'], true)) { $skip++; continue; }
|
||||||
|
|
||||||
|
$rel = "avatares/autores/autor-{$uid}.png";
|
||||||
|
$abs = $updir['basedir'] . '/' . $rel;
|
||||||
|
if (!file_exists($abs)) { echo "MISSING file uid=$uid\n"; $err++; continue; }
|
||||||
|
|
||||||
|
// si foto_perfil ya apunta a este fichero, solo se ha sobrescrito el PNG: no crear attachment nuevo
|
||||||
|
$cur = (int) get_user_meta($uid, 'foto_perfil', true);
|
||||||
|
if ($cur && get_post_meta($cur, '_wp_attached_file', true) === $rel) {
|
||||||
|
if ($apply) wp_update_attachment_metadata($cur, wp_generate_attachment_metadata($cur, $abs));
|
||||||
|
$skip++; continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!$apply) { $done++; continue; }
|
||||||
|
|
||||||
|
// guardar foto_perfil antiguo una sola vez (idempotente)
|
||||||
|
if (get_user_meta($uid, '_foto_perfil_pre62', true) === '') {
|
||||||
|
update_user_meta($uid, '_foto_perfil_pre62', $old_attach);
|
||||||
|
}
|
||||||
|
|
||||||
|
$attach = [
|
||||||
|
'post_mime_type' => 'image/png',
|
||||||
|
'post_title' => "Avatar {$name}",
|
||||||
|
'post_status' => 'inherit',
|
||||||
|
'guid' => $updir['baseurl'] . '/' . $rel,
|
||||||
|
];
|
||||||
|
$aid = wp_insert_attachment($attach, $abs, 0, true);
|
||||||
|
if (is_wp_error($aid)) { echo "ERR insert uid=$uid: ".$aid->get_error_message()."\n"; $err++; continue; }
|
||||||
|
wp_update_attachment_metadata($aid, wp_generate_attachment_metadata($aid, $abs));
|
||||||
|
update_user_meta($uid, 'foto_perfil', $aid);
|
||||||
|
$done++;
|
||||||
|
}
|
||||||
|
echo ($apply ? "APLICADO" : "DRY-RUN") . ": procesados=$done omitidos=$skip errores=$err\n";
|
||||||
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
File diff suppressed because one or more lines are too long
@@ -13,6 +13,21 @@ server {
|
|||||||
# application/octet-stream y el navegador se los descarga en vez de mostrarlos.
|
# application/octet-stream y el navegador se los descarga en vez de mostrarlos.
|
||||||
default_type text/html;
|
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 / {
|
location / {
|
||||||
try_files $uri $uri.html $uri/index.html =404;
|
try_files $uri $uri.html $uri/index.html =404;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,10 @@
|
|||||||
|
portfolio-tracker
|
||||||
|
yt-api-bridge
|
||||||
|
wordpress-web
|
||||||
|
joomla-web-php83
|
||||||
|
jellyfin
|
||||||
|
triptyk-local-wordpress-1
|
||||||
|
triptyk-local-db-1
|
||||||
|
yt-summaries
|
||||||
|
joomla-web
|
||||||
|
wordpress-mysql
|
||||||
Executable
+37
@@ -0,0 +1,37 @@
|
|||||||
|
#!/usr/bin/env bash
|
||||||
|
# Retira exclusivamente el esquema vacío fewp1 de CDMON.
|
||||||
|
# Por defecto solo hace dry-run. La eliminación real exige --apply y el token exacto.
|
||||||
|
set -euo pipefail
|
||||||
|
|
||||||
|
mode="dry-run"
|
||||||
|
if [[ "${1:-}" == "--apply" ]]; then
|
||||||
|
mode="apply"
|
||||||
|
fi
|
||||||
|
|
||||||
|
if [[ "$mode" == "apply" && "${CONFIRM_DROP_FEWP1:-}" != "DROP_FEWP1" ]]; then
|
||||||
|
echo "Refusing apply: export CONFIRM_DROP_FEWP1=DROP_FEWP1 first." >&2
|
||||||
|
exit 2
|
||||||
|
fi
|
||||||
|
|
||||||
|
set -a
|
||||||
|
source "$HOME/.hermes/profiles/feadulta/.env"
|
||||||
|
set +a
|
||||||
|
|
||||||
|
php='global $wpdb;
|
||||||
|
$count=(int)$wpdb->get_var("SELECT COUNT(*) FROM information_schema.tables WHERE table_schema=\"fewp1\"");
|
||||||
|
if ($count !== 0) { fwrite(STDERR, "ABORT: fewp1 has ".$count." tables; it is not empty.\n"); exit(3); }
|
||||||
|
echo "PASS: fewp1 exists and has 0 tables.\n";'
|
||||||
|
|
||||||
|
if [[ "$mode" == "apply" ]]; then
|
||||||
|
php+=' $ok=$wpdb->query("DROP DATABASE `fewp1`");
|
||||||
|
if ($ok === false) { fwrite(STDERR, "ABORT: DROP DATABASE failed.\n"); exit(4); }
|
||||||
|
$remaining=(int)$wpdb->get_var("SELECT COUNT(*) FROM information_schema.schemata WHERE schema_name=\"fewp1\"");
|
||||||
|
if ($remaining !== 0) { fwrite(STDERR, "ABORT: fewp1 remains after DROP.\n"); exit(5); }
|
||||||
|
echo "APPLIED: fewp1 removed; verified absent.\n";'
|
||||||
|
else
|
||||||
|
php+=' echo "DRY-RUN: would execute DROP DATABASE `fewp1`; no production change made.\n";'
|
||||||
|
fi
|
||||||
|
|
||||||
|
printf -v quoted_php '%q' "$php"
|
||||||
|
sshpass -p "$FEA_PROD_SSH_PASS" ssh -o StrictHostKeyChecking=accept-new "$FEA_PROD_SSH_HOST" \
|
||||||
|
"cd /web && wp eval $quoted_php"
|
||||||
@@ -262,6 +262,47 @@ switch ($cmd) {
|
|||||||
break;
|
break;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
case 'clone_new': {
|
||||||
|
// Clona en un ID local NUEVO. Se usa al importar desde prod cuando el
|
||||||
|
// mismo ID ya está ocupado por contenido local distinto.
|
||||||
|
$lang = (string) ($argv[2] ?? '');
|
||||||
|
$status = (string) ($argv[3] ?? '');
|
||||||
|
if ($lang === '') {
|
||||||
|
fwrite(STDERR, "uso: clone_new <lang> <status>\n"); exit(6);
|
||||||
|
}
|
||||||
|
$payload = json_decode(file_get_contents('php://stdin'), true);
|
||||||
|
if (!is_array($payload) || empty($payload['title'])) {
|
||||||
|
fwrite(STDERR, "payload inválido por stdin\n"); exit(4);
|
||||||
|
}
|
||||||
|
$postarr = [
|
||||||
|
'post_title' => wp_slash($payload['title']),
|
||||||
|
'post_content' => wp_slash($payload['content'] ?? ''),
|
||||||
|
'post_excerpt' => wp_slash($payload['excerpt'] ?? ''),
|
||||||
|
'post_status' => $status ?: ($payload['status'] ?? 'draft'),
|
||||||
|
'post_type' => $payload['type'] ?? 'post',
|
||||||
|
'post_author' => (int) ($payload['author'] ?? 1),
|
||||||
|
'post_date' => $payload['date'] ?? current_time('mysql'),
|
||||||
|
'post_date_gmt'=> $payload['date_gmt'] ?? current_time('mysql', true),
|
||||||
|
'post_name' => $payload['slug'] ?? '',
|
||||||
|
'to_ping' => '',
|
||||||
|
'pinged' => '',
|
||||||
|
];
|
||||||
|
$new_id = wp_insert_post($postarr, true);
|
||||||
|
if (is_wp_error($new_id)) { fwrite(STDERR, $new_id->get_error_message() . "\n"); exit(5); }
|
||||||
|
pll_set_post_language($new_id, $lang);
|
||||||
|
$cats = [];
|
||||||
|
foreach ((array) ($payload['cat_slugs'] ?? []) as $slug) {
|
||||||
|
$term = get_term_by('slug', (string) $slug, 'category');
|
||||||
|
if ($term && !is_wp_error($term)) $cats[] = (int) $term->term_id;
|
||||||
|
}
|
||||||
|
if (!$cats) $cats = array_values(array_unique(array_map('intval', (array) ($payload['cats'] ?? []))));
|
||||||
|
wp_set_post_categories($new_id, $cats);
|
||||||
|
set_meta_payload($new_id, normalize_meta_input($payload));
|
||||||
|
clean_post_cache($new_id);
|
||||||
|
echo $new_id;
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
|
||||||
case 'carta_sections': {
|
case 'carta_sections': {
|
||||||
// Descubre el cluster de una carta parseando sus propios enlaces internos
|
// Descubre el cluster de una carta parseando sus propios enlaces internos
|
||||||
// (misma lógica que pinta la portada, fea-carta-portada.php). No depende
|
// (misma lógica que pinta la portada, fea-carta-portada.php). No depende
|
||||||
|
|||||||
@@ -0,0 +1,135 @@
|
|||||||
|
#!/usr/bin/env python3
|
||||||
|
"""Importa las traducciones editoriales humanas adjuntas a un issue de carta.
|
||||||
|
|
||||||
|
Por defecto solo valida y muestra el plan. --apply-local crea borradores en el
|
||||||
|
WordPress Docker local; nunca publica ni toca producción.
|
||||||
|
"""
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import argparse
|
||||||
|
import hashlib
|
||||||
|
import html
|
||||||
|
import json
|
||||||
|
import re
|
||||||
|
import subprocess
|
||||||
|
import tempfile
|
||||||
|
import zipfile
|
||||||
|
from pathlib import Path
|
||||||
|
from xml.etree import ElementTree as ET
|
||||||
|
|
||||||
|
ROOT = Path(__file__).resolve().parent
|
||||||
|
HELPER = ROOT / "fea_translate_helper.php"
|
||||||
|
CONTAINER = "wordpress-web"
|
||||||
|
SOURCE_ID = 55465 # Pagola, Carta 738
|
||||||
|
LANG_BY_NAME = {"2_eng": "en", "3_fr": "fr", "4_it": "it", "5_pt": "pt"}
|
||||||
|
TRANSLATOR_LABELS = ("Translator:", "Traducteur:", "Traduzzione:", "Tradutor:")
|
||||||
|
|
||||||
|
|
||||||
|
def docx_lines(path: Path) -> list[str]:
|
||||||
|
ns = {"w": "http://schemas.openxmlformats.org/wordprocessingml/2006/main"}
|
||||||
|
with zipfile.ZipFile(path) as zf:
|
||||||
|
root = ET.fromstring(zf.read("word/document.xml"))
|
||||||
|
return [
|
||||||
|
"".join(t.text or "" for t in p.findall(".//w:t", ns)).strip()
|
||||||
|
for p in root.findall(".//w:p", ns)
|
||||||
|
if "".join(t.text or "" for t in p.findall(".//w:t", ns)).strip()
|
||||||
|
]
|
||||||
|
|
||||||
|
|
||||||
|
def value_after(lines: list[str], label: str) -> str:
|
||||||
|
try:
|
||||||
|
return lines[lines.index(label) + 1]
|
||||||
|
except (ValueError, IndexError) as exc:
|
||||||
|
raise ValueError(f"Falta {label!r}") from exc
|
||||||
|
|
||||||
|
|
||||||
|
def parse_doc(path: Path) -> dict:
|
||||||
|
lines = docx_lines(path)
|
||||||
|
title = value_after(lines, "Título:")
|
||||||
|
excerpt = value_after(lines, "Entradilla:")
|
||||||
|
author = value_after(lines, "Autor:")
|
||||||
|
start = lines.index("Cuerpo:") + 1
|
||||||
|
end = next((i for i in range(start, len(lines))
|
||||||
|
if any(lines[i].startswith(label) for label in TRANSLATOR_LABELS)), len(lines))
|
||||||
|
body = lines[start:end]
|
||||||
|
if not body:
|
||||||
|
raise ValueError("Cuerpo vacío")
|
||||||
|
translator = ""
|
||||||
|
if end < len(lines):
|
||||||
|
label = next(label for label in TRANSLATOR_LABELS if lines[end].startswith(label))
|
||||||
|
translator = lines[end][len(label):].strip()
|
||||||
|
if not translator and end + 1 < len(lines):
|
||||||
|
translator = lines[end + 1]
|
||||||
|
# La fuente española incluye el crédito/origen como último párrafo.
|
||||||
|
body.append('Publicado en: <a href="https://www.gruposdejesus.com">https://www.gruposdejesus.com</a>')
|
||||||
|
content = "\n".join(
|
||||||
|
f"<p>{line if line.startswith('Publicado en: <a ') else html.escape(line)}</p>" for line in body
|
||||||
|
)
|
||||||
|
return {
|
||||||
|
"title": title,
|
||||||
|
"excerpt": excerpt,
|
||||||
|
"author": author,
|
||||||
|
"translator": translator,
|
||||||
|
"content": content,
|
||||||
|
"source_sha256": hashlib.sha256(path.read_bytes()).hexdigest(),
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def run(cmd: list[str], stdin: str | None = None) -> str:
|
||||||
|
result = subprocess.run(cmd, input=stdin, text=True, capture_output=True)
|
||||||
|
if result.returncode:
|
||||||
|
raise RuntimeError(f"rc={result.returncode}: {result.stderr.strip()} {result.stdout.strip()}")
|
||||||
|
return result.stdout.strip()
|
||||||
|
|
||||||
|
|
||||||
|
def helper(*args: str, stdin: str | None = None) -> str:
|
||||||
|
return run(["docker", "exec", "-i", CONTAINER, "php", "/tmp/fea_translate_helper.php", *args], stdin)
|
||||||
|
|
||||||
|
|
||||||
|
def lang_for(path: Path) -> str:
|
||||||
|
for marker, lang in LANG_BY_NAME.items():
|
||||||
|
if marker in path.name:
|
||||||
|
return lang
|
||||||
|
raise ValueError(f"No reconozco idioma en {path.name}")
|
||||||
|
|
||||||
|
|
||||||
|
def apply_meta(post_id: int, doc: Path, parsed: dict) -> None:
|
||||||
|
payload = json.dumps({"id": post_id, "doc": doc.name, "sha": parsed["source_sha256"],
|
||||||
|
"translator": parsed["translator"]}, ensure_ascii=False)
|
||||||
|
php = r'''$p=json_decode(base64_decode(getenv('PAYLOAD')),true); update_post_meta($p['id'],'traduccion_automatica','0'); update_post_meta($p['id'],'traduccion_modelo','editorial-humana'); update_post_meta($p['id'],'traduccion_fuente_doc',$p['doc']); update_post_meta($p['id'],'traduccion_fuente_sha256',$p['sha']); update_post_meta($p['id'],'traduccion_editor',$p['translator']); echo 'ok';'''
|
||||||
|
import base64
|
||||||
|
encoded = base64.b64encode(payload.encode()).decode()
|
||||||
|
run(["docker", "exec", "-i", "-e", f"PAYLOAD={encoded}", CONTAINER,
|
||||||
|
"wp", "--allow-root", "eval", php])
|
||||||
|
|
||||||
|
|
||||||
|
def main() -> int:
|
||||||
|
ap = argparse.ArgumentParser()
|
||||||
|
ap.add_argument("--dir", type=Path, required=True)
|
||||||
|
ap.add_argument("--apply-local", action="store_true")
|
||||||
|
args = ap.parse_args()
|
||||||
|
docs = sorted(args.dir.glob("*.docx"))
|
||||||
|
if len(docs) != 4:
|
||||||
|
raise SystemExit(f"Se esperaban 4 DOCX, encontrados {len(docs)}")
|
||||||
|
run(["docker", "cp", str(HELPER), f"{CONTAINER}:/tmp/fea_translate_helper.php"])
|
||||||
|
for doc in docs:
|
||||||
|
lang, parsed = lang_for(doc), parse_doc(doc)
|
||||||
|
existing = helper("exists", str(SOURCE_ID), lang)
|
||||||
|
plan = {"lang": lang, "doc": doc.name, "existing": int(existing or "0"),
|
||||||
|
"title": parsed["title"], "excerpt_chars": len(parsed["excerpt"]),
|
||||||
|
"content_chars": len(parsed["content"]), "translator": parsed["translator"]}
|
||||||
|
print(json.dumps(plan, ensure_ascii=False))
|
||||||
|
if not args.apply_local:
|
||||||
|
continue
|
||||||
|
if int(existing or "0"):
|
||||||
|
raise RuntimeError(f"{lang} ya existe como #{existing}; no sobrescribo")
|
||||||
|
payload = json.dumps({"title": parsed["title"], "excerpt": parsed["excerpt"],
|
||||||
|
"content": parsed["content"], "model": "editorial-humana"}, ensure_ascii=False)
|
||||||
|
post_id = int(helper("create", str(SOURCE_ID), lang, "draft", stdin=payload))
|
||||||
|
apply_meta(post_id, doc, parsed)
|
||||||
|
print(json.dumps({"created": post_id, "lang": lang, "status": "draft"}))
|
||||||
|
return 0
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
raise SystemExit(main())
|
||||||
@@ -361,12 +361,12 @@ def _split_for_tts(text, limit=CHAR_LIMIT):
|
|||||||
return chunks
|
return chunks
|
||||||
|
|
||||||
|
|
||||||
def _synth_chunk(text, voice_id, model):
|
def _synth_chunk(text, voice_id, model, speed=1.0):
|
||||||
"""Una petición t2a. Devuelve (audio_bytes|None, rc, usage_chars)."""
|
"""Una petición t2a. Devuelve (audio_bytes|None, rc, usage_chars)."""
|
||||||
body = {
|
body = {
|
||||||
"model": model,
|
"model": model,
|
||||||
"text": text,
|
"text": text,
|
||||||
"voice_setting": {"voice_id": voice_id, "speed": 1.0, "vol": 1.0, "pitch": 0},
|
"voice_setting": {"voice_id": voice_id, "speed": speed, "vol": 1.0, "pitch": 0},
|
||||||
"audio_setting": {"sample_rate": 32000, "bitrate": 128000, "format": "mp3", "channel": 1},
|
"audio_setting": {"sample_rate": 32000, "bitrate": 128000, "format": "mp3", "channel": 1},
|
||||||
"language_boost": "Spanish",
|
"language_boost": "Spanish",
|
||||||
}
|
}
|
||||||
@@ -381,13 +381,15 @@ def _synth_chunk(text, voice_id, model):
|
|||||||
return bytes.fromhex(audio_hex), 0, usage
|
return bytes.fromhex(audio_hex), 0, usage
|
||||||
|
|
||||||
|
|
||||||
def t2a(text, voice_id, model, name):
|
def t2a(text, voice_id, model, name, speed=1.0):
|
||||||
|
if not 0.5 <= float(speed) <= 2.0:
|
||||||
|
raise ValueError(f"speed fuera de rango: {speed} (permitido 0.5–2.0)")
|
||||||
chunks = _split_for_tts(text)
|
chunks = _split_for_tts(text)
|
||||||
print(f"Sintetizando {len(text)} car con {model} / {voice_id} "
|
print(f"Sintetizando {len(text)} car con {model} / {voice_id} a {float(speed):.2f}× "
|
||||||
f"({len(chunks)} petición/es)…", flush=True)
|
f"({len(chunks)} petición/es)…", flush=True)
|
||||||
raw = OUT / f"{name}.raw.mp3"
|
raw = OUT / f"{name}.raw.mp3"
|
||||||
if len(chunks) == 1:
|
if len(chunks) == 1:
|
||||||
audio, rc, _ = _synth_chunk(chunks[0], voice_id, model)
|
audio, rc, _ = _synth_chunk(chunks[0], voice_id, model, speed)
|
||||||
if audio is None:
|
if audio is None:
|
||||||
return rc
|
return rc
|
||||||
raw.write_bytes(audio)
|
raw.write_bytes(audio)
|
||||||
@@ -398,7 +400,7 @@ def t2a(text, voice_id, model, name):
|
|||||||
import os as _os, time as _t
|
import os as _os, time as _t
|
||||||
_t.sleep(int(_os.environ.get("FEA_CHUNK_PAUSE", "35"))) # respetar TPM de MiniMax
|
_t.sleep(int(_os.environ.get("FEA_CHUNK_PAUSE", "35"))) # respetar TPM de MiniMax
|
||||||
print(f" trozo {k + 1}/{len(chunks)} ({len(ch)} car)…", flush=True)
|
print(f" trozo {k + 1}/{len(chunks)} ({len(ch)} car)…", flush=True)
|
||||||
audio, rc, _ = _synth_chunk(ch, voice_id, model)
|
audio, rc, _ = _synth_chunk(ch, voice_id, model, speed)
|
||||||
if audio is None:
|
if audio is None:
|
||||||
for p in parts:
|
for p in parts:
|
||||||
p.unlink(missing_ok=True)
|
p.unlink(missing_ok=True)
|
||||||
|
|||||||
@@ -0,0 +1,151 @@
|
|||||||
|
#!/usr/bin/env python3
|
||||||
|
"""Fase A local: borrador de Enrique + copia draft de la carta para revisión.
|
||||||
|
|
||||||
|
Por defecto no escribe WordPress. --apply-local sólo modifica el WordPress Docker local.
|
||||||
|
Nunca publica ni toca producción.
|
||||||
|
"""
|
||||||
|
import argparse
|
||||||
|
import base64
|
||||||
|
import hashlib
|
||||||
|
import html
|
||||||
|
import json
|
||||||
|
import os
|
||||||
|
import re
|
||||||
|
import subprocess
|
||||||
|
import sys
|
||||||
|
import zipfile
|
||||||
|
from pathlib import Path
|
||||||
|
from xml.etree import ElementTree as ET
|
||||||
|
|
||||||
|
ROOT = Path(__file__).resolve().parent.parent
|
||||||
|
DEFAULT_DOC = Path("/home/rafa/.hermes/cache/documents/doc_7909db597c74_7r_Enrique_Mart_nez_Lozano_tesoro_esta_ya_en_nosotros.docx")
|
||||||
|
CARTA_PROD_ID = 54914
|
||||||
|
AUTHOR_ID = 384 # Enrique Martínez Lozano, verificado localmente.
|
||||||
|
CATEGORIES = [1650, 71] # Artículos + Feadulta
|
||||||
|
SLUG = "el-tesoro-esta-ya-en-nosotros"
|
||||||
|
|
||||||
|
|
||||||
|
def docx_text(path: Path) -> list[str]:
|
||||||
|
ns = {"w": "http://schemas.openxmlformats.org/wordprocessingml/2006/main"}
|
||||||
|
with zipfile.ZipFile(path) as zf:
|
||||||
|
root = ET.fromstring(zf.read("word/document.xml"))
|
||||||
|
out = []
|
||||||
|
for p in root.findall(".//w:p", ns):
|
||||||
|
text = "".join(t.text or "" for t in p.findall(".//w:t", ns)).strip()
|
||||||
|
if text:
|
||||||
|
out.append(text)
|
||||||
|
return out
|
||||||
|
|
||||||
|
|
||||||
|
def parse_source(path: Path) -> dict:
|
||||||
|
lines = docx_text(path)
|
||||||
|
def after(label: str) -> str:
|
||||||
|
return lines[lines.index(label) + 1]
|
||||||
|
title = after("Título:")
|
||||||
|
author = after("Autor:")
|
||||||
|
assert title == "EL TESORO ESTÁ YA EN NOSOTROS", title
|
||||||
|
assert author == "ENRIQUE MARTÍNEZ LOZANO", author
|
||||||
|
body_start = lines.index("Cuerpo:") + 1
|
||||||
|
body = lines[body_start:]
|
||||||
|
# Última firma del boletín no es contenido del post: el autor va en WP.
|
||||||
|
if body[-1].startswith("ENRIQUE MARTÍNEZ LOZANO"):
|
||||||
|
body = body[:-1]
|
||||||
|
date_line, bible, *paragraphs = body
|
||||||
|
intro = lines[lines.index("Entradilla:") + 1]
|
||||||
|
content = "\n".join(
|
||||||
|
[f"<p><strong>{html.escape(date_line)}</strong></p>",
|
||||||
|
f"<p><strong>{html.escape(bible)}</strong></p>"]
|
||||||
|
+ [f"<p>{html.escape(p)}</p>" for p in paragraphs]
|
||||||
|
)
|
||||||
|
return {"title": title.lower().capitalize(), "author": author.title(), "intro": intro,
|
||||||
|
"content": content, "source_sha256": hashlib.sha256(path.read_bytes()).hexdigest()}
|
||||||
|
|
||||||
|
|
||||||
|
def prod_card() -> dict:
|
||||||
|
sys.path.insert(0, str(ROOT / "scripts"))
|
||||||
|
import sync_translations_to_prod as sync
|
||||||
|
return json.loads(sync.prod_helper("read_full", str(CARTA_PROD_ID)))
|
||||||
|
|
||||||
|
|
||||||
|
def carta_candidate(card: dict, anchor: str) -> str:
|
||||||
|
marker = '<p> </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()
|
||||||
Executable
+167
@@ -0,0 +1,167 @@
|
|||||||
|
#!/usr/bin/env bash
|
||||||
|
# Release Fase 1+2, issue #181: 68 traducciones publicadas + 16 MP3 TTS.
|
||||||
|
# Por defecto es sólo dry-run. --apply requiere confirmación explícita de Rafa.
|
||||||
|
set -euo pipefail
|
||||||
|
|
||||||
|
ROOT="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)"
|
||||||
|
cd "$ROOT"
|
||||||
|
|
||||||
|
MODE="dry-run"
|
||||||
|
if [[ "${1:-}" == "--apply" ]]; then
|
||||||
|
MODE="apply"
|
||||||
|
elif [[ "${1:-}" == "--verify" ]]; then
|
||||||
|
MODE="verify"
|
||||||
|
elif [[ "${1:-}" != "" && "${1:-}" != "--dry-run" ]]; then
|
||||||
|
echo "Uso: $0 [--dry-run|--verify|--apply]" >&2
|
||||||
|
exit 2
|
||||||
|
fi
|
||||||
|
|
||||||
|
# shellcheck disable=SC1091
|
||||||
|
source ~/.hermes/profiles/feadulta/.env
|
||||||
|
: "${FEA_PROD_WPLOAD:=/web/wp-load.php}"
|
||||||
|
if [[ "$FEA_PROD_WPLOAD" != "/web/wp-load.php" ]]; then
|
||||||
|
echo "ABORT: FEA_PROD_WPLOAD debe ser /web/wp-load.php (recibido: $FEA_PROD_WPLOAD)" >&2
|
||||||
|
exit 2
|
||||||
|
fi
|
||||||
|
|
||||||
|
RELEASE_DIR="logs/release-issue-181"
|
||||||
|
mkdir -p "$RELEASE_DIR/backups"
|
||||||
|
STAMP="$(date -u +%Y%m%dT%H%M%SZ)"
|
||||||
|
ORIGINS=(54875 54902 54903 54883 54884 54885 54886 54866 54867 54868 54869 54870 54871 54872 54873 54880 54914)
|
||||||
|
AUDIO_IDS=(54875 54902 54903 54883 54884 54885 54886 54866 54867 54868 54869 54870 54871 54872 54873 54880)
|
||||||
|
|
||||||
|
if [[ "$MODE" == "apply" && "${FEA_RELEASE_CONFIRM:-}" != "PUBLISH_ISSUE_181" ]]; then
|
||||||
|
echo "ABORT: para escribir en producción usa:" >&2
|
||||||
|
echo " FEA_RELEASE_CONFIRM=PUBLISH_ISSUE_181 $0 --apply" >&2
|
||||||
|
exit 2
|
||||||
|
fi
|
||||||
|
|
||||||
|
preflight() {
|
||||||
|
python3 - "${ORIGINS[@]}" <<'PY'
|
||||||
|
import json, sys
|
||||||
|
sys.path.insert(0, 'scripts')
|
||||||
|
import sync_translations_to_prod as sync
|
||||||
|
ids = [int(x) for x in sys.argv[1:]]
|
||||||
|
rows = []
|
||||||
|
for pid in ids:
|
||||||
|
d = json.loads(sync.prod_helper('read_full', str(pid)))
|
||||||
|
rows.append({'id': pid, 'status': d['status'], 'lang': d['lang'], 'translations': d.get('translations', {})})
|
||||||
|
assert len(rows) == 17
|
||||||
|
assert all(r['status'] == 'publish' and r['lang'] == 'es' for r in rows), rows
|
||||||
|
# Los 16 artículos no deben tener traducciones aún; la carta tampoco antes del primer release.
|
||||||
|
assert all(r['translations'] == {'es': r['id']} for r in rows), rows
|
||||||
|
print(json.dumps(rows, ensure_ascii=False, indent=2))
|
||||||
|
print('preflight=PASS sources=17')
|
||||||
|
PY
|
||||||
|
}
|
||||||
|
|
||||||
|
backup_prod_state() {
|
||||||
|
python3 - "$RELEASE_DIR/backups/prod-before-$STAMP.json" "${ORIGINS[@]}" -- "${AUDIO_IDS[@]}" <<'PY'
|
||||||
|
import json, sys
|
||||||
|
out = sys.argv[1]
|
||||||
|
sep = sys.argv.index('--')
|
||||||
|
origins = [int(x) for x in sys.argv[2:sep]]
|
||||||
|
audio_ids = [int(x) for x in sys.argv[sep+1:]]
|
||||||
|
sys.path.insert(0, 'scripts')
|
||||||
|
import sync_translations_to_prod as translations
|
||||||
|
import sync_audio_to_prod as audio
|
||||||
|
payload = {
|
||||||
|
'origins': {str(pid): json.loads(translations.prod_helper('read_full', str(pid))) for pid in origins},
|
||||||
|
'audio_before': {
|
||||||
|
str(pid): {
|
||||||
|
'url': audio.prod_helper('getmeta', str(pid), 'fea_audio_url').strip(),
|
||||||
|
'voice': audio.prod_helper('getmeta', str(pid), 'fea_audio_voice').strip(),
|
||||||
|
'done': audio.prod_helper('getmeta', str(pid), 'fea_audio_done').strip(),
|
||||||
|
} for pid in audio_ids
|
||||||
|
},
|
||||||
|
}
|
||||||
|
with open(out, 'w', encoding='utf-8') as fh:
|
||||||
|
json.dump(payload, fh, ensure_ascii=False, indent=2)
|
||||||
|
print(out)
|
||||||
|
PY
|
||||||
|
}
|
||||||
|
|
||||||
|
verify_release() {
|
||||||
|
python3 - "${ORIGINS[@]}" -- "${AUDIO_IDS[@]}" <<'PY'
|
||||||
|
import json, sys, time
|
||||||
|
sep = sys.argv.index('--')
|
||||||
|
origins = [int(x) for x in sys.argv[1:sep]]
|
||||||
|
audio_ids = [int(x) for x in sys.argv[sep+1:]]
|
||||||
|
sys.path.insert(0, 'scripts')
|
||||||
|
import sync_translations_to_prod as translations
|
||||||
|
import sync_audio_to_prod as audio
|
||||||
|
|
||||||
|
def read_post(pid):
|
||||||
|
"""Lectura server-side resiliente ante una respuesta SSH/PHP vacía transitoria."""
|
||||||
|
last = ''
|
||||||
|
for attempt in range(1, 4):
|
||||||
|
raw = translations.prod_helper('read_full', str(pid)).strip()
|
||||||
|
if raw:
|
||||||
|
try:
|
||||||
|
return json.loads(raw)
|
||||||
|
except json.JSONDecodeError as exc:
|
||||||
|
last = f'JSON inválido intento {attempt}: {exc}; prefijo={raw[:160]!r}'
|
||||||
|
else:
|
||||||
|
last = f'respuesta vacía intento {attempt}'
|
||||||
|
time.sleep(attempt)
|
||||||
|
raise AssertionError(f'No se pudo leer post #{pid}: {last}')
|
||||||
|
|
||||||
|
for pid in origins:
|
||||||
|
d = read_post(pid)
|
||||||
|
group = d.get('translations', {})
|
||||||
|
assert set(group) == {'es', 'en', 'fr', 'it', 'pt'}, (pid, group)
|
||||||
|
for lang, tid in group.items():
|
||||||
|
td = read_post(tid)
|
||||||
|
assert td['lang'] == lang and td['status'] == 'publish', (pid, lang, tid, td['lang'], td['status'])
|
||||||
|
for pid in audio_ids:
|
||||||
|
url = audio.prod_helper('getmeta', str(pid), 'fea_audio_url').strip()
|
||||||
|
done = audio.prod_helper('getmeta', str(pid), 'fea_audio_done').strip()
|
||||||
|
assert url.endswith(f'/wp-content/uploads/tts/{pid}.mp3') and done == '1', (pid, url, done)
|
||||||
|
print('verification=PASS groups=17 translations=68 audio=16')
|
||||||
|
PY
|
||||||
|
}
|
||||||
|
|
||||||
|
if [[ "$MODE" == "verify" ]]; then
|
||||||
|
echo "== Verificación server-side posterior (solo lectura) =="
|
||||||
|
verify_release | tee "$RELEASE_DIR/verification-$STAMP.txt"
|
||||||
|
echo "VERIFICACIÓN ISSUE #181 COMPLETADA"
|
||||||
|
exit 0
|
||||||
|
fi
|
||||||
|
|
||||||
|
echo "== Preflight server-side ($MODE): 17 fuentes ES =="
|
||||||
|
preflight | tee "$RELEASE_DIR/preflight-$MODE-$STAMP.json"
|
||||||
|
|
||||||
|
if [[ "$MODE" == "dry-run" ]]; then
|
||||||
|
: > "$RELEASE_DIR/translations-dry-run-$STAMP.log"
|
||||||
|
for origin in "${ORIGINS[@]}"; do
|
||||||
|
FEA_SYNC_STATUS=publish \
|
||||||
|
FEA_SYNC_LOG="$RELEASE_DIR/translations-dry-run-$STAMP.log" \
|
||||||
|
FEA_SYNC_STATE="$RELEASE_DIR/translations-$origin-state.json" \
|
||||||
|
python3 scripts/sync_translations_to_prod.py --origin "$origin" --dry-run
|
||||||
|
done
|
||||||
|
FEA_AUDIO_SYNC_LOG="$RELEASE_DIR/audio-dry-run-$STAMP.log" \
|
||||||
|
FEA_AUDIO_SYNC_STATE="$RELEASE_DIR/audio-state.json" \
|
||||||
|
python3 scripts/sync_audio_to_prod.py --ids "$(IFS=,; echo "${AUDIO_IDS[*]}")" --dry-run | tee "$RELEASE_DIR/audio-dry-run-$STAMP.log"
|
||||||
|
echo "DRY-RUN terminado: 68 traducciones publicables + 16 audios planificados."
|
||||||
|
exit 0
|
||||||
|
fi
|
||||||
|
|
||||||
|
echo "== Backup server-side previo =="
|
||||||
|
backup_prod_state | tee "$RELEASE_DIR/backup-path-$STAMP.txt"
|
||||||
|
|
||||||
|
echo "== Subiendo 68 traducciones como publish =="
|
||||||
|
for origin in "${ORIGINS[@]}"; do
|
||||||
|
FEA_SYNC_STATUS=publish \
|
||||||
|
FEA_SYNC_LOG="$RELEASE_DIR/translations-apply-$STAMP.log" \
|
||||||
|
FEA_SYNC_STATE="$RELEASE_DIR/translations-$origin-state.json" \
|
||||||
|
python3 scripts/sync_translations_to_prod.py --origin "$origin"
|
||||||
|
done
|
||||||
|
|
||||||
|
echo "== Subiendo 16 audios =="
|
||||||
|
FEA_AUDIO_SYNC_LOG="$RELEASE_DIR/audio-apply-$STAMP.log" \
|
||||||
|
FEA_AUDIO_SYNC_STATE="$RELEASE_DIR/audio-state.json" \
|
||||||
|
python3 scripts/sync_audio_to_prod.py --ids "$(IFS=,; echo "${AUDIO_IDS[*]}")"
|
||||||
|
|
||||||
|
echo "== Verificación server-side posterior =="
|
||||||
|
verify_release | tee "$RELEASE_DIR/verification-$STAMP.txt"
|
||||||
|
echo "RELEASE ISSUE #181 COMPLETADO"
|
||||||
@@ -3,9 +3,10 @@
|
|||||||
sync_audio_to_prod.py — Sube a PROD los mp3 de TTS ya generados/enlazados en
|
sync_audio_to_prod.py — Sube a PROD los mp3 de TTS ya generados/enlazados en
|
||||||
local (fea_audio_done=1) y fija el meta fea_audio_url en prod.
|
local (fea_audio_done=1) y fija el meta fea_audio_url en prod.
|
||||||
|
|
||||||
Prod (134.0.10.170) tiene glibc rota: scp/sftp NO funcionan (connection closed).
|
Prod vive en Hetzner (Coolify/Docker) desde el cutover de agosto 2026. Subida
|
||||||
Workaround: subir el binario por stdin de ssh ("cat > ruta"), igual que el resto
|
del binario por stdin de ssh ("cat > ruta" dentro del contenedor vía
|
||||||
de scripts que tocan ese servidor (ver feadulta-server-glibc-rota.md).
|
`docker exec -i`), sin depender de scp/sftp — mismo patrón que
|
||||||
|
sync_translations_to_prod.py y sync_carta_from_prod.py.
|
||||||
|
|
||||||
Uso:
|
Uso:
|
||||||
python3 sync_audio_to_prod.py --carta 54254 # sincroniza toda la cola de la carta
|
python3 sync_audio_to_prod.py --carta 54254 # sincroniza toda la cola de la carta
|
||||||
@@ -19,8 +20,10 @@ Rollback (despublica en prod lo que este script publicó):
|
|||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
import argparse
|
import argparse
|
||||||
|
import base64
|
||||||
import json
|
import json
|
||||||
import os
|
import os
|
||||||
|
import shlex
|
||||||
import subprocess
|
import subprocess
|
||||||
import time
|
import time
|
||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
@@ -31,13 +34,20 @@ DB_NAME = os.environ.get("FEA_DB_NAME", "wordpress_db")
|
|||||||
DB_USER = os.environ.get("FEA_DB_USER", "wordpress_user")
|
DB_USER = os.environ.get("FEA_DB_USER", "wordpress_user")
|
||||||
DB_PASS = os.environ.get("FEA_DB_PASS", "wordpress_pass")
|
DB_PASS = os.environ.get("FEA_DB_PASS", "wordpress_pass")
|
||||||
|
|
||||||
PROD_HOST = os.environ.get("FEA_PROD_HOST", "feadulta@134.0.10.170")
|
PROD_HOST = os.environ.get("FEA_PROD_SSH_HOST", "")
|
||||||
PROD_PASS = os.environ.get("FEA_PROD_PASS", "C6c2A!mAl3Wj.BQF")
|
PROD_PASS = os.environ.get("FEA_PROD_SSH_PASS", "")
|
||||||
PROD_WPLOAD = os.environ.get("FEA_PROD_WPLOAD", "/web/wp-load.php")
|
PROD_WPLOAD = os.environ.get("FEA_PROD_WPLOAD", "/var/www/html/wp-load.php")
|
||||||
PROD_HELPER = "/tmp/fea_post_io.php"
|
# Desde el cutover a Hetzner, WordPress vive dentro de Coolify/Docker. Si se
|
||||||
PROD_UPLOADS_TTS = "/web/wp-content/uploads/tts"
|
# define, el helper y la subida de mp3 se ejecutan dentro del contenedor.
|
||||||
|
# Espejo de sync_translations_to_prod.py / sync_carta_from_prod.py.
|
||||||
|
PROD_DOCKER_CONTAINER = os.environ.get("FEA_PROD_DOCKER_CONTAINER", "")
|
||||||
|
PROD_UPLOADS_TTS = os.environ.get("FEA_PROD_UPLOADS_TTS", "/var/www/html/wp-content/uploads/tts")
|
||||||
|
|
||||||
HELPER_SRC = Path(__file__).resolve().parent / "fea_post_io.php"
|
HELPER_SRC = Path(__file__).resolve().parent / "fea_post_io.php"
|
||||||
|
# Se evalúa en memoria con `php -r`: no se copia un helper temporal a prod.
|
||||||
|
HELPER_EVAL_CODE = "eval(base64_decode(" + repr(
|
||||||
|
base64.b64encode(HELPER_SRC.read_text(encoding="utf-8").removeprefix("<?php").encode("utf-8")).decode("ascii")
|
||||||
|
) + "));"
|
||||||
LOCAL_TTS_DIR = Path(__file__).resolve().parent.parent / "wordpress/wp-content/uploads/tts"
|
LOCAL_TTS_DIR = Path(__file__).resolve().parent.parent / "wordpress/wp-content/uploads/tts"
|
||||||
|
|
||||||
LOG_FILE = Path(os.environ.get(
|
LOG_FILE = Path(os.environ.get(
|
||||||
@@ -74,6 +84,17 @@ def local_meta(post_id: int, key: str) -> str:
|
|||||||
return r.stdout.strip()
|
return r.stdout.strip()
|
||||||
|
|
||||||
|
|
||||||
|
def prod_id_for_local(local_id: int) -> int:
|
||||||
|
"""Resolve a remapped local post to its original production ID.
|
||||||
|
|
||||||
|
``sync_carta_from_prod.py --remap-conflicts`` keeps this mapping in
|
||||||
|
``fea_prod_source_id``. Audio files remain named with the local ID, but
|
||||||
|
remote paths and production post meta must use the source ID.
|
||||||
|
"""
|
||||||
|
source_id = local_meta(local_id, "fea_prod_source_id")
|
||||||
|
return int(source_id) if source_id.isdigit() else local_id
|
||||||
|
|
||||||
|
|
||||||
def carta_article_ids(carta_id: int) -> list[int]:
|
def carta_article_ids(carta_id: int) -> list[int]:
|
||||||
q = ("SELECT post_id FROM wp_postmeta "
|
q = ("SELECT post_id FROM wp_postmeta "
|
||||||
f"WHERE meta_key='_carta_id' AND meta_value='{carta_id}' ORDER BY post_id;")
|
f"WHERE meta_key='_carta_id' AND meta_value='{carta_id}' ORDER BY post_id;")
|
||||||
@@ -85,47 +106,80 @@ def carta_article_ids(carta_id: int) -> list[int]:
|
|||||||
return [int(x) for x in r.stdout.split() if x.isdigit()]
|
return [int(x) for x in r.stdout.split() if x.isdigit()]
|
||||||
|
|
||||||
|
|
||||||
# ── Prod (glibc rota: nada de scp/sftp, todo por ssh + cat) ────────────────────
|
# ── Prod (Hetzner/Docker: todo por ssh, mp3 por stdin de "cat") ────────────────
|
||||||
def _ssh_text(remote_cmd: str, *, stdin: str | None = None, timeout: int = 120) -> str:
|
def _ssh_text(remote_cmd: str, *, stdin: str | None = None, timeout: int = 120) -> str:
|
||||||
|
# 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",
|
cmd = ["sshpass", "-p", PROD_PASS, "ssh", "-o", "StrictHostKeyChecking=accept-new",
|
||||||
"-o", "ConnectTimeout=20", PROD_HOST, remote_cmd]
|
"-o", "ConnectTimeout=20", PROD_HOST, remote_cmd]
|
||||||
|
else:
|
||||||
|
cmd = ["ssh", "-o", "StrictHostKeyChecking=accept-new",
|
||||||
|
"-o", "ConnectTimeout=20", PROD_HOST, remote_cmd]
|
||||||
r = subprocess.run(cmd, input=stdin, capture_output=True, text=True, timeout=timeout)
|
r = subprocess.run(cmd, input=stdin, capture_output=True, text=True, timeout=timeout)
|
||||||
if r.returncode != 0:
|
if r.returncode != 0:
|
||||||
raise RuntimeError(f"ssh falló ({r.returncode}): {remote_cmd[:80]}…\n{r.stderr.strip()[:400]}")
|
raise RuntimeError(f"ssh falló ({r.returncode}): {remote_cmd[:80]}…\n{r.stderr.strip()[:400]}")
|
||||||
return r.stdout
|
return r.stdout
|
||||||
|
|
||||||
|
|
||||||
|
def _remote_wrap(inner_cmd: str) -> str:
|
||||||
|
"""Envuelve un comando para que corra dentro del contenedor Docker de prod
|
||||||
|
si FEA_PROD_DOCKER_CONTAINER está definido; si no, corre en el host tal cual.
|
||||||
|
|
||||||
|
Importante: el wrapping (incluidas redirecciones como `< ruta`) debe quedar
|
||||||
|
DENTRO de la shell del contenedor (`sh -c '...'`), porque `docker exec
|
||||||
|
CONTENEDOR cmd < ruta` resuelve esa redirección en el filesystem del HOST,
|
||||||
|
no dentro del contenedor.
|
||||||
|
"""
|
||||||
|
if PROD_DOCKER_CONTAINER:
|
||||||
|
return f"docker exec -i {shlex.quote(PROD_DOCKER_CONTAINER)} sh -c {shlex.quote(inner_cmd)}"
|
||||||
|
return inner_cmd
|
||||||
|
|
||||||
|
|
||||||
def _ssh_upload_bytes(data: bytes, remote_path: str, *, timeout: int = 180) -> None:
|
def _ssh_upload_bytes(data: bytes, remote_path: str, *, timeout: int = 180) -> None:
|
||||||
|
remote_cmd = _remote_wrap(f"cat > {remote_path}")
|
||||||
|
if PROD_PASS:
|
||||||
cmd = ["sshpass", "-p", PROD_PASS, "ssh", "-o", "StrictHostKeyChecking=accept-new",
|
cmd = ["sshpass", "-p", PROD_PASS, "ssh", "-o", "StrictHostKeyChecking=accept-new",
|
||||||
"-o", "ConnectTimeout=20", PROD_HOST, f"cat > {remote_path}"]
|
"-o", "ConnectTimeout=20", PROD_HOST, remote_cmd]
|
||||||
|
else:
|
||||||
|
cmd = ["ssh", "-o", "StrictHostKeyChecking=accept-new",
|
||||||
|
"-o", "ConnectTimeout=20", PROD_HOST, remote_cmd]
|
||||||
sh(cmd, input_bytes=data, timeout=timeout)
|
sh(cmd, input_bytes=data, timeout=timeout)
|
||||||
|
|
||||||
|
|
||||||
_prod_helper_ready = False
|
|
||||||
|
|
||||||
|
|
||||||
def prod_helper(subcmd: str, *args: str) -> str:
|
def prod_helper(subcmd: str, *args: str) -> str:
|
||||||
global _prod_helper_ready
|
"""Ejecuta el helper PHP en memoria; no deja ficheros temporales en prod."""
|
||||||
if not _prod_helper_ready:
|
argv = " ".join(shlex.quote(x) for x in (subcmd, *args))
|
||||||
_ssh_upload_bytes(HELPER_SRC.read_bytes(), PROD_HELPER)
|
inner = (
|
||||||
_prod_helper_ready = True
|
f"FEA_WP_LOAD={shlex.quote(PROD_WPLOAD)} "
|
||||||
inner = f"FEA_WP_LOAD={PROD_WPLOAD} php {PROD_HELPER} {subcmd} " + " ".join(args)
|
f"php -r {shlex.quote(HELPER_EVAL_CODE)} {argv}"
|
||||||
return _ssh_text(inner, timeout=60)
|
)
|
||||||
|
if PROD_DOCKER_CONTAINER:
|
||||||
|
remote_cmd = (
|
||||||
|
f"docker exec -i -e FEA_WP_LOAD={shlex.quote(PROD_WPLOAD)} "
|
||||||
|
f"{shlex.quote(PROD_DOCKER_CONTAINER)} php -r {shlex.quote(HELPER_EVAL_CODE)} {argv}"
|
||||||
|
)
|
||||||
|
else:
|
||||||
|
remote_cmd = inner
|
||||||
|
return _ssh_text(remote_cmd, timeout=60)
|
||||||
|
|
||||||
|
|
||||||
def prod_upload_mp3(post_id: int) -> None:
|
def prod_upload_mp3(local_id: int, prod_id: int) -> None:
|
||||||
src = LOCAL_TTS_DIR / f"{post_id}.mp3"
|
src = LOCAL_TTS_DIR / f"{local_id}.mp3"
|
||||||
data = src.read_bytes()
|
data = src.read_bytes()
|
||||||
remote_path = f"{PROD_UPLOADS_TTS}/{post_id}.mp3"
|
remote_path = f"{PROD_UPLOADS_TTS}/{prod_id}.mp3"
|
||||||
_ssh_upload_bytes(data, remote_path)
|
_ssh_upload_bytes(data, remote_path)
|
||||||
# Verificación de tamaño (glibc rota => sin fiarse ciegamente del rc=0 de ssh)
|
# Verificación de tamaño: no fiarse ciegamente del rc=0 de ssh. `wc -c` debe
|
||||||
remote_size = int(_ssh_text(f"wc -c < {remote_path}").strip())
|
# correr (y resolver la redirección) DENTRO del contenedor — ver _remote_wrap.
|
||||||
|
remote_size = int(_ssh_text(_remote_wrap(f"wc -c < {remote_path}")).strip())
|
||||||
if remote_size != len(data):
|
if remote_size != len(data):
|
||||||
raise RuntimeError(f"tamaño no coincide tras subir #{post_id}: local={len(data)} remoto={remote_size}")
|
raise RuntimeError(
|
||||||
|
f"tamaño no coincide tras subir prod#{prod_id}: local={len(data)} remoto={remote_size}"
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
def prod_remove_mp3(post_id: int) -> None:
|
def prod_remove_mp3(post_id: int) -> None:
|
||||||
_ssh_text(f"rm -f {PROD_UPLOADS_TTS}/{post_id}.mp3")
|
_ssh_text(_remote_wrap(f"rm -f {PROD_UPLOADS_TTS}/{post_id}.mp3"))
|
||||||
|
|
||||||
|
|
||||||
# ── Estado ───────────────────────────────────────────────────────────────────
|
# ── Estado ───────────────────────────────────────────────────────────────────
|
||||||
@@ -149,25 +203,27 @@ def sync_one(post_id: int, state: dict, *, dry_run: bool) -> str:
|
|||||||
return "sin-audio-local"
|
return "sin-audio-local"
|
||||||
if not (LOCAL_TTS_DIR / f"{post_id}.mp3").exists():
|
if not (LOCAL_TTS_DIR / f"{post_id}.mp3").exists():
|
||||||
return "mp3-local-ausente"
|
return "mp3-local-ausente"
|
||||||
|
prod_id = prod_id_for_local(post_id)
|
||||||
if dry_run:
|
if dry_run:
|
||||||
return "PLAN: subiría mp3 + setaudio"
|
return f"PLAN: local#{post_id}.mp3 → prod#{prod_id}.mp3 + setaudio"
|
||||||
|
|
||||||
voice = local_meta(post_id, "fea_audio_voice") or "NicoFeadulta2026"
|
voice = local_meta(post_id, "fea_audio_voice") or "NicoFeadulta2026"
|
||||||
prod_upload_mp3(post_id)
|
prod_upload_mp3(post_id, prod_id)
|
||||||
prod_helper("setaudio", str(post_id), f"/wp-content/uploads/tts/{post_id}.mp3", voice)
|
prod_helper("setaudio", str(prod_id), f"/wp-content/uploads/tts/{prod_id}.mp3", voice)
|
||||||
if post_id not in state["synced"]:
|
if prod_id not in state["synced"]:
|
||||||
state["synced"].append(post_id)
|
state["synced"].append(prod_id)
|
||||||
save_state(state)
|
save_state(state)
|
||||||
return "ok"
|
return f"ok prod#{prod_id}"
|
||||||
|
|
||||||
|
|
||||||
def rollback_one(post_id: int, state: dict) -> str:
|
def rollback_one(post_id: int, state: dict) -> str:
|
||||||
prod_helper("unsetaudio", str(post_id))
|
prod_id = prod_id_for_local(post_id)
|
||||||
prod_remove_mp3(post_id)
|
prod_helper("unsetaudio", str(prod_id))
|
||||||
if post_id in state["synced"]:
|
prod_remove_mp3(prod_id)
|
||||||
state["synced"].remove(post_id)
|
if prod_id in state["synced"]:
|
||||||
|
state["synced"].remove(prod_id)
|
||||||
save_state(state)
|
save_state(state)
|
||||||
return "ok"
|
return f"ok prod#{prod_id}"
|
||||||
|
|
||||||
|
|
||||||
def main() -> int:
|
def main() -> int:
|
||||||
@@ -194,7 +250,7 @@ def main() -> int:
|
|||||||
for pid in ids:
|
for pid in ids:
|
||||||
try:
|
try:
|
||||||
if args.rollback:
|
if args.rollback:
|
||||||
if pid not in state["synced"] and not args.ids:
|
if prod_id_for_local(pid) not in state["synced"] and not args.ids:
|
||||||
res = "no-estaba-sincronizado"
|
res = "no-estaba-sincronizado"
|
||||||
skip += 1
|
skip += 1
|
||||||
else:
|
else:
|
||||||
|
|||||||
+100
-33
@@ -23,6 +23,8 @@ el mismo fea_translate_helper.php sin tocar su lógica, solo invertido
|
|||||||
Uso:
|
Uso:
|
||||||
python3 sync_carta_from_prod.py --carta 54495 --dry-run
|
python3 sync_carta_from_prod.py --carta 54495 --dry-run
|
||||||
python3 sync_carta_from_prod.py --carta 54495
|
python3 sync_carta_from_prod.py --carta 54495
|
||||||
|
python3 sync_carta_from_prod.py --ids 54875,54902,54903 --dry-run
|
||||||
|
python3 sync_carta_from_prod.py --ids 54875,54902,54903
|
||||||
|
|
||||||
Tras esto, el resto del ciclo ya existente no cambia:
|
Tras esto, el resto del ciclo ya existente no cambia:
|
||||||
translate_post.py --carta 54495 --langs en,fr,it,pt --status draft
|
translate_post.py --carta 54495 --langs en,fr,it,pt --status draft
|
||||||
@@ -34,8 +36,10 @@ Tras esto, el resto del ciclo ya existente no cambia:
|
|||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
import argparse
|
import argparse
|
||||||
|
import base64
|
||||||
import json
|
import json
|
||||||
import os
|
import os
|
||||||
|
import shlex
|
||||||
import subprocess
|
import subprocess
|
||||||
import time
|
import time
|
||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
@@ -44,7 +48,11 @@ WP_CONTAINER = os.environ.get("FEA_WP_CONTAINER", "wordpress-web")
|
|||||||
|
|
||||||
PROD_HOST = os.environ.get("FEA_PROD_SSH_HOST", "")
|
PROD_HOST = os.environ.get("FEA_PROD_SSH_HOST", "")
|
||||||
PROD_PASS = os.environ.get("FEA_PROD_SSH_PASS", "")
|
PROD_PASS = os.environ.get("FEA_PROD_SSH_PASS", "")
|
||||||
PROD_WPLOAD = os.environ.get("FEA_PROD_WPLOAD", "/web/wp-load.php")
|
PROD_WPLOAD = os.environ.get("FEA_PROD_WPLOAD", "/var/www/html/wp-load.php")
|
||||||
|
# Desde el cutover a Hetzner, WordPress vive dentro de Coolify/Docker. Si se
|
||||||
|
# define, el helper se ejecuta en memoria dentro del contenedor; no se escribe
|
||||||
|
# ningún fichero temporal en prod.
|
||||||
|
PROD_DOCKER_CONTAINER = os.environ.get("FEA_PROD_DOCKER_CONTAINER", "")
|
||||||
PROD_HELPER = "/tmp/fea_translate_helper.php"
|
PROD_HELPER = "/tmp/fea_translate_helper.php"
|
||||||
|
|
||||||
HELPER_SRC = Path(__file__).resolve().parent / "fea_translate_helper.php"
|
HELPER_SRC = Path(__file__).resolve().parent / "fea_translate_helper.php"
|
||||||
@@ -74,22 +82,35 @@ def sh(cmd: list[str], *, stdin: str | None = None, timeout: int = 120) -> str:
|
|||||||
|
|
||||||
|
|
||||||
# ── Prod (origen, solo lectura) ─────────────────────────────────────────────
|
# ── Prod (origen, solo lectura) ─────────────────────────────────────────────
|
||||||
_prod_ready = False
|
|
||||||
|
|
||||||
|
|
||||||
def _ssh(remote_cmd: str, *, stdin: str | None = None, timeout: int = 120) -> str:
|
def _ssh(remote_cmd: str, *, stdin: str | None = None, timeout: int = 120) -> str:
|
||||||
|
# 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",
|
cmd = ["sshpass", "-p", PROD_PASS, "ssh", "-o", "StrictHostKeyChecking=accept-new",
|
||||||
"-o", "ConnectTimeout=20", PROD_HOST, remote_cmd]
|
"-o", "ConnectTimeout=20", PROD_HOST, remote_cmd]
|
||||||
|
else:
|
||||||
|
cmd = ["ssh", "-o", "StrictHostKeyChecking=accept-new",
|
||||||
|
"-o", "ConnectTimeout=20", PROD_HOST, remote_cmd]
|
||||||
return sh(cmd, stdin=stdin, timeout=timeout)
|
return sh(cmd, stdin=stdin, timeout=timeout)
|
||||||
|
|
||||||
|
|
||||||
def prod_helper(subcmd: str, *args: str, stdin: str | None = None) -> str:
|
def prod_helper(subcmd: str, *args: str) -> str:
|
||||||
global _prod_ready
|
"""Run the helper in prod memory; never upload a temporary file to prod."""
|
||||||
if not _prod_ready:
|
helper_php = HELPER_SRC.read_text(encoding="utf-8").replace("<?php", "", 1)
|
||||||
_ssh(f"cat > {PROD_HELPER}", stdin=HELPER_SRC.read_text(encoding="utf-8"))
|
encoded = base64.b64encode(helper_php.encode("utf-8")).decode("ascii")
|
||||||
_prod_ready = True
|
code = f"eval(base64_decode('{encoded}'));"
|
||||||
inner = f"FEA_WP_LOAD={PROD_WPLOAD} php {PROD_HELPER} {subcmd} " + " ".join(args)
|
if PROD_DOCKER_CONTAINER:
|
||||||
return _ssh(inner, stdin=stdin, timeout=180)
|
remote = (
|
||||||
|
f"docker exec -i -e FEA_WP_LOAD={shlex.quote(PROD_WPLOAD)} "
|
||||||
|
f"{shlex.quote(PROD_DOCKER_CONTAINER)} php -r {shlex.quote(code)} -- "
|
||||||
|
+ " ".join(shlex.quote(part) for part in (subcmd, *args))
|
||||||
|
)
|
||||||
|
else:
|
||||||
|
remote = (
|
||||||
|
f"FEA_WP_LOAD={shlex.quote(PROD_WPLOAD)} php -r {shlex.quote(code)} -- "
|
||||||
|
+ " ".join(shlex.quote(part) for part in (subcmd, *args))
|
||||||
|
)
|
||||||
|
return _ssh(remote, timeout=180)
|
||||||
|
|
||||||
|
|
||||||
def prod_read_full(post_id: int) -> dict:
|
def prod_read_full(post_id: int) -> dict:
|
||||||
@@ -110,6 +131,17 @@ def seed_ids_from_carta(carta_id: int) -> list[int]:
|
|||||||
return sorted(ids)
|
return sorted(ids)
|
||||||
|
|
||||||
|
|
||||||
|
def seed_ids_from_csv(raw_ids: str) -> list[int]:
|
||||||
|
"""Parse a deliberate prod→local subset for Mixbot --parte1 (#181)."""
|
||||||
|
try:
|
||||||
|
ids = sorted({int(part.strip()) for part in raw_ids.split(",") if part.strip()})
|
||||||
|
except ValueError as exc:
|
||||||
|
raise ValueError("--ids debe ser una lista CSV de IDs numéricos") from exc
|
||||||
|
if not ids or any(pid <= 0 for pid in ids):
|
||||||
|
raise ValueError("--ids debe contener al menos un ID positivo")
|
||||||
|
return ids
|
||||||
|
|
||||||
|
|
||||||
def collect_related_posts(seed_ids: list[int]) -> tuple[dict[int, dict], list[dict[str, int]]]:
|
def collect_related_posts(seed_ids: list[int]) -> tuple[dict[int, dict], list[dict[str, int]]]:
|
||||||
posts: dict[int, dict] = {}
|
posts: dict[int, dict] = {}
|
||||||
groups: dict[tuple[tuple[str, int], ...], dict[str, int]] = {}
|
groups: dict[tuple[tuple[str, int], ...], dict[str, int]] = {}
|
||||||
@@ -153,7 +185,11 @@ def local_read_safe(post_id: int) -> dict | None:
|
|||||||
return None
|
return None
|
||||||
|
|
||||||
|
|
||||||
def local_clone(post: dict) -> int:
|
def local_clone(post: dict, *, preserve_id: bool = True) -> int:
|
||||||
|
# Conserva el ID remoto como trazabilidad incluso cuando haya que crear un
|
||||||
|
# ID local nuevo por una colisión entre entornos.
|
||||||
|
meta = dict(post.get("meta", {}))
|
||||||
|
meta["fea_prod_source_id"] = [str(post["id"])]
|
||||||
payload = {
|
payload = {
|
||||||
"title": post["title"],
|
"title": post["title"],
|
||||||
"content": post.get("content", ""),
|
"content": post.get("content", ""),
|
||||||
@@ -166,10 +202,14 @@ def local_clone(post: dict) -> int:
|
|||||||
"status": post.get("status"),
|
"status": post.get("status"),
|
||||||
"cats": post.get("cats", []),
|
"cats": post.get("cats", []),
|
||||||
"cat_slugs": post.get("cat_slugs", []),
|
"cat_slugs": post.get("cat_slugs", []),
|
||||||
"meta": post.get("meta", {}),
|
"meta": meta,
|
||||||
}
|
}
|
||||||
|
if preserve_id:
|
||||||
out = local_helper("clone", str(post["id"]), post.get("lang") or "es", STATUS,
|
out = local_helper("clone", str(post["id"]), post.get("lang") or "es", STATUS,
|
||||||
stdin=json.dumps(payload)).strip()
|
stdin=json.dumps(payload)).strip()
|
||||||
|
else:
|
||||||
|
out = local_helper("clone_new", post.get("lang") or "es", STATUS,
|
||||||
|
stdin=json.dumps(payload)).strip()
|
||||||
return int(out)
|
return int(out)
|
||||||
|
|
||||||
|
|
||||||
@@ -179,13 +219,9 @@ def local_save_group(group: dict[str, int]) -> dict[str, int]:
|
|||||||
|
|
||||||
|
|
||||||
# ── Main ─────────────────────────────────────────────────────────────────────
|
# ── Main ─────────────────────────────────────────────────────────────────────
|
||||||
def sync_carta(carta_id: int, *, dry_run: bool) -> int:
|
def sync_posts(seed_ids: list[int], *, dry_run: bool, source_label: str,
|
||||||
seed_ids = seed_ids_from_carta(carta_id)
|
remap_conflicts: bool = False) -> int:
|
||||||
log(f"Cluster descubierto desde fea_parse_carta_sections(prod#{carta_id}): "
|
log(f"Lote {source_label}: {len(seed_ids)} post(s) -> {seed_ids}")
|
||||||
f"{len(seed_ids)} post(s) -> {seed_ids}")
|
|
||||||
if not seed_ids or seed_ids == [carta_id]:
|
|
||||||
log(" ⚠️ El parser no resolvió ningún artículo enlazado (¿carta sin publicar aún, "
|
|
||||||
"o secciones sin encabezados reconocibles?). Revisa antes de continuar.")
|
|
||||||
|
|
||||||
posts, groups = collect_related_posts(seed_ids)
|
posts, groups = collect_related_posts(seed_ids)
|
||||||
|
|
||||||
@@ -196,51 +232,82 @@ def sync_carta(carta_id: int, *, dry_run: bool) -> int:
|
|||||||
conflicts.append((pid, existing.get("title", ""), p.get("title", "")))
|
conflicts.append((pid, existing.get("title", ""), p.get("title", "")))
|
||||||
|
|
||||||
if conflicts:
|
if conflicts:
|
||||||
log(f" ⚠️ {len(conflicts)} CONFLICTO(S): el ID ya existe en local con OTRO contenido "
|
log(f" ⚠️ {len(conflicts)} CONFLICTO(S): el ID ya existe en local con OTRO contenido:")
|
||||||
f"y va a ser SOBRESCRITO:")
|
|
||||||
for pid, old_title, new_title in conflicts:
|
for pid, old_title, new_title in conflicts:
|
||||||
log(f" #{pid}: local actual «{old_title[:50]}» -> prod «{new_title[:50]}»")
|
log(f" #{pid}: local actual «{old_title[:50]}» | prod «{new_title[:50]}»")
|
||||||
|
if not dry_run and not remap_conflicts:
|
||||||
|
raise RuntimeError(
|
||||||
|
"Importación cancelada: usar --remap-conflicts para crear IDs locales nuevos; "
|
||||||
|
"nunca se sobrescriben posts locales por un choque de IDs entre entornos."
|
||||||
|
)
|
||||||
|
|
||||||
if dry_run:
|
if dry_run:
|
||||||
for pid, p in posts.items():
|
for pid, p in posts.items():
|
||||||
log(f" PULL prod#{pid} [{p.get('lang','?')}] status={p.get('status')} "
|
action = "REMAPPING to new local ID" if pid in {c[0] for c in conflicts} else "PULL"
|
||||||
|
log(f" {action} prod#{pid} [{p.get('lang','?')}] status={p.get('status')} "
|
||||||
f"slug={p.get('slug','')} «{p.get('title','')[:50]}»")
|
f"slug={p.get('slug','')} «{p.get('title','')[:50]}»")
|
||||||
for group in groups:
|
for group in groups:
|
||||||
log(f" GROUP {group}")
|
log(f" GROUP {group}")
|
||||||
log("DRY-RUN: nada escrito en local.")
|
log("DRY-RUN: nada escrito en local.")
|
||||||
return 0
|
return 0
|
||||||
|
|
||||||
|
conflict_ids = {pid for pid, _, _ in conflicts}
|
||||||
|
id_map: dict[int, int] = {}
|
||||||
for pid, p in posts.items():
|
for pid, p in posts.items():
|
||||||
new_id = local_clone(p)
|
new_id = local_clone(p, preserve_id=pid not in conflict_ids)
|
||||||
|
id_map[pid] = new_id
|
||||||
if new_id != pid:
|
if new_id != pid:
|
||||||
log(f" ⚠️ prod#{pid} se clonó como local#{new_id} — ID NO preservado, revisar a mano.")
|
log(f" remap prod#{pid} -> local#{new_id} [{p.get('lang','?')}] «{p['title'][:45]}»")
|
||||||
else:
|
else:
|
||||||
log(f" clone prod#{pid} -> local#{new_id} [{p.get('lang','?')}] «{p['title'][:45]}»")
|
log(f" clone prod#{pid} -> local#{new_id} [{p.get('lang','?')}] «{p['title'][:45]}»")
|
||||||
|
|
||||||
for group in groups:
|
for group in groups:
|
||||||
if len(group) < 2:
|
local_group = {lang: id_map.get(pid, pid) for lang, pid in group.items()}
|
||||||
|
if len(local_group) < 2:
|
||||||
continue
|
continue
|
||||||
saved = local_save_group(group)
|
saved = local_save_group(local_group)
|
||||||
log(f" group enlazado en local {saved}")
|
log(f" group enlazado en local {saved}")
|
||||||
|
|
||||||
log(f"FIN sync prod->local. carta={carta_id} posts={len(posts)} conflictos_previos={len(conflicts)}")
|
log(f"FIN sync prod->local. fuente={source_label} posts={len(posts)} conflictos_previos={len(conflicts)}")
|
||||||
return 0
|
return 0
|
||||||
|
|
||||||
|
|
||||||
|
def sync_carta(carta_id: int, *, dry_run: bool, remap_conflicts: bool = False) -> int:
|
||||||
|
seed_ids = seed_ids_from_carta(carta_id)
|
||||||
|
if not seed_ids or seed_ids == [carta_id]:
|
||||||
|
log(" ⚠️ El parser no resolvió ningún artículo enlazado (¿carta sin publicar aún, "
|
||||||
|
"o secciones sin encabezados reconocibles?). Revisa antes de continuar.")
|
||||||
|
return sync_posts(seed_ids, dry_run=dry_run, source_label=f"carta prod#{carta_id}",
|
||||||
|
remap_conflicts=remap_conflicts)
|
||||||
|
|
||||||
|
|
||||||
|
def sync_ids(raw_ids: str, *, dry_run: bool, remap_conflicts: bool = False) -> int:
|
||||||
|
return sync_posts(seed_ids_from_csv(raw_ids), dry_run=dry_run,
|
||||||
|
source_label="IDs explícitos de Mixbot --parte1",
|
||||||
|
remap_conflicts=remap_conflicts)
|
||||||
|
|
||||||
|
|
||||||
def main() -> int:
|
def main() -> int:
|
||||||
ap = argparse.ArgumentParser(
|
ap = argparse.ArgumentParser(
|
||||||
description="Copia una carta (y su cluster de artículos) de PROD a LOCAL preservando IDs.")
|
description="Copia una carta (y su cluster de artículos) de PROD a LOCAL preservando IDs.")
|
||||||
ap.add_argument("--carta", type=int, required=True, help="ID del post ES de la carta en PROD.")
|
group = ap.add_mutually_exclusive_group(required=True)
|
||||||
|
group.add_argument("--carta", type=int, help="ID del post ES de la carta en PROD.")
|
||||||
|
group.add_argument("--ids", help="Lista CSV explícita de posts ES para Mixbot --parte1 (#181).")
|
||||||
ap.add_argument("--dry-run", action="store_true", help="Solo muestra el plan; no escribe en local.")
|
ap.add_argument("--dry-run", action="store_true", help="Solo muestra el plan; no escribe en local.")
|
||||||
|
ap.add_argument("--remap-conflicts", action="store_true",
|
||||||
|
help="Ante IDs ocupados localmente, crea IDs nuevos y guarda fea_prod_source_id; nunca sobrescribe.")
|
||||||
args = ap.parse_args()
|
args = ap.parse_args()
|
||||||
|
|
||||||
if not PROD_HOST or not PROD_PASS:
|
if not PROD_HOST:
|
||||||
raise SystemExit(
|
raise SystemExit(
|
||||||
"Faltan FEA_PROD_SSH_HOST / FEA_PROD_SSH_PASS en el entorno.\n"
|
"Falta FEA_PROD_SSH_HOST en el entorno (FEA_PROD_SSH_PASS es opcional: "
|
||||||
|
"el Hetzner nuevo usa auth por clave).\n"
|
||||||
"Antes de ejecutar: source ~/.hermes/profiles/feadulta/.env"
|
"Antes de ejecutar: source ~/.hermes/profiles/feadulta/.env"
|
||||||
)
|
)
|
||||||
|
|
||||||
return sync_carta(args.carta, dry_run=args.dry_run)
|
if args.carta:
|
||||||
|
return sync_carta(args.carta, dry_run=args.dry_run, remap_conflicts=args.remap_conflicts)
|
||||||
|
return sync_ids(args.ids, dry_run=args.dry_run, remap_conflicts=args.remap_conflicts)
|
||||||
|
|
||||||
|
|
||||||
if __name__ == "__main__":
|
if __name__ == "__main__":
|
||||||
|
|||||||
@@ -16,8 +16,10 @@ coincidencia local↔prod cuando prod va por detrás.
|
|||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
import argparse
|
import argparse
|
||||||
|
import base64
|
||||||
import json
|
import json
|
||||||
import os
|
import os
|
||||||
|
import shlex
|
||||||
import subprocess
|
import subprocess
|
||||||
import time
|
import time
|
||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
@@ -29,12 +31,20 @@ DB_NAME = os.environ.get("FEA_DB_NAME", "wordpress_db")
|
|||||||
DB_USER = os.environ.get("FEA_DB_USER", "wordpress_user")
|
DB_USER = os.environ.get("FEA_DB_USER", "wordpress_user")
|
||||||
DB_PASS = os.environ.get("FEA_DB_PASS", "wordpress_pass")
|
DB_PASS = os.environ.get("FEA_DB_PASS", "wordpress_pass")
|
||||||
|
|
||||||
PROD_HOST = os.environ.get("FEA_PROD_HOST", "feadulta@134.0.10.170")
|
PROD_HOST = os.environ.get("FEA_PROD_SSH_HOST", "")
|
||||||
PROD_PASS = os.environ.get("FEA_PROD_PASS", "C6c2A!mAl3Wj.BQF")
|
PROD_PASS = os.environ.get("FEA_PROD_SSH_PASS", "")
|
||||||
PROD_WPLOAD = os.environ.get("FEA_PROD_WPLOAD", "/web/wp-load.php")
|
PROD_WPLOAD = os.environ.get("FEA_PROD_WPLOAD", "/var/www/html/wp-load.php")
|
||||||
PROD_HELPER = "/tmp/fea_translate_helper.php"
|
# Desde el cutover a Hetzner, WordPress vive dentro de Coolify/Docker. Si se
|
||||||
|
# define, el helper se ejecuta en memoria dentro del contenedor; no se escribe
|
||||||
|
# ningún fichero temporal en prod. Espejo de sync_carta_from_prod.py.
|
||||||
|
PROD_DOCKER_CONTAINER = os.environ.get("FEA_PROD_DOCKER_CONTAINER", "")
|
||||||
|
|
||||||
HELPER_SRC = Path(__file__).resolve().parent / "fea_translate_helper.php"
|
HELPER_SRC = Path(__file__).resolve().parent / "fea_translate_helper.php"
|
||||||
|
# El helper se ejecuta en memoria con `php -r` en prod. Así no se copia ningún
|
||||||
|
# fichero a /tmp remoto y stdin queda disponible para el payload JSON.
|
||||||
|
HELPER_EVAL_CODE = "eval(base64_decode(" + repr(
|
||||||
|
base64.b64encode(HELPER_SRC.read_text(encoding="utf-8").removeprefix("<?php").encode("utf-8")).decode("ascii")
|
||||||
|
) + "));"
|
||||||
LOCAL_HELPER_DST = "/tmp/fea_translate_helper.php"
|
LOCAL_HELPER_DST = "/tmp/fea_translate_helper.php"
|
||||||
STATE_FILE = Path(os.environ.get("FEA_SYNC_STATE", "/tmp/feadulta-sync-state.json"))
|
STATE_FILE = Path(os.environ.get("FEA_SYNC_STATE", "/tmp/feadulta-sync-state.json"))
|
||||||
LOG_FILE = Path(os.environ.get("FEA_SYNC_LOG", "/tmp/feadulta-sync.log"))
|
LOG_FILE = Path(os.environ.get("FEA_SYNC_LOG", "/tmp/feadulta-sync.log"))
|
||||||
@@ -107,8 +117,20 @@ def local_read_full(post_id: int) -> dict:
|
|||||||
|
|
||||||
|
|
||||||
def local_translation_pairs() -> list[tuple[int, int]]:
|
def local_translation_pairs() -> list[tuple[int, int]]:
|
||||||
q = ("SELECT post_id, meta_value FROM wp_postmeta "
|
"""Devuelve (traducción_local, origen_ES_en_prod).
|
||||||
"WHERE meta_key='traduccion_origen' ORDER BY CAST(meta_value AS UNSIGNED), post_id;")
|
|
||||||
|
Normalmente ambos entornos compartían IDs. Para importaciones remapeadas,
|
||||||
|
la carta ES local guarda `fea_prod_source_id`; esa referencia remota tiene
|
||||||
|
prioridad y evita enlazar una traducción con un post distinto de prod.
|
||||||
|
"""
|
||||||
|
q = (
|
||||||
|
"SELECT tr.post_id, COALESCE(NULLIF(src.meta_value,''), tr.meta_value) "
|
||||||
|
"FROM wp_postmeta tr "
|
||||||
|
"LEFT JOIN wp_postmeta src ON src.post_id=CAST(tr.meta_value AS UNSIGNED) "
|
||||||
|
"AND src.meta_key='fea_prod_source_id' "
|
||||||
|
"WHERE tr.meta_key='traduccion_origen' "
|
||||||
|
"ORDER BY CAST(COALESCE(NULLIF(src.meta_value,''), tr.meta_value) AS UNSIGNED), tr.post_id;"
|
||||||
|
)
|
||||||
out = sh(["docker", "exec", DB_CONTAINER, "mysql", f"-u{DB_USER}", f"-p{DB_PASS}",
|
out = sh(["docker", "exec", DB_CONTAINER, "mysql", f"-u{DB_USER}", f"-p{DB_PASS}",
|
||||||
DB_NAME, "-N", "-e", q])
|
DB_NAME, "-N", "-e", q])
|
||||||
pairs = []
|
pairs = []
|
||||||
@@ -155,21 +177,31 @@ def collect_related_posts(seed_ids: list[int]) -> tuple[dict[int, dict], list[di
|
|||||||
|
|
||||||
|
|
||||||
# ── Prod ─────────────────────────────────────────────────────────────────────
|
# ── Prod ─────────────────────────────────────────────────────────────────────
|
||||||
_prod_ready = False
|
|
||||||
|
|
||||||
|
|
||||||
def _ssh(remote_cmd: str, *, stdin: str | None = None, timeout: int = 120) -> str:
|
def _ssh(remote_cmd: str, *, stdin: str | None = None, timeout: int = 120) -> str:
|
||||||
|
# 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",
|
cmd = ["sshpass", "-p", PROD_PASS, "ssh", "-o", "StrictHostKeyChecking=accept-new",
|
||||||
"-o", "ConnectTimeout=20", PROD_HOST, remote_cmd]
|
"-o", "ConnectTimeout=20", PROD_HOST, remote_cmd]
|
||||||
|
else:
|
||||||
|
cmd = ["ssh", "-o", "StrictHostKeyChecking=accept-new",
|
||||||
|
"-o", "ConnectTimeout=20", PROD_HOST, remote_cmd]
|
||||||
return sh(cmd, stdin=stdin, timeout=timeout)
|
return sh(cmd, stdin=stdin, timeout=timeout)
|
||||||
|
|
||||||
|
|
||||||
def prod_helper(subcmd: str, *args: str, stdin: str | None = None) -> str:
|
def prod_helper(subcmd: str, *args: str, stdin: str | None = None) -> str:
|
||||||
global _prod_ready
|
"""Ejecuta el helper PHP en memoria; no crea ficheros temporales en prod."""
|
||||||
if not _prod_ready:
|
argv = " ".join(shlex.quote(x) for x in (subcmd, *args))
|
||||||
_ssh(f"cat > {PROD_HELPER}", stdin=HELPER_SRC.read_text(encoding="utf-8"))
|
if PROD_DOCKER_CONTAINER:
|
||||||
_prod_ready = True
|
inner = (
|
||||||
inner = f"FEA_WP_LOAD={PROD_WPLOAD} php {PROD_HELPER} {subcmd} " + " ".join(args)
|
f"docker exec -i -e FEA_WP_LOAD={shlex.quote(PROD_WPLOAD)} "
|
||||||
|
f"{shlex.quote(PROD_DOCKER_CONTAINER)} php -r {shlex.quote(HELPER_EVAL_CODE)} {argv}"
|
||||||
|
)
|
||||||
|
else:
|
||||||
|
inner = (
|
||||||
|
f"FEA_WP_LOAD={shlex.quote(PROD_WPLOAD)} "
|
||||||
|
f"php -r {shlex.quote(HELPER_EVAL_CODE)} {argv}"
|
||||||
|
)
|
||||||
return _ssh(inner, stdin=stdin, timeout=180)
|
return _ssh(inner, stdin=stdin, timeout=180)
|
||||||
|
|
||||||
|
|
||||||
@@ -256,7 +288,12 @@ def deploy_fixed_ids(seed_ids: list[int], *, keep_existing: set[int], dry_run: b
|
|||||||
|
|
||||||
|
|
||||||
# ── Main legado ──────────────────────────────────────────────────────────────
|
# ── Main legado ──────────────────────────────────────────────────────────────
|
||||||
def legacy_sync(limit: int, origin: int) -> int:
|
def legacy_sync(limit: int, origin: int, *, dry_run: bool = False) -> int:
|
||||||
|
"""Sincroniza por origen ES+idioma, sin reutilizar IDs locales en prod.
|
||||||
|
|
||||||
|
Este es el modo seguro si producción ha avanzado y sus IDs ya pueden
|
||||||
|
colisionar con traducciones creadas en el entorno local.
|
||||||
|
"""
|
||||||
state = load_state()
|
state = load_state()
|
||||||
pairs = local_translation_pairs()
|
pairs = local_translation_pairs()
|
||||||
if origin:
|
if origin:
|
||||||
@@ -280,6 +317,10 @@ def legacy_sync(limit: int, origin: int) -> int:
|
|||||||
if key in state["done"]:
|
if key in state["done"]:
|
||||||
n_skip += 1
|
n_skip += 1
|
||||||
continue
|
continue
|
||||||
|
if dry_run:
|
||||||
|
log(f" PLAN {key}: crearía traducción [{lang}] «{t['title'][:45]}»")
|
||||||
|
n_ok += 1
|
||||||
|
continue
|
||||||
try:
|
try:
|
||||||
new_id = prod_create(src_origin, lang, t["title"], t["content"])
|
new_id = prod_create(src_origin, lang, t["title"], t["content"])
|
||||||
state["done"][key] = new_id
|
state["done"][key] = new_id
|
||||||
@@ -292,8 +333,10 @@ def legacy_sync(limit: int, origin: int) -> int:
|
|||||||
n_err += 1
|
n_err += 1
|
||||||
log(f" {key} ERROR: {exc}")
|
log(f" {key} ERROR: {exc}")
|
||||||
|
|
||||||
|
if not dry_run:
|
||||||
save_state(state)
|
save_state(state)
|
||||||
log(f"FIN sync legado. nuevos={n_ok} saltados={n_skip} errores={n_err}. Estado: {STATE_FILE}")
|
mode = "DRY-RUN" if dry_run else "SYNC"
|
||||||
|
log(f"FIN sync legado {mode}. nuevos/plan={n_ok} saltados={n_skip} errores={n_err}. Estado: {STATE_FILE}")
|
||||||
log("Recuerda en prod: ejecutar remap_translation_cats.php si alguna quedó sin categoría traducida.")
|
log("Recuerda en prod: ejecutar remap_translation_cats.php si alguna quedó sin categoría traducida.")
|
||||||
return 0
|
return 0
|
||||||
|
|
||||||
@@ -318,7 +361,7 @@ def main() -> int:
|
|||||||
keep_existing = set(parse_csv_ints(args.keep_existing))
|
keep_existing = set(parse_csv_ints(args.keep_existing))
|
||||||
return deploy_fixed_ids(seed_ids, keep_existing=keep_existing, dry_run=args.dry_run)
|
return deploy_fixed_ids(seed_ids, keep_existing=keep_existing, dry_run=args.dry_run)
|
||||||
|
|
||||||
return legacy_sync(args.limit, args.origin)
|
return legacy_sync(args.limit, args.origin, dry_run=args.dry_run)
|
||||||
|
|
||||||
|
|
||||||
if __name__ == "__main__":
|
if __name__ == "__main__":
|
||||||
|
|||||||
@@ -271,7 +271,8 @@ def save_state(state: dict) -> None:
|
|||||||
|
|
||||||
|
|
||||||
# ── Orquestación ─────────────────────────────────────────────────────────────
|
# ── Orquestación ─────────────────────────────────────────────────────────────
|
||||||
def process_post(post_id: int, langs: list[str], status: str, force: bool, state: dict) -> None:
|
def process_post(post_id: int, langs: list[str], status: str, force: bool, state: dict,
|
||||||
|
*, dry_run: bool = False) -> None:
|
||||||
src = read_post(post_id)
|
src = read_post(post_id)
|
||||||
if src.get("lang") and src["lang"] != "es":
|
if src.get("lang") and src["lang"] != "es":
|
||||||
log(f"#{post_id} no es ES (lang={src['lang']}) — saltado")
|
log(f"#{post_id} no es ES (lang={src['lang']}) — saltado")
|
||||||
@@ -286,8 +287,14 @@ def process_post(post_id: int, langs: list[str], status: str, force: bool, state
|
|||||||
state["done"][key] = existing
|
state["done"][key] = existing
|
||||||
continue
|
continue
|
||||||
if existing and force:
|
if existing and force:
|
||||||
|
if dry_run:
|
||||||
|
log(f" {lang}: PLAN regenerar traducción previa #{existing}")
|
||||||
|
continue
|
||||||
php_helper("unlink", str(post_id), lang)
|
php_helper("unlink", str(post_id), lang)
|
||||||
log(f" {lang}: --force, eliminada traducción previa #{existing}")
|
log(f" {lang}: --force, eliminada traducción previa #{existing}")
|
||||||
|
if dry_run:
|
||||||
|
log(f" {lang}: PLAN traducir con {ENGINE} y crear como {status}")
|
||||||
|
continue
|
||||||
try:
|
try:
|
||||||
t0 = time.time()
|
t0 = time.time()
|
||||||
title = translate_text(src["title"], lang, is_title=True)
|
title = translate_text(src["title"], lang, is_title=True)
|
||||||
@@ -312,6 +319,7 @@ def main() -> int:
|
|||||||
ap.add_argument("--langs", default="en,fr,it,pt", help="Idiomas destino separados por coma.")
|
ap.add_argument("--langs", default="en,fr,it,pt", help="Idiomas destino separados por coma.")
|
||||||
ap.add_argument("--status", default="draft", choices=["draft", "publish"], help="Estado de la traducción.")
|
ap.add_argument("--status", default="draft", choices=["draft", "publish"], help="Estado de la traducción.")
|
||||||
ap.add_argument("--force", action="store_true", help="Regenera aunque ya exista la traducción.")
|
ap.add_argument("--force", action="store_true", help="Regenera aunque ya exista la traducción.")
|
||||||
|
ap.add_argument("--dry-run", action="store_true", help="Muestra el plan sin llamar al LLM ni escribir en WordPress/estado.")
|
||||||
args = ap.parse_args()
|
args = ap.parse_args()
|
||||||
|
|
||||||
langs = [l.strip() for l in args.langs.split(",") if l.strip() in LANG_NAMES]
|
langs = [l.strip() for l in args.langs.split(",") if l.strip() in LANG_NAMES]
|
||||||
@@ -329,9 +337,11 @@ def main() -> int:
|
|||||||
|
|
||||||
state = load_state()
|
state = load_state()
|
||||||
for pid in ids:
|
for pid in ids:
|
||||||
process_post(pid, langs, args.status, args.force, state)
|
process_post(pid, langs, args.status, args.force, state, dry_run=args.dry_run)
|
||||||
|
if not args.dry_run:
|
||||||
save_state(state)
|
save_state(state)
|
||||||
log(f"FIN. {len(state['done'])} traducciones registradas, "
|
mode = "DRY-RUN" if args.dry_run else "FIN"
|
||||||
|
log(f"{mode}. {len(state['done'])} traducciones registradas, "
|
||||||
f"{len(state.get('errors', {}))} errores. Estado: {STATE_FILE}")
|
f"{len(state.get('errors', {}))} errores. Estado: {STATE_FILE}")
|
||||||
return 0
|
return 0
|
||||||
|
|
||||||
|
|||||||
+12
-2
@@ -29,7 +29,7 @@ sys.path.insert(0, os.path.dirname(os.path.abspath(__file__)))
|
|||||||
import minimax_tts as mm # get_post_text, add_pauses, t2a, OUT
|
import minimax_tts as mm # get_post_text, add_pauses, t2a, OUT
|
||||||
import translate_post as tp # carta_article_ids
|
import translate_post as tp # carta_article_ids
|
||||||
|
|
||||||
VOICE = "NicoFeadulta2026"
|
VOICE = os.environ.get("FEA_TTS_VOICE", "NicoFeadulta2026")
|
||||||
MODEL = "speech-2.8-hd"
|
MODEL = "speech-2.8-hd"
|
||||||
CONTAINER = "wordpress-web"
|
CONTAINER = "wordpress-web"
|
||||||
PROD = Path(__file__).resolve().parent.parent / "wordpress/wp-content/uploads/tts"
|
PROD = Path(__file__).resolve().parent.parent / "wordpress/wp-content/uploads/tts"
|
||||||
@@ -103,6 +103,8 @@ def main():
|
|||||||
help="para tras N audios OK en esta ejecución (0 = sin tope)")
|
help="para tras N audios OK en esta ejecución (0 = sin tope)")
|
||||||
parser.add_argument("--dry-run", action="store_true",
|
parser.add_argument("--dry-run", action="store_true",
|
||||||
help="imprime la cola y sale, sin sintetizar ni gastar cuota")
|
help="imprime la cola y sale, sin sintetizar ni gastar cuota")
|
||||||
|
parser.add_argument("--allow-default-voice", action="store_true",
|
||||||
|
help="permite Nico para autores sin voz digitalizada (desactivado por defecto)")
|
||||||
args = parser.parse_args()
|
args = parser.parse_args()
|
||||||
if args.ids:
|
if args.ids:
|
||||||
os.environ["FEA_TTS_IDS"] = args.ids
|
os.environ["FEA_TTS_IDS"] = args.ids
|
||||||
@@ -149,7 +151,15 @@ def main():
|
|||||||
i += 1
|
i += 1
|
||||||
continue
|
continue
|
||||||
|
|
||||||
voice = mm.voice_for_author(author, VOICE)
|
cloned_voice = mm.voice_for_author(author, "")
|
||||||
|
if cloned_voice:
|
||||||
|
voice = cloned_voice
|
||||||
|
elif args.allow_default_voice:
|
||||||
|
voice = VOICE
|
||||||
|
else:
|
||||||
|
log(f"#{pid}: autor sin voz digitalizada (author_id={author}); omitido")
|
||||||
|
i += 1
|
||||||
|
continue
|
||||||
rc = mm.t2a(mm.add_pauses(text), voice, MODEL, f"prod-{pid}")
|
rc = mm.t2a(mm.add_pauses(text), voice, MODEL, f"prod-{pid}")
|
||||||
if rc == 0:
|
if rc == 0:
|
||||||
src = mm.OUT / f"prod-{pid}.mp3"
|
src = mm.OUT / f"prod-{pid}.mp3"
|
||||||
|
|||||||
Reference in New Issue
Block a user