Merge pull request 'Sincronizar mu-plugins/ y scripts/ con el estado real del sitio (2026-07-16)' (#179) from sync-flat-2026-07-16 into main

This commit was merged in pull request #179.
This commit is contained in:
2026-07-16 00:40:49 +00:00
35 changed files with 1482 additions and 45 deletions
Executable
+10
View File
@@ -0,0 +1,10 @@
# Cache de Python
__pycache__/
*.pyc
# Logs
*.log
# Windows zone identifiers
*.Zone.Identifier
*:Zone.Identifier
+29 -2
View File
@@ -9,14 +9,17 @@ if (!defined('ABSPATH')) exit;
/** Devuelve el HTML del reproductor para el post actual, o '' si no hay audio. */ /** Devuelve el HTML del reproductor para el post actual, o '' si no hay audio. */
function fea_audio_player_html(): string { function fea_audio_player_html(): string {
$url = get_post_meta(get_the_ID(), 'fea_audio_url', true); $id = get_the_ID();
$url = get_post_meta($id, 'fea_audio_url', true);
if (!$url) return ''; if (!$url) return '';
$voice = get_post_meta($id, 'fea_audio_voice', true) ?: 'NicoFeadulta2026';
return '<div class="fea-audio">' return '<div class="fea-audio">'
. '<span class="fea-audio-label">' . '<span class="fea-audio-label">'
. '<svg viewBox="0 0 24 24" width="16" height="16" aria-hidden="true" focusable="false">' . '<svg viewBox="0 0 24 24" width="16" height="16" aria-hidden="true" focusable="false">'
. '<path fill="currentColor" d="M3 10v4h4l5 5V5L7 10H3zm13.5 2a4.5 4.5 0 0 0-2.5-4.03v8.06A4.5 4.5 0 0 0 16.5 12zM14 3.23v2.06a7 7 0 0 1 0 13.42v2.06a9 9 0 0 0 0-17.54z"/>' . '<path fill="currentColor" d="M3 10v4h4l5 5V5L7 10H3zm13.5 2a4.5 4.5 0 0 0-2.5-4.03v8.06A4.5 4.5 0 0 0 16.5 12zM14 3.23v2.06a7 7 0 0 1 0 13.42v2.06a9 9 0 0 0 0-17.54z"/>'
. '</svg> Escucha</span>' . '</svg> Escucha</span>'
. '<audio controls preload="none" src="' . esc_url($url) . '"></audio>' . '<audio controls preload="none" src="' . esc_url($url) . '"'
. ' data-fea-audio-track data-post-id="' . esc_attr($id) . '" data-voice="' . esc_attr($voice) . '"></audio>'
. '</div>'; . '</div>';
} }
@@ -60,3 +63,27 @@ add_action('wp_head', function () {
</style> </style>
<?php <?php
}); });
// Evento GA4 audio_play (issue tracking uso TTS). Un único evento por <audio>
// y carga de página, disparado en el primer 'play' (no en cada resume tras
// pausa/seek). gtag ya está definido por fea-analytics.php en wp_head prio 1,
// así que este script (footer) siempre lo encuentra disponible.
add_action('wp_footer', function () {
if (!is_singular('post')) return;
if (!get_post_meta(get_queried_object_id(), 'fea_audio_url', true)) return;
?>
<script>
document.querySelectorAll('audio[data-fea-audio-track]').forEach(function (audio) {
var fired = false;
audio.addEventListener('play', function () {
if (fired || typeof gtag !== 'function') return;
fired = true;
gtag('event', 'audio_play', {
post_id: audio.dataset.postId,
voice: audio.dataset.voice
});
});
});
</script>
<?php
});
+142
View File
@@ -0,0 +1,142 @@
<?php
/**
* Plugin Name: Fe Adulta — API carta_id
* Description: Endpoint REST para que Inma y su asistente asignen el meta interno _carta_id.
* Version: 1.0
*/
add_action('rest_api_init', function() {
register_rest_route('fea/v1', '/carta-id/(?P<post_id>\d+)', [
[
'methods' => WP_REST_Server::READABLE,
'callback' => 'fea_carta_id_api_get',
'permission_callback' => 'fea_carta_id_api_can_edit_post',
'args' => fea_carta_id_api_route_args(),
],
[
'methods' => WP_REST_Server::CREATABLE,
'callback' => 'fea_carta_id_api_update',
'permission_callback' => 'fea_carta_id_api_can_edit_post',
'args' => array_merge(fea_carta_id_api_route_args(), [
'carta_id' => [
'required' => true,
],
]),
],
[
'methods' => WP_REST_Server::DELETABLE,
'callback' => 'fea_carta_id_api_delete',
'permission_callback' => 'fea_carta_id_api_can_edit_post',
'args' => fea_carta_id_api_route_args(),
],
]);
});
function fea_carta_id_api_route_args() {
return [
'post_id' => [
'required' => true,
],
];
}
function fea_carta_id_api_can_edit_post(WP_REST_Request $request) {
if (!is_user_logged_in()) {
return new WP_Error(
'fea_carta_id_not_authenticated',
'Debes autenticarte para leer o modificar _carta_id.',
['status' => 401]
);
}
$post_id = (int) $request['post_id'];
if (!fea_carta_id_api_post_exists($post_id)) {
return true;
}
if (!current_user_can('edit_post', $post_id)) {
return new WP_Error(
'fea_carta_id_forbidden',
'No tienes permiso para editar este post.',
['status' => 403]
);
}
return true;
}
function fea_carta_id_api_get(WP_REST_Request $request) {
$post_id = (int) $request['post_id'];
$error = fea_carta_id_api_validate_post_id($post_id);
if ($error) return $error;
return fea_carta_id_api_response($post_id);
}
function fea_carta_id_api_update(WP_REST_Request $request) {
$post_id = (int) $request['post_id'];
$error = fea_carta_id_api_validate_post_id($post_id);
if ($error) return $error;
$carta_id = fea_carta_id_api_parse_positive_int($request->get_param('carta_id'));
if (!$carta_id) {
return new WP_Error(
'fea_carta_id_invalid',
'carta_id debe ser un entero positivo.',
['status' => 400]
);
}
if (!fea_carta_id_api_post_exists($carta_id)) {
return new WP_Error(
'fea_carta_id_not_found',
'carta_id debe corresponder a un post existente.',
['status' => 400]
);
}
update_post_meta($post_id, '_carta_id', $carta_id);
return fea_carta_id_api_response($post_id);
}
function fea_carta_id_api_delete(WP_REST_Request $request) {
$post_id = (int) $request['post_id'];
$error = fea_carta_id_api_validate_post_id($post_id);
if ($error) return $error;
delete_post_meta($post_id, '_carta_id');
return fea_carta_id_api_response($post_id);
}
function fea_carta_id_api_validate_post_id($post_id) {
if (!fea_carta_id_api_post_exists($post_id)) {
return new WP_Error(
'fea_carta_id_post_not_found',
'post_id debe corresponder a un post existente.',
['status' => 404]
);
}
return null;
}
function fea_carta_id_api_post_exists($post_id) {
$post = get_post($post_id);
return $post && $post->post_type === 'post';
}
function fea_carta_id_api_parse_positive_int($value) {
$int = filter_var($value, FILTER_VALIDATE_INT, ['options' => ['min_range' => 1]]);
return $int === false ? null : (int) $int;
}
function fea_carta_id_api_response($post_id) {
$carta_id = fea_carta_id_api_parse_positive_int(get_post_meta($post_id, '_carta_id', true));
return rest_ensure_response([
'post_id' => (int) $post_id,
'carta_id' => $carta_id,
]);
}
+14 -2
View File
@@ -140,9 +140,21 @@ function fea_url_to_post_id($url) {
// Enlace interno WP: deriva el slug del path, relativo al home. Agnóstico al // Enlace interno WP: deriva el slug del path, relativo al home. Agnóstico al
// entorno → funciona en local (home en .../fea) y en prod (home en la raíz). // entorno → funciona en local (home en .../fea) y en prod (home en la raíz).
// No depende de un prefijo /fea/ hardcodeado (issue #91). // No depende de un prefijo /fea/ hardcodeado (issue #91).
//
// Durante el cutover de dominio (issue #158) el `home_url()` de WP sigue en
// wp-nuevo.feadulta.com pero el contenido ya enlaza a www.feadulta.com. Para
// no romper la resolución mientras el cutover no esté cerrado, se acepta
// también una lista fija de hosts propios del sitio, no solo home_url().
// TODO: cuando #158 quede resuelto y home_url() sea www.feadulta.com, esta
// lista se puede simplificar a solo home_url().
$own_hosts = array_filter(array_unique([
strtolower((string) wp_parse_url(home_url(), PHP_URL_HOST)),
'www.feadulta.com',
'feadulta.com',
'wp-nuevo.feadulta.com',
]));
$host = wp_parse_url($url, PHP_URL_HOST); $host = wp_parse_url($url, PHP_URL_HOST);
$home_host = wp_parse_url(home_url(), PHP_URL_HOST); if ($host && !in_array(strtolower($host), $own_hosts, true)) {
if ($host && $home_host && strcasecmp($host, $home_host) !== 0) {
return null; // host externo → no es un artículo nuestro return null; // host externo → no es un artículo nuestro
} }
+76
View File
@@ -0,0 +1,76 @@
<?php
/**
* Plugin Name: FEA Cloudflare Real IP
* Description: Restaura la IP real del visitante en REMOTE_ADDR cuando la conexión
* proviene de un rango oficial de Cloudflare, leyendo CF-Connecting-IP.
* Necesario para que plugins de seguridad (LLAR), analytics y comentarios
* vean la IP del usuario y no la del proxy de Cloudflare.
* Auto-correctivo: si la conexión NO viene de Cloudflare, no toca nada.
* Version: 1.0
*/
if ( ! defined( 'ABSPATH' ) ) {
exit;
}
// Rangos oficiales de Cloudflare (https://www.cloudflare.com/ips/).
$fea_cf_ipv4 = array(
'173.245.48.0/20', '103.21.244.0/22', '103.22.200.0/22', '103.31.4.0/22',
'141.101.64.0/18', '108.162.192.0/18', '190.93.240.0/20', '188.114.96.0/20',
'197.234.240.0/22', '198.41.128.0/17', '162.158.0.0/15', '104.16.0.0/13',
'104.24.0.0/14', '172.64.0.0/13', '131.0.72.0/22',
);
$fea_cf_ipv6 = array(
'2400:cb00::/32', '2606:4700::/32', '2803:f800::/32', '2405:b500::/32',
'2405:8100::/32', '2a06:98c0::/29', '2c0f:f248::/32',
);
if ( empty( $_SERVER['REMOTE_ADDR'] ) || empty( $_SERVER['HTTP_CF_CONNECTING_IP'] ) ) {
return;
}
$fea_remote = $_SERVER['REMOTE_ADDR'];
$fea_client = trim( $_SERVER['HTTP_CF_CONNECTING_IP'] );
/**
* Comprueba si una IP está dentro de un CIDR (IPv4 o IPv6).
*/
function fea_ip_in_cidr( $ip, $cidr ) {
list( $subnet, $bits ) = array_pad( explode( '/', $cidr, 2 ), 2, null );
if ( null === $bits ) {
return false;
}
$bits = (int) $bits;
$ip_bin = @inet_pton( $ip );
$subnet_bin = @inet_pton( $subnet );
if ( false === $ip_bin || false === $subnet_bin || strlen( $ip_bin ) !== strlen( $subnet_bin ) ) {
return false;
}
$bytes = intdiv( $bits, 8 );
$rem = $bits % 8;
if ( $bytes > 0 && 0 !== substr_compare( $ip_bin, $subnet_bin, 0, $bytes ) ) {
return false;
}
if ( $rem > 0 ) {
$mask = ~( ( 1 << ( 8 - $rem ) ) - 1 ) & 0xff;
if ( ( ord( $ip_bin[ $bytes ] ) & $mask ) !== ( ord( $subnet_bin[ $bytes ] ) & $mask ) ) {
return false;
}
}
return true;
}
$fea_ranges = ( false !== strpos( $fea_remote, ':' ) ) ? $fea_cf_ipv6 : $fea_cf_ipv4;
$fea_from_cf = false;
foreach ( $fea_ranges as $fea_cidr ) {
if ( fea_ip_in_cidr( $fea_remote, $fea_cidr ) ) {
$fea_from_cf = true;
break;
}
}
// Solo confiamos en CF-Connecting-IP si la conexión proviene de Cloudflare,
// y solo si el valor es una IP válida (evita inyecciones).
if ( $fea_from_cf && false !== filter_var( $fea_client, FILTER_VALIDATE_IP ) ) {
$_SERVER['REMOTE_ADDR'] = $fea_client;
}
+109
View File
@@ -0,0 +1,109 @@
<?php
/**
* Plugin Name: Fe Adulta — API crear-autor
* Description: Endpoint REST acotado para que Mixbot (Inma) dé de alta autores
* nuevos sin depender de que Rafa esté disponible, sin necesitar
* el permiso sensible create_users de WP. El rol siempre es
* 'author' (fijo en el código, no lo decide quien llama).
* Idempotente: si el slug ya existe, devuelve el usuario existente.
* Version: 1.0
*
* Ver issue gitea.feadulta.com/rafa/feadulta#166.
*/
if (!defined('ABSPATH')) exit;
add_action('rest_api_init', function () {
register_rest_route('fea/v1', '/crear-autor', [
'methods' => WP_REST_Server::CREATABLE,
'callback' => 'fea_crear_autor_handle',
'permission_callback' => 'fea_crear_autor_can_call',
'args' => [
'nombre' => ['required' => true],
'slug' => ['required' => false],
],
]);
});
/** Editor o superior (mismo nivel que ya usan Inma/Mixbot para editar posts). */
function fea_crear_autor_can_call(WP_REST_Request $request) {
if (!is_user_logged_in()) {
return new WP_Error(
'fea_crear_autor_not_authenticated',
'Debes autenticarte para crear un autor.',
['status' => 401]
);
}
if (!current_user_can('edit_others_posts')) {
return new WP_Error(
'fea_crear_autor_forbidden',
'No tienes permiso para crear autores.',
['status' => 403]
);
}
return true;
}
function fea_crear_autor_handle(WP_REST_Request $request) {
$nombre = trim((string) $request->get_param('nombre'));
if ($nombre === '') {
return new WP_Error(
'fea_crear_autor_invalid',
'nombre es obligatorio.',
['status' => 400]
);
}
$slug_input = trim((string) $request->get_param('slug'));
$login = sanitize_user(sanitize_title($slug_input !== '' ? $slug_input : $nombre), true);
if ($login === '') {
return new WP_Error(
'fea_crear_autor_invalid',
'No se pudo derivar un slug válido de nombre/slug.',
['status' => 400]
);
}
// Idempotencia: si el login/slug ya existe, se devuelve tal cual (created=false).
$existing = get_user_by('login', $login);
if ($existing) {
return fea_crear_autor_response($existing, false);
}
$email = $login . '@feadulta.com';
$i = 2;
while (email_exists($email)) {
$email = $login . $i . '@feadulta.com';
$i++;
}
$user_id = wp_insert_user([
'user_login' => $login,
'user_pass' => wp_generate_password(20),
'user_email' => $email,
'display_name' => $nombre,
'nickname' => $nombre,
'role' => 'author', // fijo: este endpoint nunca crea otro rol.
]);
if (is_wp_error($user_id)) {
return new WP_Error(
'fea_crear_autor_failed',
'No se pudo crear el usuario: ' . $user_id->get_error_message(),
['status' => 500]
);
}
return fea_crear_autor_response(get_userdata($user_id), true);
}
function fea_crear_autor_response(WP_User $user, bool $created) {
return rest_ensure_response([
'id' => $user->ID,
'login' => $user->user_login,
'slug' => $user->user_nicename,
'display_name' => $user->display_name,
'role' => 'author',
'created' => $created,
]);
}
+13
View File
@@ -0,0 +1,13 @@
<?php
/**
* Plugin Name: Fea Search Console Verification
* Description: Mantiene la verificación de Google Search Console para
* www.feadulta.com tras el cutover Joomla -> WordPress (la plantilla de
* Joomla la tenía hardcodeada; en WP no había ningún sitio equivalente).
*/
if (!defined('ABSPATH')) exit;
add_action('wp_head', function () {
if (is_admin()) return;
echo '<meta name="google-site-verification" content="wZ_edk-78QW3w8aXtiYpzTPEJ8_dh8MkhaIDSTq8U-8" />' . "\n";
}, 1);
+31
View File
@@ -790,9 +790,40 @@ function fea_title(string $title): string {
$out = preg_replace_callback('/([\/:¿¡] *)(\p{Ll})/u', function ($m) { $out = preg_replace_callback('/([\/:¿¡] *)(\p{Ll})/u', function ($m) {
return $m[1] . mb_strtoupper($m[2], 'UTF-8'); return $m[1] . mb_strtoupper($m[2], 'UTF-8');
}, $out); }, $out);
$out = fea_recapitalizar_nombres_propios($out);
return $out; return $out;
} }
/**
* Nombres propios (personas, lugares, "Dios"/"Papa") que deben mantener mayúscula
* inicial en los títulos de portada. Los títulos originales se guardan en MAYÚSCULAS
* y fea_title() los minusculiza salvo la primera letra, así que esta señal se pierde
* para cualquier nombre propio que no sea la primera palabra del título. Añadir aquí
* cuando una carta nueva mencione un nombre que salga mal capitalizado en portada.
* Ver issue rafa/feadulta#160.
*/
function fea_nombres_propios(): array {
return [
'Aguirre', 'Crepin', 'Monga', 'Papa', 'Lampedusa', 'Europa', 'España', 'Dios',
];
}
function fea_recapitalizar_nombres_propios(string $texto): string {
static $regex = null, $map = null;
if ($regex === null) {
$nombres = fea_nombres_propios();
usort($nombres, fn($a, $b) => mb_strlen($b, 'UTF-8') - mb_strlen($a, 'UTF-8'));
$alt = implode('|', array_map(fn($n) => preg_quote($n, '/'), $nombres));
$regex = '/(?<![\p{L}])(' . $alt . ')(?![\p{L}])/iu';
$map = [];
foreach ($nombres as $n) $map[mb_strtolower($n, 'UTF-8')] = $n;
}
return preg_replace_callback($regex, function ($m) use ($map) {
$k = mb_strtolower($m[1], 'UTF-8');
return $map[$k] ?? $m[1];
}, $texto);
}
/** Lista de libros bíblicos (para avatar genérico de lecturas/eucaristías). #61 */ /** Lista de libros bíblicos (para avatar genérico de lecturas/eucaristías). #61 */
function fea_libros_biblicos(): array { function fea_libros_biblicos(): array {
return [ return [
+24
View File
@@ -0,0 +1,24 @@
<?php
/**
* URLs de Joomla que no tengan equivalente en WordPress (404 genuino) se
* redirigen a antiguo.feadulta.com, donde Joomla se sigue sirviendo tras el
* cutover, en vez de mostrar el 404 de WordPress.
*/
add_action('template_redirect', function () {
if (!is_404()) {
return;
}
$path = untrailingslashit(parse_url($_SERVER['REQUEST_URI'], PHP_URL_PATH));
// La antigua home de Joomla (con prefijo de idioma /es/) debe llevar a
// la home nueva, no a la web antigua.
if ($path === '/es') {
wp_redirect(home_url('/'), 301);
exit;
}
wp_redirect('https://antiguo.feadulta.com' . $_SERVER['REQUEST_URI'], 301);
exit;
}, 0);
+6 -11
View File
@@ -1,23 +1,18 @@
<?php <?php
/** /**
* fea-pensamientos — Galerías Joomla y pausa aleatoria en artículos. * fea-pensamientos — Galerías (Pensamientos, jornadas) y pausa aleatoria en artículos.
* *
* Reutiliza /images de Joomla sin duplicar ficheros en WordPress. * Las imágenes viven en wp-content/uploads/joomla-galleries/ (copiadas de Joomla
* el 2026-07-08 tras el cutover) — WordPress ya no depende del filesystem de
* Joomla/antiguo.feadulta.com para servirlas.
*/ */
if (!defined('FEA_JOOMLA_IMAGES_DIR')) { if (!defined('FEA_JOOMLA_IMAGES_DIR')) {
define('FEA_JOOMLA_IMAGES_DIR', file_exists('/web/images') ? '/web/images' : '/var/www/joomla-images'); define('FEA_JOOMLA_IMAGES_DIR', WP_CONTENT_DIR . '/uploads/joomla-galleries');
} }
if (!defined('FEA_JOOMLA_IMAGES_URL')) { if (!defined('FEA_JOOMLA_IMAGES_URL')) {
$fea_is_prod = (defined('ABSPATH') && strpos((string) ABSPATH, '/web/wp-nuevo/') === 0) define('FEA_JOOMLA_IMAGES_URL', content_url('uploads/joomla-galleries'));
|| (isset($_SERVER['HTTP_HOST']) && preg_match('/(^|\.)feadulta\.com$/', (string) $_SERVER['HTTP_HOST']))
|| file_exists('/web/images');
define(
'FEA_JOOMLA_IMAGES_URL',
$fea_is_prod ? 'https://www.feadulta.com/images' : 'https://farmer.taild3aaf6.ts.net/joomla/images'
);
} }
if (!defined('FEA_PENS_DIR')) { if (!defined('FEA_PENS_DIR')) {
+16
View File
@@ -32,6 +32,16 @@ defined('FEA_AUTORES_EXCLUIR') or define('FEA_AUTORES_EXCLUIR', [
*/ */
defined('FEA_CATS_CARTA_EXCLUIR') or define('FEA_CATS_CARTA_EXCLUIR', [6, 21, 22]); defined('FEA_CATS_CARTA_EXCLUIR') or define('FEA_CATS_CARTA_EXCLUIR', [6, 21, 22]);
/**
* Categorías RESIDUALES de la migración K2: páginas-índice por autor (listado de enlaces
* a sus artículos), no son artículos. 37 = "Lista de autores habituales", 38 = "Lista
* completa de autores por orden alfabético" (~1.169 posts). Por su gran tamaño (varios KB
* de títulos enlazados) el ranking FULLTEXT las puntúa altísimo y tapan el resultado real
* (bug reportado en feedback: "Tu verdadero ser" de Fray Marcos sacaba la ficha-índice del
* autor antes que el artículo). Se excluyen de los resultados de búsqueda.
*/
defined('FEA_CATS_RESIDUAL_EXCLUIR') or define('FEA_CATS_RESIDUAL_EXCLUIR', [37, 38]);
// ───────────────────────────────────────────────────────────────── // ─────────────────────────────────────────────────────────────────
// i18n mínimo (es / en / fr / it / pt) // i18n mínimo (es / en / fr / it / pt)
@@ -139,6 +149,12 @@ add_action('pre_get_posts', function (WP_Query $q): void {
// Categoría (tema) // Categoría (tema)
if ($fea_cat > 0) $q->set('cat', $fea_cat); if ($fea_cat > 0) $q->set('cat', $fea_cat);
// Excluir páginas-índice residuales K2 (ver FEA_CATS_RESIDUAL_EXCLUIR) de los
// resultados de búsqueda. No aplica si el usuario ya filtró por una categoría concreta.
if ($is_search && $fea_cat <= 0) {
$q->set('category__not_in', FEA_CATS_RESIDUAL_EXCLUIR);
}
// Cita bíblica: coincidencia por PREFIJO (el valor empieza por el término, ej. "Jn"). // Cita bíblica: coincidencia por PREFIJO (el valor empieza por el término, ej. "Jn").
// Usamos REGEXP '^<term>' con el término escapado para evitar metacaracteres. // Usamos REGEXP '^<term>' con el término escapado para evitar metacaracteres.
if ($fea_cita !== '') { if ($fea_cita !== '') {
+33 -3
View File
@@ -6,7 +6,7 @@
* relevancia FULLTEXT si no se pide otro criterio de orden. Degradación elegante: * relevancia FULLTEXT si no se pide otro criterio de orden. Degradación elegante:
* si no hay término o el índice FULLTEXT no existe, usa el comportamiento nativo. * si no hay término o el índice FULLTEXT no existe, usa el comportamiento nativo.
* Convive con fea-search-advanced.php (filtros pre_get_posts de autor/cat/cita/fecha). * Convive con fea-search-advanced.php (filtros pre_get_posts de autor/cat/cita/fecha).
* Version: 1.1 * Version: 1.3
*/ */
if (!defined('ABSPATH')) exit; if (!defined('ABSPATH')) exit;
@@ -38,16 +38,46 @@ function fea_ft_index_exists(): bool {
} }
/** /**
* Calcula el término FULLTEXT en Boolean Mode (cada palabra con prefijo *). * Calcula el término FULLTEXT en Boolean Mode.
*
* Caso normal (sin comillas): cada palabra requerida con prefijo + y sufijo *. El '+'
* fuerza AND entre palabras — sin él, Boolean Mode las une con OR implícito y cualquier
* post que contenga UNA sola palabra común (p.ej. "ser") entra en el resultado, lo que en
* la práctica devolvía casi todo el sitio (~24.700 de ~24.780 posts, bug #8 reportado en
* feedback: "Tu verdadero ser" devolvía cientos de artículos irrelevantes).
*
* Caso frase exacta (entrecomillado, ej. `"tu verdadero ser"`): Boolean Mode soporta
* nativamente búsqueda de frase entre comillas dobles (coincidencia de adyacencia, no
* solo de palabras sueltas). Reenviamos el contenido saneado tal cual entre comillas, sin
* partirlo en palabras con + ni *, para que el usuario que escribe entre comillas obtenga
* de verdad una búsqueda de cadena exacta.
*
* Devuelve '' si el término sanitizado queda vacío. * Devuelve '' si el término sanitizado queda vacío.
*/ */
function fea_ft_boolean_term(string $raw): string { function fea_ft_boolean_term(string $raw): string {
// Frase exacta: todo el término entre comillas dobles.
if (preg_match('/^"(.*)"$/us', $raw, $m)) {
$phrase = trim(substr(preg_replace('/[^\p{L}\p{N}\s\'\-]/u', '', $m[1]), 0, 200));
if ($phrase === '') return '';
return '"' . $phrase . '"';
}
$term = trim(substr(preg_replace('/[^\p{L}\p{N}\s\'\-]/u', '', $raw), 0, 200)); $term = trim(substr(preg_replace('/[^\p{L}\p{N}\s\'\-]/u', '', $raw), 0, 200));
if ($term === '') return ''; if ($term === '') return '';
global $wpdb; global $wpdb;
$words = preg_split('/\s+/', $term); $words = preg_split('/\s+/', $term);
return implode('* ', array_map(fn($w) => $wpdb->esc_like($w), $words)) . '*';
// Descartamos palabras por debajo de innodb_ft_min_token_size (3 en este servidor):
// MySQL las excluye del índice, y forzarlas igualmente como término obligatorio ('+')
// no aporta nada al filtrado (WP igual las ignora) pero SÍ rompe el cálculo de
// relevancia: si un término '+' no existe en el índice, MATCH()...AGAINST() en modo
// boolean puede devolver 0 aunque la fila cumpla el resto de términos, dejando el
// ORDER BY sin poder distinguir resultados relevantes de irrelevantes.
$words = array_values(array_filter($words, fn($w) => mb_strlen($w) >= 3));
if (empty($words)) return '';
return implode(' ', array_map(fn($w) => '+' . $wpdb->esc_like($w) . '*', $words));
} }
/** /**
+15 -2
View File
@@ -2,7 +2,9 @@
/** /**
* fea-share — Sección "Comparte Fe Adulta" en single posts + Open Graph tags. * fea-share — Sección "Comparte Fe Adulta" en single posts + Open Graph tags.
* Botones: Facebook, Instagram (Web Share API + fallback copiar), Imprimir. * Botones: Facebook, Instagram (Web Share API + fallback copiar), Imprimir.
* Sin plugins externos, sin JS de terceros, sin tracking. * Sin plugins externos, sin JS de terceros. El botón de Facebook dispara un
* evento GA4 share_click (mismo patrón que fea-audio-player.php: data-attribute
* + listener en wp_footer que llama a gtag, ya inicializado por fea-analytics.php).
*/ */
/** Solo artículos reales: single post_type=post, excluyendo institucionales. */ /** Solo artículos reales: single post_type=post, excluyendo institucionales. */
@@ -44,6 +46,7 @@ function fea_share_block_html(): string {
$svg_pr = '<svg viewBox="0 0 24 24" width="17" height="17" aria-hidden="true" focusable="false" fill="currentColor"><path d="M19 8H5c-1.66 0-3 1.34-3 3v6h4v4h12v-4h4v-6c0-1.66-1.34-3-3-3zm-3 11H8v-5h8v5zm3-7a1 1 0 0 1 0-2 1 1 0 0 1 0 2zm-1-9H6v4h12V3z"/></svg>'; $svg_pr = '<svg viewBox="0 0 24 24" width="17" height="17" aria-hidden="true" focusable="false" fill="currentColor"><path d="M19 8H5c-1.66 0-3 1.34-3 3v6h4v4h12v-4h4v-6c0-1.66-1.34-3-3-3zm-3 11H8v-5h8v5zm3-7a1 1 0 0 1 0-2 1 1 0 0 1 0 2zm-1-9H6v4h12V3z"/></svg>';
$copied_msg = esc_js($t['copied']); $copied_msg = esc_js($t['copied']);
$post_id = get_the_ID();
return ' return '
<div class="fea-share"> <div class="fea-share">
@@ -52,7 +55,8 @@ function fea_share_block_html(): string {
<span class="fea-share-label">' . esc_html($t['section']) . '</span> <span class="fea-share-label">' . esc_html($t['section']) . '</span>
<a href="https://www.facebook.com/sharer/sharer.php?u=' . $url . '" <a href="https://www.facebook.com/sharer/sharer.php?u=' . $url . '"
target="_blank" rel="noopener noreferrer nofollow" target="_blank" rel="noopener noreferrer nofollow"
class="fea-share-item fea-share-fb"> class="fea-share-item fea-share-fb"
data-fea-share-track="facebook" data-post-id="' . esc_attr($post_id) . '">
' . $svg_fb . '<span>' . esc_html($t['fb']) . '</span> ' . $svg_fb . '<span>' . esc_html($t['fb']) . '</span>
</a> </a>
<span class="fea-share-sep" aria-hidden="true">·</span> <span class="fea-share-sep" aria-hidden="true">·</span>
@@ -198,6 +202,15 @@ add_action('wp_footer', function () {
} }
}); });
})(); })();
document.querySelectorAll('[data-fea-share-track]').forEach(function (el) {
el.addEventListener('click', function () {
if (typeof gtag !== 'function') return;
gtag('event', 'share_click', {
method: el.dataset.feaShareTrack,
post_id: el.dataset.postId
});
});
});
</script> </script>
<?php <?php
}, 20); }, 20);
+15 -3
View File
@@ -19,6 +19,7 @@ if ($action === 'get') {
'title' => $p->post_title, 'title' => $p->post_title,
'content' => $p->post_content, 'content' => $p->post_content,
'status' => $p->post_status, 'status' => $p->post_status,
'author' => (int)$p->post_author,
], JSON_UNESCAPED_UNICODE)); ], JSON_UNESCAPED_UNICODE));
exit(0); exit(0);
} }
@@ -43,10 +44,11 @@ if ($action === 'getmeta') {
exit(0); exit(0);
} }
if ($action === 'setaudio') { // setaudio <id> <relpath> if ($action === 'setaudio') { // setaudio <id> <relpath> [voice_id]
$id = (int)$argv[2]; $id = (int)$argv[2];
$voice = $argv[4] ?? 'NicoFeadulta2026';
update_post_meta($id, 'fea_audio_url', home_url($argv[3])); update_post_meta($id, 'fea_audio_url', home_url($argv[3]));
update_post_meta($id, 'fea_audio_voice', 'NicoFeadulta2026'); update_post_meta($id, 'fea_audio_voice', $voice);
update_post_meta($id, 'fea_audio_done', '1'); update_post_meta($id, 'fea_audio_done', '1');
delete_post_meta($id, 'fea_audio_error'); delete_post_meta($id, 'fea_audio_error');
fwrite(STDOUT, "ok " . home_url($argv[3]) . "\n"); fwrite(STDOUT, "ok " . home_url($argv[3]) . "\n");
@@ -58,5 +60,15 @@ if ($action === 'setflag') { // setflag <id> <key> <value>
exit(0); exit(0);
} }
fwrite(STDERR, "uso: get|update|getmeta|setaudio|setflag\n"); if ($action === 'unsetaudio') { // unsetaudio <id> (rollback: despublica el audio)
$id = (int)$argv[2];
delete_post_meta($id, 'fea_audio_url');
delete_post_meta($id, 'fea_audio_voice');
delete_post_meta($id, 'fea_audio_done');
delete_post_meta($id, 'fea_audio_error');
fwrite(STDOUT, "ok despublicado $id\n");
exit(0);
}
fwrite(STDERR, "uso: get|update|getmeta|setaudio|setflag|unsetaudio\n");
exit(2); exit(2);
+1 -1
View File
@@ -17,7 +17,7 @@
* FROM ew4r_k2_items i LEFT JOIN ew4r_users u ON u.id=i.created_by \ * FROM ew4r_k2_items i LEFT JOIN ew4r_users u ON u.id=i.created_by \
* WHERE i.id IN ($IDS);" > /tmp/autores143.tsv * 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 * 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 * APPLY=1 FEA_TSV=/tmp/autores143.tsv wp eval-file scripts/fix_k2_authors.php # aplica
* *
+59
View File
@@ -0,0 +1,59 @@
# Homenajes Mardones / Galarreta + fixes varios (sesión 2026-06-23)
Scripts de la sesión 2026-06-23 (issues Gitea **#130, #131, #133, #137, #138**, todas cerradas).
Todo aplicado en **local** (Docker `wordpress-web`) y **prod** (wp-nuevo, vía `ssh feadulta@… && wp eval-file`).
## Convenciones del despliegue a prod (wp-nuevo)
- Prod corre en la **raíz** del dominio (`https://wp-nuevo.feadulta.com`), local bajo `/fea`.
→ al llevar contenido con rutas, transformar `/fea/wp-content``/wp-content`.
- `proc_open` deshabilitado en prod → usar `wp eval-file` (PHP puro). `scp` no va → subir con
`ssh '… cat > /entrada/FICHERO'`; imágenes en bloque con `tar cf -``ssh 'tar x'`.
- **IDs de contenido migrado coinciden local↔prod** (posts, template parts, users, cats).
- **Lección clave:** para arreglar posts ya migrados con texto malo (idioma/contenido),
**sobrescribir EN SITIO en prod** (mismos IDs) en vez de borrar+recrear → preserva los grupos
Polylang. Para contenido NUEVO (noticia, 23 posts Mardones) crear con **meta idempotente**.
## #138 — Mardones: 23 páginas `anterior/*.htm` → 23 posts individuales
Origen: el homenaje WP **17990** (`mardones-2`, com_content Joomla 501) enlazaba 23 páginas
estáticas de la web V1 FrontPage (`/web/anterior/*.htm`, windows-1252). 8 «Aprendiendo a orar»
(serie de Mardones) + 15 homenaje (obituarios, álbum de fotos, audio→conferencia, currículo…).
Pipeline:
1. `mardones_build_manifest.py` — descarga local de los 23 `.htm`, decodifica **win-1252→UTF-8**,
limpia basura FrontPage, extrae párrafos `<p>` (descarta cabecera «cristianos siglo veintiuno»),
preserva enlaces externos, inserta imágenes. Genera `mardones_manifest.json`.
Títulos = texto del enlace en 17990; autor: serie orar = **710 (Mardones)**, homenajes = **1**.
2. `mardones_create_posts.php` (local) / `mardones_prod_create_and_repoint.php` (prod) — crea los 23
posts (lang es, cat 38, publish), idempotente por meta **`_mardones_src`**=nombre de fichero.
3. `mardones_repoint_homenaje.php` — repunta los 23 `href="anterior/X.htm"` del 17990 a los nuevos
permalinks (prod lo hace el script combinado).
4. Imágenes (8: `homenaje-album-1..7.jpg`, `homenaje-charla-mejico.jpg`) → `uploads/anterior-mardones/`.
IDs creados en local: **5357753599**. En prod: IDs propios (se resuelven por meta).
### Retoques posteriores (`mardones_fix_fecha_y_target.php`, local+prod)
- **Fecha:** los posts se crearon con fecha de hoy → reajustados a **2012-10-10 19:26:00**
(la del propio homenaje 17990) para que no aparezcan como entradas nuevas en feeds/archivos.
- **Ventana nueva:** los enlaces del homenaje 17990 heredaban `target="_blank"` del original →
eliminado (`target="_blank"`/`rel`) para que abran en la **misma ventana** (navegación interna).
*Nota:* los enlaces externos DENTRO de cada post (p.ej. la conferencia iteso.mx en homenaje-3)
sí conservan `target="_blank"`.
## #138 — Galarreta
`galarreta_repoint_presentaciones.php` (local) / `galarreta_prod_fixes.php` (prod):
- 10 presentaciones de `presentaciones-gala` (19107) estaban mal mapeadas (apuntaban a artículos
random) → repuntadas a sus destinos correctos (19105/19104/19102/19106/19101/19119/19123/19132/19131/19143).
- «Libros y e-books» (en la página 17918) → tienda externa
`https://edicionesfeadulta.com/?filters=autor[galarreta]` (13 libros), `target="_blank"`.
- iframe de YouTube forzado a `https`.
- Auditados `articulos-galarreta` (19086) y `comentarios-galarreta` (19085): sin enlaces rotos reales.
## #130 / #131 / #133 / #137 (sync a prod)
- `issue130_prod_noticia_papa.php` — crea la noticia «León XIV en España…» (Joomla 9082, no migrada)
en cat 41 Noticias de alcance, idempotente por `_fgj2wp_old_id=9082`.
- `issue131_prod_footer_suma.php` — enlaza la imagen `este_portal.gif` del footer (template 42370)
a `/numeros/` (post 18036, «La suma de todos»).
- `issue133_prod_idiomas.php` — 5 reasignaciones de idioma + sobrescritura en sitio de los 3 `fr`
que contenían inglés (43755/47152/47136) + catalán 13339 → francés, enlazado al ES 13338.
- `issue137_prod_inmemoriam.php` — repunta In memoriam (19112): Galarreta→17918, Mardones→17990,
Mari Patxi Ayerra→23519 (artículo de fallecimiento).
@@ -0,0 +1,25 @@
<?php
// 1) presentaciones-gala 19107: repuntar 10
$map=[
"sugerencias-para-las-bodas"=>19105,"el-eterno-problema-del-mundo-y-los-hijos"=>19104,
"necesitamos-un-pepe"=>19102,"lucas-18-1-8"=>19106,"el-perdon-primero"=>19101,
"alabar-a-dios-y-darle-gracias"=>19119,"2-reyes-5-14-17-2-timoteo-2-8-13"=>19123,
"creer-en-dios-es-apostar-por-la-vida"=>19132,"lucas-17-5-10"=>19131,"la-gran-evasion"=>19143,
];
$c=get_post(19107)->post_content; $n=0;
foreach($map as $bad=>$dst){
if(!get_post($dst)){ echo " dst $dst NO EXISTE\n"; continue; }
$url=get_permalink($dst);
$c2=preg_replace('#https?://[^"]*?/'.preg_quote($bad,'#').'/#',$url,$c,1,$cnt);
if($cnt>0){ $c=$c2; $n++; }
}
wp_update_post(["ID"=>19107,"post_content"=>$c]);
echo "presentaciones repuntadas: $n/10\n";
// 2) libros + youtube en 17918
$c=get_post(17918)->post_content;
$ext="https://edicionesfeadulta.com/?filters=autor[galarreta]";
$c=preg_replace('#https?://[^"]*?/libros-y-e-books/#',$ext,$c,-1,$cl);
$c=preg_replace('#(<a [^>]*href=")'.preg_quote($ext,'#').'("(?![^>]*target))#','$1'.$ext.'$2 target="_blank" rel="noopener"',$c);
$c=str_replace('src="//www.youtube.com/embed/','src="https://www.youtube.com/embed/',$c);
wp_update_post(["ID"=>17918,"post_content"=>$c]);
echo "libros->externo: $cl | youtube https aplicado | foto JE: ".(strpos($c,'edicionesfeadulta.com')!==false?'ok':'?')."\n";
@@ -0,0 +1,25 @@
<?php
// Repuntar las 10 presentaciones de presentaciones-gala (19107) a sus destinos correctos
$map=[
"sugerencias-para-las-bodas"=>19105,
"el-eterno-problema-del-mundo-y-los-hijos"=>19104,
"necesitamos-un-pepe"=>19102,
"lucas-18-1-8"=>19106,
"el-perdon-primero"=>19101,
"alabar-a-dios-y-darle-gracias"=>19119,
"2-reyes-5-14-17-2-timoteo-2-8-13"=>19123,
"creer-en-dios-es-apostar-por-la-vida"=>19132,
"lucas-17-5-10"=>19131,
"la-gran-evasion"=>19143,
];
$c=get_post(19107)->post_content; $n=0; $report=[];
foreach($map as $badslug=>$dest){
$destUrl=get_permalink($dest);
// sustituir el href que contiene /fea/<badslug>/
$pat='#https?://[^"]*?/fea/'.preg_quote($badslug,'#').'/#';
$c2=preg_replace($pat,$destUrl,$c,1,$cnt);
if($cnt>0){ $c=$c2; $n++; $report[]="OK $badslug -> $destUrl"; }
else $report[]="NO ENCONTRADO $badslug";
}
wp_update_post(["ID"=>19107,"post_content"=>$c]);
echo implode("\n",$report)."\nTotal repuntados: $n/10\n";
@@ -0,0 +1,13 @@
<?php
global $wpdb;
$ex=$wpdb->get_var($wpdb->prepare("SELECT post_id FROM {$wpdb->postmeta} WHERE meta_key='_fgj2wp_old_id' AND meta_value=%s","9082"));
if($ex){ echo "ya existe en prod: $ex\n"; return; }
$content=file_get_contents('/entrada/9082_content.html');
$id=wp_insert_post(["post_type"=>"post","post_status"=>"publish","post_author"=>890,
"post_title"=>"El plan (casi definitivo) de León XIV en España, al descubierto: de Carabanchel a Arguineguín, pasando por Montserrat",
"post_content"=>$content,"post_date"=>"2026-04-09 12:07:26","to_ping"=>"","pinged"=>"","post_content_filtered"=>""],true);
if(is_wp_error($id)){ echo "ERROR: ".$id->get_error_message()."\n"; return; }
update_post_meta($id,"_fgj2wp_old_id","9082");
wp_set_post_categories($id,[41]);
if(function_exists('pll_set_post_language')) pll_set_post_language($id,'es');
echo "creado prod $id cat=".implode(",",wp_get_post_categories($id))." status=".get_post_status($id)."\n";
@@ -0,0 +1,14 @@
<?php
$id=42370; $c=get_post($id)->post_content; $url=get_permalink(18036);
if(strpos($c,'<a href="'.$url.'"><img')!==false || preg_match('#<a [^>]*href="[^"]*numeros[^"]*"><img[^>]*este_portal#',$c)){ echo "ya enlazado\n"; return; }
// localizar el <img ... este_portal.gif ...> y envolverlo
if(preg_match('#<img[^>]*este_portal\.gif[^>]*/?>#i',$c,$m)){
$img=$m[0];
$c=str_replace($img,'<a href="'.esc_url($url).'">'.$img.'</a>',$c);
// linkDestination none->custom para el bloque (id 26996)
$c=preg_replace('#("id":26996,[^}]*?"linkDestination":")none(")#','${1}custom${2}',$c);
wp_update_post(["ID"=>$id,"post_content"=>$c]);
echo "OK enlazado este_portal -> $url\n";
} else echo "NO se encontró img este_portal.gif en 42370 (prod)\n";
// verificar
if(preg_match('#<a href="([^"]+)"><img[^>]*este_portal#',get_post($id)->post_content,$v)) echo "verif: -> ".$v[1]."\n";
@@ -0,0 +1,23 @@
<?php
// 1) reasignaciones de idioma (mismos IDs en prod)
$fix=[4424=>"fr",11129=>"it",12934=>"en",5891=>"en",3141=>"it"];
foreach($fix as $id=>$lang){
if(!get_post($id)){ echo " $id NO EXISTE\n"; continue; }
$old=pll_get_post_language($id); pll_set_post_language($id,$lang);
echo " $id: $old -> ".pll_get_post_language($id)."\n";
}
// 2) sobrescribir fr malos + catalan con francés correcto (overwrite en sitio)
$payload=json_decode(file_get_contents('/entrada/fr_payload.json'),true);
foreach($payload as $it){
$dst=$it['dst'];
if(!get_post($dst)){ echo " dst $dst NO EXISTE\n"; continue; }
wp_update_post(["ID"=>$dst,"post_title"=>$it['title'],"post_content"=>$it['content']]);
pll_set_post_language($dst,'fr');
echo " overwrite $dst -> fr (".mb_substr($it['title'],0,30).")\n";
}
// 3) catalan 13339: enlazar grupo con ES 13338
if(get_post(13338) && get_post(13339)){
pll_set_post_language(13338,'es');
pll_save_post_translations(["es"=>13338,"fr"=>13339]);
echo " grupo 13339: ".json_encode(pll_get_post_translations(13339))."\n";
}
@@ -0,0 +1,20 @@
<?php
$c=get_post(19112)->post_content;
$map=[
"el-bautismo-de-jesus-jesus-hijo-del-padre"=>17918, // Galarreta homenaje
"jean-baptiste-et-son-mouton"=>17990, // Mardones homenaje
"nuestra-vida-sea-servicio"=>23519, // Mari Patxi fallecimiento
];
$n=0;
foreach($map as $slug=>$id){
$url=get_permalink($id);
$pat='#https?://[^"]*?/'.preg_quote($slug,'#').'/#';
$c2=preg_replace($pat,$url,$c,1,$cnt);
if($cnt>0){ $c=$c2; $n++; echo " OK $slug -> $url\n"; } else echo " NO ENCONTRADO $slug\n";
}
if($n) wp_update_post(["ID"=>19112,"post_content"=>$c]);
// verificar 8 enlaces
preg_match_all('#href="([^"]+)"#',get_post(19112)->post_content,$m);
$ok=0;$bad=0;
foreach($m[1] as $h){ if(preg_match('#/([^/"]+)/?$#',$h,$sm)){ $p=get_posts(["name"=>$sm[1],"post_status"=>"publish","posts_per_page"=>1,"suppress_filters"=>true]); if($p)$ok++; else {$bad++; echo " ROTO $h\n";} } }
echo "repuntados=$n | enlaces in-memoriam OK=$ok bad=$bad\n";
@@ -0,0 +1,93 @@
import re, json, html, glob
IMG_BASE = "/fea/wp-content/uploads/anterior-mardones/"
# filename -> (titulo, autor_wp, [imagenes])
ORAR_AUTHOR = 710 # Mardones
HOM_AUTHOR = 1 # genérico (como página 17990)
META = {
"orar-1-dejarmequerer":("Dejarme querer",ORAR_AUTHOR,[]),
"orar-2-escuchar":("Escuchar",ORAR_AUTHOR,[]),
"orar-3-estar":("Estar",ORAR_AUTHOR,[]),
"orar-4-hacersitioadios":("Hacer sitio a Dios",ORAR_AUTHOR,[]),
"orar-5-laoracionadulta":("La oración adulta",ORAR_AUTHOR,[]),
"orar-6-orarconevangelio":("Orar con el evangelio",ORAR_AUTHOR,[]),
"orar-7-orarrepitiendo":("Orar repitiendo una palabra o frase breve",ORAR_AUTHOR,[]),
"orar-8-tuestasdentro":("Tú estás dentro",ORAR_AUTHOR,[]),
"homenaje-segundo-aniversario":("En el segundo aniversario",HOM_AUTHOR,[]),
"homenaje-14noviembre":("14 de noviembre de 2007",HOM_AUTHOR,[]),
"homenaje-PRIMERaniversario":("Primer aniversario",HOM_AUTHOR,[]),
"homenaje-aniversario":("Programa en el aniversario",HOM_AUTHOR,["homenaje-charla-mejico.jpg"]),
"homenaje-rostrointerior":("Rostro interior de José María Mardones",HOM_AUTHOR,[]),
"homenaje-cartaReyes":("Carta a un Maestro",HOM_AUTHOR,[]),
"homenaje-9":("Semblanza",HOM_AUTHOR,[]),
"homenaje-8":("Álbum de fotos de José María Mardones",HOM_AUTHOR,
["homenaje-album-%d.jpg"%i for i in range(1,8)]),
"homenaje-1":("Funeral del sábado 24 de junio",HOM_AUTHOR,[]),
"homenaje-2":("Funeral del jueves 29 de junio",HOM_AUTHOR,[]),
"homenaje-5":("Obituario en El Mundo",HOM_AUTHOR,[]),
"homenaje-6":("Necrológicas en El País",HOM_AUTHOR,[]),
"homenaje-4":("Salmo a la Encarnación de Dios",HOM_AUTHOR,[]),
"homenaje-3":("Conferencia: ¿Por qué preocuparse por los demás? Ética y convivencia",HOM_AUTHOR,[]),
"homenaje-7":("Currículo de José María Mardones",HOM_AUTHOR,[]),
}
BOILER = re.compile(r'cristianos siglo veintiuno|^I N M E M O R I A M$|^HOMENAJE$|^homenaje a JOSE|APRENDIENDO A ORAR cristianos', re.I)
def extract(fn):
raw=open(fn,'rb').read().decode('windows-1252',errors='replace')
raw=re.sub(r'(?is)<(script|style).*?</\1>','',raw)
raw=re.sub(r'(?is)<!--.*?-->','',raw)
blocks=[]
for p in re.findall(r'(?is)<p\b[^>]*>(.*?)</p>',raw):
# preservar enlaces externos
links=re.findall(r'(?is)<a\s+[^>]*href="(https?://[^"]+)"[^>]*>(.*?)</a>',p)
t=re.sub(r'(?is)<[^>]+>','',p)
t=html.unescape(t).replace('\xa0',' ')
t=re.sub(r'\s+',' ',t).strip()
if len(t)<3:
# párrafo solo-enlace
if links:
for href,txt in links:
txt=re.sub(r'<[^>]+>','',txt).strip() or href
blocks.append(('link',href,txt))
continue
if BOILER.search(t):
continue
# ¿el párrafo contiene un enlace externo embebido? añadir como link extra
blocks.append(('p',t,links))
return blocks
def build_html(blocks, imgs):
out=[]
for b in blocks:
if b[0]=='p':
if re.match(r'^https?://\S+$', b[1]):
out.append('<p><a href="%s" target="_blank" rel="noopener">%s</a></p>'%(html.escape(b[1]),html.escape(b[1])))
continue
txt=html.escape(b[1])
# re-incrustar enlaces externos que estaban en el párrafo
for href,atxt in b[2]:
atxt_clean=html.escape(re.sub(r'<[^>]+>','',atxt).strip() or href)
# no siempre está el texto en txt; añadimos al final si no
out.append("<p>%s</p>"%txt)
elif b[0]=='link':
out.append('<p><a href="%s" target="_blank" rel="noopener">%s</a></p>'%(html.escape(b[1]),html.escape(b[2])))
# imágenes al final
for im in imgs:
out.append('<p><img src="%s%s" alt="José María Mardones" style="max-width:100%%;height:auto"/></p>'%(IMG_BASE,im))
return "\n".join(out)
manifest=[]
for fn in sorted(glob.glob("*.htm")):
key=fn[:-4]
if key not in META:
print("SIN META:",key); continue
title,author,imgs=META[key]
blocks=extract(fn)
content=build_html(blocks,imgs)
manifest.append({"file":key,"title":title,"author":author,"content":content,"nparas":len([b for b in blocks if b[0]=='p']),"nimgs":len(imgs)})
json.dump(manifest,open("manifest.json","w"),ensure_ascii=False,indent=1)
print("manifest:",len(manifest),"posts")
for m in manifest: print(f" {m['file']:34} '{m['title'][:35]}' autor={m['author']} paras={m['nparas']} imgs={m['nimgs']} len={len(m['content'])}")
@@ -0,0 +1,21 @@
<?php
$man=json_decode(file_get_contents('/tmp/manifest.json'),true);
$map=[]; $created=0; $skipped=0;
foreach($man as $m){
// idempotencia
$ex=get_posts(["meta_key"=>"_mardones_src","meta_value"=>$m['file'],"post_type"=>"post","post_status"=>"any","posts_per_page"=>1,"suppress_filters"=>true]);
if($ex){ $map[$m['file']]=$ex[0]->ID; $skipped++; continue; }
$id=wp_insert_post([
"post_type"=>"post","post_status"=>"publish","post_author"=>(int)$m['author'],
"post_title"=>$m['title'],"post_content"=>$m['content'],
"to_ping"=>"","pinged"=>"","post_content_filtered"=>"",
],true);
if(is_wp_error($id)){ echo "ERROR ".$m['file'].": ".$id->get_error_message()."\n"; continue; }
update_post_meta($id,"_mardones_src",$m['file']);
wp_set_post_categories($id,[38]);
if(function_exists('pll_set_post_language')) pll_set_post_language($id,'es');
$map[$m['file']]=$id; $created++;
}
file_put_contents('/tmp/mardones_map.json',json_encode($map));
echo "creados=$created skipped=$skipped total=".count($map)."\n";
foreach($map as $f=>$id) echo " $f -> $id\n";
@@ -0,0 +1,14 @@
<?php
// 1) fecha: alinear con el homenaje 17990 (2012-10-10)
$date="2012-10-10 19:26:00"; $gmt=get_gmt_from_date($date);
$ids=get_posts(["meta_key"=>"_mardones_src","post_type"=>"post","posts_per_page"=>-1,"fields"=>"ids","post_status"=>"any","suppress_filters"=>true]);
$nd=0;
foreach($ids as $id){ wp_update_post(["ID"=>$id,"post_date"=>$date,"post_date_gmt"=>$gmt]); $nd++; }
echo "fechas actualizadas: $nd posts -> $date\n";
// 2) quitar target=_blank (y rel) de los enlaces internos del homenaje 17990
$c=get_post(17990)->post_content;
$before=substr_count(strtolower($c),'target="_blank"');
$c=str_replace([' target="_blank"',' rel="noopener"',' rel="noreferrer"'],'',$c);
wp_update_post(["ID"=>17990,"post_content"=>$c]);
$after=substr_count(strtolower(get_post(17990)->post_content),'target="_blank"');
echo "target=_blank en 17990: antes=$before despues=$after\n";
File diff suppressed because one or more lines are too long
@@ -0,0 +1,26 @@
<?php
$man=json_decode(file_get_contents('/entrada/manifest_prod.json'),true);
$map=[]; $created=0;$skip=0;
foreach($man as $m){
$ex=get_posts(["meta_key"=>"_mardones_src","meta_value"=>$m['file'],"post_type"=>"post","post_status"=>"any","posts_per_page"=>1,"suppress_filters"=>true]);
if($ex){ $map[$m['file']]=$ex[0]->ID; $skip++; continue; }
$author=(int)$m['author']; if($author && !get_userdata($author)) $author=1;
$id=wp_insert_post(["post_type"=>"post","post_status"=>"publish","post_author"=>$author,
"post_title"=>$m['title'],"post_content"=>$m['content'],"to_ping"=>"","pinged"=>"","post_content_filtered"=>""],true);
if(is_wp_error($id)){ echo "ERR ".$m['file'].": ".$id->get_error_message()."\n"; continue; }
update_post_meta($id,"_mardones_src",$m['file']);
wp_set_post_categories($id,[38]);
if(function_exists('pll_set_post_language')) pll_set_post_language($id,'es');
$map[$m['file']]=$id; $created++;
}
echo "creados=$created skip=$skip total=".count($map)."\n";
// repuntar 17990
$c=get_post(17990)->post_content; $n=0;
foreach($map as $file=>$id){
$url=get_permalink($id);
$c2=preg_replace('#href="anterior/'.preg_quote($file,'#').'\.htm"#i','href="'.$url.'"',$c,1,$cnt);
if($cnt>0){ $c=$c2; $n++; }
}
wp_update_post(["ID"=>17990,"post_content"=>$c]);
$rest=preg_match_all('#anterior/[\w-]+\.htm#i',get_post(17990)->post_content,$mm);
echo "17990 repuntados=$n/23 | anterior/*.htm restantes=$rest\n";
@@ -0,0 +1,17 @@
<?php
$map=json_decode(file_get_contents('/tmp/mardones_map.json'),true);
$c=get_post(17990)->post_content; $n=0; $miss=[];
foreach($map as $file=>$id){
$url=get_permalink($id);
// href="anterior/FILE.htm" (puede tener mayúsculas y espacios delante)
$pat='#href="anterior/'.preg_quote($file,'#').'\.htm"#i';
$c2=preg_replace($pat,'href="'.$url.'"',$c,1,$cnt);
if($cnt>0){ $c=$c2; $n++; } else $miss[]=$file;
}
wp_update_post(["ID"=>17990,"post_content"=>$c]);
echo "repuntados=$n/23\n";
if($miss) echo "NO ENCONTRADOS: ".implode(", ",$miss)."\n";
// verificar que no quedan anterior/*.htm
$rest=preg_match_all('#anterior/[\w-]+\.htm#i',get_post(17990)->post_content,$mm);
echo "enlaces anterior/*.htm restantes: $rest\n";
if($rest) foreach($mm[0] as $x) echo " $x\n";
+1 -1
View File
@@ -8,7 +8,7 @@
* user_meta _foto_perfil_pre143. Idempotente (si ya apunta, solo regenera metadata). * 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). * 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 * 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 * APPLY=1 FEA_TSV=/tmp/users29.tsv wp eval-file scripts/import_avatars_143.php # aplica
*/ */
+31 -7
View File
@@ -45,6 +45,22 @@ def creds():
KEY, GID = creds() KEY, GID = creds()
H_JSON = {"Authorization": f"Bearer {KEY}", "Content-Type": "application/json"} H_JSON = {"Authorization": f"Bearer {KEY}", "Content-Type": "application/json"}
# Voz clonada por autor (WP user_id -> voice_id). Issue #152: solo se aplica a
# los artículos de ese autor; el resto sigue con la voz por defecto (Nico).
AUTHOR_VOICES = {
382: "FrayMarcosFeadulta2026", # Fray Marcos
383: "PagolaFeadulta2026", # José Antonio Pagola
774: "SicreFeadulta2026", # José Luis Sicre
386: "ArregiFeadulta2026", # José Arregi
}
def voice_for_author(author_id, default_voice):
try:
return AUTHOR_VOICES.get(int(author_id), default_voice)
except (TypeError, ValueError):
return default_voice
def _q(url): def _q(url):
return f"{url}?GroupId={GID}" if GID else url return f"{url}?GroupId={GID}" if GID else url
@@ -83,7 +99,7 @@ def get_post_text(pid):
raw = html.unescape(raw) raw = html.unescape(raw)
paras = [re.sub(r"\s+", " ", p).strip() for p in raw.split("\n") if len(p.strip()) > 1] paras = [re.sub(r"\s+", " ", p).strip() for p in raw.split("\n") if len(p.strip()) > 1]
paras = trim_after_author_signature(paras) paras = trim_after_author_signature(paras)
return d["title"], "\n\n".join(paras) return d["title"], "\n\n".join(paras), d.get("author")
def is_author_signature(text): def is_author_signature(text):
@@ -114,12 +130,20 @@ def is_author_signature(text):
def trim_after_author_signature(paras): def trim_after_author_signature(paras):
out = [] """Corta tras la firma final del autor, buscando desde el final hacia
atrás para no confundirla con encabezados iniciales (p.ej. "CORPUS (A)",
"DOMINGO XI (A)") que también matchean la heurística de is_author_signature
pero aparecen como primer párrafo, no como firma. Solo cuenta como firma
si hay al menos 200 caracteres de contenido real antes de ella."""
prefix_len = 0
prefix_lens = []
for p in paras: for p in paras:
out.append(p) prefix_lens.append(prefix_len)
if is_author_signature(p): prefix_len += len(p)
break for i in range(len(paras) - 1, -1, -1):
return out if prefix_lens[i] > 200 and is_author_signature(paras[i]):
return paras[: i + 1]
return paras
def _sent_pause(n_words, short, long_): def _sent_pause(n_words, short, long_):
@@ -413,7 +437,7 @@ def main():
elif cmd == "carta": elif cmd == "carta":
pid, voice_id = sys.argv[2], sys.argv[3] pid, voice_id = sys.argv[2], sys.argv[3]
model = sys.argv[4] if len(sys.argv) > 4 else "speech-2.8-turbo" model = sys.argv[4] if len(sys.argv) > 4 else "speech-2.8-turbo"
title, text = get_post_text(int(pid)) title, text, _author = get_post_text(int(pid))
name = sys.argv[5] if len(sys.argv) > 5 else f"carta-minimax-{pid}-{model.split('-')[-1]}" name = sys.argv[5] if len(sys.argv) > 5 else f"carta-minimax-{pid}-{model.split('-')[-1]}"
text = add_pauses(text) text = add_pauses(text)
print(f"Post #{pid}: «{title}» ({len(text)} car con pausas)") print(f"Post #{pid}: «{title}» ({len(text)} car con pausas)")
+219
View File
@@ -0,0 +1,219 @@
#!/usr/bin/env python3
"""
sync_audio_to_prod.py — Sube a PROD los mp3 de TTS ya generados/enlazados en
local (fea_audio_done=1) y fija el meta fea_audio_url en prod.
Prod (134.0.10.170) tiene glibc rota: scp/sftp NO funcionan (connection closed).
Workaround: subir el binario por stdin de ssh ("cat > ruta"), igual que el resto
de scripts que tocan ese servidor (ver feadulta-server-glibc-rota.md).
Uso:
python3 sync_audio_to_prod.py --carta 54254 # sincroniza toda la cola de la carta
python3 sync_audio_to_prod.py --ids 912,919,3001 # ids concretos
python3 sync_audio_to_prod.py --carta 54254 --dry-run # solo plan, no toca prod
Rollback (despublica en prod lo que este script publicó):
python3 sync_audio_to_prod.py --rollback --carta 54254
python3 sync_audio_to_prod.py --rollback --ids 912,919
"""
from __future__ import annotations
import argparse
import json
import os
import subprocess
import time
from pathlib import Path
WP_CONTAINER = os.environ.get("FEA_WP_CONTAINER", "wordpress-web")
DB_CONTAINER = os.environ.get("FEA_DB_CONTAINER", "wordpress-mysql")
DB_NAME = os.environ.get("FEA_DB_NAME", "wordpress_db")
DB_USER = os.environ.get("FEA_DB_USER", "wordpress_user")
DB_PASS = os.environ.get("FEA_DB_PASS", "wordpress_pass")
PROD_HOST = os.environ.get("FEA_PROD_HOST", "feadulta@134.0.10.170")
PROD_PASS = os.environ.get("FEA_PROD_PASS", "C6c2A!mAl3Wj.BQF")
PROD_WPLOAD = os.environ.get("FEA_PROD_WPLOAD", "/web/wp-load.php")
PROD_HELPER = "/tmp/fea_post_io.php"
PROD_UPLOADS_TTS = "/web/wp-content/uploads/tts"
HELPER_SRC = Path(__file__).resolve().parent / "fea_post_io.php"
LOCAL_TTS_DIR = Path(__file__).resolve().parent.parent / "wordpress/wp-content/uploads/tts"
LOG_FILE = Path(os.environ.get(
"FEA_AUDIO_SYNC_LOG",
str(Path(__file__).resolve().parent.parent / "logs/feadulta-audio-sync.log"),
))
STATE_FILE = Path(os.environ.get(
"FEA_AUDIO_SYNC_STATE",
str(Path(__file__).resolve().parent.parent / "logs/feadulta-audio-sync-state.json"),
))
def log(msg: str) -> None:
line = f"[{time.strftime('%Y-%m-%d %H:%M:%S')}] {msg}"
print(line, flush=True)
LOG_FILE.parent.mkdir(parents=True, exist_ok=True)
with LOG_FILE.open("a", encoding="utf-8") as f:
f.write(line + "\n")
def sh(cmd: list[str], *, input_bytes: bytes | None = None, timeout: int = 120) -> str:
r = subprocess.run(cmd, input=input_bytes, capture_output=True, timeout=timeout)
if r.returncode != 0:
raise RuntimeError(f"cmd falló ({r.returncode}): {' '.join(cmd[:4])}\n{r.stderr.decode(errors='replace')[:400]}")
return r.stdout.decode(errors="replace")
# ── Local ────────────────────────────────────────────────────────────────────
def local_meta(post_id: int, key: str) -> str:
r = subprocess.run(
["docker", "exec", WP_CONTAINER, "php", "/tmp/fea_post_io.php", "getmeta", str(post_id), key],
capture_output=True, text=True, timeout=60,
)
return r.stdout.strip()
def carta_article_ids(carta_id: int) -> list[int]:
q = ("SELECT post_id FROM wp_postmeta "
f"WHERE meta_key='_carta_id' AND meta_value='{carta_id}' ORDER BY post_id;")
r = subprocess.run(
["docker", "exec", DB_CONTAINER, "mysql", f"-u{DB_USER}", f"-p{DB_PASS}",
DB_NAME, "-N", "-e", q],
capture_output=True, text=True, timeout=60,
)
return [int(x) for x in r.stdout.split() if x.isdigit()]
# ── Prod (glibc rota: nada de scp/sftp, todo por ssh + cat) ────────────────────
def _ssh_text(remote_cmd: str, *, stdin: str | None = None, timeout: int = 120) -> str:
cmd = ["sshpass", "-p", PROD_PASS, "ssh", "-o", "StrictHostKeyChecking=accept-new",
"-o", "ConnectTimeout=20", PROD_HOST, remote_cmd]
r = subprocess.run(cmd, input=stdin, capture_output=True, text=True, timeout=timeout)
if r.returncode != 0:
raise RuntimeError(f"ssh falló ({r.returncode}): {remote_cmd[:80]}\n{r.stderr.strip()[:400]}")
return r.stdout
def _ssh_upload_bytes(data: bytes, remote_path: str, *, timeout: int = 180) -> None:
cmd = ["sshpass", "-p", PROD_PASS, "ssh", "-o", "StrictHostKeyChecking=accept-new",
"-o", "ConnectTimeout=20", PROD_HOST, f"cat > {remote_path}"]
sh(cmd, input_bytes=data, timeout=timeout)
_prod_helper_ready = False
def prod_helper(subcmd: str, *args: str) -> str:
global _prod_helper_ready
if not _prod_helper_ready:
_ssh_upload_bytes(HELPER_SRC.read_bytes(), PROD_HELPER)
_prod_helper_ready = True
inner = f"FEA_WP_LOAD={PROD_WPLOAD} php {PROD_HELPER} {subcmd} " + " ".join(args)
return _ssh_text(inner, timeout=60)
def prod_upload_mp3(post_id: int) -> None:
src = LOCAL_TTS_DIR / f"{post_id}.mp3"
data = src.read_bytes()
remote_path = f"{PROD_UPLOADS_TTS}/{post_id}.mp3"
_ssh_upload_bytes(data, remote_path)
# Verificación de tamaño (glibc rota => sin fiarse ciegamente del rc=0 de ssh)
remote_size = int(_ssh_text(f"wc -c < {remote_path}").strip())
if remote_size != len(data):
raise RuntimeError(f"tamaño no coincide tras subir #{post_id}: local={len(data)} remoto={remote_size}")
def prod_remove_mp3(post_id: int) -> None:
_ssh_text(f"rm -f {PROD_UPLOADS_TTS}/{post_id}.mp3")
# ── Estado ───────────────────────────────────────────────────────────────────
def load_state() -> dict:
if STATE_FILE.exists():
try:
return json.loads(STATE_FILE.read_text())
except json.JSONDecodeError:
pass
return {"synced": []}
def save_state(state: dict) -> None:
STATE_FILE.parent.mkdir(parents=True, exist_ok=True)
STATE_FILE.write_text(json.dumps(state, ensure_ascii=False, indent=2))
# ── Sync ─────────────────────────────────────────────────────────────────────
def sync_one(post_id: int, state: dict, *, dry_run: bool) -> str:
if local_meta(post_id, "fea_audio_done") != "1":
return "sin-audio-local"
if not (LOCAL_TTS_DIR / f"{post_id}.mp3").exists():
return "mp3-local-ausente"
if dry_run:
return "PLAN: subiría mp3 + setaudio"
voice = local_meta(post_id, "fea_audio_voice") or "NicoFeadulta2026"
prod_upload_mp3(post_id)
prod_helper("setaudio", str(post_id), f"/wp-content/uploads/tts/{post_id}.mp3", voice)
if post_id not in state["synced"]:
state["synced"].append(post_id)
save_state(state)
return "ok"
def rollback_one(post_id: int, state: dict) -> str:
prod_helper("unsetaudio", str(post_id))
prod_remove_mp3(post_id)
if post_id in state["synced"]:
state["synced"].remove(post_id)
save_state(state)
return "ok"
def main() -> int:
ap = argparse.ArgumentParser(description="Sincroniza audio TTS local→prod (o lo despublica con --rollback).")
ap.add_argument("--carta", type=int, default=0, help="WP post ID de la carta local; sincroniza toda su cola.")
ap.add_argument("--ids", default="", help="Lista CSV de post IDs concretos.")
ap.add_argument("--rollback", action="store_true", help="Despublica en prod en vez de publicar.")
ap.add_argument("--dry-run", action="store_true", help="Solo muestra el plan; no toca prod.")
args = ap.parse_args()
if args.ids:
ids = [int(x) for x in args.ids.split(",") if x.strip().isdigit()]
elif args.carta:
ids = carta_article_ids(args.carta)
else:
ap.error("hace falta --carta o --ids")
return 2
state = load_state()
mode = "ROLLBACK" if args.rollback else "SYNC"
log(f"=== INICIO {mode} audio→prod. {len(ids)} posts candidatos: {ids} ===")
ok = skip = err = 0
for pid in ids:
try:
if args.rollback:
if pid not in state["synced"] and not args.ids:
res = "no-estaba-sincronizado"
skip += 1
else:
res = rollback_one(pid, state)
ok += 1
else:
res = sync_one(pid, state, dry_run=args.dry_run)
if res == "ok" or res.startswith("PLAN"):
ok += 1
else:
skip += 1
log(f" #{pid}: {res}")
except Exception as exc: # noqa: BLE001
err += 1
log(f" #{pid}: ERROR {exc}")
log(f"=== FIN {mode}. ok={ok} skip={skip} error={err}. Estado: {STATE_FILE} ===")
return 1 if err else 0
if __name__ == "__main__":
raise SystemExit(main())
+3 -3
View File
@@ -31,7 +31,7 @@ DB_PASS = os.environ.get("FEA_DB_PASS", "wordpress_pass")
PROD_HOST = os.environ.get("FEA_PROD_HOST", "feadulta@134.0.10.170") PROD_HOST = os.environ.get("FEA_PROD_HOST", "feadulta@134.0.10.170")
PROD_PASS = os.environ.get("FEA_PROD_PASS", "C6c2A!mAl3Wj.BQF") PROD_PASS = os.environ.get("FEA_PROD_PASS", "C6c2A!mAl3Wj.BQF")
PROD_WPLOAD = os.environ.get("FEA_PROD_WPLOAD", "/web/wp-nuevo/wp-load.php") PROD_WPLOAD = os.environ.get("FEA_PROD_WPLOAD", "/web/wp-load.php")
PROD_HELPER = "/tmp/fea_translate_helper.php" PROD_HELPER = "/tmp/fea_translate_helper.php"
HELPER_SRC = Path(__file__).resolve().parent / "fea_translate_helper.php" HELPER_SRC = Path(__file__).resolve().parent / "fea_translate_helper.php"
@@ -45,7 +45,7 @@ STATUS = os.environ.get("FEA_SYNC_STATUS", "draft")
# instalación cuelga de la raíz. Se reescriben al desplegar para no dejar enlaces # instalación cuelga de la raíz. Se reescriben al desplegar para no dejar enlaces
# rotos (Tailscale es inaccesible para los visitantes). # rotos (Tailscale es inaccesible para los visitantes).
LOCAL_BASE = os.environ.get("FEA_LOCAL_BASE", "https://farmer.taild3aaf6.ts.net/fea") LOCAL_BASE = os.environ.get("FEA_LOCAL_BASE", "https://farmer.taild3aaf6.ts.net/fea")
PROD_BASE = os.environ.get("FEA_PROD_BASE", "https://wp-nuevo.feadulta.com") PROD_BASE = os.environ.get("FEA_PROD_BASE", "https://www.feadulta.com")
def log(msg: str) -> None: def log(msg: str) -> None:
@@ -76,7 +76,7 @@ def parse_csv_ints(raw: str) -> list[int]:
def localize_urls(text: str | None) -> tuple[str, int]: def localize_urls(text: str | None) -> tuple[str, int]:
"""Reescribe URLs absolutas local→prod en el contenido antes de subirlo. """Reescribe URLs absolutas local→prod en el contenido antes de subirlo.
Equivale al search-replace `farmer.taild3aaf6.ts.net/fea` → `wp-nuevo.feadulta.com` Equivale al search-replace `farmer.taild3aaf6.ts.net/fea` → `www.feadulta.com`
pero aplicado en origen, así el contenido llega ya correcto a prod (issue #91). pero aplicado en origen, así el contenido llega ya correcto a prod (issue #91).
Devuelve (texto, nº de reemplazos). Devuelve (texto, nº de reemplazos).
""" """
+1 -1
View File
@@ -16,7 +16,7 @@
* Uso (local): docker exec wordpress-web php /var/www/html/scripts/... (o vía cwd) * Uso (local): docker exec wordpress-web php /var/www/html/scripts/... (o vía cwd)
* php scripts/translate_lectura_titles.php # dry-run + reporte * php scripts/translate_lectura_titles.php # dry-run + reporte
* APPLY=1 php scripts/translate_lectura_titles.php # aplica * 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); error_reporting(E_ALL & ~E_DEPRECATED & ~E_NOTICE);
+12 -8
View File
@@ -1,8 +1,10 @@
#!/usr/bin/env python3 #!/usr/bin/env python3
"""Orquestador nocturno: locuta cartas ES del gap con MiniMax (voz Nico), una a """Orquestador nocturno: locuta cartas ES del gap con MiniMax, una a una,
una, repartido en el tiempo. Reanudable (meta fea_audio_done) y con freno ante repartido en el tiempo. Voz por defecto Nico; los artículos de autores con voz
la cuota (para tras N fallos seguidos). NO toca el front; solo genera el mp3 y clonada (AUTHOR_VOICES en minimax_tts.py, ej. Fray Marcos) usan la suya propia.
asocia la URL al post (meta fea_audio_url). Reanudable (meta fea_audio_done) y con freno ante la cuota (para tras N fallos
seguidos). NO toca el front; solo genera el mp3 y asocia la URL al post (meta
fea_audio_url).
Lanzar: nohup ~/tts-local/xtts-venv/bin/python scripts/tts_produce.py > /tmp/feadulta-tts-prod.out 2>&1 & Lanzar: nohup ~/tts-local/xtts-venv/bin/python scripts/tts_produce.py > /tmp/feadulta-tts-prod.out 2>&1 &
Log: /tmp/feadulta-tts-prod.log Log: /tmp/feadulta-tts-prod.log
@@ -78,7 +80,7 @@ def main():
i += 1 i += 1
continue continue
try: try:
title, text = mm.get_post_text(pid) title, text, author = mm.get_post_text(pid)
except Exception as e: # noqa: BLE001 except Exception as e: # noqa: BLE001
log(f"#{pid}: error leyendo ({e}); skip") log(f"#{pid}: error leyendo ({e}); skip")
php("setflag", str(pid), "fea_audio_skip", "1") php("setflag", str(pid), "fea_audio_skip", "1")
@@ -90,15 +92,17 @@ def main():
i += 1 i += 1
continue continue
rc = mm.t2a(mm.add_pauses(text), VOICE, MODEL, f"prod-{pid}") voice = mm.voice_for_author(author, VOICE)
rc = mm.t2a(mm.add_pauses(text), voice, MODEL, f"prod-{pid}")
if rc == 0: if rc == 0:
src = mm.OUT / f"prod-{pid}.mp3" src = mm.OUT / f"prod-{pid}.mp3"
dst = PROD / f"{pid}.mp3" dst = PROD / f"{pid}.mp3"
shutil.move(str(src), str(dst)) shutil.move(str(src), str(dst))
php("setaudio", str(pid), f"/wp-content/uploads/tts/{pid}.mp3") php("setaudio", str(pid), f"/wp-content/uploads/tts/{pid}.mp3", voice)
ok += 1 ok += 1
consec = 0 consec = 0
log(f"#{pid} OK «{title[:45]}» → tts/{pid}.mp3 (total {ok})") voice_tag = f" [{voice}]" if voice != VOICE else ""
log(f"#{pid} OK «{title[:45]}»{voice_tag} → tts/{pid}.mp3 (total {ok})")
i += 1 i += 1
time.sleep(INTERVAL) time.sleep(INTERVAL)
else: else:
+144
View File
@@ -0,0 +1,144 @@
<?php
/**
* verify_carta_lang_links.php
*
* QA de una carta traducida: para cada idioma (en/fr/it/pt), revisa TODOS los
* enlaces internos de feadulta.com y comprueba que apuntan a contenido en ese
* mismo idioma (prefijo /en/, /fr/, /it/, /pt/ y slug correcto).
*
* Detecta dos tipos de fallo:
* - SIN_TRADUCIR: el link apunta al artículo en español porque nunca se
* tradujo/creó su equivalente en ese idioma.
* - MISMATCH: el link tiene prefijo de idioma pero el slug no es el
* correcto (p.ej. suffix -2 divergente entre local y prod), y resuelve a
* contenido de OTRO idioma o a nada.
*
* Solo reporta, no modifica nada (a diferencia de repoint_carta_links.php).
*
* Uso:
* FEA_WP_LOAD=/web/wp-load.php CARTA=54254 php verify_carta_lang_links.php
* CARTA=54254 php verify_carta_lang_links.php (usa /var/www/html/wp-load.php, para Docker local)
*/
require getenv('FEA_WP_LOAD') ?: '/var/www/html/wp-load.php';
$CARTA = (int)(getenv('CARTA') ?: 0);
if (!$CARTA) {
fwrite(STDERR, "Falta CARTA=<id del post en español>\n");
exit(1);
}
$LANGS = ['en', 'fr', 'it', 'pt'];
function resolve_post_from_href($href) {
if (preg_match('~[?&]p=(\d+)~', $href, $m)) {
return (int)$m[1];
}
$path = preg_replace('~^https?://[^/]+~i', '', $href);
$path = preg_replace('~[?#].*$~', '', $path);
$path = preg_replace('~^/fea~', '', $path);
$path = preg_replace('~^/(en|fr|it|pt|es)(/|$)~', '/', $path);
$segs = array_values(array_filter(explode('/', $path), 'strlen'));
if (count($segs) !== 1) {
return 0; // categorías, home, rutas multi-segmento: no aplica
}
$p = get_page_by_path($segs[0], OBJECT, ['post', 'page']);
return $p ? $p->ID : 0;
}
function href_lang_prefix($href) {
$path = preg_replace('~^https?://[^/]+~i', '', $href);
$path = preg_replace('~^/fea~', '', $path);
if (preg_match('~^/(en|fr|it|pt)(/|$)~', $path, $m)) {
return $m[1];
}
return null;
}
$translations = pll_get_post_translations($CARTA);
if (empty($translations)) {
fwrite(STDERR, "El post $CARTA no tiene grupo de traducciones Polylang.\n");
exit(1);
}
echo "Verificando enlaces de la carta ES=$CARTA\n";
echo "Traducciones: " . json_encode($translations) . "\n\n";
$total_sin_traducir = 0;
$total_mismatch = 0;
$total_rotos = 0;
foreach ($LANGS as $lang) {
if (empty($translations[$lang])) {
echo "[$lang] SIN TRADUCCIÓN DE LA CARTA (no existe post en este idioma)\n\n";
continue;
}
$pid = $translations[$lang];
$post = get_post($pid);
if (!$post) {
echo "[$lang] post $pid no encontrado\n\n";
continue;
}
// Captura cualquier href absoluto http(s) — funciona tanto en local (dominio
// Tailscale) como en prod (www.feadulta.com); resolve_post_from_href() ya
// descarta lo que no resuelva a un post interno (externos, mailto, etc.).
preg_match_all('~href="(https?://[^"]+)"~i', $post->post_content, $m);
$hrefs = array_values(array_unique($m[1]));
$issues = [];
foreach ($hrefs as $href) {
$target_id = resolve_post_from_href($href);
if (!$target_id) {
continue; // home, categorías, rutas no resolubles a un post concreto
}
$target_lang = pll_get_post_language($target_id) ?: 'es';
$prefix = href_lang_prefix($href);
if ($target_lang === $lang && $prefix === $lang) {
continue; // todo correcto
}
// Averiguar el ES de origen del contenido apuntado, para buscar la traducción correcta
if ($target_lang === 'es') {
$es_id = $target_id;
} else {
$tr = pll_get_post_translations($target_id);
$es_id = $tr['es'] ?? null;
}
$correct_id = $es_id ? pll_get_post($es_id, $lang) : null;
if ($correct_id && $correct_id != $target_id) {
$correct_url = get_permalink($correct_id);
$issues[] = [
'tipo' => 'MISMATCH',
'detalle' => "href=\"$href\" resuelve a post $target_id (lang=$target_lang) correcto: $correct_url (post $correct_id)",
];
$total_mismatch++;
} elseif ($correct_id && $correct_id == $target_id && $prefix !== $lang) {
// resuelve al post correcto pero con URL/slug distinta a la actual (permalink cambió)
$correct_url = get_permalink($correct_id);
$issues[] = [
'tipo' => 'MISMATCH',
'detalle' => "href=\"$href\" no coincide con el permalink actual: $correct_url (post $correct_id)",
];
$total_mismatch++;
} elseif (!$correct_id && $target_lang !== $lang) {
$issues[] = [
'tipo' => 'SIN_TRADUCIR',
'detalle' => "href=\"$href\" -> post $target_id (lang=$target_lang) sin traducción en '$lang'",
];
$total_sin_traducir++;
}
}
echo "[$lang] post $pid " . count($hrefs) . " enlaces internos, " . count($issues) . " con problemas\n";
foreach ($issues as $issue) {
echo " {$issue['tipo']}: {$issue['detalle']}\n";
}
echo "\n";
}
echo "RESUMEN: $total_sin_traducir sin traducir, $total_mismatch mismatch de idioma/slug\n";
exit(($total_sin_traducir + $total_mismatch + $total_rotos) > 0 ? 2 : 0);