Sincronizar mu-plugins/ y scripts/ con el estado real del sitio (2026-07-16)

Este repo llevaba desde el 28-jun sin actualizarse (salvo sync_carta_from_prod.py).
Se pone al dia con 3 semanas de trabajo que solo vivian en el checkout local
completo del proyecto:

- Fix del buscador (issue #178): AND obligatorio en FULLTEXT (antes devolvia
  practicamente todo el sitio con cualquier busqueda), exclusion de paginas
  indice residuales de la migracion K2, soporte de frase exacta entre comillas.
- Nuevos mu-plugins ya desplegados a prod: fea-carta-id-api, fea-cloudflare-realip,
  fea-crear-autor-api, fea-gsc-verification, fea-legacy-redirect (cutover
  Joomla->WP, ver issue #162).
- TTS multi-voz por autor, scripts de traduccion, sync de audio a prod,
  homenajes Mardones/Galarreta.

Se mantiene la estructura plana del repo (mu-plugins/ + scripts/, sin el
wordpress/wp-content/ del checkout completo) tal y como documenta el README:
es la convencion establecida para este repo, mas limpia para quien solo
necesita leer el codigo. Anadido .gitignore (cache de Python que no debia
commitearse).

Fuente: checkout local completo del proyecto, rama main (commit 69e849d).
This commit is contained in:
2026-07-15 20:26:43 -04:00
parent d044e229dc
commit 6b61250b0b
35 changed files with 1482 additions and 45 deletions
+15 -3
View File
@@ -19,6 +19,7 @@ if ($action === 'get') {
'title' => $p->post_title,
'content' => $p->post_content,
'status' => $p->post_status,
'author' => (int)$p->post_author,
], JSON_UNESCAPED_UNICODE));
exit(0);
}
@@ -43,10 +44,11 @@ if ($action === 'getmeta') {
exit(0);
}
if ($action === 'setaudio') { // setaudio <id> <relpath>
if ($action === 'setaudio') { // setaudio <id> <relpath> [voice_id]
$id = (int)$argv[2];
$voice = $argv[4] ?? 'NicoFeadulta2026';
update_post_meta($id, 'fea_audio_url', home_url($argv[3]));
update_post_meta($id, 'fea_audio_voice', 'NicoFeadulta2026');
update_post_meta($id, 'fea_audio_voice', $voice);
update_post_meta($id, 'fea_audio_done', '1');
delete_post_meta($id, 'fea_audio_error');
fwrite(STDOUT, "ok " . home_url($argv[3]) . "\n");
@@ -58,5 +60,15 @@ if ($action === 'setflag') { // setflag <id> <key> <value>
exit(0);
}
fwrite(STDERR, "uso: get|update|getmeta|setaudio|setflag\n");
if ($action === 'unsetaudio') { // unsetaudio <id> (rollback: despublica el audio)
$id = (int)$argv[2];
delete_post_meta($id, 'fea_audio_url');
delete_post_meta($id, 'fea_audio_voice');
delete_post_meta($id, 'fea_audio_done');
delete_post_meta($id, 'fea_audio_error');
fwrite(STDOUT, "ok despublicado $id\n");
exit(0);
}
fwrite(STDERR, "uso: get|update|getmeta|setaudio|setflag|unsetaudio\n");
exit(2);
+1 -1
View File
@@ -17,7 +17,7 @@
* FROM ew4r_k2_items i LEFT JOIN ew4r_users u ON u.id=i.created_by \
* WHERE i.id IN ($IDS);" > /tmp/autores143.tsv
*
* Uso (en el servidor, dentro de /web/wp-nuevo):
* Uso (en el servidor, dentro de /web):
* FEA_TSV=/tmp/autores143.tsv wp eval-file scripts/fix_k2_authors.php # dry-run
* APPLY=1 FEA_TSV=/tmp/autores143.tsv wp eval-file scripts/fix_k2_authors.php # aplica
*
+59
View File
@@ -0,0 +1,59 @@
# Homenajes Mardones / Galarreta + fixes varios (sesión 2026-06-23)
Scripts de la sesión 2026-06-23 (issues Gitea **#130, #131, #133, #137, #138**, todas cerradas).
Todo aplicado en **local** (Docker `wordpress-web`) y **prod** (wp-nuevo, vía `ssh feadulta@… && wp eval-file`).
## Convenciones del despliegue a prod (wp-nuevo)
- Prod corre en la **raíz** del dominio (`https://wp-nuevo.feadulta.com`), local bajo `/fea`.
→ al llevar contenido con rutas, transformar `/fea/wp-content``/wp-content`.
- `proc_open` deshabilitado en prod → usar `wp eval-file` (PHP puro). `scp` no va → subir con
`ssh '… cat > /entrada/FICHERO'`; imágenes en bloque con `tar cf -``ssh 'tar x'`.
- **IDs de contenido migrado coinciden local↔prod** (posts, template parts, users, cats).
- **Lección clave:** para arreglar posts ya migrados con texto malo (idioma/contenido),
**sobrescribir EN SITIO en prod** (mismos IDs) en vez de borrar+recrear → preserva los grupos
Polylang. Para contenido NUEVO (noticia, 23 posts Mardones) crear con **meta idempotente**.
## #138 — Mardones: 23 páginas `anterior/*.htm` → 23 posts individuales
Origen: el homenaje WP **17990** (`mardones-2`, com_content Joomla 501) enlazaba 23 páginas
estáticas de la web V1 FrontPage (`/web/anterior/*.htm`, windows-1252). 8 «Aprendiendo a orar»
(serie de Mardones) + 15 homenaje (obituarios, álbum de fotos, audio→conferencia, currículo…).
Pipeline:
1. `mardones_build_manifest.py` — descarga local de los 23 `.htm`, decodifica **win-1252→UTF-8**,
limpia basura FrontPage, extrae párrafos `<p>` (descarta cabecera «cristianos siglo veintiuno»),
preserva enlaces externos, inserta imágenes. Genera `mardones_manifest.json`.
Títulos = texto del enlace en 17990; autor: serie orar = **710 (Mardones)**, homenajes = **1**.
2. `mardones_create_posts.php` (local) / `mardones_prod_create_and_repoint.php` (prod) — crea los 23
posts (lang es, cat 38, publish), idempotente por meta **`_mardones_src`**=nombre de fichero.
3. `mardones_repoint_homenaje.php` — repunta los 23 `href="anterior/X.htm"` del 17990 a los nuevos
permalinks (prod lo hace el script combinado).
4. Imágenes (8: `homenaje-album-1..7.jpg`, `homenaje-charla-mejico.jpg`) → `uploads/anterior-mardones/`.
IDs creados en local: **5357753599**. En prod: IDs propios (se resuelven por meta).
### Retoques posteriores (`mardones_fix_fecha_y_target.php`, local+prod)
- **Fecha:** los posts se crearon con fecha de hoy → reajustados a **2012-10-10 19:26:00**
(la del propio homenaje 17990) para que no aparezcan como entradas nuevas en feeds/archivos.
- **Ventana nueva:** los enlaces del homenaje 17990 heredaban `target="_blank"` del original →
eliminado (`target="_blank"`/`rel`) para que abran en la **misma ventana** (navegación interna).
*Nota:* los enlaces externos DENTRO de cada post (p.ej. la conferencia iteso.mx en homenaje-3)
sí conservan `target="_blank"`.
## #138 — Galarreta
`galarreta_repoint_presentaciones.php` (local) / `galarreta_prod_fixes.php` (prod):
- 10 presentaciones de `presentaciones-gala` (19107) estaban mal mapeadas (apuntaban a artículos
random) → repuntadas a sus destinos correctos (19105/19104/19102/19106/19101/19119/19123/19132/19131/19143).
- «Libros y e-books» (en la página 17918) → tienda externa
`https://edicionesfeadulta.com/?filters=autor[galarreta]` (13 libros), `target="_blank"`.
- iframe de YouTube forzado a `https`.
- Auditados `articulos-galarreta` (19086) y `comentarios-galarreta` (19085): sin enlaces rotos reales.
## #130 / #131 / #133 / #137 (sync a prod)
- `issue130_prod_noticia_papa.php` — crea la noticia «León XIV en España…» (Joomla 9082, no migrada)
en cat 41 Noticias de alcance, idempotente por `_fgj2wp_old_id=9082`.
- `issue131_prod_footer_suma.php` — enlaza la imagen `este_portal.gif` del footer (template 42370)
a `/numeros/` (post 18036, «La suma de todos»).
- `issue133_prod_idiomas.php` — 5 reasignaciones de idioma + sobrescritura en sitio de los 3 `fr`
que contenían inglés (43755/47152/47136) + catalán 13339 → francés, enlazado al ES 13338.
- `issue137_prod_inmemoriam.php` — repunta In memoriam (19112): Galarreta→17918, Mardones→17990,
Mari Patxi Ayerra→23519 (artículo de fallecimiento).
@@ -0,0 +1,25 @@
<?php
// 1) presentaciones-gala 19107: repuntar 10
$map=[
"sugerencias-para-las-bodas"=>19105,"el-eterno-problema-del-mundo-y-los-hijos"=>19104,
"necesitamos-un-pepe"=>19102,"lucas-18-1-8"=>19106,"el-perdon-primero"=>19101,
"alabar-a-dios-y-darle-gracias"=>19119,"2-reyes-5-14-17-2-timoteo-2-8-13"=>19123,
"creer-en-dios-es-apostar-por-la-vida"=>19132,"lucas-17-5-10"=>19131,"la-gran-evasion"=>19143,
];
$c=get_post(19107)->post_content; $n=0;
foreach($map as $bad=>$dst){
if(!get_post($dst)){ echo " dst $dst NO EXISTE\n"; continue; }
$url=get_permalink($dst);
$c2=preg_replace('#https?://[^"]*?/'.preg_quote($bad,'#').'/#',$url,$c,1,$cnt);
if($cnt>0){ $c=$c2; $n++; }
}
wp_update_post(["ID"=>19107,"post_content"=>$c]);
echo "presentaciones repuntadas: $n/10\n";
// 2) libros + youtube en 17918
$c=get_post(17918)->post_content;
$ext="https://edicionesfeadulta.com/?filters=autor[galarreta]";
$c=preg_replace('#https?://[^"]*?/libros-y-e-books/#',$ext,$c,-1,$cl);
$c=preg_replace('#(<a [^>]*href=")'.preg_quote($ext,'#').'("(?![^>]*target))#','$1'.$ext.'$2 target="_blank" rel="noopener"',$c);
$c=str_replace('src="//www.youtube.com/embed/','src="https://www.youtube.com/embed/',$c);
wp_update_post(["ID"=>17918,"post_content"=>$c]);
echo "libros->externo: $cl | youtube https aplicado | foto JE: ".(strpos($c,'edicionesfeadulta.com')!==false?'ok':'?')."\n";
@@ -0,0 +1,25 @@
<?php
// Repuntar las 10 presentaciones de presentaciones-gala (19107) a sus destinos correctos
$map=[
"sugerencias-para-las-bodas"=>19105,
"el-eterno-problema-del-mundo-y-los-hijos"=>19104,
"necesitamos-un-pepe"=>19102,
"lucas-18-1-8"=>19106,
"el-perdon-primero"=>19101,
"alabar-a-dios-y-darle-gracias"=>19119,
"2-reyes-5-14-17-2-timoteo-2-8-13"=>19123,
"creer-en-dios-es-apostar-por-la-vida"=>19132,
"lucas-17-5-10"=>19131,
"la-gran-evasion"=>19143,
];
$c=get_post(19107)->post_content; $n=0; $report=[];
foreach($map as $badslug=>$dest){
$destUrl=get_permalink($dest);
// sustituir el href que contiene /fea/<badslug>/
$pat='#https?://[^"]*?/fea/'.preg_quote($badslug,'#').'/#';
$c2=preg_replace($pat,$destUrl,$c,1,$cnt);
if($cnt>0){ $c=$c2; $n++; $report[]="OK $badslug -> $destUrl"; }
else $report[]="NO ENCONTRADO $badslug";
}
wp_update_post(["ID"=>19107,"post_content"=>$c]);
echo implode("\n",$report)."\nTotal repuntados: $n/10\n";
@@ -0,0 +1,13 @@
<?php
global $wpdb;
$ex=$wpdb->get_var($wpdb->prepare("SELECT post_id FROM {$wpdb->postmeta} WHERE meta_key='_fgj2wp_old_id' AND meta_value=%s","9082"));
if($ex){ echo "ya existe en prod: $ex\n"; return; }
$content=file_get_contents('/entrada/9082_content.html');
$id=wp_insert_post(["post_type"=>"post","post_status"=>"publish","post_author"=>890,
"post_title"=>"El plan (casi definitivo) de León XIV en España, al descubierto: de Carabanchel a Arguineguín, pasando por Montserrat",
"post_content"=>$content,"post_date"=>"2026-04-09 12:07:26","to_ping"=>"","pinged"=>"","post_content_filtered"=>""],true);
if(is_wp_error($id)){ echo "ERROR: ".$id->get_error_message()."\n"; return; }
update_post_meta($id,"_fgj2wp_old_id","9082");
wp_set_post_categories($id,[41]);
if(function_exists('pll_set_post_language')) pll_set_post_language($id,'es');
echo "creado prod $id cat=".implode(",",wp_get_post_categories($id))." status=".get_post_status($id)."\n";
@@ -0,0 +1,14 @@
<?php
$id=42370; $c=get_post($id)->post_content; $url=get_permalink(18036);
if(strpos($c,'<a href="'.$url.'"><img')!==false || preg_match('#<a [^>]*href="[^"]*numeros[^"]*"><img[^>]*este_portal#',$c)){ echo "ya enlazado\n"; return; }
// localizar el <img ... este_portal.gif ...> y envolverlo
if(preg_match('#<img[^>]*este_portal\.gif[^>]*/?>#i',$c,$m)){
$img=$m[0];
$c=str_replace($img,'<a href="'.esc_url($url).'">'.$img.'</a>',$c);
// linkDestination none->custom para el bloque (id 26996)
$c=preg_replace('#("id":26996,[^}]*?"linkDestination":")none(")#','${1}custom${2}',$c);
wp_update_post(["ID"=>$id,"post_content"=>$c]);
echo "OK enlazado este_portal -> $url\n";
} else echo "NO se encontró img este_portal.gif en 42370 (prod)\n";
// verificar
if(preg_match('#<a href="([^"]+)"><img[^>]*este_portal#',get_post($id)->post_content,$v)) echo "verif: -> ".$v[1]."\n";
@@ -0,0 +1,23 @@
<?php
// 1) reasignaciones de idioma (mismos IDs en prod)
$fix=[4424=>"fr",11129=>"it",12934=>"en",5891=>"en",3141=>"it"];
foreach($fix as $id=>$lang){
if(!get_post($id)){ echo " $id NO EXISTE\n"; continue; }
$old=pll_get_post_language($id); pll_set_post_language($id,$lang);
echo " $id: $old -> ".pll_get_post_language($id)."\n";
}
// 2) sobrescribir fr malos + catalan con francés correcto (overwrite en sitio)
$payload=json_decode(file_get_contents('/entrada/fr_payload.json'),true);
foreach($payload as $it){
$dst=$it['dst'];
if(!get_post($dst)){ echo " dst $dst NO EXISTE\n"; continue; }
wp_update_post(["ID"=>$dst,"post_title"=>$it['title'],"post_content"=>$it['content']]);
pll_set_post_language($dst,'fr');
echo " overwrite $dst -> fr (".mb_substr($it['title'],0,30).")\n";
}
// 3) catalan 13339: enlazar grupo con ES 13338
if(get_post(13338) && get_post(13339)){
pll_set_post_language(13338,'es');
pll_save_post_translations(["es"=>13338,"fr"=>13339]);
echo " grupo 13339: ".json_encode(pll_get_post_translations(13339))."\n";
}
@@ -0,0 +1,20 @@
<?php
$c=get_post(19112)->post_content;
$map=[
"el-bautismo-de-jesus-jesus-hijo-del-padre"=>17918, // Galarreta homenaje
"jean-baptiste-et-son-mouton"=>17990, // Mardones homenaje
"nuestra-vida-sea-servicio"=>23519, // Mari Patxi fallecimiento
];
$n=0;
foreach($map as $slug=>$id){
$url=get_permalink($id);
$pat='#https?://[^"]*?/'.preg_quote($slug,'#').'/#';
$c2=preg_replace($pat,$url,$c,1,$cnt);
if($cnt>0){ $c=$c2; $n++; echo " OK $slug -> $url\n"; } else echo " NO ENCONTRADO $slug\n";
}
if($n) wp_update_post(["ID"=>19112,"post_content"=>$c]);
// verificar 8 enlaces
preg_match_all('#href="([^"]+)"#',get_post(19112)->post_content,$m);
$ok=0;$bad=0;
foreach($m[1] as $h){ if(preg_match('#/([^/"]+)/?$#',$h,$sm)){ $p=get_posts(["name"=>$sm[1],"post_status"=>"publish","posts_per_page"=>1,"suppress_filters"=>true]); if($p)$ok++; else {$bad++; echo " ROTO $h\n";} } }
echo "repuntados=$n | enlaces in-memoriam OK=$ok bad=$bad\n";
@@ -0,0 +1,93 @@
import re, json, html, glob
IMG_BASE = "/fea/wp-content/uploads/anterior-mardones/"
# filename -> (titulo, autor_wp, [imagenes])
ORAR_AUTHOR = 710 # Mardones
HOM_AUTHOR = 1 # genérico (como página 17990)
META = {
"orar-1-dejarmequerer":("Dejarme querer",ORAR_AUTHOR,[]),
"orar-2-escuchar":("Escuchar",ORAR_AUTHOR,[]),
"orar-3-estar":("Estar",ORAR_AUTHOR,[]),
"orar-4-hacersitioadios":("Hacer sitio a Dios",ORAR_AUTHOR,[]),
"orar-5-laoracionadulta":("La oración adulta",ORAR_AUTHOR,[]),
"orar-6-orarconevangelio":("Orar con el evangelio",ORAR_AUTHOR,[]),
"orar-7-orarrepitiendo":("Orar repitiendo una palabra o frase breve",ORAR_AUTHOR,[]),
"orar-8-tuestasdentro":("Tú estás dentro",ORAR_AUTHOR,[]),
"homenaje-segundo-aniversario":("En el segundo aniversario",HOM_AUTHOR,[]),
"homenaje-14noviembre":("14 de noviembre de 2007",HOM_AUTHOR,[]),
"homenaje-PRIMERaniversario":("Primer aniversario",HOM_AUTHOR,[]),
"homenaje-aniversario":("Programa en el aniversario",HOM_AUTHOR,["homenaje-charla-mejico.jpg"]),
"homenaje-rostrointerior":("Rostro interior de José María Mardones",HOM_AUTHOR,[]),
"homenaje-cartaReyes":("Carta a un Maestro",HOM_AUTHOR,[]),
"homenaje-9":("Semblanza",HOM_AUTHOR,[]),
"homenaje-8":("Álbum de fotos de José María Mardones",HOM_AUTHOR,
["homenaje-album-%d.jpg"%i for i in range(1,8)]),
"homenaje-1":("Funeral del sábado 24 de junio",HOM_AUTHOR,[]),
"homenaje-2":("Funeral del jueves 29 de junio",HOM_AUTHOR,[]),
"homenaje-5":("Obituario en El Mundo",HOM_AUTHOR,[]),
"homenaje-6":("Necrológicas en El País",HOM_AUTHOR,[]),
"homenaje-4":("Salmo a la Encarnación de Dios",HOM_AUTHOR,[]),
"homenaje-3":("Conferencia: ¿Por qué preocuparse por los demás? Ética y convivencia",HOM_AUTHOR,[]),
"homenaje-7":("Currículo de José María Mardones",HOM_AUTHOR,[]),
}
BOILER = re.compile(r'cristianos siglo veintiuno|^I N M E M O R I A M$|^HOMENAJE$|^homenaje a JOSE|APRENDIENDO A ORAR cristianos', re.I)
def extract(fn):
raw=open(fn,'rb').read().decode('windows-1252',errors='replace')
raw=re.sub(r'(?is)<(script|style).*?</\1>','',raw)
raw=re.sub(r'(?is)<!--.*?-->','',raw)
blocks=[]
for p in re.findall(r'(?is)<p\b[^>]*>(.*?)</p>',raw):
# preservar enlaces externos
links=re.findall(r'(?is)<a\s+[^>]*href="(https?://[^"]+)"[^>]*>(.*?)</a>',p)
t=re.sub(r'(?is)<[^>]+>','',p)
t=html.unescape(t).replace('\xa0',' ')
t=re.sub(r'\s+',' ',t).strip()
if len(t)<3:
# párrafo solo-enlace
if links:
for href,txt in links:
txt=re.sub(r'<[^>]+>','',txt).strip() or href
blocks.append(('link',href,txt))
continue
if BOILER.search(t):
continue
# ¿el párrafo contiene un enlace externo embebido? añadir como link extra
blocks.append(('p',t,links))
return blocks
def build_html(blocks, imgs):
out=[]
for b in blocks:
if b[0]=='p':
if re.match(r'^https?://\S+$', b[1]):
out.append('<p><a href="%s" target="_blank" rel="noopener">%s</a></p>'%(html.escape(b[1]),html.escape(b[1])))
continue
txt=html.escape(b[1])
# re-incrustar enlaces externos que estaban en el párrafo
for href,atxt in b[2]:
atxt_clean=html.escape(re.sub(r'<[^>]+>','',atxt).strip() or href)
# no siempre está el texto en txt; añadimos al final si no
out.append("<p>%s</p>"%txt)
elif b[0]=='link':
out.append('<p><a href="%s" target="_blank" rel="noopener">%s</a></p>'%(html.escape(b[1]),html.escape(b[2])))
# imágenes al final
for im in imgs:
out.append('<p><img src="%s%s" alt="José María Mardones" style="max-width:100%%;height:auto"/></p>'%(IMG_BASE,im))
return "\n".join(out)
manifest=[]
for fn in sorted(glob.glob("*.htm")):
key=fn[:-4]
if key not in META:
print("SIN META:",key); continue
title,author,imgs=META[key]
blocks=extract(fn)
content=build_html(blocks,imgs)
manifest.append({"file":key,"title":title,"author":author,"content":content,"nparas":len([b for b in blocks if b[0]=='p']),"nimgs":len(imgs)})
json.dump(manifest,open("manifest.json","w"),ensure_ascii=False,indent=1)
print("manifest:",len(manifest),"posts")
for m in manifest: print(f" {m['file']:34} '{m['title'][:35]}' autor={m['author']} paras={m['nparas']} imgs={m['nimgs']} len={len(m['content'])}")
@@ -0,0 +1,21 @@
<?php
$man=json_decode(file_get_contents('/tmp/manifest.json'),true);
$map=[]; $created=0; $skipped=0;
foreach($man as $m){
// idempotencia
$ex=get_posts(["meta_key"=>"_mardones_src","meta_value"=>$m['file'],"post_type"=>"post","post_status"=>"any","posts_per_page"=>1,"suppress_filters"=>true]);
if($ex){ $map[$m['file']]=$ex[0]->ID; $skipped++; continue; }
$id=wp_insert_post([
"post_type"=>"post","post_status"=>"publish","post_author"=>(int)$m['author'],
"post_title"=>$m['title'],"post_content"=>$m['content'],
"to_ping"=>"","pinged"=>"","post_content_filtered"=>"",
],true);
if(is_wp_error($id)){ echo "ERROR ".$m['file'].": ".$id->get_error_message()."\n"; continue; }
update_post_meta($id,"_mardones_src",$m['file']);
wp_set_post_categories($id,[38]);
if(function_exists('pll_set_post_language')) pll_set_post_language($id,'es');
$map[$m['file']]=$id; $created++;
}
file_put_contents('/tmp/mardones_map.json',json_encode($map));
echo "creados=$created skipped=$skipped total=".count($map)."\n";
foreach($map as $f=>$id) echo " $f -> $id\n";
@@ -0,0 +1,14 @@
<?php
// 1) fecha: alinear con el homenaje 17990 (2012-10-10)
$date="2012-10-10 19:26:00"; $gmt=get_gmt_from_date($date);
$ids=get_posts(["meta_key"=>"_mardones_src","post_type"=>"post","posts_per_page"=>-1,"fields"=>"ids","post_status"=>"any","suppress_filters"=>true]);
$nd=0;
foreach($ids as $id){ wp_update_post(["ID"=>$id,"post_date"=>$date,"post_date_gmt"=>$gmt]); $nd++; }
echo "fechas actualizadas: $nd posts -> $date\n";
// 2) quitar target=_blank (y rel) de los enlaces internos del homenaje 17990
$c=get_post(17990)->post_content;
$before=substr_count(strtolower($c),'target="_blank"');
$c=str_replace([' target="_blank"',' rel="noopener"',' rel="noreferrer"'],'',$c);
wp_update_post(["ID"=>17990,"post_content"=>$c]);
$after=substr_count(strtolower(get_post(17990)->post_content),'target="_blank"');
echo "target=_blank en 17990: antes=$before despues=$after\n";
File diff suppressed because one or more lines are too long
@@ -0,0 +1,26 @@
<?php
$man=json_decode(file_get_contents('/entrada/manifest_prod.json'),true);
$map=[]; $created=0;$skip=0;
foreach($man as $m){
$ex=get_posts(["meta_key"=>"_mardones_src","meta_value"=>$m['file'],"post_type"=>"post","post_status"=>"any","posts_per_page"=>1,"suppress_filters"=>true]);
if($ex){ $map[$m['file']]=$ex[0]->ID; $skip++; continue; }
$author=(int)$m['author']; if($author && !get_userdata($author)) $author=1;
$id=wp_insert_post(["post_type"=>"post","post_status"=>"publish","post_author"=>$author,
"post_title"=>$m['title'],"post_content"=>$m['content'],"to_ping"=>"","pinged"=>"","post_content_filtered"=>""],true);
if(is_wp_error($id)){ echo "ERR ".$m['file'].": ".$id->get_error_message()."\n"; continue; }
update_post_meta($id,"_mardones_src",$m['file']);
wp_set_post_categories($id,[38]);
if(function_exists('pll_set_post_language')) pll_set_post_language($id,'es');
$map[$m['file']]=$id; $created++;
}
echo "creados=$created skip=$skip total=".count($map)."\n";
// repuntar 17990
$c=get_post(17990)->post_content; $n=0;
foreach($map as $file=>$id){
$url=get_permalink($id);
$c2=preg_replace('#href="anterior/'.preg_quote($file,'#').'\.htm"#i','href="'.$url.'"',$c,1,$cnt);
if($cnt>0){ $c=$c2; $n++; }
}
wp_update_post(["ID"=>17990,"post_content"=>$c]);
$rest=preg_match_all('#anterior/[\w-]+\.htm#i',get_post(17990)->post_content,$mm);
echo "17990 repuntados=$n/23 | anterior/*.htm restantes=$rest\n";
@@ -0,0 +1,17 @@
<?php
$map=json_decode(file_get_contents('/tmp/mardones_map.json'),true);
$c=get_post(17990)->post_content; $n=0; $miss=[];
foreach($map as $file=>$id){
$url=get_permalink($id);
// href="anterior/FILE.htm" (puede tener mayúsculas y espacios delante)
$pat='#href="anterior/'.preg_quote($file,'#').'\.htm"#i';
$c2=preg_replace($pat,'href="'.$url.'"',$c,1,$cnt);
if($cnt>0){ $c=$c2; $n++; } else $miss[]=$file;
}
wp_update_post(["ID"=>17990,"post_content"=>$c]);
echo "repuntados=$n/23\n";
if($miss) echo "NO ENCONTRADOS: ".implode(", ",$miss)."\n";
// verificar que no quedan anterior/*.htm
$rest=preg_match_all('#anterior/[\w-]+\.htm#i',get_post(17990)->post_content,$mm);
echo "enlaces anterior/*.htm restantes: $rest\n";
if($rest) foreach($mm[0] as $x) echo " $x\n";
+1 -1
View File
@@ -8,7 +8,7 @@
* user_meta _foto_perfil_pre143. Idempotente (si ya apunta, solo regenera metadata).
*
* Entrada: TSV «uid<TAB>display_name» (env FEA_TSV, por defecto /tmp/users29.tsv).
* Uso (en el servidor, dentro de /web/wp-nuevo):
* Uso (en el servidor, dentro de /web):
* FEA_TSV=/tmp/users29.tsv wp eval-file scripts/import_avatars_143.php # dry-run
* APPLY=1 FEA_TSV=/tmp/users29.tsv wp eval-file scripts/import_avatars_143.php # aplica
*/
+31 -7
View File
@@ -45,6 +45,22 @@ def creds():
KEY, GID = creds()
H_JSON = {"Authorization": f"Bearer {KEY}", "Content-Type": "application/json"}
# Voz clonada por autor (WP user_id -> voice_id). Issue #152: solo se aplica a
# los artículos de ese autor; el resto sigue con la voz por defecto (Nico).
AUTHOR_VOICES = {
382: "FrayMarcosFeadulta2026", # Fray Marcos
383: "PagolaFeadulta2026", # José Antonio Pagola
774: "SicreFeadulta2026", # José Luis Sicre
386: "ArregiFeadulta2026", # José Arregi
}
def voice_for_author(author_id, default_voice):
try:
return AUTHOR_VOICES.get(int(author_id), default_voice)
except (TypeError, ValueError):
return default_voice
def _q(url):
return f"{url}?GroupId={GID}" if GID else url
@@ -83,7 +99,7 @@ def get_post_text(pid):
raw = html.unescape(raw)
paras = [re.sub(r"\s+", " ", p).strip() for p in raw.split("\n") if len(p.strip()) > 1]
paras = trim_after_author_signature(paras)
return d["title"], "\n\n".join(paras)
return d["title"], "\n\n".join(paras), d.get("author")
def is_author_signature(text):
@@ -114,12 +130,20 @@ def is_author_signature(text):
def trim_after_author_signature(paras):
out = []
"""Corta tras la firma final del autor, buscando desde el final hacia
atrás para no confundirla con encabezados iniciales (p.ej. "CORPUS (A)",
"DOMINGO XI (A)") que también matchean la heurística de is_author_signature
pero aparecen como primer párrafo, no como firma. Solo cuenta como firma
si hay al menos 200 caracteres de contenido real antes de ella."""
prefix_len = 0
prefix_lens = []
for p in paras:
out.append(p)
if is_author_signature(p):
break
return out
prefix_lens.append(prefix_len)
prefix_len += len(p)
for i in range(len(paras) - 1, -1, -1):
if prefix_lens[i] > 200 and is_author_signature(paras[i]):
return paras[: i + 1]
return paras
def _sent_pause(n_words, short, long_):
@@ -413,7 +437,7 @@ def main():
elif cmd == "carta":
pid, voice_id = sys.argv[2], sys.argv[3]
model = sys.argv[4] if len(sys.argv) > 4 else "speech-2.8-turbo"
title, text = get_post_text(int(pid))
title, text, _author = get_post_text(int(pid))
name = sys.argv[5] if len(sys.argv) > 5 else f"carta-minimax-{pid}-{model.split('-')[-1]}"
text = add_pauses(text)
print(f"Post #{pid}: «{title}» ({len(text)} car con pausas)")
+219
View File
@@ -0,0 +1,219 @@
#!/usr/bin/env python3
"""
sync_audio_to_prod.py — Sube a PROD los mp3 de TTS ya generados/enlazados en
local (fea_audio_done=1) y fija el meta fea_audio_url en prod.
Prod (134.0.10.170) tiene glibc rota: scp/sftp NO funcionan (connection closed).
Workaround: subir el binario por stdin de ssh ("cat > ruta"), igual que el resto
de scripts que tocan ese servidor (ver feadulta-server-glibc-rota.md).
Uso:
python3 sync_audio_to_prod.py --carta 54254 # sincroniza toda la cola de la carta
python3 sync_audio_to_prod.py --ids 912,919,3001 # ids concretos
python3 sync_audio_to_prod.py --carta 54254 --dry-run # solo plan, no toca prod
Rollback (despublica en prod lo que este script publicó):
python3 sync_audio_to_prod.py --rollback --carta 54254
python3 sync_audio_to_prod.py --rollback --ids 912,919
"""
from __future__ import annotations
import argparse
import json
import os
import subprocess
import time
from pathlib import Path
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")
PROD_HOST = os.environ.get("FEA_PROD_HOST", "feadulta@134.0.10.170")
PROD_PASS = os.environ.get("FEA_PROD_PASS", "C6c2A!mAl3Wj.BQF")
PROD_WPLOAD = os.environ.get("FEA_PROD_WPLOAD", "/web/wp-load.php")
PROD_HELPER = "/tmp/fea_post_io.php"
PROD_UPLOADS_TTS = "/web/wp-content/uploads/tts"
HELPER_SRC = Path(__file__).resolve().parent / "fea_post_io.php"
LOCAL_TTS_DIR = Path(__file__).resolve().parent.parent / "wordpress/wp-content/uploads/tts"
LOG_FILE = Path(os.environ.get(
"FEA_AUDIO_SYNC_LOG",
str(Path(__file__).resolve().parent.parent / "logs/feadulta-audio-sync.log"),
))
STATE_FILE = Path(os.environ.get(
"FEA_AUDIO_SYNC_STATE",
str(Path(__file__).resolve().parent.parent / "logs/feadulta-audio-sync-state.json"),
))
def log(msg: str) -> None:
line = f"[{time.strftime('%Y-%m-%d %H:%M:%S')}] {msg}"
print(line, flush=True)
LOG_FILE.parent.mkdir(parents=True, exist_ok=True)
with LOG_FILE.open("a", encoding="utf-8") as f:
f.write(line + "\n")
def sh(cmd: list[str], *, input_bytes: bytes | None = None, timeout: int = 120) -> str:
r = subprocess.run(cmd, input=input_bytes, capture_output=True, timeout=timeout)
if r.returncode != 0:
raise RuntimeError(f"cmd falló ({r.returncode}): {' '.join(cmd[:4])}\n{r.stderr.decode(errors='replace')[:400]}")
return r.stdout.decode(errors="replace")
# ── Local ────────────────────────────────────────────────────────────────────
def local_meta(post_id: int, key: str) -> str:
r = subprocess.run(
["docker", "exec", WP_CONTAINER, "php", "/tmp/fea_post_io.php", "getmeta", str(post_id), key],
capture_output=True, text=True, timeout=60,
)
return r.stdout.strip()
def carta_article_ids(carta_id: int) -> list[int]:
q = ("SELECT post_id FROM wp_postmeta "
f"WHERE meta_key='_carta_id' AND meta_value='{carta_id}' ORDER BY post_id;")
r = subprocess.run(
["docker", "exec", DB_CONTAINER, "mysql", f"-u{DB_USER}", f"-p{DB_PASS}",
DB_NAME, "-N", "-e", q],
capture_output=True, text=True, timeout=60,
)
return [int(x) for x in r.stdout.split() if x.isdigit()]
# ── Prod (glibc rota: nada de scp/sftp, todo por ssh + cat) ────────────────────
def _ssh_text(remote_cmd: str, *, stdin: str | None = None, timeout: int = 120) -> str:
cmd = ["sshpass", "-p", PROD_PASS, "ssh", "-o", "StrictHostKeyChecking=accept-new",
"-o", "ConnectTimeout=20", PROD_HOST, remote_cmd]
r = subprocess.run(cmd, input=stdin, capture_output=True, text=True, timeout=timeout)
if r.returncode != 0:
raise RuntimeError(f"ssh falló ({r.returncode}): {remote_cmd[:80]}\n{r.stderr.strip()[:400]}")
return r.stdout
def _ssh_upload_bytes(data: bytes, remote_path: str, *, timeout: int = 180) -> None:
cmd = ["sshpass", "-p", PROD_PASS, "ssh", "-o", "StrictHostKeyChecking=accept-new",
"-o", "ConnectTimeout=20", PROD_HOST, f"cat > {remote_path}"]
sh(cmd, input_bytes=data, timeout=timeout)
_prod_helper_ready = False
def prod_helper(subcmd: str, *args: str) -> str:
global _prod_helper_ready
if not _prod_helper_ready:
_ssh_upload_bytes(HELPER_SRC.read_bytes(), PROD_HELPER)
_prod_helper_ready = True
inner = f"FEA_WP_LOAD={PROD_WPLOAD} php {PROD_HELPER} {subcmd} " + " ".join(args)
return _ssh_text(inner, timeout=60)
def prod_upload_mp3(post_id: int) -> None:
src = LOCAL_TTS_DIR / f"{post_id}.mp3"
data = src.read_bytes()
remote_path = f"{PROD_UPLOADS_TTS}/{post_id}.mp3"
_ssh_upload_bytes(data, remote_path)
# Verificación de tamaño (glibc rota => sin fiarse ciegamente del rc=0 de ssh)
remote_size = int(_ssh_text(f"wc -c < {remote_path}").strip())
if remote_size != len(data):
raise RuntimeError(f"tamaño no coincide tras subir #{post_id}: local={len(data)} remoto={remote_size}")
def prod_remove_mp3(post_id: int) -> None:
_ssh_text(f"rm -f {PROD_UPLOADS_TTS}/{post_id}.mp3")
# ── Estado ───────────────────────────────────────────────────────────────────
def load_state() -> dict:
if STATE_FILE.exists():
try:
return json.loads(STATE_FILE.read_text())
except json.JSONDecodeError:
pass
return {"synced": []}
def save_state(state: dict) -> None:
STATE_FILE.parent.mkdir(parents=True, exist_ok=True)
STATE_FILE.write_text(json.dumps(state, ensure_ascii=False, indent=2))
# ── Sync ─────────────────────────────────────────────────────────────────────
def sync_one(post_id: int, state: dict, *, dry_run: bool) -> str:
if local_meta(post_id, "fea_audio_done") != "1":
return "sin-audio-local"
if not (LOCAL_TTS_DIR / f"{post_id}.mp3").exists():
return "mp3-local-ausente"
if dry_run:
return "PLAN: subiría mp3 + setaudio"
voice = local_meta(post_id, "fea_audio_voice") or "NicoFeadulta2026"
prod_upload_mp3(post_id)
prod_helper("setaudio", str(post_id), f"/wp-content/uploads/tts/{post_id}.mp3", voice)
if post_id not in state["synced"]:
state["synced"].append(post_id)
save_state(state)
return "ok"
def rollback_one(post_id: int, state: dict) -> str:
prod_helper("unsetaudio", str(post_id))
prod_remove_mp3(post_id)
if post_id in state["synced"]:
state["synced"].remove(post_id)
save_state(state)
return "ok"
def main() -> int:
ap = argparse.ArgumentParser(description="Sincroniza audio TTS local→prod (o lo despublica con --rollback).")
ap.add_argument("--carta", type=int, default=0, help="WP post ID de la carta local; sincroniza toda su cola.")
ap.add_argument("--ids", default="", help="Lista CSV de post IDs concretos.")
ap.add_argument("--rollback", action="store_true", help="Despublica en prod en vez de publicar.")
ap.add_argument("--dry-run", action="store_true", help="Solo muestra el plan; no toca prod.")
args = ap.parse_args()
if args.ids:
ids = [int(x) for x in args.ids.split(",") if x.strip().isdigit()]
elif args.carta:
ids = carta_article_ids(args.carta)
else:
ap.error("hace falta --carta o --ids")
return 2
state = load_state()
mode = "ROLLBACK" if args.rollback else "SYNC"
log(f"=== INICIO {mode} audio→prod. {len(ids)} posts candidatos: {ids} ===")
ok = skip = err = 0
for pid in ids:
try:
if args.rollback:
if pid not in state["synced"] and not args.ids:
res = "no-estaba-sincronizado"
skip += 1
else:
res = rollback_one(pid, state)
ok += 1
else:
res = sync_one(pid, state, dry_run=args.dry_run)
if res == "ok" or res.startswith("PLAN"):
ok += 1
else:
skip += 1
log(f" #{pid}: {res}")
except Exception as exc: # noqa: BLE001
err += 1
log(f" #{pid}: ERROR {exc}")
log(f"=== FIN {mode}. ok={ok} skip={skip} error={err}. Estado: {STATE_FILE} ===")
return 1 if err else 0
if __name__ == "__main__":
raise SystemExit(main())
+3 -3
View File
@@ -31,7 +31,7 @@ DB_PASS = os.environ.get("FEA_DB_PASS", "wordpress_pass")
PROD_HOST = os.environ.get("FEA_PROD_HOST", "feadulta@134.0.10.170")
PROD_PASS = os.environ.get("FEA_PROD_PASS", "C6c2A!mAl3Wj.BQF")
PROD_WPLOAD = os.environ.get("FEA_PROD_WPLOAD", "/web/wp-nuevo/wp-load.php")
PROD_WPLOAD = os.environ.get("FEA_PROD_WPLOAD", "/web/wp-load.php")
PROD_HELPER = "/tmp/fea_translate_helper.php"
HELPER_SRC = Path(__file__).resolve().parent / "fea_translate_helper.php"
@@ -45,7 +45,7 @@ STATUS = os.environ.get("FEA_SYNC_STATUS", "draft")
# instalación cuelga de la raíz. Se reescriben al desplegar para no dejar enlaces
# rotos (Tailscale es inaccesible para los visitantes).
LOCAL_BASE = os.environ.get("FEA_LOCAL_BASE", "https://farmer.taild3aaf6.ts.net/fea")
PROD_BASE = os.environ.get("FEA_PROD_BASE", "https://wp-nuevo.feadulta.com")
PROD_BASE = os.environ.get("FEA_PROD_BASE", "https://www.feadulta.com")
def log(msg: str) -> None:
@@ -76,7 +76,7 @@ def parse_csv_ints(raw: str) -> list[int]:
def localize_urls(text: str | None) -> tuple[str, int]:
"""Reescribe URLs absolutas local→prod en el contenido antes de subirlo.
Equivale al search-replace `farmer.taild3aaf6.ts.net/fea` → `wp-nuevo.feadulta.com`
Equivale al search-replace `farmer.taild3aaf6.ts.net/fea` → `www.feadulta.com`
pero aplicado en origen, así el contenido llega ya correcto a prod (issue #91).
Devuelve (texto, nº de reemplazos).
"""
+1 -1
View File
@@ -16,7 +16,7 @@
* Uso (local): docker exec wordpress-web php /var/www/html/scripts/... (o vía cwd)
* php scripts/translate_lectura_titles.php # dry-run + reporte
* APPLY=1 php scripts/translate_lectura_titles.php # aplica
* Prod: FEA_WP_LOAD=/web/wp-nuevo/wp-load.php php translate_lectura_titles.php
* Prod: FEA_WP_LOAD=/web/wp-load.php php translate_lectura_titles.php
*/
error_reporting(E_ALL & ~E_DEPRECATED & ~E_NOTICE);
+12 -8
View File
@@ -1,8 +1,10 @@
#!/usr/bin/env python3
"""Orquestador nocturno: locuta cartas ES del gap con MiniMax (voz Nico), una a
una, repartido en el tiempo. Reanudable (meta fea_audio_done) y con freno ante
la cuota (para tras N fallos seguidos). NO toca el front; solo genera el mp3 y
asocia la URL al post (meta fea_audio_url).
"""Orquestador nocturno: locuta cartas ES del gap con MiniMax, una a una,
repartido en el tiempo. Voz por defecto Nico; los artículos de autores con voz
clonada (AUTHOR_VOICES en minimax_tts.py, ej. Fray Marcos) usan la suya propia.
Reanudable (meta fea_audio_done) y con freno ante la cuota (para tras N fallos
seguidos). NO toca el front; solo genera el mp3 y asocia la URL al post (meta
fea_audio_url).
Lanzar: nohup ~/tts-local/xtts-venv/bin/python scripts/tts_produce.py > /tmp/feadulta-tts-prod.out 2>&1 &
Log: /tmp/feadulta-tts-prod.log
@@ -78,7 +80,7 @@ def main():
i += 1
continue
try:
title, text = mm.get_post_text(pid)
title, text, author = mm.get_post_text(pid)
except Exception as e: # noqa: BLE001
log(f"#{pid}: error leyendo ({e}); skip")
php("setflag", str(pid), "fea_audio_skip", "1")
@@ -90,15 +92,17 @@ def main():
i += 1
continue
rc = mm.t2a(mm.add_pauses(text), VOICE, MODEL, f"prod-{pid}")
voice = mm.voice_for_author(author, VOICE)
rc = mm.t2a(mm.add_pauses(text), voice, MODEL, f"prod-{pid}")
if rc == 0:
src = mm.OUT / f"prod-{pid}.mp3"
dst = PROD / f"{pid}.mp3"
shutil.move(str(src), str(dst))
php("setaudio", str(pid), f"/wp-content/uploads/tts/{pid}.mp3")
php("setaudio", str(pid), f"/wp-content/uploads/tts/{pid}.mp3", voice)
ok += 1
consec = 0
log(f"#{pid} OK «{title[:45]}» → tts/{pid}.mp3 (total {ok})")
voice_tag = f" [{voice}]" if voice != VOICE else ""
log(f"#{pid} OK «{title[:45]}»{voice_tag} → tts/{pid}.mp3 (total {ok})")
i += 1
time.sleep(INTERVAL)
else:
+144
View File
@@ -0,0 +1,144 @@
<?php
/**
* verify_carta_lang_links.php
*
* QA de una carta traducida: para cada idioma (en/fr/it/pt), revisa TODOS los
* enlaces internos de feadulta.com y comprueba que apuntan a contenido en ese
* mismo idioma (prefijo /en/, /fr/, /it/, /pt/ y slug correcto).
*
* Detecta dos tipos de fallo:
* - SIN_TRADUCIR: el link apunta al artículo en español porque nunca se
* tradujo/creó su equivalente en ese idioma.
* - MISMATCH: el link tiene prefijo de idioma pero el slug no es el
* correcto (p.ej. suffix -2 divergente entre local y prod), y resuelve a
* contenido de OTRO idioma o a nada.
*
* Solo reporta, no modifica nada (a diferencia de repoint_carta_links.php).
*
* Uso:
* FEA_WP_LOAD=/web/wp-load.php CARTA=54254 php verify_carta_lang_links.php
* CARTA=54254 php verify_carta_lang_links.php (usa /var/www/html/wp-load.php, para Docker local)
*/
require getenv('FEA_WP_LOAD') ?: '/var/www/html/wp-load.php';
$CARTA = (int)(getenv('CARTA') ?: 0);
if (!$CARTA) {
fwrite(STDERR, "Falta CARTA=<id del post en español>\n");
exit(1);
}
$LANGS = ['en', 'fr', 'it', 'pt'];
function resolve_post_from_href($href) {
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; // categorías, home, rutas multi-segmento: no aplica
}
$p = get_page_by_path($segs[0], OBJECT, ['post', 'page']);
return $p ? $p->ID : 0;
}
function href_lang_prefix($href) {
$path = preg_replace('~^https?://[^/]+~i', '', $href);
$path = preg_replace('~^/fea~', '', $path);
if (preg_match('~^/(en|fr|it|pt)(/|$)~', $path, $m)) {
return $m[1];
}
return null;
}
$translations = pll_get_post_translations($CARTA);
if (empty($translations)) {
fwrite(STDERR, "El post $CARTA no tiene grupo de traducciones Polylang.\n");
exit(1);
}
echo "Verificando enlaces de la carta ES=$CARTA\n";
echo "Traducciones: " . json_encode($translations) . "\n\n";
$total_sin_traducir = 0;
$total_mismatch = 0;
$total_rotos = 0;
foreach ($LANGS as $lang) {
if (empty($translations[$lang])) {
echo "[$lang] SIN TRADUCCIÓN DE LA CARTA (no existe post en este idioma)\n\n";
continue;
}
$pid = $translations[$lang];
$post = get_post($pid);
if (!$post) {
echo "[$lang] post $pid no encontrado\n\n";
continue;
}
// Captura cualquier href absoluto http(s) — funciona tanto en local (dominio
// Tailscale) como en prod (www.feadulta.com); resolve_post_from_href() ya
// descarta lo que no resuelva a un post interno (externos, mailto, etc.).
preg_match_all('~href="(https?://[^"]+)"~i', $post->post_content, $m);
$hrefs = array_values(array_unique($m[1]));
$issues = [];
foreach ($hrefs as $href) {
$target_id = resolve_post_from_href($href);
if (!$target_id) {
continue; // home, categorías, rutas no resolubles a un post concreto
}
$target_lang = pll_get_post_language($target_id) ?: 'es';
$prefix = href_lang_prefix($href);
if ($target_lang === $lang && $prefix === $lang) {
continue; // todo correcto
}
// Averiguar el ES de origen del contenido apuntado, para buscar la traducción correcta
if ($target_lang === 'es') {
$es_id = $target_id;
} else {
$tr = pll_get_post_translations($target_id);
$es_id = $tr['es'] ?? null;
}
$correct_id = $es_id ? pll_get_post($es_id, $lang) : null;
if ($correct_id && $correct_id != $target_id) {
$correct_url = get_permalink($correct_id);
$issues[] = [
'tipo' => 'MISMATCH',
'detalle' => "href=\"$href\" resuelve a post $target_id (lang=$target_lang) correcto: $correct_url (post $correct_id)",
];
$total_mismatch++;
} elseif ($correct_id && $correct_id == $target_id && $prefix !== $lang) {
// resuelve al post correcto pero con URL/slug distinta a la actual (permalink cambió)
$correct_url = get_permalink($correct_id);
$issues[] = [
'tipo' => 'MISMATCH',
'detalle' => "href=\"$href\" no coincide con el permalink actual: $correct_url (post $correct_id)",
];
$total_mismatch++;
} elseif (!$correct_id && $target_lang !== $lang) {
$issues[] = [
'tipo' => 'SIN_TRADUCIR',
'detalle' => "href=\"$href\" -> post $target_id (lang=$target_lang) sin traducción en '$lang'",
];
$total_sin_traducir++;
}
}
echo "[$lang] post $pid " . count($hrefs) . " enlaces internos, " . count($issues) . " con problemas\n";
foreach ($issues as $issue) {
echo " {$issue['tipo']}: {$issue['detalle']}\n";
}
echo "\n";
}
echo "RESUMEN: $total_sin_traducir sin traducir, $total_mismatch mismatch de idioma/slug\n";
exit(($total_sin_traducir + $total_mismatch + $total_rotos) > 0 ? 2 : 0);