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:
Regular → Executable
+63
-6
@@ -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);
|
||||
}
|
||||
});
|
||||
|
||||
@@ -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)));
|
||||
|
||||
Regular → Executable
+35
-9
@@ -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";
|
||||
|
||||
@@ -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)));
|
||||
@@ -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,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);
|
||||
|
||||
@@ -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";
|
||||
@@ -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
|
||||
*
|
||||
|
||||
@@ -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})")
|
||||
@@ -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
|
||||
*/
|
||||
|
||||
@@ -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";
|
||||
@@ -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";
|
||||
@@ -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)
|
||||
|
||||
@@ -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]}")
|
||||
|
||||
@@ -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'])
|
||||
|
||||
@@ -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()
|
||||
@@ -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";
|
||||
@@ -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',
|
||||
|
||||
@@ -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";
|
||||
@@ -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,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);
|
||||
|
||||
@@ -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()
|
||||
@@ -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";
|
||||
@@ -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())
|
||||
Executable
+76
@@ -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"
|
||||
@@ -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])
|
||||
@@ -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);
|
||||
|
||||
@@ -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)")
|
||||
|
||||
@@ -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()
|
||||
@@ -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()
|
||||
@@ -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())
|
||||
@@ -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()
|
||||
@@ -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()
|
||||
@@ -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";
|
||||
Reference in New Issue
Block a user