Handoff carta 46956: traducciones Haiku + evangelios descargados + scripts; despliegue prod pendiente (issue #88, #87)
This commit is contained in:
@@ -0,0 +1,86 @@
|
||||
# Handoff — Carta 46956 «Entre todos» (sesión 2026-06-17)
|
||||
|
||||
Estado para que **Codex** continúe. Se hizo TODO en **local** (Docker `wordpress-web`/`wordpress-mysql`). **Nada está en prod todavía.** El despliegue a prod quedó **pendiente y SIN hacer** (Rafa quiere ir por partes).
|
||||
|
||||
Repo: `/home/rafa/joomla-migration` (= remoto Gitea `rafa/feadulta`). Ver también wiki «Ciclo carta nueva» y memoria del ciclo.
|
||||
|
||||
---
|
||||
|
||||
## 1. Qué se hizo en LOCAL (todo verificado)
|
||||
|
||||
### Carta y artículos
|
||||
- **Carta 46956 «Entre todos»** (ES). Estaba en estado `future` por desfase de zona horaria (server US detrás de Madrid) → **publicada** (`post_status=publish`, fecha 2026-06-18). **Issue #87 (cerrado).**
|
||||
- **19 artículos** con `_carta_id=46956` (IDs 46937–46955). De ellos **15 son ES** (46937–46951) y 4 ya venían en otros idiomas desde Joomla.
|
||||
- **Cluster multiidioma** 46951–46955 = el MISMO artículo en 5 idiomas; **enlazado** en Polylang: `{es:46951, fr:46952, en:46953, it:46954, pt:46955}` (no se tradujo, ya estaba).
|
||||
|
||||
### Traducciones (motor Haiku)
|
||||
- **Carta + 14 artículos ES → EN/FR/IT/PT = 60 posts** (IDs **46959–47018**), `publish`.
|
||||
- Motor: **`FEA_ENGINE=haiku`** (nuevo flag en `scripts/translate_post.py`) → usa Claude Haiku 4.5 vía `translate_haiku.py`. API key en `/home/rafa/portfolio-tracker/.env`. Coste ~0.6–0.8 €/carta.
|
||||
- Comando: `FEA_ENGINE=haiku python3 scripts/translate_post.py --carta 46956 --langs en,fr,it,pt --status draft` (luego publicado).
|
||||
|
||||
### Evangelio / lecturas bíblicas (**issue #88**)
|
||||
- El pasaje **ES `MATEO 10, 26-33` (post 2682)** solo existía en ES. Política: **DESCARGAR la Biblia oficial, NO traducir con LLM**.
|
||||
- Descargado de **bolls.life** y creados 4 posts (IDs **47079–47082**), enlazados Polylang a 2682, cat "Lecturas bíblicas" mapeada, `publish`:
|
||||
- EN=**Douay-Rheims** (católica), PT=**CNBB** (católica), IT=**Nuova Riveduta 2006** (única moderna IT), FR=**Bible du Semeur** (no hay católica libre en bolls).
|
||||
- Scripts: `scripts/fetch_lectura_bolls.py` (descarga) + `scripts/create_lecturas.php` (crea posts).
|
||||
|
||||
### Enlaces internos de las cartas
|
||||
Las cartas traían enlaces Joomla legacy `es/buscadoravanzado/item/<k2_id>-<slug>.html` (rotos) y luego enlaces al pasaje ES. Arreglado en 2 pasos:
|
||||
1. `scripts/fix_carta_joomla_links.php` — mapea `item/<id>` por meta `_fgj2wp_old_k2_id` → permalink WP del artículo en el idioma de cada carta.
|
||||
2. `scripts/repoint_carta_links.php` — repunta enlaces ya-permalink que apuntaban al ES → a la traducción del idioma de la carta (p.ej. el evangelio).
|
||||
- `APPLY=1 CARTA=46956 docker exec ... php /tmp/<script>.php`. Sin mapear (se dejan): `tablon-de-anuncios`, `noticias-de-alcance`, ítem `18151 el-buen-humor` (fuera del delta), multimedia `9155`.
|
||||
|
||||
### Categorías "carta de la semana"
|
||||
- `scripts/demote_old_cartasemana.php` — deja en `cartasemana` (term 6) **solo la carta nueva** en CADA idioma (count=1 → el menú redirige directo al single). Movía las cartas anteriores a "cartas de otras semanas". Aplicado: count=1 en es/en/fr/it/pt.
|
||||
|
||||
### Verificación local (vía Caddy, NO localhost:8081)
|
||||
- Cartas: `/fea/entre-todos/`, `/fea/en/among-all/`, `/fea/fr/entre-tous/`, `/fea/it/tra-tutti/`, `/fea/pt/entre-todos-2/` — todas publish.
|
||||
- Menú "carta de la semana" por idioma → 302 al single de cada idioma.
|
||||
- Evangelio enlaza al pasaje en su idioma (47079–47082), todos HTTP 200.
|
||||
|
||||
---
|
||||
|
||||
## 2. Estado de PROD (verificado read-only 2026-06-17)
|
||||
|
||||
SSH correcto: **`sshpass -p 'C6c2A!mAl3Wj.BQF' ssh feadulta@134.0.10.170`** (la pass que dio Rafa `6Rm2qOF@eundwpda` es de cPanel/FTP, NO sirve para SSH). wp-cli en `/web/wp-nuevo`. NO scp (usar `ssh 'cat > ruta'`).
|
||||
|
||||
- **MAX post ID en prod = 46809.** Los IDs locales nuevos (46937–47082) están LIBRES en prod.
|
||||
- **La carta de esta semana NO está en prod.** La "carta de la semana" actual en prod es la **anterior: 45018 «La puerta pequeña»** (2026-06-13).
|
||||
- El pasaje ES **2682 `MATEO 10,26-33` SÍ está** en prod (post viejo). Faltan sus 4 traducciones.
|
||||
|
||||
---
|
||||
|
||||
## 3. PENDIENTE: desplegar la carta 46956 a prod (lo que paró Rafa)
|
||||
|
||||
Objetivo: que la carta de esta semana se vea bien en prod en los 5 idiomas. Implica el delta ES + traducciones + evangelios + estructura. **Rafa quiere ir por partes** (NO hacer todo de golpe sin su OK).
|
||||
|
||||
### ⚠️ Problema de coincidencia de IDs (clave)
|
||||
La regla "IDs ES coinciden local↔prod" se ha ROTO: prod va por detrás (max 46809) mientras local hizo el delta cuando su max era 46936. Una reimportación fresca desde Joomla en prod asignaría IDs ~46810, **distintos de los locales**, y rompería el casado de traducciones/enlaces.
|
||||
- **Enfoque recomendado:** replicar los posts locales a prod **preservando los IDs** (46937–47082 + nada de 2682 que ya está), ya que están libres en prod. Es decir, NO reimportar desde Joomla: volcar el contenido local (ES + traducciones + evangelios) a prod con `wp_insert_post`/inserción con ID explícito, reconstruir Polylang + categorías + metas (`_carta_id`, `_fgj2wp_old_k2_id`), y aplicar los mismos arreglos de enlaces y degradado de categoría.
|
||||
- Revisar `scripts/sync_translations_to_prod.py` (opción A, ya validada para traducciones reusando texto local) y adaptarlo/extenderlo para incluir el delta ES y los pasajes del evangelio con ID preservado.
|
||||
|
||||
### Checklist prod (por partes, con OK de Rafa en cada salto)
|
||||
1. [ ] Delta ES a prod: carta 46956 + artículos 46937–46955 con IDs preservados, metas (`_carta_id`, `_fgj2wp_old_k2_id`), categorías, idioma ES. Revisar.
|
||||
2. [ ] Cluster multiidioma 46951–46955: enlazar Polylang en prod (los 5 ya existirían tras paso 1).
|
||||
3. [ ] Traducciones EN/FR/IT/PT (carta + artículos): 46959–47018 con IDs preservados, Polylang, categorías.
|
||||
4. [ ] Evangelio: crear en prod las 4 traducciones del 2682 (47079–47082) enlazadas a 2682 (que ya está en prod). Reusar `fetch_lectura_bolls.py` + `create_lecturas.php` adaptado a `FEA_WP_LOAD=/web/wp-nuevo/wp-load.php`.
|
||||
5. [ ] Enlaces internos en prod: `fix_carta_joomla_links.php` + `repoint_carta_links.php` (con `FEA_WP_LOAD` de prod).
|
||||
6. [ ] Degradar carta anterior en prod: `demote_old_cartasemana.php CARTA=46956` (mueve 45018 y traducciones a "otras semanas", deja count=1 por idioma).
|
||||
7. [ ] Verificar vía Cloudflare/web: cartas y evangelio en los 5 idiomas (NB: en prod Cloudflare bloquea curl; verificar con `wp eval` o navegador).
|
||||
8. [ ] (Después) TTS de la carta nueva — paso 5 del ciclo, prioritario sobre el gap.
|
||||
|
||||
---
|
||||
|
||||
## 4. Scripts nuevos de esta sesión (en `scripts/`)
|
||||
- `fix_carta_joomla_links.php` — enlaces Joomla `.html` → permalink WP por idioma.
|
||||
- `repoint_carta_links.php` — repunta enlaces ya-permalink al idioma de la carta.
|
||||
- `publish_carta.php` — draft→publish de carta+artículos+traducciones, corrige fechas futuras (timezone).
|
||||
- `demote_old_cartasemana.php` — deja count=1 en `cartasemana` por idioma.
|
||||
- `fetch_lectura_bolls.py` + `create_lecturas.php` — descarga e inserta pasajes bíblicos (issue #88).
|
||||
- `translate_post.py` — añadido flag `FEA_ENGINE=haiku`.
|
||||
|
||||
## 5. Issues Gitea relacionados
|
||||
- **#84** — Migrar delta carta nueva (prerequisito scripts; ya corregidos).
|
||||
- **#87** — Carta no aparecía (estado `future` por timezone). **CERRADO.**
|
||||
- **#88** — Lecturas bíblicas solo en ES → descargar EN/FR/IT/PT. **ABIERTO** (esta semana hecha en local; backfill histórico pendiente).
|
||||
- Crear/usar el issue de **despliegue a prod** (este handoff) para el seguimiento.
|
||||
@@ -0,0 +1,30 @@
|
||||
<?php
|
||||
require '/var/www/html/wp-load.php';
|
||||
$ES = 2682; // MATEO 10, 26-33 (es)
|
||||
$src = get_post($ES);
|
||||
$tr_html = json_decode(file_get_contents('/tmp/lecturas_mateo.json'), true);
|
||||
$BOOK = ['en'=>'MATTHEW','fr'=>'MATTHIEU','it'=>'MATTEO','pt'=>'MATEUS'];
|
||||
$VER = ['en'=>'Douay-Rheims Bible','fr'=>'Bible du Semeur 2015','it'=>'Nuova Riveduta 2006','pt'=>'Bíblia CNBB 2002'];
|
||||
$REST = 'MATEO 10, 26-33'; // título es
|
||||
$tail = preg_replace('~^MATEO~','',$REST); // " 10, 26-33"
|
||||
$es_cats = wp_get_post_categories($ES);
|
||||
|
||||
$grp = pll_get_post_translations($ES); if(!$grp) $grp=['es'=>$ES];
|
||||
foreach (['en','fr','it','pt'] as $lang) {
|
||||
$exist = (int)pll_get_post($ES,$lang);
|
||||
if ($exist && get_post($exist)) { echo "$lang ya existe #$exist — saltado\n"; $grp[$lang]=$exist; continue; }
|
||||
$title = $BOOK[$lang].$tail;
|
||||
$id = wp_insert_post([
|
||||
'post_title'=>$title,'post_content'=>$tr_html[$lang],'post_status'=>'publish',
|
||||
'post_type'=>'post','post_author'=>(int)$src->post_author,'post_date'=>$src->post_date,
|
||||
], true);
|
||||
if (is_wp_error($id)) { echo "$lang ERROR ".$id->get_error_message()."\n"; continue; }
|
||||
pll_set_post_language($id,$lang);
|
||||
$mapped=[]; foreach($es_cats as $c){ $tc=(int)pll_get_term($c,$lang); $mapped[]=$tc?:$c; }
|
||||
wp_set_post_categories($id, array_values(array_unique($mapped)));
|
||||
$grp[$lang]=$id; pll_save_post_translations($grp);
|
||||
update_post_meta($id,'lectura_fuente',$VER[$lang]);
|
||||
update_post_meta($id,'traduccion_origen',$ES);
|
||||
echo "$lang creado #$id «$title» [".$VER[$lang]."]\n";
|
||||
}
|
||||
echo "Grupo final: ".json_encode(pll_get_post_translations($ES))."\n";
|
||||
@@ -0,0 +1,43 @@
|
||||
<?php
|
||||
/**
|
||||
* Ciclo carta nueva — deja en la categoría "carta de la semana" SOLO la carta nueva,
|
||||
* en TODOS los idiomas (ES + EN/FR/IT/PT), moviendo las anteriores a "cartas de otras
|
||||
* semanas". Necesario para que el menú/categoría de cada idioma tenga count=1 y redirija
|
||||
* directo al single (carta-semana-plugin). En ES suele hacerse al importar; las
|
||||
* traducciones NO lo heredan -> ejecutar esto tras publicar las traducciones.
|
||||
*
|
||||
* Deriva los términos por Polylang desde los términos ES base:
|
||||
* cartasemana = term 6 | cartas-de-otras-semanas = term 21
|
||||
*
|
||||
* Uso: CARTA=<es_id> php demote_old_cartasemana.php (dry-run)
|
||||
* APPLY=1 CARTA=<es_id> php demote_old_cartasemana.php
|
||||
*/
|
||||
require getenv('FEA_WP_LOAD') ?: '/var/www/html/wp-load.php';
|
||||
$APPLY = getenv('APPLY') === '1';
|
||||
$CARTA = (int)(getenv('CARTA') ?: 0);
|
||||
if (!$CARTA) { fwrite(STDERR,"Falta CARTA=<es_id>\n"); exit(1); }
|
||||
|
||||
$cs_terms = pll_get_term_translations(6); // cartasemana por idioma
|
||||
$otras_terms = pll_get_term_translations(21); // cartas de otras semanas por idioma
|
||||
$carta_tr = pll_get_post_translations($CARTA);
|
||||
|
||||
foreach ($cs_terms as $lang=>$cs) {
|
||||
$keep = $carta_tr[$lang] ?? 0;
|
||||
$otras = $otras_terms[$lang] ?? 0;
|
||||
$posts = get_posts(['post_type'=>'post','numberposts'=>-1,'post_status'=>'any','fields'=>'ids',
|
||||
'tax_query'=>[['taxonomy'=>'category','field'=>'term_id','terms'=>$cs]]]);
|
||||
$moved=0;
|
||||
foreach ($posts as $pid) {
|
||||
if ($pid == $keep) continue;
|
||||
if ($APPLY) {
|
||||
if ($otras) wp_set_object_terms($pid,[(int)$otras],'category',true); // append a otras
|
||||
wp_remove_object_terms($pid,[(int)$cs],'category');
|
||||
}
|
||||
$moved++;
|
||||
}
|
||||
if ($APPLY) clean_term_cache([$cs,$otras],'category');
|
||||
$t=get_term($cs);
|
||||
echo sprintf("%s: %s %d | '%s' count=%d keep=#%d\n", strtoupper($lang),
|
||||
$APPLY?"movidas":"se moverían", $moved, $t->slug, $t->count, $keep);
|
||||
}
|
||||
echo $APPLY ? "APLICADO\n" : "DRY-RUN (APPLY=1 para aplicar)\n";
|
||||
@@ -0,0 +1,21 @@
|
||||
#!/usr/bin/env python3
|
||||
"""Descarga un pasaje bíblico en EN/FR/IT/PT desde bolls.life (issue #88).
|
||||
NO traducir la Biblia con LLM: descargar versiones oficiales.
|
||||
Edita BOOK/CH/V0/V1 y TR según el pasaje. Salida JSON {lang: html} a /tmp/lecturas_<x>.json
|
||||
Traducciones usadas (carta 46956, Mateo 10,26-33): EN=DRB (católica), PT=CNBB (católica),
|
||||
IT=NR06 (única moderna IT), FR=BDS. Libros bolls: Mateo=40. Ver languages.json del sitio.
|
||||
"""
|
||||
import json, urllib.request, re, sys
|
||||
TR={"en":"DRB","fr":"BDS","it":"NR06","pt":"CNBB"}
|
||||
BOOK=int(sys.argv[1]) if len(sys.argv)>1 else 40
|
||||
CH=int(sys.argv[2]) if len(sys.argv)>2 else 10
|
||||
V0=int(sys.argv[3]) if len(sys.argv)>3 else 26
|
||||
V1=int(sys.argv[4]) if len(sys.argv)>4 else 33
|
||||
def clean(t): return re.sub(r"\s+"," ",re.sub(r"<[^>]+>"," ",t)).strip()
|
||||
out={}
|
||||
for lang,code in TR.items():
|
||||
data=json.load(urllib.request.urlopen(f"https://bolls.life/get-text/{code}/{BOOK}/{CH}/",timeout=20))
|
||||
v={x["verse"]:clean(x["text"]) for x in data if V0<=x["verse"]<=V1}
|
||||
out[lang]="\n".join(f'<p><sup><strong>{n}</strong></sup> {v[n]}</p>' for n in range(V0,V1+1) if n in v)
|
||||
json.dump(out,open("/tmp/lecturas_fetched.json","w"),ensure_ascii=False)
|
||||
print("OK ->/tmp/lecturas_fetched.json", {k:len(x) for k,x in out.items()})
|
||||
@@ -0,0 +1,42 @@
|
||||
<?php
|
||||
require '/var/www/html/wp-load.php';
|
||||
global $wpdb;
|
||||
$APPLY = getenv("APPLY") === "1";
|
||||
$BAK = "/tmp/fix_carta_links_bak"; if ($APPLY) @mkdir($BAK,0777,true);
|
||||
|
||||
$CARTA = (int)(getenv("CARTA") ?: 46956);
|
||||
$ids = array_values(pll_get_post_translations($CARTA)) ?: [$CARTA];
|
||||
|
||||
// k2_id -> ES post
|
||||
function es_post_by_k2($k2){ global $wpdb; return (int)$wpdb->get_var($wpdb->prepare(
|
||||
"SELECT post_id FROM wp_postmeta WHERE meta_key='_fgj2wp_old_k2_id' AND meta_value=%s LIMIT 1",$k2)); }
|
||||
// slug -> ES post
|
||||
function es_post_by_slug($slug){ global $wpdb; return (int)$wpdb->get_var($wpdb->prepare(
|
||||
"SELECT ID FROM wp_posts WHERE post_name=%s AND post_type='post' AND post_status IN('publish','draft','future') LIMIT 1",$slug)); }
|
||||
|
||||
$tot=0;
|
||||
foreach($ids as $pid){
|
||||
$post=get_post($pid); if(!$post) continue;
|
||||
$lang=pll_get_post_language($pid) ?: 'es';
|
||||
$chg=0; $miss=[];
|
||||
$new=preg_replace_callback('~href="([^"]+)"~i', function($m) use($lang,&$chg,&$miss){
|
||||
$href=html_entity_decode(trim($m[1]));
|
||||
if(stripos($href,'.html')===false) return $m[0]; // solo legacy joomla .html
|
||||
if(stripos($href,'feadulta.com')!==false) return $m[0]; // dominio viejo absoluto -> dejar
|
||||
$es=0;
|
||||
if(preg_match('~/item/(\d+)-~',$href,$mm)) $es=es_post_by_k2($mm[1]);
|
||||
if(!$es && preg_match('~/?([a-z0-9-]+)\.html$~i',$href,$mm)) $es=es_post_by_slug($mm[1]);
|
||||
if(!$es){ $miss[]=$href; return $m[0]; }
|
||||
$t=pll_get_post($es,$lang) ?: $es;
|
||||
$url=get_permalink($t);
|
||||
if(!$url) return $m[0];
|
||||
$chg++;
|
||||
return 'href="'.esc_url($url).'"';
|
||||
}, $post->post_content);
|
||||
echo sprintf("#%d [%s] «%s» — %d reescritos%s\n",$pid,$lang,mb_substr($post->post_title,0,30),$chg,
|
||||
$miss?(" | sin mapear: ".implode(", ",array_slice($miss,0,4))):"");
|
||||
$tot+=$chg;
|
||||
if($APPLY && $chg){ file_put_contents("$BAK/$pid.html",$post->post_content);
|
||||
$wpdb->update($wpdb->posts,['post_content'=>$new],['ID'=>$pid]); clean_post_cache($pid); }
|
||||
}
|
||||
echo "\n".($APPLY?"APLICADO":"DRY-RUN").": $tot enlaces.\n";
|
||||
@@ -0,0 +1,26 @@
|
||||
<?php
|
||||
require '/var/www/html/wp-load.php';
|
||||
$CARTA = 46956;
|
||||
// IDs objetivo: carta + sus artículos ES, y TODAS sus traducciones Polylang.
|
||||
$es_ids = [$CARTA];
|
||||
$arts = get_posts(['post_type'=>'post','numberposts'=>-1,'post_status'=>'any',
|
||||
'fields'=>'ids','meta_key'=>'_carta_id','meta_value'=>$CARTA]);
|
||||
$es_ids = array_merge($es_ids, $arts);
|
||||
$all = [];
|
||||
foreach ($es_ids as $id) foreach (pll_get_post_translations($id) as $tid) $all[$tid]=true;
|
||||
$all = array_keys($all);
|
||||
|
||||
$now = current_time('mysql'); $now_gmt = current_time('mysql', true);
|
||||
$pub=0; $skip=0; $fixed=0;
|
||||
foreach ($all as $id) {
|
||||
$p = get_post($id); if(!$p) continue;
|
||||
if ($p->post_status === 'publish') { $skip++; continue; }
|
||||
$data = ['ID'=>$id, 'post_status'=>'publish'];
|
||||
if (strtotime($p->post_date_gmt) > strtotime($now_gmt)) { // fecha futura -> a ahora
|
||||
$data['post_date']=$now; $data['post_date_gmt']=$now_gmt; $fixed++;
|
||||
}
|
||||
$r = wp_update_post($data, true);
|
||||
if (is_wp_error($r)) { echo "ERR #$id: ".$r->get_error_message()."\n"; continue; }
|
||||
clean_post_cache($id); $pub++;
|
||||
}
|
||||
echo "Publicados: $pub | ya estaban: $skip | fechas futuras corregidas: $fixed\n";
|
||||
@@ -0,0 +1,41 @@
|
||||
<?php
|
||||
require '/var/www/html/wp-load.php';
|
||||
global $wpdb;
|
||||
$APPLY = getenv("APPLY")==="1";
|
||||
$CARTA = (int)(getenv("CARTA") ?: 46956);
|
||||
$BAK="/tmp/repoint_bak"; if($APPLY) @mkdir($BAK,0777,true);
|
||||
|
||||
function resolve_post($href){
|
||||
// ?p=ID
|
||||
if (preg_match('~[?&]p=(\d+)~',$href,$m)) return (int)$m[1];
|
||||
$path = preg_replace('~^https?://[^/]+~i','',$href);
|
||||
$path = preg_replace('~[?#].*$~','',$path);
|
||||
$path = preg_replace('~^/fea~','',$path);
|
||||
$path = preg_replace('~^/(en|fr|it|pt|es)(/|$)~','/',$path);
|
||||
$segs = array_values(array_filter(explode('/',$path),'strlen'));
|
||||
if (count($segs)!==1) return 0;
|
||||
$p = get_page_by_path($segs[0], OBJECT, 'post');
|
||||
return $p ? $p->ID : 0;
|
||||
}
|
||||
|
||||
foreach (array_values(pll_get_post_translations($CARTA)) as $pid) {
|
||||
$post=get_post($pid); if(!$post) continue;
|
||||
$lang=pll_get_post_language($pid) ?: 'es';
|
||||
$chg=0;
|
||||
$new=preg_replace_callback('~href="([^"]+)"~i', function($m) use($lang,&$chg){
|
||||
$href=$m[1];
|
||||
if (stripos($href,'.html')!==false) return $m[0]; // legacy lo maneja el otro script
|
||||
$tid=resolve_post($href);
|
||||
if(!$tid) return $m[0];
|
||||
$plang=pll_get_post_language($tid);
|
||||
if(!$plang || $plang===$lang) return $m[0]; // ya está en el idioma correcto
|
||||
$t=pll_get_post($tid,$lang);
|
||||
if(!$t || $t==$tid) return $m[0]; // no hay traducción -> dejar
|
||||
$url=get_permalink($t); if(!$url) return $m[0];
|
||||
$chg++; return 'href="'.esc_url($url).'"';
|
||||
}, $post->post_content);
|
||||
echo "#$pid [$lang] — $chg repuntado(s)\n";
|
||||
if($APPLY && $chg){ file_put_contents("$BAK/$pid.html",$post->post_content);
|
||||
$wpdb->update($wpdb->posts,['post_content'=>$new],['ID'=>$pid]); clean_post_cache($pid); }
|
||||
}
|
||||
echo $APPLY?"APLICADO\n":"DRY-RUN\n";
|
||||
@@ -0,0 +1,300 @@
|
||||
#!/usr/bin/env python3
|
||||
"""
|
||||
translate_post.py — Traduce posts de feadulta (ES → EN/FR/IT/PT) con Gemma 4B local
|
||||
y los enlaza como traducciones de Polylang, SIN servicios de pago.
|
||||
|
||||
Diseño (issue rafa/feadulta#75, fase 1):
|
||||
- Gemma (LM Studio, http://172.19.128.1:1234/v1) traduce título + contenido HTML.
|
||||
- Reglas estrictas: preserva HTML/shortcodes, NO traduce referencias bíblicas, respeta
|
||||
nombres propios y un glosario fijo del proyecto. Traducción fiel (sin resumir).
|
||||
- La lógica WordPress/Polylang vive en fea_translate_helper.php (corre dentro del
|
||||
contenedor cargando wp-load.php; no necesita wp-cli ni proc_open).
|
||||
- Idempotente y reanudable: si ya existe la traducción en ese idioma, se salta.
|
||||
|
||||
Uso:
|
||||
python3 scripts/translate_post.py --post-id 45018 --langs en,fr,it,pt
|
||||
python3 scripts/translate_post.py --carta 45018 # carta + sus _carta_id
|
||||
python3 scripts/translate_post.py --post-id 45018 --langs en --status publish --force
|
||||
|
||||
Pensado para que Codex lo lance en lote sobre la cola priorizada (cartas/destacados).
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import argparse
|
||||
import json
|
||||
import os
|
||||
import re
|
||||
import subprocess
|
||||
import sys
|
||||
import time
|
||||
from pathlib import Path
|
||||
|
||||
# ── Configuración ────────────────────────────────────────────────────────────
|
||||
WP_CONTAINER = os.environ.get("FEA_WP_CONTAINER", "wordpress-web")
|
||||
DB_CONTAINER = os.environ.get("FEA_DB_CONTAINER", "wordpress-mysql")
|
||||
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")
|
||||
|
||||
LM_BASE_URL = os.environ.get("OPENAI_BASE_URL", "http://172.19.128.1:1234/v1")
|
||||
MODEL = os.environ.get("LOCAL_MODEL", "google/gemma-4-e4b")
|
||||
|
||||
# Motor de traducción: "gemma" (local, por defecto) o "haiku" (Claude Haiku 4.5 vía API).
|
||||
# Haiku da más calidad y no necesita trocear (contexto 200k). Reutiliza translate_haiku.py.
|
||||
ENGINE = os.environ.get("FEA_ENGINE", "gemma").lower()
|
||||
if ENGINE == "haiku":
|
||||
MODEL = "claude-haiku-4-5"
|
||||
sys.path.insert(0, str(Path(__file__).resolve().parent))
|
||||
import translate_haiku # carga la API key de portfolio-tracker/.env
|
||||
|
||||
HELPER_SRC = Path(__file__).resolve().parent / "fea_translate_helper.php"
|
||||
HELPER_DST = "/tmp/fea_translate_helper.php"
|
||||
STATE_FILE = Path(os.environ.get("FEA_TR_STATE", "/tmp/feadulta-translate-state.json"))
|
||||
LOG_FILE = Path(os.environ.get("FEA_TR_LOG", "/tmp/feadulta-translate.log"))
|
||||
|
||||
LANG_NAMES = {"en": "English", "fr": "French (français)", "it": "Italian (italiano)", "pt": "Portuguese (português)"}
|
||||
|
||||
# Glosario: términos que NO se traducen o se fijan.
|
||||
GLOSSARY = {
|
||||
"Fe Adulta": {"en": "Fe Adulta", "fr": "Fe Adulta", "it": "Fe Adulta", "pt": "Fe Adulta"},
|
||||
"EFFA": {"en": "EFFA", "fr": "EFFA", "it": "EFFA", "pt": "EFFA"},
|
||||
}
|
||||
|
||||
CHUNK_LIMIT = 5000 # caracteres por llamada a Gemma (parte por </p> si se supera)
|
||||
|
||||
|
||||
# ── Utilidades de proceso ────────────────────────────────────────────────────
|
||||
def log(msg: str) -> None:
|
||||
line = f"[{time.strftime('%Y-%m-%d %H:%M:%S')}] {msg}"
|
||||
print(line, flush=True)
|
||||
try:
|
||||
LOG_FILE.open("a", encoding="utf-8").write(line + "\n")
|
||||
except OSError:
|
||||
pass
|
||||
|
||||
|
||||
def sh(cmd: list[str], *, stdin: str | None = None, timeout: int = 120) -> str:
|
||||
r = subprocess.run(cmd, input=stdin, capture_output=True, text=True, timeout=timeout)
|
||||
if r.returncode != 0:
|
||||
raise RuntimeError(f"cmd falló ({r.returncode}): {' '.join(cmd)}\n{r.stderr.strip()}")
|
||||
return r.stdout
|
||||
|
||||
|
||||
_helper_ready = False
|
||||
|
||||
|
||||
def php_helper(subcmd: str, *args: str, stdin: str | None = None) -> str:
|
||||
"""Copia el helper al contenedor (una vez) y lo ejecuta cargando wp-load.php."""
|
||||
global _helper_ready
|
||||
if not _helper_ready:
|
||||
sh(["docker", "cp", str(HELPER_SRC), f"{WP_CONTAINER}:{HELPER_DST}"])
|
||||
_helper_ready = True
|
||||
cmd = ["docker", "exec", "-i", WP_CONTAINER, "php", HELPER_DST, subcmd, *args]
|
||||
return sh(cmd, stdin=stdin, timeout=180)
|
||||
|
||||
|
||||
# ── Gemma (LM Studio) ────────────────────────────────────────────────────────
|
||||
def gemma(messages: list[dict], *, max_tokens: int) -> str:
|
||||
import urllib.request
|
||||
|
||||
body = json.dumps({
|
||||
"model": MODEL,
|
||||
"messages": messages,
|
||||
"temperature": 0.2,
|
||||
"max_tokens": max_tokens,
|
||||
"reasoning_effort": "none",
|
||||
}).encode("utf-8")
|
||||
req = urllib.request.Request(
|
||||
f"{LM_BASE_URL}/chat/completions", data=body,
|
||||
headers={"Content-Type": "application/json"},
|
||||
)
|
||||
with urllib.request.urlopen(req, timeout=300) as resp:
|
||||
data = json.loads(resp.read().decode("utf-8"))
|
||||
return data["choices"][0]["message"]["content"]
|
||||
|
||||
|
||||
def _extract(text: str) -> str:
|
||||
"""Extrae la traducción del ÚLTIMO bloque <<<INI>>>…<<<FIN>>>.
|
||||
|
||||
Gemma (modo reasoning) escribe un preámbulo que MENCIONA las propias marcas,
|
||||
así que hay que quedarse con la última ocurrencia, no la primera.
|
||||
"""
|
||||
start_tok, end_tok = "<<<INI>>>", "<<<FIN>>>"
|
||||
i = text.rfind(start_tok)
|
||||
if i != -1:
|
||||
rest = text[i + len(start_tok):]
|
||||
j = rest.find(end_tok)
|
||||
out = (rest[:j] if j != -1 else rest).strip()
|
||||
else:
|
||||
out = text.strip()
|
||||
# Quita vallas de código markdown si Gemma las añade.
|
||||
out = re.sub(r"^```[a-z]*\n?", "", out)
|
||||
out = re.sub(r"\n?```$", "", out)
|
||||
return out.strip()
|
||||
|
||||
|
||||
def _system_prompt(lang: str) -> str:
|
||||
target = LANG_NAMES[lang]
|
||||
glos = "; ".join(f'"{k}" → "{v[lang]}"' for k, v in GLOSSARY.items())
|
||||
return (
|
||||
f"Eres un traductor profesional de textos religiosos cristianos (espiritualidad y "
|
||||
f"teología católica). Traduce del español al {target}. REGLAS ESTRICTAS:\n"
|
||||
f"1. Conserva EXACTAMENTE el marcado HTML (etiquetas y atributos) y los shortcodes "
|
||||
f"entre [ ] y { '{' } { '}' }. No los traduzcas ni los reordenes.\n"
|
||||
f"2. NO traduzcas las referencias bíblicas ni sus abreviaturas (p.ej. 'Jn 3, 16', "
|
||||
f"'Isaías 5, 1-7', 'Mt 5'). Déjalas idénticas.\n"
|
||||
f"3. Conserva los nombres propios de persona y lugar (salvo exónimos establecidos).\n"
|
||||
f"4. Glosario fijo: {glos}.\n"
|
||||
f"5. Traducción FIEL: no resumas, no añadas, no comentes.\n"
|
||||
f"6. Devuelve SOLO la traducción entre las marcas <<<INI>>> y <<<FIN>>>, sin nada más."
|
||||
)
|
||||
|
||||
|
||||
def translate_text(text: str, lang: str, *, is_title: bool = False) -> str:
|
||||
text = text.strip()
|
||||
if not text:
|
||||
return ""
|
||||
if ENGINE == "haiku":
|
||||
out, _usage = translate_haiku.translate(text, lang, is_title=is_title)
|
||||
return out
|
||||
user = f"<<<INI>>>{text}<<<FIN>>>"
|
||||
if is_title:
|
||||
kind = "el TÍTULO"
|
||||
task = (
|
||||
f"Traduce {kind} que va entre las marcas.\n"
|
||||
f"Debe quedar en {LANG_NAMES[lang]} de forma natural. "
|
||||
f"No lo dejes en inglés salvo que el original ya sea un nombre propio o una marca.\n"
|
||||
f"Responde solo con el título traducido entre las marcas:\n{user}"
|
||||
)
|
||||
else:
|
||||
kind = "el texto"
|
||||
task = f"Traduce {kind} que va entre las marcas:\n{user}"
|
||||
messages = [
|
||||
{"role": "system", "content": _system_prompt(lang)},
|
||||
{"role": "user", "content": task},
|
||||
]
|
||||
max_tokens = max(800, int(len(text) * 1.6))
|
||||
raw = gemma(messages, max_tokens=max_tokens)
|
||||
return _extract(raw)
|
||||
|
||||
|
||||
def translate_html(html: str, lang: str) -> str:
|
||||
"""Trocea por párrafos si el contenido es largo, para no saturar el contexto de Gemma."""
|
||||
if ENGINE == "haiku":
|
||||
# Haiku tiene 200k de contexto: el artículo entero de una vez (mejor coherencia).
|
||||
return translate_text(html, lang)
|
||||
if len(html) <= CHUNK_LIMIT:
|
||||
return translate_text(html, lang)
|
||||
parts = re.split(r"(?<=</p>)", html)
|
||||
chunks, buf = [], ""
|
||||
for p in parts:
|
||||
if len(buf) + len(p) > CHUNK_LIMIT and buf:
|
||||
chunks.append(buf)
|
||||
buf = ""
|
||||
buf += p
|
||||
if buf:
|
||||
chunks.append(buf)
|
||||
log(f" contenido largo ({len(html)} car) → {len(chunks)} trozos")
|
||||
return "".join(translate_text(c, lang) for c in chunks)
|
||||
|
||||
|
||||
# ── Datos / estado ───────────────────────────────────────────────────────────
|
||||
def read_post(post_id: int) -> dict:
|
||||
return json.loads(php_helper("read", str(post_id)))
|
||||
|
||||
|
||||
def translation_exists(es_id: int, lang: str) -> int:
|
||||
return int(php_helper("exists", str(es_id), lang).strip() or "0")
|
||||
|
||||
|
||||
def create_translation(es_id: int, lang: str, title: str, content: str, status: str) -> int:
|
||||
payload = json.dumps({"title": title, "content": content, "model": MODEL})
|
||||
return int(php_helper("create", str(es_id), lang, status, stdin=payload).strip())
|
||||
|
||||
|
||||
def carta_article_ids(carta_id: int) -> list[int]:
|
||||
q = (f"SELECT post_id FROM wp_postmeta WHERE meta_key='_carta_id' "
|
||||
f"AND meta_value='{carta_id}' ORDER BY post_id;")
|
||||
out = sh(["docker", "exec", DB_CONTAINER, "mysql", f"-u{DB_USER}", f"-p{DB_PASS}",
|
||||
DB_NAME, "-N", "-e", q])
|
||||
return [int(x) for x in out.split() if x.strip().isdigit()]
|
||||
|
||||
|
||||
def load_state() -> dict:
|
||||
if STATE_FILE.exists():
|
||||
try:
|
||||
return json.loads(STATE_FILE.read_text())
|
||||
except json.JSONDecodeError:
|
||||
pass
|
||||
return {"done": {}}
|
||||
|
||||
|
||||
def save_state(state: dict) -> None:
|
||||
STATE_FILE.write_text(json.dumps(state, ensure_ascii=False, indent=2))
|
||||
|
||||
|
||||
# ── Orquestación ─────────────────────────────────────────────────────────────
|
||||
def process_post(post_id: int, langs: list[str], status: str, force: bool, state: dict) -> None:
|
||||
src = read_post(post_id)
|
||||
if src.get("lang") and src["lang"] != "es":
|
||||
log(f"#{post_id} no es ES (lang={src['lang']}) — saltado")
|
||||
return
|
||||
log(f"#{post_id} «{src['title'][:60]}»")
|
||||
|
||||
for lang in langs:
|
||||
key = f"{post_id}:{lang}"
|
||||
existing = translation_exists(post_id, lang)
|
||||
if existing and not force:
|
||||
log(f" {lang}: ya existe (#{existing}) — saltado")
|
||||
state["done"][key] = existing
|
||||
continue
|
||||
if existing and force:
|
||||
php_helper("unlink", str(post_id), lang)
|
||||
log(f" {lang}: --force, eliminada traducción previa #{existing}")
|
||||
try:
|
||||
t0 = time.time()
|
||||
title = translate_text(src["title"], lang, is_title=True)
|
||||
content = translate_html(src["content"], lang)
|
||||
new_id = create_translation(post_id, lang, title, content, status)
|
||||
dt = time.time() - t0
|
||||
log(f" {lang}: creado #{new_id} ({dt:.0f}s) → «{title[:50]}»")
|
||||
state["done"][key] = new_id
|
||||
save_state(state)
|
||||
except Exception as exc: # noqa: BLE001
|
||||
log(f" {lang}: ERROR {exc}")
|
||||
state.setdefault("errors", {})[key] = str(exc)
|
||||
save_state(state)
|
||||
|
||||
|
||||
def main() -> int:
|
||||
ap = argparse.ArgumentParser(description="Traduce posts de feadulta con Gemma local + Polylang.")
|
||||
g = ap.add_mutually_exclusive_group(required=True)
|
||||
g.add_argument("--post-id", type=int, help="ID de un post ES a traducir.")
|
||||
g.add_argument("--carta", type=int, help="ID de carta: traduce la carta y todos sus artículos (_carta_id).")
|
||||
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("--force", action="store_true", help="Regenera aunque ya exista la traducción.")
|
||||
args = ap.parse_args()
|
||||
|
||||
langs = [l.strip() for l in args.langs.split(",") if l.strip() in LANG_NAMES]
|
||||
if not langs:
|
||||
log("Sin idiomas válidos."); return 1
|
||||
|
||||
if args.post_id:
|
||||
ids = [args.post_id]
|
||||
else:
|
||||
ids = [args.carta] + carta_article_ids(args.carta)
|
||||
log(f"Carta {args.carta}: {len(ids)} posts (carta + {len(ids)-1} artículos)")
|
||||
|
||||
state = load_state()
|
||||
for pid in ids:
|
||||
process_post(pid, langs, args.status, args.force, state)
|
||||
save_state(state)
|
||||
log(f"FIN. {len(state['done'])} traducciones registradas, "
|
||||
f"{len(state.get('errors', {}))} errores. Estado: {STATE_FILE}")
|
||||
return 0
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
raise SystemExit(main())
|
||||
Reference in New Issue
Block a user