Sync: guardar en el historial el trabajo de las ultimas semanas que solo vivia en el disco

Este repo local tenia origin apuntando al Gitea local (localhost:3000), que Rafa
declaro archivado el 2026-06-28 (commit 962f33a, desarrollo movido a
gitea.feadulta.com). Ese memo nunca llego a este checkout: main quedo congelado
y todo el trabajo real de las ultimas 3+ semanas se fue commiteando solo en la
rama fix/multiidioma-portada-132 (ya fusionada a main sin perdida, commit
2504666), mientras que ademas se acumulaban 78 cambios sin commitear en el
working tree que nunca llegaron a NINGUN historial de git.

Este commit consolida esos cambios sueltos: TTS multi-voz (tts_*.py), scripts
de traduccion (translate_haiku.py, pretranslate_en_haiku.py, sync_translations_to_prod.py),
mu-plugins nuevos desplegados a prod (fea-beta-feedback, fea-cloudflare-realip,
fea-legacy-redirect, fea-gsc-verification, fea-support-campaign, fea-ui, etc.),
scripts de mantenimiento de enlaces/cartas, capturas E2E (tools/e2e/shot_*.cjs)
y documentacion de sesiones recientes.

Excluido deliberadamente (no es codigo versionable): tts-voices/ (3.3GB de
muestras de audio para clonacion de voz, anadido a .gitignore), logs/ (logs de
ejecucion, anadido a .gitignore), y 2 ficheros vacios accidentales + 2 copias
duplicadas sueltas en la raiz que ya existen en su ubicacion correcta.
This commit is contained in:
2026-07-15 20:03:21 -04:00
parent 250466696b
commit 69e849d38e
72 changed files with 5413 additions and 220 deletions
Regular → Executable
+63 -6
View File
@@ -2,17 +2,74 @@
/**
* Plugin Name: Fe Adulta — Carta de la Semana
* Description: Redirige las URLs de carta al archivo de categoría correspondiente.
* Version: 1.4
* Version: 1.8
*/
// Redirigir las páginas custom a las categorías
add_action('template_redirect', function() {
if (is_page('carta-de-la-semana')) {
wp_redirect(home_url('/category/cartasemana/'), 302);
add_action("template_redirect", function() {
if (is_page("carta-de-la-semana")) {
wp_redirect(home_url("/category/cartasemana/"), 302);
exit;
}
if (is_page('la-semana-pasada')) {
wp_redirect(home_url('/category/carta-semana-pasada/'), 302);
if (is_page("la-semana-pasada")) {
wp_redirect(home_url("/category/carta-semana-pasada/"), 302);
exit;
}
});
// Las categorías de carta actual/anterior siempre llevan al post traducido que
// corresponde a la categoría española canónica. No dependemos del count ni de
// las relaciones traducidas, que pueden quedar desfasadas durante una importación.
add_action("template_redirect", function() {
if (!is_category()) return;
$cat = get_queried_object();
if (!$cat || empty($cat->term_id)) return;
$source_cat_id = (int) $cat->term_id;
if (function_exists('pll_get_term')) {
$spanish_cat_id = (int) pll_get_term($source_cat_id, 'es');
if ($spanish_cat_id) $source_cat_id = $spanish_cat_id;
}
if (!in_array($source_cat_id, [6, 22], true)) return;
global $wpdb;
$source_post_id = (int) $wpdb->get_var($wpdb->prepare(
"SELECT p.ID
FROM {$wpdb->posts} p
INNER JOIN {$wpdb->term_relationships} tr ON tr.object_id = p.ID
INNER JOIN {$wpdb->term_taxonomy} tt ON tt.term_taxonomy_id = tr.term_taxonomy_id
WHERE tt.taxonomy = 'category' AND tt.term_id = %d
AND p.post_type = 'post' AND p.post_status = 'publish'
ORDER BY p.post_date DESC, p.ID DESC
LIMIT 1",
$source_cat_id
));
if (!$source_post_id) return;
$post_id = $source_post_id;
if (function_exists('pll_current_language') && function_exists('pll_get_post')) {
$lang = pll_current_language();
$translated = $lang ? (int) pll_get_post($source_post_id, $lang) : 0;
if ($translated) $post_id = $translated;
}
$url = get_permalink($post_id);
if (!$url) return;
wp_safe_redirect($url, 302);
exit;
}, 9);
// Mostrar 50 artículos por página en los archivos de cartas
add_action("pre_get_posts", function($query) {
if (!$query->is_main_query() || is_admin()) return;
if ($query->is_category([
"cartasemana", "carta-semana-pasada", "cartas-de-otras-semanas",
"letter-of-the-week", "lettre-de-la-semaine", "lettera-della-settimana", "carta-da-semana",
"carta-semana-pasada-en", "carta-semana-pasada-fr",
"carta-semana-pasada-it", "carta-semana-pasada-pt",
"letters-from-other-weeks", "lettres-des-autres-semaines",
"lettere-delle-altre-settimane", "cartas-de-outras-semanas",
])) {
$query->set("posts_per_page", 50);
}
});
+19 -6
View File
@@ -1,24 +1,37 @@
<?php
require '/var/www/html/wp-load.php';
$ES = 2682; // MATEO 10, 26-33 (es)
require getenv('FEA_WP_LOAD') ?: '/var/www/html/wp-load.php';
$ES = (int)(getenv('ES_ID') ?: 2682); // MATEO 10, 26-33 (es)
$src = get_post($ES);
$tr_html = json_decode(file_get_contents('/tmp/lecturas_mateo.json'), true);
$tr_html = json_decode(file_get_contents(getenv('LECTURAS_JSON') ?: '/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
$REST = getenv('LECTURA_TITULO_ES') ?: 'MATEO 10, 26-33'; // título es
$tail = preg_replace('~^MATEO~','',$REST); // " 10, 26-33"
$es_cats = wp_get_post_categories($ES);
$target_ids = array_values(array_filter(array_map('intval', explode(',', getenv('TARGET_IDS') ?: ''))));
$target_map = [];
foreach (['en','fr','it','pt'] as $idx => $lang0) {
if (!empty($target_ids[$idx])) $target_map[$lang0] = (int)$target_ids[$idx];
}
$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([
$postarr = [
'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);
];
$target = (int)($target_map[$lang] ?? 0);
if ($target && get_post($target)) {
$postarr['ID'] = $target;
} elseif ($target) {
$postarr['import_id'] = $target;
}
$id = wp_insert_post($postarr, true);
if (is_wp_error($id)) { echo "$lang ERROR ".$id->get_error_message()."\n"; continue; }
if ($target && (int)$id !== $target) { echo "$lang ERROR id esperado $target creado $id\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)));
+35 -9
View File
@@ -1,13 +1,10 @@
<?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.
* Ciclo carta nueva — sincroniza "esta semana", "semana pasada" y "otras semanas"
* en TODOS los idiomas (ES + EN/FR/IT/PT).
*
* Deriva los términos por Polylang desde los términos ES base:
* cartasemana = term 6 | cartas-de-otras-semanas = term 21
* cartasemana = term 6 | otras semanas = term 21 | semana pasada = term 22
*
* Uso: CARTA=<es_id> php demote_old_cartasemana.php (dry-run)
* APPLY=1 CARTA=<es_id> php demote_old_cartasemana.php
@@ -19,11 +16,26 @@ 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
$last_terms = pll_get_term_translations(22); // carta semana pasada por idioma
$carta_tr = pll_get_post_translations($CARTA);
$last_es_posts = get_posts([
'post_type' => 'post',
'numberposts' => 1,
'post_status' => 'publish',
'fields' => 'ids',
'cat' => 22,
'orderby' => 'date',
'order' => 'DESC',
'suppress_filters' => true,
]);
$last_es = (int) ($last_es_posts[0] ?? 0);
$last_tr = $last_es ? pll_get_post_translations($last_es) : [];
foreach ($cs_terms as $lang=>$cs) {
$keep = $carta_tr[$lang] ?? 0;
$otras = $otras_terms[$lang] ?? 0;
$last = $last_terms[$lang] ?? 0;
$keep_last = $last_tr[$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;
@@ -35,9 +47,23 @@ foreach ($cs_terms as $lang=>$cs) {
}
$moved++;
}
if ($APPLY) clean_term_cache([$cs,$otras],'category');
$last_posts = $last ? get_posts([
'post_type'=>'post','numberposts'=>-1,'post_status'=>'any','fields'=>'ids',
'tax_query'=>[['taxonomy'=>'category','field'=>'term_id','terms'=>$last]],
]) : [];
$last_removed = 0;
foreach ($last_posts as $pid) {
if ($pid == $keep_last) continue;
if ($APPLY) wp_remove_object_terms($pid, [(int)$last], 'category');
$last_removed++;
}
if ($APPLY && $last && $keep_last) {
wp_set_object_terms($keep_last, [(int)$last], 'category', true);
}
if ($APPLY) clean_term_cache(array_filter([$cs,$otras,$last]),'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 sprintf("%s: %s %d de actual | anterior=#%d (limpia %d) | '%s' keep=#%d\n",
strtoupper($lang), $APPLY?"movidas":"se moverían", $moved,
$keep_last, $last_removed, $t->slug, $keep);
}
echo $APPLY ? "APLICADO\n" : "DRY-RUN (APPLY=1 para aplicar)\n";
+77
View File
@@ -0,0 +1,77 @@
<?php
/**
* Issue #80 — detecta traducciones con fragmentos en español sin traducir.
* Señal: una frase sin traducir queda IDÉNTICA a la del ES original.
* Para cada traducción (en/fr/it/pt) compara sus frases contra el set de frases
* del ES enlazado (Polylang) y calcula el % de caracteres que coinciden literal.
*
* Uso (en contenedor): php detect_untranslated.php [umbral] [status]
* umbral: ratio mínimo para marcar (def 0.12)
* status: draft (def) | publish | any
*/
require "/var/www/html/wp-load.php";
global $wpdb;
$THRESH = isset($argv[1]) ? (float)$argv[1] : 0.12;
$STATUS = $argv[2] ?? 'draft';
function norm_text($html) {
$t = preg_replace('~(?i)</p>|<br\s*/?>|</h[1-6]>~', "\n", $html);
$t = preg_replace('~<[^>]+>~', ' ', $t);
$t = preg_replace('~\[[^\]]+\]~', ' ', $t);
$t = html_entity_decode($t, ENT_QUOTES);
return $t;
}
/** Frases normalizadas de longitud >= 40 (las cortas dan falsos positivos). */
function sentences($html) {
$t = norm_text($html);
$parts = preg_split('~(?<=[.!?…])\s+|\n+~u', $t);
$out = [];
foreach ($parts as $s) {
$s = trim(preg_replace('~\s+~u', ' ', $s));
$s = mb_strtolower($s);
if (mb_strlen($s) >= 40) $out[$s] = mb_strlen($s);
}
return $out;
}
$statuses = $STATUS === 'any' ? ['draft','publish'] : [$STATUS];
$in = "'" . implode("','", $statuses) . "'";
$ids = $wpdb->get_col(
"SELECT p.ID FROM wp_posts p
JOIN wp_term_relationships tr ON tr.object_id=p.ID
JOIN wp_term_taxonomy tt ON tt.term_taxonomy_id=tr.term_taxonomy_id AND tt.taxonomy='language'
JOIN wp_terms t ON t.term_id=tt.term_id AND t.slug IN ('en','fr','it','pt')
WHERE p.post_type='post' AND p.post_status IN ($in)
GROUP BY p.ID"
);
$by_lang = []; $offenders = [];
foreach ($ids as $id) {
$lang = pll_get_post_language($id);
$es = pll_get_post((int)$id, 'es');
if (!$es) continue;
$tr_s = sentences(get_post($id)->post_content);
if (!$tr_s) continue;
$es_s = sentences(get_post($es)->post_content);
if (!$es_s) continue;
$total = array_sum($tr_s); $match = 0;
foreach ($tr_s as $s => $len) if (isset($es_s[$s])) $match += $len;
$ratio = $total ? $match / $total : 0;
$by_lang[$lang]['n'] = ($by_lang[$lang]['n'] ?? 0) + 1;
if ($ratio >= $THRESH) {
$by_lang[$lang]['bad'] = ($by_lang[$lang]['bad'] ?? 0) + 1;
$offenders[] = [$id, $lang, $es, round($ratio, 2), get_post($id)->post_title];
}
}
usort($offenders, fn($a, $b) => $b[3] <=> $a[3]);
echo "=== Traducciones con fragmentos ES (ratio >= $THRESH, status=$STATUS) ===\n";
foreach ($offenders as $o)
echo sprintf("#%d [%s] ratio=%.2f es=%d %s\n", $o[0], $o[1], $o[3], $o[2], mb_substr($o[4], 0, 45));
echo "\n--- resumen por idioma ---\n";
foreach ($by_lang as $l => $d)
echo sprintf("%s: %d/%d con fragmentos ES\n", $l, $d['bad'] ?? 0, $d['n']);
echo "TOTAL ofensores: " . count($offenders) . "\n";
// Volcar IDs para el reprocesado
file_put_contents('/tmp/untranslated_ids.txt', implode("\n", array_map(fn($o) => $o[0], $offenders)));
+65
View File
@@ -0,0 +1,65 @@
<?php
/**
* Arregla los enlaces internos de la carta que apuntan a contenido com_content
* de Joomla (multimedia, vídeos, cantoral...) con forma legacy
* es/<seccion>/<joomla_content_id>-<slug>.html
* que fix_carta_joomla_links.php NO mapea (solo trata /item/<k2>-...). Resuelve
* el número (id de contenido Joomla) por meta `_fgj2wp_old_content_id` y, en su
* defecto, `_fgj2wp_old_id` (contenido migrado en el bulk original) → permalink
* WP en el idioma de cada carta (degrada a ES si no hay traducción).
*
* Deja intactos los enlaces absolutos a feadulta.com (navegación externa) y los
* índices de sección (tablon-de-anuncios.html, noticias-de-alcance.html, etc.).
*
* Uso: CARTA=<es_id> php fix_carta_content_links.php (dry-run)
* APPLY=1 CARTA=<es_id> php fix_carta_content_links.php
*/
require getenv('FEA_WP_LOAD') ?: '/var/www/html/wp-load.php';
global $wpdb;
$APPLY = getenv('APPLY') === '1';
$CARTA = (int)(getenv('CARTA') ?: 0);
if (!$CARTA) { fwrite(STDERR, "Falta CARTA=<es_id>\n"); exit(1); }
$BAK = "/tmp/fix_carta_content_bak"; if ($APPLY) @mkdir($BAK, 0777, true);
function content_es_post($jid) {
global $wpdb;
foreach (['_fgj2wp_old_content_id', '_fgj2wp_old_id'] as $mk) {
$pid = $wpdb->get_var($wpdb->prepare(
"SELECT post_id FROM {$wpdb->postmeta} WHERE meta_key=%s AND meta_value=%s LIMIT 1", $mk, (string)$jid));
if ($pid) return (int)$pid;
}
return 0;
}
$tot = 0;
foreach (pll_get_post_translations($CARTA) as $lang => $pid) {
$post = get_post($pid); if (!$post) continue;
$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 .html
if (stripos($href, 'feadulta.com') !== false) return $m[0]; // absoluto externo → dejar
if (stripos($href, '/item/') !== false) return $m[0]; // K2 lo trata otro script
if (!preg_match('~/(\d+)-[^/"]+\.html$~i', $href, $mm)) return $m[0]; // necesita <id>-slug.html
$es = content_es_post((int)$mm[1]);
if (!$es) { $miss[] = $href; return $m[0]; }
$t = function_exists('pll_get_post') ? (pll_get_post($es, $lang) ?: $es) : $es;
$url = get_permalink($t);
if (!$url || strpos($url, '?p=') !== false) return $m[0];
$chg++;
return 'href="' . esc_url($url) . '"';
}, $post->post_content);
printf("#%d [%s] «%s» — %d enlaces de contenido%s\n", $pid, $lang, mb_substr($post->post_title,0,26), $chg,
$miss ? (" | sin mapear: " . implode(", ", array_slice($miss,0,3))) : "");
$tot += $chg;
if ($APPLY && $chg) {
file_put_contents("$BAK/$pid.html", $post->post_content);
wp_update_post(['ID'=>$pid, 'post_content'=>$new]);
clean_post_cache($pid);
}
}
if ($APPLY) {
$wpdb->query("DELETE FROM {$wpdb->options} WHERE option_name LIKE '_transient_fea_carta_sections_%'");
$wpdb->query("DELETE FROM {$wpdb->options} WHERE option_name LIKE '_transient_timeout_fea_carta_sections_%'");
}
echo ($APPLY ? "APLICADO" : "DRY-RUN") . ": $tot enlaces.\n";
+1 -1
View File
@@ -1,5 +1,5 @@
<?php
require '/var/www/html/wp-load.php';
require getenv('FEA_WP_LOAD') ?: '/var/www/html/wp-load.php';
global $wpdb;
$APPLY = getenv("APPLY") === "1";
$BAK = "/tmp/fix_carta_links_bak"; if ($APPLY) @mkdir($BAK,0777,true);
+104
View File
@@ -0,0 +1,104 @@
<?php
/**
* Issue #75 — normaliza los enlaces internos de las cartas (ES + traducciones).
*
* Bugs que arregla:
* - Cartas ES con enlaces a http://localhost:8081/<slug>/ (rompen en prod).
* - Traducciones con enlaces relativos /<slug>/ (rompen en local, falta /fea).
* - Enlaces que apuntan a un artículo en otro idioma (los re-apunta a la
* traducción del MISMO idioma de la carta si existe).
*
* Para CADA <a href>: si el slug resuelve a un post del sitio, se reescribe al
* permalink absoluto del post en el idioma de la página (fallback: el que haya).
* Si el slug NO resuelve a ningún post (legacy .html, externos), se deja intacto.
*
* Uso (dentro del contenedor, con WP cargado):
* php fix_carta_links.php -> DRY-RUN (no escribe nada)
* APPLY=1 php fix_carta_links.php -> aplica y guarda backup en /tmp/fix_links_bak/
*/
if (!defined('ABSPATH')) {
require getenv('FEA_WP_LOAD') ?: '/var/www/html/wp-load.php';
}
global $wpdb;
$APPLY = getenv("APPLY") === "1";
$BAKDIR = "/tmp/fix_links_bak";
if ($APPLY) @mkdir($BAKDIR, 0777, true);
// Conjunto de trabajo: posts ES con localhost:8081 + todas sus traducciones.
$es_ids = $wpdb->get_col(
"SELECT ID FROM wp_posts WHERE post_type='post'
AND post_status IN ('publish','draft')
AND post_content LIKE '%localhost:8081%'"
);
$targets = [];
foreach ($es_ids as $id) {
$targets[$id] = true;
foreach (pll_get_post_translations($id) as $tid) $targets[$tid] = true;
}
$targets = array_keys($targets);
/** Extrae el slug candidato de un href, o null si no parece interno. */
function slug_from_href($href) {
$href = html_entity_decode(trim($href));
if ($href === '' || $href[0] === '#') return null;
if (preg_match('~^(mailto:|tel:|javascript:)~i', $href)) return null;
if (stripos($href, '.html') !== false) return null; // legacy Joomla
if (stripos($href, 'feadulta.com') !== false) return null; // dominio viejo
if (strpos($href, '%') !== false) return null; // placeholders [unsubscribe]
// Quitar querystring / fragment
$href = preg_replace('~[?#].*$~', '', $href);
// Quitar esquema+host si los hay
$path = preg_replace('~^https?://[^/]+~i', '', $href);
if ($path === '') return null;
if ($path[0] !== '/') return null; // relativo raro -> no tocar
if (stripos($path, '/category/') !== false) return null; // categorías, no posts
if (stripos($path, '/wp-') === 0) return null;
// Quitar /fea y prefijo de idioma
$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 null; // solo /<slug>/ de un nivel
return $segs[0];
}
$total_posts = 0; $total_links = 0; $samples = 0;
foreach ($targets as $pid) {
$post = get_post($pid);
if (!$post) continue;
$lang = pll_get_post_language($pid) ?: 'es';
$content = $post->post_content;
$changes = 0;
$new = preg_replace_callback('~href="([^"]*)"~i', function($m) use ($lang, &$changes, $wpdb) {
$href = $m[1];
$slug = slug_from_href($href);
if ($slug === null) return $m[0];
$found = $wpdb->get_var($wpdb->prepare(
"SELECT ID FROM wp_posts WHERE post_name=%s AND post_type='post'
AND post_status='publish' LIMIT 1", $slug));
if (!$found) return $m[0]; // no es un post -> intacto
// Resolver a la traducción del idioma de la página
$target = pll_get_post((int)$found, $lang);
if (!$target) $target = (int)$found;
$url = get_permalink($target);
if (!$url || $url === $href) return $m[0];
$changes++;
return 'href="' . esc_url($url) . '"';
}, $content);
if ($changes > 0) {
$total_posts++; $total_links += $changes;
echo sprintf("#%d [%s] «%s» — %d enlace(s) reescrito(s)\n",
$pid, $lang, mb_substr($post->post_title, 0, 40), $changes);
if ($APPLY) {
file_put_contents("$BAKDIR/$pid.html", $content);
$wpdb->update($wpdb->posts, ['post_content' => $new], ['ID' => $pid]);
clean_post_cache($pid);
}
}
}
echo "\n";
echo ($APPLY ? "APLICADO" : "DRY-RUN") . ": $total_links enlaces en $total_posts posts.\n";
if (!$APPLY) echo "Para aplicar: APPLY=1 php fix_carta_links.php (backup en $BAKDIR)\n";
+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
*
+33
View File
@@ -0,0 +1,33 @@
#!/usr/bin/env python3
"""Issue #90 — círculo 200px RGBA (esquinas transparentes) para 2 autores nuevos.
Recorte cuadrado centrado en la cara + máscara circular supersampleada.
"""
from PIL import Image, ImageDraw
SRC = "/home/rafa/Feadulta"
OUT = "/home/rafa/joomla-migration/wordpress/wp-content/uploads/avatares/autores"
SIZE = 200
SS = 4 # supersampling para borde suave
# foto, uid, (left, top, side) recorte cuadrado en coords del original
JOBS = [
("MP_Lopez.jpeg", 474, (120, 0, 880)),
("A_delaCruz.jpeg", 993, (128, 0, 785)),
]
mask = Image.new("L", (SIZE * SS, SIZE * SS), 0)
ImageDraw.Draw(mask).ellipse((0, 0, SIZE * SS - 1, SIZE * SS - 1), fill=255)
mask = mask.resize((SIZE, SIZE), Image.LANCZOS)
for fname, uid, (l, t, side) in JOBS:
im = Image.open(f"{SRC}/{fname}").convert("RGB")
W, H = im.size
# clamp dentro de la imagen
l = max(0, min(l, W - side))
t = max(0, min(t, H - side))
crop = im.crop((l, t, l + side, t + side)).resize((SIZE, SIZE), Image.LANCZOS)
out = Image.new("RGBA", (SIZE, SIZE), (0, 0, 0, 0))
out.paste(crop, (0, 0))
out.putalpha(mask)
out.save(f"{OUT}/autor-{uid}.png")
print(f"OK autor-{uid}.png <- {fname} crop=({l},{t},{side})")
+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
*/
+59
View File
@@ -0,0 +1,59 @@
<?php
/**
* Issue #81 — avatares nuevos de 6 colaboradores habituales.
* Reapunta foto_perfil (user_meta) al PNG uploads/avatares/autores/autor-<uid>.png.
* Si foto_perfil ya apuntaba a ese fichero (caso #62), solo regenera metadata.
* Backup del foto_perfil anterior en user_meta _foto_perfil_pre75 (revertible).
*
* Uso (dentro del contenedor):
* php import_avatars_75.php -> DRY-RUN
* APPLY=1 php import_avatars_75.php -> aplica
*/
require "/var/www/html/wp-load.php";
require_once ABSPATH . 'wp-admin/includes/image.php';
$apply = getenv('APPLY') === '1';
$updir = wp_get_upload_dir();
$authors = [
[384, "Enrique Martínez Lozano"],
[583, "Fidel Aizpurúa"],
[1138, "Guadalupe Labrador"],
[383, "José Antonio Pagola"],
[774, "José Luis Sicre"],
[775, "Miguel A. Munárriz"],
];
$done = $regen = $err = 0;
foreach ($authors as [$uid, $name]) {
$rel = "avatares/autores/autor-{$uid}.png";
$abs = $updir['basedir'] . '/' . $rel;
if (!file_exists($abs)) { echo "MISSING uid=$uid ($name)\n"; $err++; continue; }
$cur = (int) get_user_meta($uid, 'foto_perfil', true);
// foto_perfil ya apunta a este fichero -> solo se sobrescribió el PNG
if ($cur && get_post_meta($cur, '_wp_attached_file', true) === $rel) {
echo "REGEN #$uid $name (attachment $cur ya apunta al PNG)\n";
if ($apply) wp_update_attachment_metadata($cur, wp_generate_attachment_metadata($cur, $abs));
$regen++; continue;
}
echo "NUEVO #$uid $name (foto_perfil actual: " . ($cur ?: 'ninguna') . ")\n";
if (!$apply) { $done++; continue; }
if (get_user_meta($uid, '_foto_perfil_pre75', true) === '') {
update_user_meta($uid, '_foto_perfil_pre75', $cur);
}
$aid = wp_insert_attachment([
'post_mime_type' => 'image/png',
'post_title' => "Avatar {$name}",
'post_status' => 'inherit',
'guid' => $updir['baseurl'] . '/' . $rel,
], $abs, 0, true);
if (is_wp_error($aid)) { echo " ERR: " . $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 "\n" . ($apply ? "APLICADO" : "DRY-RUN") . ": nuevos=$done regen=$regen errores=$err\n";
+55
View File
@@ -0,0 +1,55 @@
<?php
/**
* Issue #90 — avatares nuevos de 2 colaboradores nuevos (Mari Paz Lopez, Africa de la Cruz).
* Reapunta foto_perfil (user_meta) al PNG uploads/avatares/autores/autor-<uid>.png.
* Si foto_perfil ya apuntaba a ese fichero (caso #62), solo regenera metadata.
* Backup del foto_perfil anterior en user_meta _foto_perfil_pre81 (revertible).
*
* Uso (dentro del contenedor):
* php import_avatars_75.php -> DRY-RUN
* APPLY=1 php import_avatars_75.php -> aplica
*/
require "/var/www/html/wp-load.php";
require_once ABSPATH . 'wp-admin/includes/image.php';
$apply = getenv('APPLY') === '1';
$updir = wp_get_upload_dir();
$authors = [
[474, "Mari Paz López Santos"],
[993, "África de la Cruz Tomé"],
];
$done = $regen = $err = 0;
foreach ($authors as [$uid, $name]) {
$rel = "avatares/autores/autor-{$uid}.png";
$abs = $updir['basedir'] . '/' . $rel;
if (!file_exists($abs)) { echo "MISSING uid=$uid ($name)\n"; $err++; continue; }
$cur = (int) get_user_meta($uid, 'foto_perfil', true);
// foto_perfil ya apunta a este fichero -> solo se sobrescribió el PNG
if ($cur && get_post_meta($cur, '_wp_attached_file', true) === $rel) {
echo "REGEN #$uid $name (attachment $cur ya apunta al PNG)\n";
if ($apply) wp_update_attachment_metadata($cur, wp_generate_attachment_metadata($cur, $abs));
$regen++; continue;
}
echo "NUEVO #$uid $name (foto_perfil actual: " . ($cur ?: 'ninguna') . ")\n";
if (!$apply) { $done++; continue; }
if (get_user_meta($uid, '_foto_perfil_pre81', true) === '') {
update_user_meta($uid, '_foto_perfil_pre81', $cur);
}
$aid = wp_insert_attachment([
'post_mime_type' => 'image/png',
'post_title' => "Avatar {$name}",
'post_status' => 'inherit',
'guid' => $updir['baseurl'] . '/' . $rel,
], $abs, 0, true);
if (is_wp_error($aid)) { echo " ERR: " . $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 "\n" . ($apply ? "APLICADO" : "DRY-RUN") . ": nuevos=$done regen=$regen errores=$err\n";
+26 -8
View File
@@ -20,7 +20,7 @@ from datetime import datetime
JOOMLA_SSH_HOST = "134.0.10.170"
JOOMLA_SSH_USER = "feadulta"
JOOMLA_SSH_PASS = "6Rm2qOF@eundwpda"
JOOMLA_SSH_PASS = "C6c2A!mAl3Wj.BQF"
JOOMLA_DB_HOST = "127.0.0.1"
JOOMLA_DB_USER = "fejoomla3"
JOOMLA_DB_PASS = "5FF-}5^[>7^pK4W9"
@@ -31,7 +31,7 @@ WP_DB_USER = "wordpress_user"
WP_DB_PASS = "wordpress_pass"
WP_DB_NAME = "wordpress_db"
LAST_CONTENT_ID = 9043 # último ew4r_content.id ya en WP
LAST_CONTENT_ID = None # se calcula dinámicamente en main(): MAX(_fgj2wp_old_content_id) en WP
# WP term_ids y sus term_taxonomy_ids (se cargan dinámicamente)
CAT_FEADULTA = 71
@@ -50,7 +50,7 @@ DRY_RUN = '--dry-run' in sys.argv
# ── Helpers ────────────────────────────────────────────────────────────────────
def joomla_query(query: str) -> list[dict]:
mysql_cmd = (f"mysql -h {JOOMLA_DB_HOST} -u {JOOMLA_DB_USER} "
mysql_cmd = (f"mysql --skip-ssl -h {JOOMLA_DB_HOST} -u {JOOMLA_DB_USER} "
f"-p'{JOOMLA_DB_PASS}' {JOOMLA_DB_NAME} "
f"--default-character-set=utf8mb4 -B")
cmd = ['sshpass', '-p', JOOMLA_SSH_PASS,
@@ -111,6 +111,11 @@ def unhex(val: str) -> str:
# ── Main ───────────────────────────────────────────────────────────────────────
def main():
global LAST_CONTENT_ID
# Detección dinámica del último ew4r_content (carta) ya importado
r = wp_mysql("SELECT MAX(CAST(meta_value AS UNSIGNED)) m FROM wp_postmeta "
"WHERE meta_key='_fgj2wp_old_content_id'")
LAST_CONTENT_ID = int(r[0]['m']) if r and r[0].get('m') and r[0]['m'] != 'NULL' else 9043
print(f"=== Import nuevas cartas (ew4r_content id > {LAST_CONTENT_ID}) "
f"{'[DRY RUN]' if DRY_RUN else '[LIVE]'} ===\n")
@@ -172,7 +177,10 @@ def main():
created_by = int(item.get('created_by', 0) or 0)
content = intro + ('\n<!--more-->\n' + full if full.strip() else '')
wp_author = user_map.get(created_by, 1)
# La carta semanal SIEMPRE la firma Inma Calvo (WP user 1048 icalvotorre),
# aunque en Joomla la cree el webmaster (José Chicharro / josek 1049).
CARTA_AUTHOR = 1048
wp_author = CARTA_AUTHOR
wp_cats = CATID_TO_WP.get(catid, [CAT_CARTAS_OTRAS, CAT_FEADULTA])
fecha_carta = created[:10] # YYYY-MM-DD
@@ -237,18 +245,28 @@ def main():
print("\n=== Asignando _carta_id a artículos K2 ===")
# Obtener los artículos K2 nuevos con su fecha (id 15)
# Obtener los artículos K2 con su fecha (id 15), acotando por la fecha más
# antigua de las cartas importadas en esta ejecución (evita recorrer todo).
min_fecha = min(fecha_a_wp_carta.keys())
k2_query = (
f"SELECT id, HEX(extra_fields) ef "
f"FROM ew4r_k2_items WHERE published=1 AND id > 17873 ORDER BY id;"
f"FROM ew4r_k2_items WHERE published=1 AND created >= '{min_fecha} 00:00:00' "
f"ORDER BY id;"
)
k2_items = joomla_query(k2_query)
print(f"Artículos K2 a procesar: {len(k2_items)}")
print(f"Artículos K2 a procesar (desde {min_fecha}): {len(k2_items)}")
assigned = 0
for k2item in k2_items:
k2_id = int(k2item['id'])
wp_id = k2_id + 26040 # offset conocido
# wp_id REAL por meta (NO offset fijo, que pisaba metas en deltas sucesivos)
wp_rows = wp_mysql(
f"SELECT post_id FROM wp_postmeta WHERE meta_key='_fgj2wp_old_k2_id' "
f"AND meta_value='{k2_id}' LIMIT 1"
)
if not wp_rows:
continue
wp_id = int(wp_rows[0]['post_id'])
ef_raw = unhex(k2item.get('ef',''))
# Parsear fecha (id 15)
+7 -2
View File
@@ -51,7 +51,7 @@ DRY_RUN = '--dry-run' in sys.argv
# ── Helpers ────────────────────────────────────────────────────────────────────
def joomla_query(query: str) -> list[dict]:
mysql_cmd = (f"mysql -h {JOOMLA_DB_HOST} -u {JOOMLA_DB_USER} "
mysql_cmd = (f"mysql --skip-ssl -h {JOOMLA_DB_HOST} -u {JOOMLA_DB_USER} "
f"-p'{JOOMLA_DB_PASS}' {JOOMLA_DB_NAME} "
f"--default-character-set=utf8mb4 -B")
cmd = ['sshpass', '-p', JOOMLA_SSH_PASS,
@@ -184,7 +184,12 @@ def main():
continue
content = intro + ('\n<!--more-->\n' + full if full.strip() else '')
wp_author = user_map.get(created_by, 1)
# Multimedia/pensamientos/vídeos/cantoral (catid 54/77/65) son contenido
# propio de FeAdulta → autor "Fe Adulta" (WP user 890), no el webmaster
# que los sube en Joomla. El resto conserva su autor real (noticias, etc.).
FEADULTA_AUTHOR = 890
FEADULTA_CATIDS = {54, 77, 65}
wp_author = FEADULTA_AUTHOR if catid in FEADULTA_CATIDS else user_map.get(created_by, 1)
wp_cats = CATID_TO_WP.get(catid, [1])
print(f" [{joomla_id}] catid={catid} | {title[:50]}")
+14 -7
View File
@@ -25,7 +25,7 @@ from datetime import datetime
JOOMLA_SSH_HOST = "134.0.10.170"
JOOMLA_SSH_USER = "feadulta"
JOOMLA_SSH_PASS = "6Rm2qOF@eundwpda"
JOOMLA_SSH_PASS = "C6c2A!mAl3Wj.BQF"
JOOMLA_DB_HOST = "127.0.0.1"
JOOMLA_DB_USER = "fejoomla3"
JOOMLA_DB_PASS = "5FF-}5^[>7^pK4W9"
@@ -37,7 +37,7 @@ WP_DB_PASS = "wordpress_pass"
WP_DB_NAME = "wordpress_db"
WP_DB_HOST = "wordpress-mysql" # dentro del container
LAST_K2_ID = 17873 # último ID importado en WP
LAST_K2_ID = None # se calcula dinámicamente en main(): MAX(_fgj2wp_old_k2_id) en WP
# WP term_taxonomy_ids (obtenidos con SELECT tt.term_taxonomy_id FROM wp_term_taxonomy tt WHERE tt.term_id=N)
# Precalculados:
@@ -58,7 +58,7 @@ def ssh_mysql(query: str) -> list[dict]:
cmd = [
'sshpass', '-p', JOOMLA_SSH_PASS,
'ssh', f'{JOOMLA_SSH_USER}@{JOOMLA_SSH_HOST}',
f'mysql -h {JOOMLA_DB_HOST} -u {JOOMLA_DB_USER} '
f'mysql --skip-ssl -h {JOOMLA_DB_HOST} -u {JOOMLA_DB_USER} '
f'-p{repr(JOOMLA_DB_PASS)} {JOOMLA_DB_NAME} '
f'--default-character-set=utf8mb4 -B -e "{query}"'
]
@@ -207,6 +207,11 @@ def determine_categories(ef: dict, title: str) -> list[int]:
# ── Import principal ───────────────────────────────────────────────────────────
def main():
global LAST_K2_ID
# Detección dinámica del último K2 importado (evita hardcodear y re-importar deltas previos)
r = wp_mysql("SELECT MAX(CAST(meta_value AS UNSIGNED)) m FROM wp_postmeta "
"WHERE meta_key='_fgj2wp_old_k2_id'")
LAST_K2_ID = int(r[0]['m']) if r and r[0].get('m') and r[0]['m'] != 'NULL' else 17873
print(f"=== Import K2 items > {LAST_K2_ID} → WP local {'[DRY RUN]' if DRY_RUN else '[LIVE]'} ===\n")
user_map = load_user_map()
@@ -238,7 +243,7 @@ def main():
f"WHERE published=1 AND id > {LAST_K2_ID} ORDER BY id;"
)
mysql_cmd = (
f"mysql -h {JOOMLA_DB_HOST} -u {JOOMLA_DB_USER} "
f"mysql --skip-ssl -h {JOOMLA_DB_HOST} -u {JOOMLA_DB_USER} "
f"-p'{JOOMLA_DB_PASS}' {JOOMLA_DB_NAME} "
f"--default-character-set=utf8mb4 -B"
)
@@ -340,10 +345,12 @@ def main():
stats['ok'] += 1
continue
# Obtener el ID del post recién insertado
new_id_rows = wp_mysql("SELECT LAST_INSERT_ID() as new_id")
# Obtener el ID del post recién insertado. NO usar LAST_INSERT_ID(): cada
# docker exec abre una conexión nueva y devolvería 0. MAX(ID) es fiable
# en uso secuencial (sin inserciones concurrentes).
new_id_rows = wp_mysql("SELECT MAX(ID) as new_id FROM wp_posts")
if not new_id_rows:
print(f" [ERROR] No se pudo obtener LAST_INSERT_ID para k2_id={k2_id}")
print(f" [ERROR] No se pudo obtener el ID del post para k2_id={k2_id}")
stats['err'] += 1
continue
new_wp_id = int(new_id_rows[0]['new_id'])
+125
View File
@@ -0,0 +1,125 @@
#!/usr/bin/env python3
"""Pre-traduce a EN con Haiku los posts del gap que Gemma AÚN no ha alcanzado.
Crea el post EN + enlace Polylang (reutiliza fea_translate_helper.php, igual que
Gemma) ANTES de que Gemma llegue. Cuando Gemma llega, ve la traducción EN ya
enlazada en Polylang y la salta (translate_post.py:233), haciendo solo FR/IT/PT.
Así el EN se hace UNA vez y bien, sin el reprocesado posterior.
Coordinación: recorre los posts en el MISMO orden que Gemma, localiza por dónde
va (último :en en el state) y arranca `--margin` posts por delante para no
colisionar con el que Gemma está procesando ahora. Haiku (API) es mucho más
rápido que Gemma local, así que se aleja y nunca la alcanza.
Uso:
pretranslate_en_haiku.py # PLAN: muestra arranque y pendientes
pretranslate_en_haiku.py --apply # crea los EN
opciones: --margin N (def 2), --limit N
"""
import argparse
import json
import os
import sys
import time
HERE = os.path.dirname(os.path.abspath(__file__))
sys.path.insert(0, HERE)
import translate_post as tp # read_post, translation_exists, create_translation, carta_article_ids
from translate_haiku import translate # Haiku
# Mismo orden que translate_gap.sh
CARTAS = "45018 44997 44975 44230 44229 44228 44090 44089 44088 44087 44086 44085 44084 44083 42590".split()
def read_state():
"""Lee el state de Gemma con reintentos (lo reescribe en vivo)."""
for _ in range(6):
try:
d = json.loads(open(tp.STATE_FILE).read())
if d.get("done"):
return d
except (json.JSONDecodeError, FileNotFoundError):
pass
time.sleep(0.5)
sys.exit("No pude leer el state de Gemma con contenido; aborto por seguridad.")
def build_order():
"""Lista global de post_ids en el orden exacto en que Gemma los procesa."""
g = []
for c in CARTAS:
g.append(int(c))
g.extend(tp.carta_article_ids(int(c)))
return g
def main():
ap = argparse.ArgumentParser()
ap.add_argument("--apply", action="store_true")
ap.add_argument("--margin", type=int, default=2)
ap.add_argument("--limit", type=int, default=0)
args = ap.parse_args()
state = read_state()
done = state["done"]
order = build_order()
# Frente de Gemma = último índice con su :en ya hecho.
front = -1
for i, pid in enumerate(order):
if f"{pid}:en" in done:
front = i
if front < 0:
sys.exit("No encuentro el frente de Gemma en la lista; aborto.")
start = front + 1 + args.margin
work = order[start:]
if args.limit:
work = work[:args.limit]
cur = order[front]
print(f"Gemma va por #{cur} (índice {front}/{len(order)-1}).")
print(f"Margen {args.margin} → arranco en índice {start} (#{order[start] if start < len(order) else ''}).")
print(f"Posts pendientes a pre-traducir: {len(work)}")
if work:
print(f" primeros: {work[:5]}")
print(f" últimos: {work[-5:]}")
if not args.apply:
print("\nMODO PLAN (no se crea nada). Añade --apply para ejecutar.")
return
tot_in = tot_out = 0.0
created = skipped = 0
for pid in work:
if tp.translation_exists(pid, "en"):
print(f"#{pid}: EN ya existe (Gemma se adelantó) — salto")
skipped += 1
continue
try:
src = tp.read_post(pid)
except Exception as e: # noqa: BLE001
print(f"#{pid}: no pude leer ({e}) — salto")
continue
if src.get("lang") and src["lang"] != "es":
continue
body, u1 = translate(src["content"], "en")
title, u2 = translate(src["title"], "en", is_title=True)
tot_in += u1.input_tokens + u2.input_tokens
tot_out += u1.output_tokens + u2.output_tokens
# Re-chequeo justo antes de crear (ventana de carrera con Gemma).
if tp.translation_exists(pid, "en"):
print(f"#{pid}: EN apareció mientras traducía — salto")
skipped += 1
continue
new_id = tp.create_translation(pid, "en", title, body, "draft")
created += 1
print(f"#{pid} → EN #{new_id} «{title[:45]}»")
cost = tot_in / 1e6 * 1.0 + tot_out / 1e6 * 5.0
print(f"\nCreados: {created} Saltados: {skipped}")
print(f"Tokens in={int(tot_in)} out={int(tot_out)} coste=${cost:.4f}")
if __name__ == "__main__":
main()
+44
View File
@@ -0,0 +1,44 @@
<?php
/**
* Convierte los enlaces internos `...?p=<id>` de las cartas (grupo Polylang) a
* su permalink "bonito" por slug. Necesario cuando se arreglaron los enlaces de
* la carta con los artículos en DRAFT: get_permalink() devolvía `?p=<id>`, que
* el parser de portada (fea_url_to_post_id, resuelve por slug) NO reconoce → los
* artículos no salían en sus secciones. Con los posts ya en publish, get_permalink
* da el slug. Solo datos, sin tocar mu-plugins. Dry-run por defecto.
*
* Uso: CARTA=<es_id> php prettify_carta_links.php (dry-run)
* APPLY=1 CARTA=<es_id> php prettify_carta_links.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); }
$BAK = "/tmp/prettify_carta_bak"; if ($APPLY) @mkdir($BAK, 0777, true);
$tot = 0;
foreach (pll_get_post_translations($CARTA) as $lang => $pid) {
$post = get_post($pid); if (!$post) continue;
$chg = 0;
$new = preg_replace_callback('~href="([^"]*[?&]p=(\d+)[^"]*)"~i', function($m) use (&$chg) {
$id = (int) $m[2];
$url = get_permalink($id);
if (!$url || strpos($url, '?p=') !== false) return $m[0]; // sigue feo → dejar
$chg++;
return 'href="' . esc_url($url) . '"';
}, $post->post_content);
echo sprintf("#%d [%s] «%s» — %d enlaces ?p= → slug\n", $pid, $lang, mb_substr($post->post_title,0,28), $chg);
$tot += $chg;
if ($APPLY && $chg) {
file_put_contents("$BAK/$pid.html", $post->post_content);
wp_update_post(['ID'=>$pid, 'post_content'=>$new]);
clean_post_cache($pid);
}
}
// Invalida los transients de secciones de la portada para que recoja los cambios.
if ($APPLY) {
global $wpdb;
$wpdb->query("DELETE FROM {$wpdb->options} WHERE option_name LIKE '_transient_fea_carta_sections_%'");
$wpdb->query("DELETE FROM {$wpdb->options} WHERE option_name LIKE '_transient_timeout_fea_carta_sections_%'");
}
echo ($APPLY ? "APLICADO" : "DRY-RUN") . ": $tot enlaces.\n";
+2 -2
View File
@@ -1,6 +1,6 @@
<?php
require '/var/www/html/wp-load.php';
$CARTA = 46956;
require getenv('FEA_WP_LOAD') ?: '/var/www/html/wp-load.php';
$CARTA = (int)(getenv('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',
+40
View File
@@ -0,0 +1,40 @@
<?php
// Remapea los enlaces internos de las traducciones de una carta: convierte
// permalinks/IDs que apuntan al artículo ES → permalink del artículo en el
// idioma de CADA carta (vía Polylang). Necesario cuando la carta ES se tradujo
// DESPUÉS de haberle fijado los enlaces a permalink (las traducciones heredan
// los permalinks ES). Solo toca traducciones (no la ES). Dry-run por defecto.
require getenv('FEA_WP_LOAD') ?: '/var/www/html/wp-load.php';
global $wpdb;
$APPLY = getenv("APPLY") === "1";
$BAK = "/tmp/remap_carta_tr_bak"; if ($APPLY) @mkdir($BAK,0777,true);
$CARTA = (int)(getenv("CARTA") ?: 53644);
$tr = pll_get_post_translations($CARTA);
$tot = 0;
foreach($tr as $lang=>$pid){
if($lang === 'es') continue; // la ES ya está bien
$post = get_post($pid); if(!$post) continue;
$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,'farmer.taild3aaf6.ts.net')===false && stripos($href,'/fea/')===false) return $m[0];
$es = 0;
if(preg_match('~[?&]p=(\d+)~',$href,$mm)) $es=(int)$mm[1]; // forma ?p=ID
if(!$es) $es = (int)url_to_postid($href); // forma /slug/
if(!$es){ return $m[0]; }
if(pll_get_post_language($es) !== 'es'){ return $m[0]; } // solo si apunta a ES
$t = pll_get_post($es,$lang);
if(!$t || $t==$es){ $miss[]=$href; return $m[0]; } // sin traducción → dejar
$url = get_permalink($t);
if(!$url) return $m[0];
$chg++;
return 'href="'.esc_url($url).'"';
}, $post->post_content);
echo sprintf("#%d [%s] «%s» — %d remapeados%s\n",$pid,$lang,mb_substr($post->post_title,0,30),$chg,
$miss?(" | sin traducción: ".count($miss)):"");
$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 ($APPLY?"APLICADO":"DRY-RUN").": $tot enlaces.\n";
+42
View File
@@ -0,0 +1,42 @@
<?php
/**
* Remapea las categorías de las traducciones automáticas (meta traduccion_origen)
* a los términos traducidos de su propio idioma. Idempotente y sin llamar a Gemma.
*
* Arregla las traducciones creadas antes de que fea_translate_helper.php mapeara
* categorías (issue #75): p.ej. una carta EN que quedó en la categoría ES `cartasemana`
* pasa a la categoría EN `letter-of-the-week`, poblando el archivo de carta por idioma.
*
* Uso: docker exec wordpress-web php /tmp/remap_translation_cats.php
*/
$_SERVER['REQUEST_URI'] = $_SERVER['REQUEST_URI'] ?? '/';
$_SERVER['HTTP_HOST'] = $_SERVER['HTTP_HOST'] ?? 'farmer.taild3aaf6.ts.net';
require_once '/var/www/html/wp-load.php';
if (!function_exists('pll_get_term')) { fwrite(STDERR, "Polylang no disponible\n"); exit(2); }
global $wpdb;
$ids = $wpdb->get_col("SELECT DISTINCT post_id FROM {$wpdb->postmeta} WHERE meta_key='traduccion_origen'");
$fixed = 0;
foreach ($ids as $pid) {
$pid = (int) $pid;
$lang = pll_get_post_language($pid);
if (!$lang || $lang === 'es') continue;
$cats = wp_get_post_categories($pid);
$mapped = [];
$changed = false;
foreach ($cats as $c) {
$tc = (int) pll_get_term($c, $lang);
if ($tc && $tc !== $c) { $mapped[] = $tc; $changed = true; }
else { $mapped[] = $c; }
}
if ($changed) {
wp_set_post_categories($pid, array_values(array_unique($mapped)));
$fixed++;
}
}
echo "Remapeadas categorías en $fixed traducciones (de " . count($ids) . " revisadas)\n";
+1 -1
View File
@@ -1,5 +1,5 @@
<?php
require '/var/www/html/wp-load.php';
require getenv('FEA_WP_LOAD') ?: '/var/www/html/wp-load.php';
global $wpdb;
$APPLY = getenv("APPLY")==="1";
$CARTA = (int)(getenv("CARTA") ?: 46956);
+128
View File
@@ -0,0 +1,128 @@
#!/usr/bin/env python3
"""Reprocesa traducciones ROTAS con Claude Haiku 4.5 (API directa).
Coge el ES original, lo traduce con Haiku y SOBRESCRIBE la traducción ya
existente (in-place, sin duplicar). Detecta los rotos por ratio de longitud.
Uso:
reprocess_en_haiku.py --auto --langs en --limit 100 # EN rotos
reprocess_en_haiku.py --auto --langs fr,it,pt --limit 50 # otros idiomas
reprocess_en_haiku.py --ids 44205 --langs en # src concretos
añade --apply para ESCRIBIR en la BD (sin él = dry-run).
"""
import argparse
import json
import os
import re
import subprocess
import sys
sys.path.insert(0, os.path.dirname(os.path.abspath(__file__)))
from translate_haiku import translate # noqa: E402
STATE = "/tmp/feadulta-translate-state.json"
CONTAINER = "wordpress-web"
RATIO_BROKEN = 0.45
def dexec(args):
return subprocess.run(["docker", "exec", CONTAINER, *args],
capture_output=True, text=True)
def dcp(remote, local):
subprocess.run(["docker", "cp", f"{CONTAINER}:{remote}", local], check=True)
def dcp_to(local, remote):
subprocess.run(["docker", "cp", local, f"{CONTAINER}:{remote}"], check=True)
def get_post(pid):
r = dexec(["php", "/tmp/fea_post_io.php", "get", str(pid)])
if r.returncode != 0:
raise RuntimeError(f"get {pid}: {r.stderr.strip()}")
dcp("/tmp/fea_es.json", "/tmp/fea_es.json")
return json.load(open("/tmp/fea_es.json"))
def strip_len(html):
return len(re.sub(r"<[^>]*>", "", html))
def find_broken(state, langs, limit):
"""Devuelve [(src, lang), ...] de traducciones rotas."""
out = []
for key, tid in state["done"].items():
src, lang = key.split(":")
if lang not in langs:
continue
try:
es = get_post(int(src))
tr = get_post(int(tid))
except RuntimeError:
continue
olen = strip_len(es["content"])
if olen < 40:
continue
if strip_len(tr["content"]) / olen < RATIO_BROKEN:
out.append((int(src), lang))
if len(out) >= limit:
break
return out
def main():
ap = argparse.ArgumentParser()
ap.add_argument("--ids", nargs="*", type=int, default=[])
ap.add_argument("--langs", default="en")
ap.add_argument("--auto", action="store_true")
ap.add_argument("--limit", type=int, default=100)
ap.add_argument("--apply", action="store_true")
args = ap.parse_args()
langs = [l.strip() for l in args.langs.split(",") if l.strip()]
state = json.load(open(STATE))
if args.auto:
print(f"Autodetectando rotos (ratio<{RATIO_BROKEN}) en {langs}")
pairs = find_broken(state, langs, args.limit)
print(f"Encontrados: {pairs}")
else:
pairs = [(src, lang) for src in args.ids for lang in langs]
tot_in = tot_out = 0.0
for src, lang in pairs:
tid = state["done"].get(f"{src}:{lang}")
if not tid:
print(f"[{src}:{lang}] sin traducción en state; salto")
continue
es = get_post(src)
body, u1 = translate(es["content"], lang)
title, u2 = translate(es["title"], lang, is_title=True)
tot_in += u1.input_tokens + u2.input_tokens
tot_out += u1.output_tokens + u2.output_tokens
print(f"\n===== src #{src} [{lang}] -> #{tid} =====")
print(f"TÍTULO {lang}: {title}")
print(f"cuerpo ES={strip_len(es['content'])} -> {lang}={strip_len(body)}")
if args.apply:
open("/tmp/fea_title.txt", "w").write(title)
open("/tmp/fea_body.txt", "w").write(body)
dcp_to("/tmp/fea_title.txt", "/tmp/fea_title.txt")
dcp_to("/tmp/fea_body.txt", "/tmp/fea_body.txt")
r = dexec(["php", "/tmp/fea_post_io.php", "update", str(tid),
"/tmp/fea_title.txt", "/tmp/fea_body.txt"])
print(("APLICADO: " + r.stdout.strip()) if r.returncode == 0
else ("FALLO: " + r.stderr.strip()))
else:
print("(dry-run)")
cost = tot_in / 1e6 * 1.0 + tot_out / 1e6 * 5.0
print(f"\nTOTAL tokens: in={int(tot_in)} out={int(tot_out)} coste=${cost:.4f}")
print("MODO: " + ("APLICADO a BD" if args.apply else "DRY-RUN"))
if __name__ == "__main__":
main()
+83
View File
@@ -0,0 +1,83 @@
<?php
/**
* Ciclo carta nueva — ROTACIÓN de la "carta de la semana" en TODOS los idiomas.
*
* Rotación (en este orden, para no perder ninguna):
* 1) la que estaba en "semana pasada" -> queda solo en "otras semanas"
* 2) la que estaba en "semana actual" -> pasa a "semana pasada"
* 3) la carta NUEVA -> pasa a "semana actual"
*
* Robusto/autocorrige: la NUEVA es el parámetro CARTA; la "semana pasada" se
* deriva como la carta publicada más reciente que NO es la nueva (por fecha),
* no por quién estuviera en el término (que puede estar roto). Garantiza
* count=1 en "actual" y count=1 en "pasada" por idioma. "Otras semanas" (21)
* es el cajón base que conservan TODAS las cartas.
*
* Términos ES base (se derivan por Polylang a cada idioma):
* actual = 6 (cartasemana) | pasada = 22 (carta-semana-pasada) | otras = 21
*
* Uso: CARTA=<es_id> php rotate_cartas.php (dry-run)
* APPLY=1 CARTA=<es_id> php rotate_cartas.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); }
$actual_terms = pll_get_term_translations(6);
$pasada_terms = pll_get_term_translations(22);
$otras_terms = pll_get_term_translations(21);
$carta_tr = pll_get_post_translations($CARTA);
function cartas_en($terms) { // posts publish en cualquiera de esos términos (mismo idioma), por fecha desc
return get_posts(['post_type'=>'post','post_status'=>'publish','numberposts'=>-1,'fields'=>'ids',
'orderby'=>'date','order'=>'DESC','suppress_filters'=>true,
'tax_query'=>[['taxonomy'=>'category','field'=>'term_id','terms'=>array_values(array_filter($terms))]]]);
}
foreach ($actual_terms as $lang => $t_actual) {
$t_pasada = (int)($pasada_terms[$lang] ?? 0);
$t_otras = (int)($otras_terms[$lang] ?? 0);
$t_actual = (int)$t_actual;
$new = (int)($carta_tr[$lang] ?? 0);
// Conjunto de cartas de ESTE idioma (los términos ya son por idioma) por fecha desc.
$all = cartas_en([$t_actual, $t_pasada, $t_otras]);
// "semana pasada" = la más reciente que no es la nueva.
$prev = 0; foreach ($all as $pid) { if ($pid != $new) { $prev = $pid; break; } }
// Posts actualmente marcados como actual/pasada (conjunto pequeño a limpiar).
$flagged = get_posts(['post_type'=>'post','post_status'=>'any','numberposts'=>-1,'fields'=>'ids',
'suppress_filters'=>true,
'tax_query'=>[['taxonomy'=>'category','field'=>'term_id','terms'=>array_values(array_filter([$t_actual,$t_pasada]))]]]);
if ($APPLY) {
// 1) limpiar: quitar actual+pasada de cualquiera salvo los dos destinos.
foreach ($flagged as $pid) {
if ($pid == $new || $pid == $prev) continue;
wp_remove_object_terms($pid, array_values(array_filter([$t_actual,$t_pasada])), 'category');
}
// 2) NUEVA -> semana actual (y fuera de pasada). Mantener otras.
if ($new) {
wp_set_object_terms($new, [$t_actual], 'category', true);
if ($t_pasada) wp_remove_object_terms($new, [$t_pasada], 'category');
if ($t_otras) wp_set_object_terms($new, [$t_otras], 'category', true);
}
// 3) ANTERIOR -> semana pasada (y fuera de actual). Mantener otras.
if ($prev && $t_pasada) {
wp_set_object_terms($prev, [$t_pasada], 'category', true);
wp_remove_object_terms($prev, [$t_actual], 'category');
if ($t_otras) wp_set_object_terms($prev, [$t_otras], 'category', true);
}
clean_term_cache(array_filter([$t_actual,$t_pasada,$t_otras]), 'category');
}
$cleaned = count(array_diff($flagged, [$new, $prev]));
$tn = $new ? get_post($new) : null;
$tp = $prev ? get_post($prev) : null;
printf("%s: actual=#%d «%s» | pasada=#%d «%s» | degradadas a 'otras' %d post(s)%s\n",
strtoupper($lang), $new, $tn?mb_substr($tn->post_title,0,26):'-',
$prev, $tp?mb_substr($tp->post_title,0,26):'-',
$cleaned, $APPLY?'':' [DRY-RUN]');
}
echo $APPLY ? "APLICADO\n" : "DRY-RUN (APPLY=1 para aplicar)\n";
+325
View File
@@ -0,0 +1,325 @@
#!/usr/bin/env python3
"""
sync_translations_to_prod.py — Sincroniza contenido local a PROD reutilizando el
texto ya verificado en local.
Tiene dos modos:
1. Legado: sincroniza traducciones automáticas (`traduccion_origen`) suponiendo que
el post ES origen ya existe en prod con el mismo ID.
2. IDs preservados: clona posts locales a prod con ID explícito, copiando contenido,
slug, metas y categorías, y después reconstruye los grupos Polylang exactos.
El modo 2 es el que usa el handoff de la carta 46956 para evitar romper la
coincidencia local↔prod cuando prod va por detrás.
"""
from __future__ import annotations
import argparse
import json
import os
import subprocess
import time
from pathlib import Path
# ── Config ───────────────────────────────────────────────────────────────────
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_translate_helper.php"
HELPER_SRC = Path(__file__).resolve().parent / "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"))
LOG_FILE = Path(os.environ.get("FEA_SYNC_LOG", "/tmp/feadulta-sync.log"))
STATUS = os.environ.get("FEA_SYNC_STATUS", "draft")
# URLs absolutas del entorno local que NO deben llegar a prod (issue #91): el
# post_content local arrastra el host de Tailscale con prefijo /fea; en prod la
# 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://www.feadulta.com")
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[:3])}\n{r.stderr.strip()[:400]}")
return r.stdout
def parse_csv_ints(raw: str) -> list[int]:
out: list[int] = []
for part in raw.split(","):
part = part.strip()
if part.isdigit():
out.append(int(part))
return out
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` → `www.feadulta.com`
pero aplicado en origen, así el contenido llega ya correcto a prod (issue #91).
Devuelve (texto, nº de reemplazos).
"""
if not text or not LOCAL_BASE:
return text or "", 0
n = text.count(LOCAL_BASE)
return (text.replace(LOCAL_BASE, PROD_BASE), n) if n else (text, 0)
# ── Local ────────────────────────────────────────────────────────────────────
_local_ready = False
def local_helper(subcmd: str, *args: str) -> str:
global _local_ready
if not _local_ready:
sh(["docker", "cp", str(HELPER_SRC), f"{WP_CONTAINER}:{LOCAL_HELPER_DST}"])
_local_ready = True
return sh(["docker", "exec", "-i", WP_CONTAINER, "php", LOCAL_HELPER_DST, subcmd, *args], timeout=180)
def local_read(post_id: int) -> dict:
return json.loads(local_helper("read", str(post_id)))
def local_read_full(post_id: int) -> dict:
return json.loads(local_helper("read_full", str(post_id)))
def local_translation_pairs() -> list[tuple[int, int]]:
q = ("SELECT post_id, meta_value FROM wp_postmeta "
"WHERE meta_key='traduccion_origen' ORDER BY CAST(meta_value AS UNSIGNED), post_id;")
out = sh(["docker", "exec", DB_CONTAINER, "mysql", f"-u{DB_USER}", f"-p{DB_PASS}",
DB_NAME, "-N", "-e", q])
pairs = []
for line in out.splitlines():
parts = line.split("\t")
if len(parts) == 2 and parts[0].isdigit() and parts[1].isdigit():
pairs.append((int(parts[0]), int(parts[1])))
return pairs
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;")
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.isdigit()]
def collect_related_posts(seed_ids: list[int]) -> tuple[dict[int, dict], list[dict[str, int]]]:
posts: dict[int, dict] = {}
groups: dict[tuple[tuple[str, int], ...], dict[str, int]] = {}
for seed in seed_ids:
info = local_read_full(seed)
posts[seed] = info
raw_group = info.get("translations") or {}
group = {
lang: int(pid)
for lang, pid in raw_group.items()
if str(pid).isdigit()
}
if not group:
lang = info.get("lang") or "es"
group = {lang: seed}
sig = tuple(sorted(group.items()))
groups[sig] = group
all_ids = sorted({pid for group in groups.values() for pid in group.values()})
for pid in all_ids:
if pid not in posts:
posts[pid] = local_read_full(pid)
return posts, list(groups.values())
# ── Prod ─────────────────────────────────────────────────────────────────────
_prod_ready = False
def _ssh(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]
return sh(cmd, stdin=stdin, timeout=timeout)
def prod_helper(subcmd: str, *args: str, stdin: str | None = None) -> str:
global _prod_ready
if not _prod_ready:
_ssh(f"cat > {PROD_HELPER}", stdin=HELPER_SRC.read_text(encoding="utf-8"))
_prod_ready = True
inner = f"FEA_WP_LOAD={PROD_WPLOAD} php {PROD_HELPER} {subcmd} " + " ".join(args)
return _ssh(inner, stdin=stdin, timeout=180)
def prod_create(origin: int, lang: str, title: str, content: str) -> int:
content, n = localize_urls(content)
if n:
log(f" localize origin={origin} [{lang}]: {n} URL(s) Tailscale→prod")
payload = json.dumps({"title": title, "content": content, "model": "google/gemma-4-e4b (sync)"})
out = prod_helper("create", str(origin), lang, STATUS, stdin=payload).strip()
return int(out)
def prod_clone(post: dict) -> int:
content, n1 = localize_urls(post.get("content", ""))
excerpt, n2 = localize_urls(post.get("excerpt", ""))
if n1 or n2:
log(f" localize #{post['id']} [{post.get('lang','?')}]: {n1 + n2} URL(s) Tailscale→prod")
payload = {
"title": post["title"],
"content": content,
"excerpt": excerpt,
"slug": post.get("slug", ""),
"type": post.get("type", "post"),
"author": post.get("author", 1),
"date": post.get("date"),
"date_gmt": post.get("date_gmt"),
"status": post.get("status"),
"cats": post.get("cats", []),
"cat_slugs": post.get("cat_slugs", []),
"meta": post.get("meta", {}),
}
out = prod_helper("clone", str(post["id"]), post["lang"], STATUS, stdin=json.dumps(payload)).strip()
return int(out)
def prod_save_group(group: dict[str, int]) -> dict[str, int]:
out = prod_helper("save_translations", stdin=json.dumps({"translations": group})).strip()
return json.loads(out)
# ── Estado ───────────────────────────────────────────────────────────────────
def load_state() -> dict:
if STATE_FILE.exists():
try:
return json.loads(STATE_FILE.read_text())
except json.JSONDecodeError:
pass
return {"done": {}, "errors": {}}
def save_state(state: dict) -> None:
STATE_FILE.write_text(json.dumps(state, ensure_ascii=False, indent=2))
# ── Modo IDs preservados ─────────────────────────────────────────────────────
def deploy_fixed_ids(seed_ids: list[int], *, keep_existing: set[int], dry_run: bool) -> int:
posts, groups = collect_related_posts(seed_ids)
clone_ids = [pid for pid in posts if pid not in keep_existing]
clone_ids.sort(key=lambda pid: (0 if posts[pid].get("lang") == "es" else 1, pid))
log(f"Plan IDs preservados: seeds={seed_ids} clone={len(clone_ids)} grupos={len(groups)} status={STATUS}")
if keep_existing:
log(f"IDs marcados como ya existentes en prod: {sorted(keep_existing)}")
if dry_run:
for pid in clone_ids:
p = posts[pid]
log(f" CLONE #{pid} [{p.get('lang','?')}] slug={p.get('slug','')} cats={len(p.get('cat_slugs', []))}")
for group in groups:
log(f" GROUP {group}")
return 0
for pid in clone_ids:
p = posts[pid]
new_id = prod_clone(p)
log(f" clone #{pid} [{p.get('lang','?')}] → prod #{new_id} «{p['title'][:45]}»")
for group in groups:
saved = prod_save_group(group)
log(f" group enlazado {saved}")
log("FIN sync IDs preservados.")
return 0
# ── Main legado ──────────────────────────────────────────────────────────────
def legacy_sync(limit: int, origin: int) -> int:
state = load_state()
pairs = local_translation_pairs()
if origin:
pairs = [p for p in pairs if p[1] == origin]
log(f"Traducciones locales a sincronizar: {len(pairs)} (status={STATUS})")
n_ok = n_skip = n_err = 0
for tid, src_origin in pairs:
if limit and (n_ok + n_err) >= limit:
break
try:
t = local_read(tid)
except Exception as exc: # noqa: BLE001
log(f" local read #{tid} ERROR: {exc}")
n_err += 1
continue
lang = t.get("lang", "")
if lang in ("", "es"):
continue
key = f"{src_origin}:{lang}"
if key in state["done"]:
n_skip += 1
continue
try:
new_id = prod_create(src_origin, lang, t["title"], t["content"])
state["done"][key] = new_id
save_state(state)
n_ok += 1
log(f" {key} → prod #{new_id} «{t['title'][:45]}»")
except Exception as exc: # noqa: BLE001
state["errors"][key] = str(exc)[:300]
save_state(state)
n_err += 1
log(f" {key} ERROR: {exc}")
save_state(state)
log(f"FIN sync legado. nuevos={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.")
return 0
def main() -> int:
ap = argparse.ArgumentParser(description="Sincroniza contenido local→prod reutilizando el texto local.")
ap.add_argument("--limit", type=int, default=0, help="Modo legado: máximo de traducciones a sincronizar.")
ap.add_argument("--origin", type=int, default=0, help="Modo legado: solo traducciones de este ES.")
ap.add_argument("--carta", type=int, default=0, help="Modo IDs preservados: carta ES y todo su cluster.")
ap.add_argument("--ids", default="", help="Modo IDs preservados: lista CSV de posts semilla a clonar/enlazar.")
ap.add_argument("--keep-existing", default="", help="IDs que ya existen en prod y no deben clonarse.")
ap.add_argument("--dry-run", action="store_true", help="Solo muestra el plan; no toca prod.")
args = ap.parse_args()
seed_ids: list[int] = []
if args.carta:
seed_ids = [args.carta, *carta_article_ids(args.carta)]
elif args.ids:
seed_ids = parse_csv_ints(args.ids)
if seed_ids:
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 legacy_sync(args.limit, args.origin)
if __name__ == "__main__":
raise SystemExit(main())
+76
View File
@@ -0,0 +1,76 @@
#!/usr/bin/env bash
# ============================================================================
# translate_gap.sh — Traduce el "gap" marzo→ahora de feadulta a EN/FR/IT/PT.
#
# Hace TODO de una vez: comprobaciones previas, traduce las 15 cartas semanales
# (marzo a junio) + sus artículos con Gemma local, remapea categorías y enseña
# el recuento final. Idempotente y REANUDABLE: re-ejecutar es seguro (salta lo
# ya traducido). NO publica nada (todo queda en borrador / draft).
#
# USO (un solo comando, en segundo plano):
# nohup bash scripts/translate_gap.sh > /tmp/feadulta-gap.out 2>&1 &
# Y para ver el progreso:
# tail -f /tmp/feadulta-gap.log
#
# Ver issue rafa/feadulta#75.
# ============================================================================
set -u
cd "$(dirname "$0")/.." || { echo "No puedo entrar en el repo"; exit 1; }
LOG=/tmp/feadulta-gap.log
LANGS="en,fr,it,pt"
# Cartas del gap (de más reciente a más antigua). Override opcional: CARTAS="45018" bash ...
CARTAS="${CARTAS:-45018 44997 44975 44230 44229 44228 44090 44089 44088 44087 44086 44085 44084 44083 42590}"
ts() { date '+%Y-%m-%d %H:%M:%S'; }
say() { echo "[$(ts)] $*" | tee -a "$LOG"; }
say "================ INICIO batch del gap (draft) ================"
# 1) LM Studio + Gemma cargado
say "Preflight 1/2: LM Studio / Gemma..."
if ! curl -s --max-time 10 http://172.19.128.1:1234/v1/models 2>/dev/null | grep -q 'gemma-4-e4b'; then
say "ERROR: LM Studio no responde o 'google/gemma-4-e4b' no está cargado."
say " -> En Windows: abre LM Studio, carga 'google/gemma-4-e4b', server en 0.0.0.0:1234."
exit 1
fi
say " OK: Gemma disponible."
# 2) Contenedores docker arriba
say "Preflight 2/2: contenedores docker..."
for cnt in wordpress-web wordpress-mysql; do
if ! docker ps --format '{{.Names}}' | grep -qx "$cnt"; then
say "ERROR: el contenedor '$cnt' no está arriba. Arranca el stack (docker compose up -d) y reintenta."
exit 1
fi
done
say " OK: wordpress-web y wordpress-mysql arriba."
# 3) Traducir cada carta + sus artículos
N=$(echo $CARTAS | wc -w)
i=0
for c in $CARTAS; do
i=$((i+1))
say "=== Carta $c ($i/$N) -> $LANGS (draft) ==="
python3 scripts/translate_post.py --carta "$c" --langs "$LANGS" --status draft 2>&1 | tee -a "$LOG"
done
# 4) Remap de categorías (idempotente, sin Gemma): mete cada traducción en la
# categoría de su idioma (arregla el archivo de carta por idioma).
say "Remapeando categorías de todas las traducciones..."
docker cp scripts/remap_translation_cats.php wordpress-web:/tmp/remap_translation_cats.php >/dev/null 2>&1
docker exec wordpress-web php /tmp/remap_translation_cats.php 2>&1 | tee -a "$LOG"
# 5) Recuento final por idioma
say "Recuento final de traducciones por idioma (meta traduccion_origen):"
docker exec wordpress-mysql mysql -uwordpress_user -pwordpress_pass wordpress_db -N -e "
SELECT t.slug, COUNT(*) FROM wp_postmeta m
JOIN wp_term_relationships tr ON m.post_id=tr.object_id
JOIN wp_term_taxonomy tt ON tr.term_taxonomy_id=tt.term_taxonomy_id AND tt.taxonomy='language'
JOIN wp_terms t ON tt.term_id=t.term_id
WHERE m.meta_key='traduccion_origen' GROUP BY t.slug;" 2>/dev/null | tee -a "$LOG"
say "================ FIN batch del gap ================"
say "Todo en DRAFT. No se ha publicado nada. Avisa a Rafa para revisar antes de publicar."
say "Log completo: $LOG"
+87
View File
@@ -0,0 +1,87 @@
#!/usr/bin/env python3
"""Traduce ES->EN con Claude Haiku 4.5 vía API directa. Prueba de coste/calidad.
Lee la ANTHROPIC_API_KEY de portfolio-tracker/.env (la misma que usa el
portfolio tracker para trade setups). Reporta tokens reales de la API.
"""
import os
import re
import sys
# Cargar API key del .env de portfolio-tracker sin pisar el entorno existente.
ENV_PATH = "/home/rafa/portfolio-tracker/.env"
if "ANTHROPIC_API_KEY" not in os.environ:
for line in open(ENV_PATH):
line = line.strip()
if line.startswith("ANTHROPIC_API_KEY="):
os.environ["ANTHROPIC_API_KEY"] = line.split("=", 1)[1].strip().strip('"').strip("'")
break
import anthropic
MODEL = "claude-haiku-4-5"
LANG_NAMES = {"en": "English", "fr": "French (français)",
"it": "Italian (italiano)", "pt": "Portuguese (português)"}
def system_prompt(lang: str) -> str:
target = LANG_NAMES[lang]
return (
f"Eres un traductor profesional de textos religiosos cristianos "
f"(espiritualidad y teología católica). Traduce del español al {target}. "
f"REGLAS ESTRICTAS:\n"
f"1. Conserva EXACTAMENTE el marcado HTML (etiquetas y atributos) y los "
f"shortcodes entre [ ] y {{ }}. No los traduzcas ni los reordenes.\n"
f"2. NO traduzcas las referencias bíblicas ni sus abreviaturas "
f"(p.ej. 'Jn 3, 16', 'Mt 5'). Déjalas idénticas.\n"
f"3. Conserva los nombres propios de persona y lugar (salvo exónimos establecidos).\n"
f"4. Términos litúrgicos correctos (p.ej. 'Cuaresma' = Lent/Carême/Quaresima/Quaresma; "
f"NO inventes palabras).\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 extract(text: str) -> str:
# Coge el bloque <<<INI>>>...<<<FIN>>> de contenido MÁS LARGO (robusto al
# bug del runner local, donde el modelo a veces re-menciona las marcas).
blocks = re.findall(r"<<<INI>>>(.*?)<<<FIN>>>", text, re.S)
out = max(blocks, key=len).strip() if blocks else text.strip()
out = re.sub(r"^```[a-z]*\n?", "", out)
out = re.sub(r"\n?```$", "", out)
return out.strip()
def translate(text: str, lang: str, *, is_title: bool = False) -> tuple[str, object]:
client = anthropic.Anthropic()
kind = "el TÍTULO" if is_title else "el texto"
user = (
f"Traduce {kind} que va entre las marcas. "
f"Debe quedar en {LANG_NAMES[lang]} de forma natural.\n"
f"<<<INI>>>{text}<<<FIN>>>"
)
max_tokens = max(1024, int(len(text) * 0.9))
resp = client.messages.create(
model=MODEL,
max_tokens=min(max_tokens, 16000),
system=system_prompt(lang),
messages=[{"role": "user", "content": user}],
)
body = "".join(b.text for b in resp.content if b.type == "text")
return extract(body), resp.usage
if __name__ == "__main__":
path = sys.argv[1] if len(sys.argv) > 1 else "/tmp/orig_es.html"
lang = sys.argv[2] if len(sys.argv) > 2 else "en"
src = open(path).read()
out, usage = translate(src, lang)
open("/tmp/trad_haiku.html", "w").write(out)
cost = usage.input_tokens / 1e6 * 1.0 + usage.output_tokens / 1e6 * 5.0
print(f"MODEL={MODEL} lang={lang}")
print(f"input_tokens={usage.input_tokens} output_tokens={usage.output_tokens}")
print(f"coste_articulo=${cost:.5f}")
print(f"chars_in={len(src)} chars_out={len(out)}")
print("--- primeras 500 car ---")
print(out[:500])
+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);
+42 -2
View File
@@ -22,6 +22,7 @@ Pensado para que Codex lo lance en lote sobre la cola priorizada (cartas/destaca
from __future__ import annotations
import argparse
import fcntl
import json
import os
import re
@@ -47,6 +48,12 @@ 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
elif ENGINE == "minimax":
MODEL = os.environ.get("LOCAL_MODEL", "MiniMax-Text-01")
MINIMAX_URL = os.environ.get("MINIMAX_URL", "https://api.minimax.io/v1/text/chatcompletion_v2")
_kf = Path(os.environ.get("MINIMAX_KEY_FILE", "/home/rafa/Feadulta/minimax.txt"))
_keys = [l.strip() for l in _kf.read_text().splitlines() if l.strip().startswith("sk-")]
MINIMAX_KEY = _keys[-1] if _keys else ""
HELPER_SRC = Path(__file__).resolve().parent / "fea_translate_helper.php"
HELPER_DST = "/tmp/fea_translate_helper.php"
@@ -114,6 +121,24 @@ def gemma(messages: list[dict], *, max_tokens: int) -> str:
return data["choices"][0]["message"]["content"]
def minimax(messages: list[dict], *, max_tokens: int) -> str:
import urllib.request
body = json.dumps({
"model": MODEL,
"messages": messages,
"temperature": 0.2,
"max_tokens": max_tokens,
}).encode("utf-8")
req = urllib.request.Request(
MINIMAX_URL, data=body,
headers={"Content-Type": "application/json", "Authorization": f"Bearer {MINIMAX_KEY}"},
)
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>>>.
@@ -175,7 +200,8 @@ def translate_text(text: str, lang: str, *, is_title: bool = False) -> str:
{"role": "user", "content": task},
]
max_tokens = max(800, int(len(text) * 1.6))
raw = gemma(messages, max_tokens=max_tokens)
engine_fn = minimax if ENGINE == "minimax" else gemma
raw = engine_fn(messages, max_tokens=max_tokens)
return _extract(raw)
@@ -208,9 +234,19 @@ def translation_exists(es_id: int, lang: str) -> int:
return int(php_helper("exists", str(es_id), lang).strip() or "0")
WP_LOCK_FILE = Path(os.environ.get("FEA_TR_LOCK", "/tmp/feadulta-translate.lock"))
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())
# Lock entre procesos: serializa SOLO la escritura/enlace Polylang (rápido), no la
# traducción LLM (lenta), para que 4 streams por idioma no pisen el grupo de traducciones.
with WP_LOCK_FILE.open("w") as lk:
fcntl.flock(lk, fcntl.LOCK_EX)
try:
return int(php_helper("create", str(es_id), lang, status, stdin=payload).strip())
finally:
fcntl.flock(lk, fcntl.LOCK_UN)
def carta_article_ids(carta_id: int) -> list[int]:
@@ -272,6 +308,7 @@ def main() -> int:
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).")
g.add_argument("--ids-file", help="Fichero con un ID de post ES por línea.")
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.")
@@ -283,6 +320,9 @@ def main() -> int:
if args.post_id:
ids = [args.post_id]
elif args.ids_file:
ids = [int(x) for x in Path(args.ids_file).read_text().split() if x.strip().isdigit()]
log(f"ids-file {args.ids_file}: {len(ids)} posts")
else:
ids = [args.carta] + carta_article_ids(args.carta)
log(f"Carta {args.carta}: {len(ids)} posts (carta + {len(ids)-1} artículos)")
+99
View File
@@ -0,0 +1,99 @@
#!/usr/bin/env python3
"""Locuta una carta/artículo entero de feadulta con la voz clonada (XTTS-v2 + GPU).
Saca el texto del post ES, lo trocea por párrafos, lo locuta con la voz de
referencia (calculando los latents del hablante UNA sola vez), concatena con
pausas y añade comfort noise. Issue #76.
Uso:
tts_carta.py <post_id> <muestra_voz.wav> [nombre_salida]
"""
import html
import json
import os
import re
import subprocess
import sys
from pathlib import Path
os.environ.setdefault("COQUI_TOS_AGREED", "1")
import numpy as np # noqa: E402
import soundfile as sf # noqa: E402
import torch # noqa: E402
from TTS.api import TTS # noqa: E402
DEVICE = "cuda" if torch.cuda.is_available() and not os.environ.get("FEA_CPU") else "cpu"
OUT = Path(__file__).resolve().parent.parent / "wordpress/wp-content/uploads/tts-samples"
SR = 24000
CONTAINER = "wordpress-web"
def get_post_text(pid):
subprocess.run(["docker", "exec", CONTAINER, "php", "/tmp/fea_post_io.php", "get", str(pid)],
check=True, capture_output=True)
subprocess.run(["docker", "cp", f"{CONTAINER}:/tmp/fea_es.json", "/tmp/fea_es.json"], check=True)
d = json.load(open("/tmp/fea_es.json"))
raw = d["content"]
# Conserva límites de párrafo antes de quitar tags.
raw = re.sub(r"(?i)</p>|<br\s*/?>|</h[1-6]>", "\n", raw)
raw = re.sub(r"<[^>]+>", "", raw) # quita tags
raw = re.sub(r"\[[^\]]+\]", "", raw) # quita shortcodes
raw = html.unescape(raw)
paras = [re.sub(r"\s+", " ", p).strip() for p in raw.split("\n")]
paras = [p for p in paras if len(p) > 1]
return d["title"], paras
def main():
if len(sys.argv) < 3:
sys.exit("uso: tts_carta.py <post_id> <muestra_voz.wav> [nombre_salida]")
pid = int(sys.argv[1])
spk = sys.argv[2]
name = sys.argv[3] if len(sys.argv) > 3 else f"carta-{pid}"
title, paras = get_post_text(pid)
print(f"Post #{pid}: «{title}» ({len(paras)} párrafos, {sum(len(p) for p in paras)} car)")
print(f"Cargando XTTS-v2 en {DEVICE}", flush=True)
tts = TTS("tts_models/multilingual/multi-dataset/xtts_v2").to(DEVICE)
model = tts.synthesizer.tts_model
print("Calculando timbre del hablante (1 vez)…", flush=True)
gpt_cond, spk_emb = model.get_conditioning_latents(audio_path=[spk])
pause = np.zeros(int(SR * 0.35), dtype=np.float32)
pieces = []
import time
t0 = time.time()
for i, para in enumerate(paras, 1):
out = model.inference(
para, "es", gpt_cond, spk_emb,
temperature=0.65, repetition_penalty=5.0, top_k=50, top_p=0.85,
enable_text_splitting=True,
)
pieces.append(np.asarray(out["wav"], dtype=np.float32))
pieces.append(pause)
print(f" párrafo {i}/{len(paras)} ({len(para)} car) ok", flush=True)
audio = np.concatenate(pieces)
dt = time.time() - t0
dur = len(audio) / SR
print(f"Síntesis: {dt:.1f}s para {dur:.1f}s de audio (x{dur/dt:.1f} tiempo real) en {DEVICE}")
raw = OUT / f"{name}.raw.wav"
sf.write(raw, audio, SR)
wav = OUT / f"{name}.wav"
subprocess.run([
"ffmpeg", "-y", "-i", str(raw), "-filter_complex",
"anoisesrc=color=brown:amplitude=0.004:sample_rate=24000[n];"
"[n]highpass=f=120,lowpass=f=3800[nf];"
"[0:a][nf]amix=inputs=2:duration=first:dropout_transition=0:normalize=0[a]",
"-map", "[a]", "-ar", "24000", str(wav),
], capture_output=True)
raw.unlink(missing_ok=True)
mp3 = OUT / f"{name}.mp3"
subprocess.run(["ffmpeg", "-y", "-i", str(wav), "-b:a", "96k", str(mp3)], capture_output=True)
print(f"OK -> {mp3} ({dur:.0f}s de audio)")
if __name__ == "__main__":
main()
+51
View File
@@ -0,0 +1,51 @@
#!/usr/bin/env python3
"""Locuta una carta entera con edge-tts (online, gratis). Para comparar con XTTS.
Uso: tts_carta_edge.py <post_id> [voz] [nombre_salida]
voz por defecto: es-ES-XimenaNeural
"""
import html
import json
import os
import re
import subprocess
import sys
from pathlib import Path
EDGE = os.path.expanduser("~/.hermes/hermes-agent/venv/bin/edge-tts")
OUT = Path(__file__).resolve().parent.parent / "wordpress/wp-content/uploads/tts-samples"
CONTAINER = "wordpress-web"
def get_post_text(pid):
subprocess.run(["docker", "exec", CONTAINER, "php", "/tmp/fea_post_io.php", "get", str(pid)],
check=True, capture_output=True)
subprocess.run(["docker", "cp", f"{CONTAINER}:/tmp/fea_es.json", "/tmp/fea_es.json"], check=True)
d = json.load(open("/tmp/fea_es.json"))
raw = d["content"]
raw = re.sub(r"(?i)</p>|<br\s*/?>|</h[1-6]>", "\n", raw)
raw = re.sub(r"<[^>]+>", "", raw)
raw = re.sub(r"\[[^\]]+\]", "", raw)
raw = html.unescape(raw)
paras = [re.sub(r"\s+", " ", p).strip() for p in raw.split("\n")]
return d["title"], [p for p in paras if len(p) > 1]
def main():
pid = int(sys.argv[1])
voice = sys.argv[2] if len(sys.argv) > 2 else "es-ES-XimenaNeural"
name = sys.argv[3] if len(sys.argv) > 3 else f"carta-edge-{pid}"
title, paras = get_post_text(pid)
text = "\n\n".join(paras)
txt_path = "/tmp/carta_text.txt"
open(txt_path, "w").write(text)
print(f"Post #{pid}: «{title}» ({len(text)} car) → {voice}")
OUT.mkdir(parents=True, exist_ok=True)
mp3 = OUT / f"{name}.mp3"
subprocess.run([EDGE, "--voice", voice, "--file", txt_path,
"--write-media", str(mp3)], check=True)
print(f"OK -> {mp3}")
if __name__ == "__main__":
main()
+82
View File
@@ -0,0 +1,82 @@
#!/usr/bin/env python3
"""
tts_eval.py — Genera la MISMA frase de feadulta con varias voces/modelos TTS para
compararlas (evaluación de voz, issue #76). Incluye:
- edge-tts Ximena (referencia, gratis, ya la usamos) — siempre.
- Modelos premium vía Hugging Face Inference Providers (consume crédito HF) — opcional.
Objetivo: ELEGIR voz. Para producción en masa NO se usa HF (sale caro); el modelo
abierto ganador se corre en LOCAL (RTX 5060 Ti) gratis. Ver análisis en #76.
Uso:
# Solo la referencia local (gratis):
python3 scripts/tts_eval.py
# Con modelos HF (necesita token; gasta unos céntimos del crédito):
HF_TOKEN=hf_xxx python3 scripts/tts_eval.py --hf
Salida: ./tts-eval/<nombre>.mp3 (escúchalos y elige).
"""
from __future__ import annotations
import argparse, os, subprocess, sys
from pathlib import Path
SAMPLE = (
"Bienvenido a Fe Adulta. La humanidad abriga una esperanza: verse liberada de la "
"esclavitud y alcanzar la libertad de los hijos de Dios. Una fe adulta es una fe "
"personal, valiente, sin miedos infantiles. Detente un instante y respira."
)
OUT = Path(__file__).resolve().parent.parent / "tts-eval"
EDGE = os.path.expanduser("~/.hermes/hermes-agent/venv/bin/edge-tts")
# Candidatos vía HF Inference Providers (provider, model). Verifica disponibilidad en la
# pestaña "Inference Providers" de cada modelo en huggingface.co — el routing cambia.
HF_CANDIDATES = [
("fal-ai", "fal-ai/f5-tts"),
("fal-ai", "fal-ai/chatterbox/text-to-speech"),
("hf-inference", "myshell-ai/MeloTTS-Spanish"),
]
def edge_samples():
OUT.mkdir(exist_ok=True)
for voz in ("es-ES-XimenaNeural", "es-ES-ElviraNeural", "es-MX-JorgeNeural"):
dst = OUT / f"edge-{voz}.mp3"
print(f"edge-tts {voz} ...", flush=True)
subprocess.run([EDGE, "--voice", voz, "--text", SAMPLE, "--write-media", str(dst)],
capture_output=True)
print(f" -> {OUT}")
def hf_samples():
try:
from huggingface_hub import InferenceClient
except ImportError:
sys.exit("Falta huggingface_hub: pip install huggingface_hub")
token = os.environ.get("HF_TOKEN")
if not token:
sys.exit("Define HF_TOKEN para usar --hf")
OUT.mkdir(exist_ok=True)
for provider, model in HF_CANDIDATES:
name = model.replace("/", "_")
try:
client = InferenceClient(provider=provider, api_key=token)
audio = client.text_to_speech(SAMPLE, model=model)
dst = OUT / f"hf-{name}.mp3"
dst.write_bytes(audio)
print(f"OK {provider}:{model} -> {dst.name}")
except Exception as exc: # noqa: BLE001
print(f"FALLO {provider}:{model} -> {exc}")
def main():
ap = argparse.ArgumentParser()
ap.add_argument("--hf", action="store_true", help="También generar con modelos HF (gasta crédito).")
args = ap.parse_args()
edge_samples()
if args.hf:
hf_samples()
print("\nEscucha los .mp3 en", OUT, "y elige. Para producción: correr el modelo abierto ganador en local.")
if __name__ == "__main__":
raise SystemExit(main())
+41
View File
@@ -0,0 +1,41 @@
#!/usr/bin/env python3
"""Genera la muestra de feadulta con Kokoro (TTS local, gratis). Issue #76.
Voces español: ef_dora (fem), em_alex / em_santa (masc). lang_code 'e' = español.
Salida en uploads/tts-samples/ como kokoro-<voz>.wav (+ mp3 si hay ffmpeg).
"""
import subprocess
import sys
from pathlib import Path
import numpy as np
import soundfile as sf
from kokoro import KPipeline
SAMPLE = (
"Bienvenido a Fe Adulta. La humanidad abriga una esperanza: verse liberada de la "
"esclavitud y alcanzar la libertad de los hijos de Dios. Una fe adulta es una fe "
"personal, valiente, sin miedos infantiles. Detente un instante y respira."
)
OUT = Path(__file__).resolve().parent.parent / "wordpress/wp-content/uploads/tts-samples"
SR = 24000
VOICES = sys.argv[1:] or ["ef_dora", "em_alex"]
def main():
OUT.mkdir(parents=True, exist_ok=True)
pipe = KPipeline(lang_code="e") # español
for voice in VOICES:
chunks = [audio for _, _, audio in pipe(SAMPLE, voice=voice)]
audio = np.concatenate(chunks) if len(chunks) > 1 else chunks[0]
wav = OUT / f"kokoro-{voice}.wav"
sf.write(wav, audio, SR)
mp3 = OUT / f"kokoro-{voice}.mp3"
subprocess.run(["ffmpeg", "-y", "-i", str(wav), "-b:a", "96k", str(mp3)],
capture_output=True)
dur = len(audio) / SR
print(f"OK {voice}: {dur:.1f}s -> {mp3.name}")
if __name__ == "__main__":
main()
+72
View File
@@ -0,0 +1,72 @@
#!/usr/bin/env python3
"""Clona una voz con XTTS-v2 (local) y locuta la muestra de feadulta. Issue #76.
Uso:
tts_xtts.py <muestra_voz.wav|mp3> [nombre_salida]
La muestra: 6-20s de voz limpia en español. Salida en uploads/tts-samples/.
NOTA: XTTS-v2 tiene licencia no comercial (CPML). En CPU tarda ~1-2 min por
muestra; con GPU sería casi instantáneo.
"""
import os
import subprocess
import sys
from pathlib import Path
os.environ.setdefault("COQUI_TOS_AGREED", "1") # acepta licencia CPML no-interactivo
import torch # noqa: E402
from TTS.api import TTS # noqa: E402
DEVICE = "cuda" if torch.cuda.is_available() and not os.environ.get("FEA_CPU") else "cpu"
SAMPLE = (
"Bienvenido a Fe Adulta. La humanidad abriga una esperanza: verse liberada de la "
"esclavitud y alcanzar la libertad de los hijos de Dios. Una fe adulta es una fe "
"personal, valiente, sin miedos infantiles. Detente un instante y respira."
)
OUT = Path(__file__).resolve().parent.parent / "wordpress/wp-content/uploads/tts-samples"
def main():
if len(sys.argv) < 2:
sys.exit("uso: tts_xtts.py <muestra_voz.wav|mp3> [nombre_salida]")
spk = sys.argv[1]
name = sys.argv[2] if len(sys.argv) > 2 else "xtts-clon"
OUT.mkdir(parents=True, exist_ok=True)
print(f"Cargando XTTS-v2 en {DEVICE}", flush=True)
tts = TTS("tts_models/multilingual/multi-dataset/xtts_v2").to(DEVICE)
raw = OUT / f"{name}.raw.wav"
print(f"Clonando voz de {spk} y locutando…", flush=True)
tts.tts_to_file(
text=SAMPLE, speaker_wav=spk, language="es", file_path=str(raw),
temperature=0.65, # menos aleatoriedad → más estable
length_penalty=1.0,
repetition_penalty=5.0, # reduce artefactos/balbuceos en español
top_k=50,
top_p=0.85,
enable_text_splitting=True, # parte por frases → mejor prosodia
)
# Comfort noise: ruido marrón suave y constante que rellena los silencios de
# comas/puntos para que no contrasten con el suelo de ruido del habla clonada.
wav = OUT / f"{name}.wav"
if os.environ.get("FEA_NO_COMFORT"):
subprocess.run(["ffmpeg", "-y", "-i", str(raw), str(wav)], capture_output=True)
else:
subprocess.run([
"ffmpeg", "-y", "-i", str(raw), "-filter_complex",
"anoisesrc=color=brown:amplitude=0.004:sample_rate=24000[n];"
"[n]highpass=f=120,lowpass=f=3800[nf];"
"[0:a][nf]amix=inputs=2:duration=first:dropout_transition=0:normalize=0[a]",
"-map", "[a]", "-ar", "24000", str(wav),
], capture_output=True)
raw.unlink(missing_ok=True)
mp3 = OUT / f"{name}.mp3"
subprocess.run(["ffmpeg", "-y", "-i", str(wav), "-b:a", "96k", str(mp3)],
capture_output=True)
print(f"OK -> {mp3}")
if __name__ == "__main__":
main()
+153
View File
@@ -0,0 +1,153 @@
<?php
/**
* Unpublishes migrated posts whose title or slug is only a date (dd-mm-yyyy).
*
* Usage:
* docker cp scripts/unpublish_date_slug_posts.php wordpress-web:/tmp/unpublish_date_slug_posts.php
* docker exec wordpress-web php /tmp/unpublish_date_slug_posts.php --dry-run
* docker exec wordpress-web php /tmp/unpublish_date_slug_posts.php --apply
*/
$cli_args = array_merge($argv ?? [], $args ?? []);
$apply = getenv('APPLY') === '1' || in_array('--apply', $cli_args, true);
$dry_run = in_array('--dry-run', $cli_args, true) || !$apply;
$backup_table = 'wp_fea_date_slug_posts_backup';
$where = "
post_type = 'post'
AND post_status = 'publish'
AND (
post_title REGEXP '^[0-9]{2}-[0-9]{2}-[0-9]{4}$'
OR post_name REGEXP '^[0-9]{2}-[0-9]{2}-[0-9]{4}$'
)
";
echo "=== Unpublish date-like migrated posts ===\n";
echo $dry_run ? "[DRY RUN]\n\n" : "[APPLY]\n\n";
if (isset($GLOBALS['wpdb']) && $GLOBALS['wpdb'] instanceof wpdb) {
global $wpdb;
$posts_table = $wpdb->posts;
$backup_table = $wpdb->prefix . 'fea_date_slug_posts_backup';
$count = (int)$wpdb->get_var("SELECT COUNT(*) FROM $posts_table WHERE $where");
echo "Matching published posts: $count\n";
$sample = $wpdb->get_results("
SELECT ID, post_date, post_title, post_name
FROM $posts_table
WHERE $where
ORDER BY post_date DESC
LIMIT 20
", ARRAY_A);
foreach ($sample as $row) {
echo sprintf(
" #%d %s %s (%s)\n",
$row['ID'],
$row['post_date'],
$row['post_title'],
$row['post_name']
);
}
if ($dry_run) {
echo "\nNo changes made. Re-run with APPLY=1 to set these posts to draft.\n";
return;
}
$wpdb->query("
CREATE TABLE IF NOT EXISTS $backup_table (
post_id BIGINT UNSIGNED NOT NULL PRIMARY KEY,
old_status VARCHAR(20) NOT NULL,
old_modified DATETIME NOT NULL,
backed_up_at DATETIME NOT NULL
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4
");
$backed_up = $wpdb->query("
INSERT IGNORE INTO $backup_table (post_id, old_status, old_modified, backed_up_at)
SELECT ID, post_status, post_modified, NOW()
FROM $posts_table
WHERE $where
");
$updated = $wpdb->query("
UPDATE $posts_table
SET post_status = 'draft',
post_modified = NOW(),
post_modified_gmt = UTC_TIMESTAMP()
WHERE $where
");
echo "\nBacked up rows in $backup_table: $backed_up\n";
echo "Posts set to draft: $updated\n";
return;
}
$db_host = getenv('WORDPRESS_DB_HOST') ?: 'wordpress-db';
$db_name = getenv('WORDPRESS_DB_NAME') ?: 'wordpress_db';
$db_user = getenv('WORDPRESS_DB_USER') ?: 'wordpress_user';
$db_pass = getenv('WORDPRESS_DB_PASSWORD') ?: 'wordpress_pass';
$pdo = new PDO("mysql:host=$db_host;dbname=$db_name;charset=utf8mb4", $db_user, $db_pass, [
PDO::ATTR_ERRMODE => PDO::ERRMODE_EXCEPTION,
]);
$count = (int)$pdo->query("SELECT COUNT(*) FROM wp_posts WHERE $where")->fetchColumn();
echo "Matching published posts: $count\n";
$sample = $pdo->query("
SELECT ID, post_date, post_title, post_name
FROM wp_posts
WHERE $where
ORDER BY post_date DESC
LIMIT 20
")->fetchAll(PDO::FETCH_ASSOC);
foreach ($sample as $row) {
echo sprintf(
" #%d %s %s (%s)\n",
$row['ID'],
$row['post_date'],
$row['post_title'],
$row['post_name']
);
}
if ($dry_run) {
echo "\nNo changes made. Re-run with --apply to set these posts to draft.\n";
exit(0);
}
$pdo->exec("
CREATE TABLE IF NOT EXISTS $backup_table (
post_id BIGINT UNSIGNED NOT NULL PRIMARY KEY,
old_status VARCHAR(20) NOT NULL,
old_modified DATETIME NOT NULL,
backed_up_at DATETIME NOT NULL
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4
");
$pdo->beginTransaction();
$backed_up = $pdo->exec("
INSERT IGNORE INTO $backup_table (post_id, old_status, old_modified, backed_up_at)
SELECT ID, post_status, post_modified, NOW()
FROM wp_posts
WHERE $where
");
$updated = $pdo->exec("
UPDATE wp_posts
SET post_status = 'draft',
post_modified = NOW(),
post_modified_gmt = UTC_TIMESTAMP()
WHERE $where
");
$pdo->commit();
echo "\nBacked up rows in $backup_table: $backed_up\n";
echo "Posts set to draft: $updated\n";