Sincronizar mu-plugins/ y scripts/ con el estado real del sitio (2026-07-16) #179
Executable
+10
@@ -0,0 +1,10 @@
|
||||
# Cache de Python
|
||||
__pycache__/
|
||||
*.pyc
|
||||
|
||||
# Logs
|
||||
*.log
|
||||
|
||||
# Windows zone identifiers
|
||||
*.Zone.Identifier
|
||||
*:Zone.Identifier
|
||||
@@ -9,14 +9,17 @@ if (!defined('ABSPATH')) exit;
|
||||
|
||||
/** Devuelve el HTML del reproductor para el post actual, o '' si no hay audio. */
|
||||
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 '';
|
||||
$voice = get_post_meta($id, 'fea_audio_voice', true) ?: 'NicoFeadulta2026';
|
||||
return '<div class="fea-audio">'
|
||||
. '<span class="fea-audio-label">'
|
||||
. '<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"/>'
|
||||
. '</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>';
|
||||
}
|
||||
|
||||
@@ -60,3 +63,27 @@ add_action('wp_head', function () {
|
||||
</style>
|
||||
<?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
|
||||
});
|
||||
|
||||
@@ -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,
|
||||
]);
|
||||
}
|
||||
@@ -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
|
||||
// entorno → funciona en local (home en .../fea) y en prod (home en la raíz).
|
||||
// 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);
|
||||
$home_host = wp_parse_url(home_url(), PHP_URL_HOST);
|
||||
if ($host && $home_host && strcasecmp($host, $home_host) !== 0) {
|
||||
if ($host && !in_array(strtolower($host), $own_hosts, true)) {
|
||||
return null; // host externo → no es un artículo nuestro
|
||||
}
|
||||
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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,
|
||||
]);
|
||||
}
|
||||
@@ -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);
|
||||
@@ -790,9 +790,40 @@ function fea_title(string $title): string {
|
||||
$out = preg_replace_callback('/([\/:¿¡] *)(\p{Ll})/u', function ($m) {
|
||||
return $m[1] . mb_strtoupper($m[2], 'UTF-8');
|
||||
}, $out);
|
||||
$out = fea_recapitalizar_nombres_propios($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 */
|
||||
function fea_libros_biblicos(): array {
|
||||
return [
|
||||
|
||||
@@ -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);
|
||||
@@ -1,23 +1,18 @@
|
||||
<?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')) {
|
||||
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')) {
|
||||
$fea_is_prod = (defined('ABSPATH') && strpos((string) ABSPATH, '/web/wp-nuevo/') === 0)
|
||||
|| (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'
|
||||
);
|
||||
define('FEA_JOOMLA_IMAGES_URL', content_url('uploads/joomla-galleries'));
|
||||
}
|
||||
|
||||
if (!defined('FEA_PENS_DIR')) {
|
||||
|
||||
@@ -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]);
|
||||
|
||||
/**
|
||||
* 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)
|
||||
@@ -139,6 +149,12 @@ add_action('pre_get_posts', function (WP_Query $q): void {
|
||||
// Categoría (tema)
|
||||
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").
|
||||
// Usamos REGEXP '^<term>' con el término escapado para evitar metacaracteres.
|
||||
if ($fea_cita !== '') {
|
||||
|
||||
@@ -6,7 +6,7 @@
|
||||
* 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.
|
||||
* 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;
|
||||
|
||||
@@ -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.
|
||||
*/
|
||||
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));
|
||||
if ($term === '') return '';
|
||||
|
||||
global $wpdb;
|
||||
$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));
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -2,7 +2,9 @@
|
||||
/**
|
||||
* fea-share — Sección "Comparte Fe Adulta" en single posts + Open Graph tags.
|
||||
* 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. */
|
||||
@@ -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>';
|
||||
|
||||
$copied_msg = esc_js($t['copied']);
|
||||
$post_id = get_the_ID();
|
||||
|
||||
return '
|
||||
<div class="fea-share">
|
||||
@@ -52,7 +55,8 @@ function fea_share_block_html(): string {
|
||||
<span class="fea-share-label">' . esc_html($t['section']) . '</span>
|
||||
<a href="https://www.facebook.com/sharer/sharer.php?u=' . $url . '"
|
||||
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>
|
||||
</a>
|
||||
<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>
|
||||
<?php
|
||||
}, 20);
|
||||
|
||||
+15
-3
@@ -19,6 +19,7 @@ if ($action === 'get') {
|
||||
'title' => $p->post_title,
|
||||
'content' => $p->post_content,
|
||||
'status' => $p->post_status,
|
||||
'author' => (int)$p->post_author,
|
||||
], JSON_UNESCAPED_UNICODE));
|
||||
exit(0);
|
||||
}
|
||||
@@ -43,10 +44,11 @@ if ($action === 'getmeta') {
|
||||
exit(0);
|
||||
}
|
||||
|
||||
if ($action === 'setaudio') { // setaudio <id> <relpath>
|
||||
if ($action === 'setaudio') { // setaudio <id> <relpath> [voice_id]
|
||||
$id = (int)$argv[2];
|
||||
$voice = $argv[4] ?? 'NicoFeadulta2026';
|
||||
update_post_meta($id, 'fea_audio_url', home_url($argv[3]));
|
||||
update_post_meta($id, 'fea_audio_voice', 'NicoFeadulta2026');
|
||||
update_post_meta($id, 'fea_audio_voice', $voice);
|
||||
update_post_meta($id, 'fea_audio_done', '1');
|
||||
delete_post_meta($id, 'fea_audio_error');
|
||||
fwrite(STDOUT, "ok " . home_url($argv[3]) . "\n");
|
||||
@@ -58,5 +60,15 @@ if ($action === 'setflag') { // setflag <id> <key> <value>
|
||||
exit(0);
|
||||
}
|
||||
|
||||
fwrite(STDERR, "uso: get|update|getmeta|setaudio|setflag\n");
|
||||
if ($action === 'unsetaudio') { // unsetaudio <id> (rollback: despublica el audio)
|
||||
$id = (int)$argv[2];
|
||||
delete_post_meta($id, 'fea_audio_url');
|
||||
delete_post_meta($id, 'fea_audio_voice');
|
||||
delete_post_meta($id, 'fea_audio_done');
|
||||
delete_post_meta($id, 'fea_audio_error');
|
||||
fwrite(STDOUT, "ok despublicado $id\n");
|
||||
exit(0);
|
||||
}
|
||||
|
||||
fwrite(STDERR, "uso: get|update|getmeta|setaudio|setflag|unsetaudio\n");
|
||||
exit(2);
|
||||
|
||||
@@ -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,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: **53577–53599**. 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";
|
||||
@@ -8,7 +8,7 @@
|
||||
* user_meta _foto_perfil_pre143. Idempotente (si ya apunta, solo regenera metadata).
|
||||
*
|
||||
* Entrada: TSV «uid<TAB>display_name» (env FEA_TSV, por defecto /tmp/users29.tsv).
|
||||
* Uso (en el servidor, dentro de /web/wp-nuevo):
|
||||
* Uso (en el servidor, dentro de /web):
|
||||
* FEA_TSV=/tmp/users29.tsv wp eval-file scripts/import_avatars_143.php # dry-run
|
||||
* APPLY=1 FEA_TSV=/tmp/users29.tsv wp eval-file scripts/import_avatars_143.php # aplica
|
||||
*/
|
||||
|
||||
+31
-7
@@ -45,6 +45,22 @@ def creds():
|
||||
KEY, GID = creds()
|
||||
H_JSON = {"Authorization": f"Bearer {KEY}", "Content-Type": "application/json"}
|
||||
|
||||
# Voz clonada por autor (WP user_id -> voice_id). Issue #152: solo se aplica a
|
||||
# los artículos de ese autor; el resto sigue con la voz por defecto (Nico).
|
||||
AUTHOR_VOICES = {
|
||||
382: "FrayMarcosFeadulta2026", # Fray Marcos
|
||||
383: "PagolaFeadulta2026", # José Antonio Pagola
|
||||
774: "SicreFeadulta2026", # José Luis Sicre
|
||||
386: "ArregiFeadulta2026", # José Arregi
|
||||
}
|
||||
|
||||
|
||||
def voice_for_author(author_id, default_voice):
|
||||
try:
|
||||
return AUTHOR_VOICES.get(int(author_id), default_voice)
|
||||
except (TypeError, ValueError):
|
||||
return default_voice
|
||||
|
||||
|
||||
def _q(url):
|
||||
return f"{url}?GroupId={GID}" if GID else url
|
||||
@@ -83,7 +99,7 @@ def get_post_text(pid):
|
||||
raw = html.unescape(raw)
|
||||
paras = [re.sub(r"\s+", " ", p).strip() for p in raw.split("\n") if len(p.strip()) > 1]
|
||||
paras = trim_after_author_signature(paras)
|
||||
return d["title"], "\n\n".join(paras)
|
||||
return d["title"], "\n\n".join(paras), d.get("author")
|
||||
|
||||
|
||||
def is_author_signature(text):
|
||||
@@ -114,12 +130,20 @@ def is_author_signature(text):
|
||||
|
||||
|
||||
def trim_after_author_signature(paras):
|
||||
out = []
|
||||
"""Corta tras la firma final del autor, buscando desde el final hacia
|
||||
atrás para no confundirla con encabezados iniciales (p.ej. "CORPUS (A)",
|
||||
"DOMINGO XI (A)") que también matchean la heurística de is_author_signature
|
||||
pero aparecen como primer párrafo, no como firma. Solo cuenta como firma
|
||||
si hay al menos 200 caracteres de contenido real antes de ella."""
|
||||
prefix_len = 0
|
||||
prefix_lens = []
|
||||
for p in paras:
|
||||
out.append(p)
|
||||
if is_author_signature(p):
|
||||
break
|
||||
return out
|
||||
prefix_lens.append(prefix_len)
|
||||
prefix_len += len(p)
|
||||
for i in range(len(paras) - 1, -1, -1):
|
||||
if prefix_lens[i] > 200 and is_author_signature(paras[i]):
|
||||
return paras[: i + 1]
|
||||
return paras
|
||||
|
||||
|
||||
def _sent_pause(n_words, short, long_):
|
||||
@@ -413,7 +437,7 @@ def main():
|
||||
elif cmd == "carta":
|
||||
pid, voice_id = sys.argv[2], sys.argv[3]
|
||||
model = sys.argv[4] if len(sys.argv) > 4 else "speech-2.8-turbo"
|
||||
title, text = get_post_text(int(pid))
|
||||
title, text, _author = get_post_text(int(pid))
|
||||
name = sys.argv[5] if len(sys.argv) > 5 else f"carta-minimax-{pid}-{model.split('-')[-1]}"
|
||||
text = add_pauses(text)
|
||||
print(f"Post #{pid}: «{title}» ({len(text)} car con pausas)")
|
||||
|
||||
@@ -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())
|
||||
@@ -31,7 +31,7 @@ DB_PASS = os.environ.get("FEA_DB_PASS", "wordpress_pass")
|
||||
|
||||
PROD_HOST = os.environ.get("FEA_PROD_HOST", "feadulta@134.0.10.170")
|
||||
PROD_PASS = os.environ.get("FEA_PROD_PASS", "C6c2A!mAl3Wj.BQF")
|
||||
PROD_WPLOAD = os.environ.get("FEA_PROD_WPLOAD", "/web/wp-nuevo/wp-load.php")
|
||||
PROD_WPLOAD = os.environ.get("FEA_PROD_WPLOAD", "/web/wp-load.php")
|
||||
PROD_HELPER = "/tmp/fea_translate_helper.php"
|
||||
|
||||
HELPER_SRC = Path(__file__).resolve().parent / "fea_translate_helper.php"
|
||||
@@ -45,7 +45,7 @@ STATUS = os.environ.get("FEA_SYNC_STATUS", "draft")
|
||||
# instalación cuelga de la raíz. Se reescriben al desplegar para no dejar enlaces
|
||||
# rotos (Tailscale es inaccesible para los visitantes).
|
||||
LOCAL_BASE = os.environ.get("FEA_LOCAL_BASE", "https://farmer.taild3aaf6.ts.net/fea")
|
||||
PROD_BASE = os.environ.get("FEA_PROD_BASE", "https://wp-nuevo.feadulta.com")
|
||||
PROD_BASE = os.environ.get("FEA_PROD_BASE", "https://www.feadulta.com")
|
||||
|
||||
|
||||
def log(msg: str) -> None:
|
||||
@@ -76,7 +76,7 @@ def parse_csv_ints(raw: str) -> list[int]:
|
||||
def localize_urls(text: str | None) -> tuple[str, int]:
|
||||
"""Reescribe URLs absolutas local→prod en el contenido antes de subirlo.
|
||||
|
||||
Equivale al search-replace `farmer.taild3aaf6.ts.net/fea` → `wp-nuevo.feadulta.com`
|
||||
Equivale al search-replace `farmer.taild3aaf6.ts.net/fea` → `www.feadulta.com`
|
||||
pero aplicado en origen, así el contenido llega ya correcto a prod (issue #91).
|
||||
Devuelve (texto, nº de reemplazos).
|
||||
"""
|
||||
|
||||
@@ -16,7 +16,7 @@
|
||||
* Uso (local): docker exec wordpress-web php /var/www/html/scripts/... (o vía cwd)
|
||||
* php scripts/translate_lectura_titles.php # dry-run + reporte
|
||||
* APPLY=1 php scripts/translate_lectura_titles.php # aplica
|
||||
* Prod: FEA_WP_LOAD=/web/wp-nuevo/wp-load.php php translate_lectura_titles.php
|
||||
* Prod: FEA_WP_LOAD=/web/wp-load.php php translate_lectura_titles.php
|
||||
*/
|
||||
|
||||
error_reporting(E_ALL & ~E_DEPRECATED & ~E_NOTICE);
|
||||
|
||||
+12
-8
@@ -1,8 +1,10 @@
|
||||
#!/usr/bin/env python3
|
||||
"""Orquestador nocturno: locuta cartas ES del gap con MiniMax (voz Nico), una a
|
||||
una, repartido en el tiempo. Reanudable (meta fea_audio_done) y con freno ante
|
||||
la cuota (para tras N fallos seguidos). NO toca el front; solo genera el mp3 y
|
||||
asocia la URL al post (meta fea_audio_url).
|
||||
"""Orquestador nocturno: locuta cartas ES del gap con MiniMax, una a una,
|
||||
repartido en el tiempo. Voz por defecto Nico; los artículos de autores con voz
|
||||
clonada (AUTHOR_VOICES en minimax_tts.py, ej. Fray Marcos) usan la suya propia.
|
||||
Reanudable (meta fea_audio_done) y con freno ante la cuota (para tras N fallos
|
||||
seguidos). NO toca el front; solo genera el mp3 y asocia la URL al post (meta
|
||||
fea_audio_url).
|
||||
|
||||
Lanzar: nohup ~/tts-local/xtts-venv/bin/python scripts/tts_produce.py > /tmp/feadulta-tts-prod.out 2>&1 &
|
||||
Log: /tmp/feadulta-tts-prod.log
|
||||
@@ -78,7 +80,7 @@ def main():
|
||||
i += 1
|
||||
continue
|
||||
try:
|
||||
title, text = mm.get_post_text(pid)
|
||||
title, text, author = mm.get_post_text(pid)
|
||||
except Exception as e: # noqa: BLE001
|
||||
log(f"#{pid}: error leyendo ({e}); skip")
|
||||
php("setflag", str(pid), "fea_audio_skip", "1")
|
||||
@@ -90,15 +92,17 @@ def main():
|
||||
i += 1
|
||||
continue
|
||||
|
||||
rc = mm.t2a(mm.add_pauses(text), VOICE, MODEL, f"prod-{pid}")
|
||||
voice = mm.voice_for_author(author, VOICE)
|
||||
rc = mm.t2a(mm.add_pauses(text), voice, MODEL, f"prod-{pid}")
|
||||
if rc == 0:
|
||||
src = mm.OUT / f"prod-{pid}.mp3"
|
||||
dst = PROD / f"{pid}.mp3"
|
||||
shutil.move(str(src), str(dst))
|
||||
php("setaudio", str(pid), f"/wp-content/uploads/tts/{pid}.mp3")
|
||||
php("setaudio", str(pid), f"/wp-content/uploads/tts/{pid}.mp3", voice)
|
||||
ok += 1
|
||||
consec = 0
|
||||
log(f"#{pid} OK «{title[:45]}» → tts/{pid}.mp3 (total {ok})")
|
||||
voice_tag = f" [{voice}]" if voice != VOICE else ""
|
||||
log(f"#{pid} OK «{title[:45]}»{voice_tag} → tts/{pid}.mp3 (total {ok})")
|
||||
i += 1
|
||||
time.sleep(INTERVAL)
|
||||
else:
|
||||
|
||||
@@ -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);
|
||||
Reference in New Issue
Block a user