Cómo crear un agregador de feeds RSS en WordPress (sin arruinar tu WPO)

Sin comentarios

28.08.2026|

Sin comentarios

Tiempo de lectura Lectura: 27 min, 28 s
Número de palabras Palabras: 5083
Número de visitas Visitas: 3
Icono de traducción
El snippet permite añadir dos grupos de feeds. Uno de disposición horizontal con extracto y otro de cuadrícula sin extracto pensado para imágenes.

Como quizá ya hayas podido ver, se ha recuperado la página de "Noticias" y se ha remodelado la de "Viñetas" que dependían de un servicio externo (Elfsight). Para ello se ha hecho un motorcillo que ejerce de agregador/lector y la carga es casi instantánea. En este post encontrarás el código genérico, por si quieres probarlo. También puedes usarlo en una página privada como simple lector.

En el caso de las viñetas, se ha añadido paginación con un límite de 10 páginas a 15 piezas por página, suficiente pasa consultar un histórico de unos 4 días. Para las noticias se muestran las 10 publicaciones más recientes en total, ordenadas cronológicamente y con paginación para poder leer las anteriores hasta un límite de 10 páginas, también unos cuatro días aprox. (dependiendo del flujo de publicación del total de fuentes).

 Puedes sugerir nuevas fuentes (siempre que tengan feed RSS). Para saber si la dirección que propones es válida puedes acudir a Simple Pie y probarla antes.

Auge y declive de la sindicación: XML y Feeds

Antes de entrar en el código, es necesario entender qué estamos usando. Un archivo XML (eXtensible Markup Language) es un lenguaje de marcas diseñado para almacenar y transportar datos de forma estructurada. Basado en esto nació el Feed RSS (Really Simple Syndication), un archivo estandarizado que las webs generan automáticamente cada vez que publican contenido.

  • La era dorada: En la década de los 2000, con la explosión de los blogs, el RSS era el rey. Herramientas ya desaparecidas como Google Reader permitían a los usuarios suscribirse a cientos de sitios y leerlos en un solo lugar sin tener que visitar cada página web individualmente, aún así, siguen existiendo alternativas para la lectura de feeds.
  • El desuso actual: Hoy en día, los feeds están en declive por varios motivos. Con los blogs muriendo a puñados cada día y la irrupción de las redes sociales, los algoritmos de retención y las newsletters cerradas, además de las IA de los buscadores parasitando resúmenes (lo que se conoce ya como Zero Click Search, búsqueda de cero clics) muchos medios han eliminado sus RSS para forzar al usuario a navegar por sus sitios. Esto ha hecho que cada vez sea más complicado encontrar fuentes temáticas disponibles con RSS.

Cómo funciona este motor de feeds en WordPress

Este snippet está diseñado con una arquitectura que da prioridad al rendimiento y el WPO.

  • Procesamiento asíncrono (AJAX invisible): No bloquea la carga de la página. El servidor descarga los feeds en la sombra, en pequeños lotes de 5 en 5, activado por el tráfico orgánico de los visitantes.
  • Cero impacto en la base de datos: Los datos procesados se guardan en la tabla de opciones con el estado autoload = false. Esto significa que no ensucian la memoria global del sitio y solo se cargan cuando la página de lectura lo solicita.
  • Enlaces limpios: Sirve los artículos externos con los atributos target="_blank" y rel="noopener nofollow", protegiendo el SEO de tu web y evitando fugas de autoridad.

Consejos sobre el uso de contenido de terceros

Si bien el formato está pensado para servir el contenido en lectores y páginas, al agregar contenido ajeno, es imprescindible aplicar buenas prácticas éticas y técnicas para no perjudicar ni a tu servidor ni a los autores originales:

  • Hotlinking y bloqueo de imágenes: El snippet carga las imágenes directamente desde el servidor original (hotlinking). Esto ahorra espacio en tu disco, pero los autores o propietarios pueden imponer limitaciones y restricciones, si tiene protecciones anti-hotlinking en su .htaccess, las imágenes aparecerán rotas.
  • Respeto por el ancho de banda: No abuses de la frecuencia de sincronización. Hacer peticiones constantes a servidores de terceros puede hacer que su firewall (como Cloudflare) bloquee la IP de tu servidor por considerarlo un bot malicioso.
  • Propiedad intelectual: Extrae solo el titular y un breve resumen (el código lo limita a 25 palabras). El objetivo de un agregador ético es redirigir tráfico al autor original, no robarle el contenido completo.
  • El contenido que se sirve puede cambiar. Esto algo importante a lo que prestar atención. La fuente puede cambiar el contenido que muestra o incluso eliminar el feed. Esto puede derivar en que desaparezca de tu sitio o se muestre algo diferente a lo que esperabas. revisas tus fuentes cada cierto tiempo.

Código

Su uso es muy simple. Añades el código en el functions.php de tu plantilla o en tu plugin de snippets y aparecerá el área de administración en Ajustes/Gestor de Feeds con los shortcodes y los ajustes para añadir, pausar o borrar los enlaces añadidos y exportar e importar un JSON con tus listas.

Bajo el código encontrarás algunos detalles técnicos sobre el rendimiento.

// ========================================================================
// 1. Lector de Feeds - MOTOR DE PROCESAMIENTO POR LOTES (AJAX Y MANUAL)
// ========================================================================

function lector_procesar_lote_feed_generico($tipo) {
    $opt_urls = ($tipo === 'grupo_1') ? 'lector_feeds_g1_urls' : 'lector_feeds_g2_urls';
    $urls_data = get_option($opt_urls, []);
    $active_urls = [];
    foreach ($urls_data as $item) {
        if (is_array($item) && $item['estado'] === 'activo') $active_urls[] = $item['url'];
    }

    if (empty($active_urls)) return 0;

    $idx_opt = 'lector_ajax_idx_' . $tipo;
    $current_idx = (int) get_option($idx_opt, 0);

    if ($current_idx >= count($active_urls)) {
        $current_idx = 0;
    }

    $batch = array_slice($active_urls, $current_idx, 5);
    update_option($idx_opt, $current_idx + 5, false);

    if (empty($batch)) return 0;

    include_once(ABSPATH . WPINC . '/feed.php');
    
    $custom_timeout = function($feed) { $feed->set_timeout(3); };
    add_action('wp_feed_options', $custom_timeout);
    
    $custom_cache = function(){ return 300; }; 
    add_filter('wp_feed_cache_transient_lifetime', $custom_cache);
    
    $feed = fetch_feed($batch);
    
    remove_action('wp_feed_options', $custom_timeout);
    remove_filter('wp_feed_cache_transient_lifetime', $custom_cache);
    
    $procesados = 0;
    if (!is_wp_error($feed)) {
        $items = $feed->get_items(0, 30); 
        
        $compilados_opt = 'lector_items_compilados_' . $tipo;
        $existentes = get_option($compilados_opt, []); 
        $imagen_por_defecto = 'https://s.w.org/images/core/emoji/14.0.0/svg/1f4f0.svg';
        
        $nuevos = [];
        foreach ($items as $item) {
            $enlace = $item->get_permalink();
            if (!$enlace) continue;

            $timestamp = $item->get_date('U');
            if (!$timestamp) $timestamp = time();

            $imagen_url = '';
            if ($enclosure = $item->get_enclosure()) { $imagen_url = $enclosure->get_link(); } 
            if (empty($imagen_url)) {
                preg_match('/<img[^>]+src=[\'"]([^\'"]+)[\'"][^>]*>/i', $item->get_content(), $matches);
                if (!empty($matches[1])) { $imagen_url = $matches[1]; }
            }
            if (empty($imagen_url)) { $imagen_url = $imagen_por_defecto; }
            
            $fuente_titulo = '';
            if ($feed_parent = $item->get_feed()) { $fuente_titulo = $feed_parent->get_title(); }
            
            $nuevo_item = [
                'enlace' => $enlace,
                'titulo' => $item->get_title(),
                'timestamp' => $timestamp,
                'imagen' => $imagen_url,
                'fuente' => $fuente_titulo
            ];
            
            if ($tipo === 'grupo_1') {
                $nuevo_item['extracto'] = wp_trim_words(strip_tags($item->get_description()), 25, '...');
            }
            
            $nuevos[$enlace] = $nuevo_item;
            $procesados++;
        }
        
        foreach ($nuevos as $link => $data) {
            $existentes[$link] = $data;
        }
        
        uasort($existentes, function($a, $b) {
            return $b['timestamp'] <=> $a['timestamp'];
        });
        
        $limite = ($tipo === 'grupo_1') ? 100 : 150;
        $existentes = array_slice($existentes, 0, $limite, true);
        
        update_option($compilados_opt, $existentes, false);
    }
    return $procesados;
}

add_action('wp_ajax_lector_procesar_fondo_gen', 'lector_ajax_handler_gen');
add_action('wp_ajax_nopriv_lector_procesar_fondo_gen', 'lector_ajax_handler_gen');
function lector_ajax_handler_gen() {
    $tipo = isset($_GET['tipo']) ? sanitize_text_field($_GET['tipo']) : '';
    if (in_array($tipo, ['grupo_1', 'grupo_2'])) {
        lector_procesar_lote_feed_generico($tipo);
    }
    wp_send_json_success();
}


// ========================================================================
// 2. PROCESAR IMPORTACIÓN/EXPORTACIÓN DE ARCHIVOS
// ========================================================================
add_action('admin_init', 'lector_feeds_gen_procesar_archivos');
function lector_feeds_gen_procesar_archivos() {
    if (!current_user_can('manage_options')) return;

    // EXPORTAR JSON
    if (isset($_POST['exportar_g1']) && check_admin_referer('exportar_g1_nonce')) {
        $data = get_option('lector_feeds_g1_urls', []);
        header('Content-disposition: attachment; filename=feeds-grupo-1.json');
        header('Content-type: application/json'); echo wp_json_encode($data); exit;
    }
    if (isset($_POST['exportar_g2']) && check_admin_referer('exportar_g2_nonce')) {
        $data = get_option('lector_feeds_g2_urls', []);
        header('Content-disposition: attachment; filename=feeds-grupo-2.json');
        header('Content-type: application/json'); echo wp_json_encode($data); exit;
    }

    // IMPORTAR JSON
    function procesar_importacion_json_gen($file_input, $option_name, $nonce_name, $success_msg) {
        if (isset($_POST[$file_input]) && check_admin_referer($nonce_name) && !empty($_FILES['archivo_'.$file_input]['tmp_name'])) {
            $json = json_decode(file_get_contents($_FILES['archivo_'.$file_input]['tmp_name']), true);
            if (is_array($json)) {
                $limpios = [];
                foreach ($json as $item) {
                    if (!empty($item['url'])) {
                        $limpios[] = ['url' => sanitize_url($item['url']), 'estado' => (isset($item['estado']) && $item['estado'] === 'pausado') ? 'pausado' : 'activo'];
                    }
                }
                update_option($option_name, $limpios, false);
                add_settings_error('lector_mensajes_gen', 'import_exito', $success_msg, 'success');
            } else { add_settings_error('lector_mensajes_gen', 'import_error', 'JSON no válido.', 'error'); }
        }
    }
    procesar_importacion_json_gen('importar_g1', 'lector_feeds_g1_urls', 'importar_g1_nonce', 'JSON del Grupo 1 importado.');
    procesar_importacion_json_gen('importar_g2', 'lector_feeds_g2_urls', 'importar_g2_nonce', 'JSON del Grupo 2 importado.');

    // IMPORTAR OPML
    function procesar_importacion_opml_gen($file_input, $option_name, $nonce_name) {
        if (isset($_POST[$file_input]) && check_admin_referer($nonce_name) && !empty($_FILES['archivo_'.$file_input]['tmp_name'])) {
            libxml_use_internal_errors(true);
            $xml = simplexml_load_file($_FILES['archivo_'.$file_input]['tmp_name']);
            if ($xml) {
                $existentes = get_option($option_name, []);
                $urls_actuales = array_column($existentes, 'url');
                $outlines = $xml->xpath('//outline[@xmlUrl]');
                $anadidos = 0;
                if (!empty($outlines)) {
                    foreach ($outlines as $node) {
                        $url = sanitize_url((string)$node['xmlUrl']);
                        if ($url && !in_array($url, $urls_actuales)) {
                            $existentes[] = ['url' => $url, 'estado' => 'activo'];
                            $urls_actuales[] = $url;
                            $anadidos++;
                        }
                    }
                    update_option($option_name, $existentes, false);
                    add_settings_error('lector_mensajes_gen', 'import_opml_exito', "OPML importado. $anadidos fuentes nuevas.", 'success');
                }
            } else { add_settings_error('lector_mensajes_gen', 'import_opml_error', 'OPML no válido.', 'error'); }
        }
    }
    procesar_importacion_opml_gen('importar_opml_g1', 'lector_feeds_g1_urls', 'importar_opml_g1_nonce');
    procesar_importacion_opml_gen('importar_opml_g2', 'lector_feeds_g2_urls', 'importar_opml_g2_nonce');
}


// ========================================================================
// 3. INTERFAZ DE ADMINISTRACIÓN GENÉRICA
// ========================================================================
add_action('admin_menu', 'lector_feeds_gen_menu');
function lector_feeds_gen_menu() {
    add_options_page('Gestor de Feeds', 'Gestor de Feeds', 'manage_options', 'lector-feeds-gen', 'lector_feeds_gen_admin_page');
}

function lector_feeds_gen_admin_page() {
    if (!current_user_can('manage_options')) return;

    if (isset($_POST['sinc_g1']) && check_admin_referer('sinc_g1_nonce')) {
        $extraidos = lector_procesar_lote_feed_generico('grupo_1');
        echo '<div class="updated"><p>Sincronización manual: Lote Grupo 1 procesado ('.$extraidos.' items comprobados).</p></div>';
    }
    if (isset($_POST['sinc_g2']) && check_admin_referer('sinc_g2_nonce')) {
        $extraidos = lector_procesar_lote_feed_generico('grupo_2');
        echo '<div class="updated"><p>Sincronización manual: Lote Grupo 2 procesado ('.$extraidos.' items comprobados).</p></div>';
    }

    if (isset($_POST['vaciar_g1']) && check_admin_referer('vaciar_g1_nonce')) {
        delete_option('lector_items_compilados_grupo_1');
        delete_option('lector_ajax_idx_grupo_1');
        echo '<div class="updated"><p>Historial Grupo 1 vaciado.</p></div>';
    }
    if (isset($_POST['vaciar_g2']) && check_admin_referer('vaciar_g2_nonce')) {
        delete_option('lector_items_compilados_grupo_2');
        delete_option('lector_ajax_idx_grupo_2');
        echo '<div class="updated"><p>Historial Grupo 2 vaciado.</p></div>';
    }

    function procesar_guardado_gen($nuevo_feed, $url_feed, $nonce, $option_name) {
        $urls = get_option($option_name, []);
        if (isset($_POST[$nuevo_feed]) && !empty($_POST[$url_feed]) && check_admin_referer($nonce)) {
            $nueva_url = sanitize_url($_POST[$url_feed]);
            $existe = false;
            foreach ($urls as $item) { if ($item['url'] === $nueva_url) { $existe = true; break; } }
            if (!$existe) {
                $urls[] = ['url' => $nueva_url, 'estado' => 'activo'];
                update_option($option_name, $urls, false);
                echo '<div class="updated"><p>Feed añadido.</p></div>';
            } else { echo '<div class="error"><p>Ese feed ya está registrado.</p></div>'; }
        }
    }
    procesar_guardado_gen('nuevo_feed_g1', 'url_feed_g1', 'guardar_g1_nonce', 'lector_feeds_g1_urls');
    procesar_guardado_gen('nuevo_feed_g2', 'url_feed_g2', 'guardar_g2_nonce', 'lector_feeds_g2_urls');

    function procesar_actualizacion_gen($btn_actualizar, $nonce, $array_eliminar, $array_estados, $option_name) {
        if (isset($_POST[$btn_actualizar]) && check_admin_referer($nonce)) {
            $urls = get_option($option_name, []);
            if (!empty($_POST[$array_eliminar])) foreach ($_POST[$array_eliminar] as $i) unset($urls[$i]);
            if (!empty($_POST[$array_estados])) {
                foreach ($_POST[$array_estados] as $i => $est) {
                    if (isset($urls[$i])) $urls[$i]['estado'] = ($est === 'pausado') ? 'pausado' : 'activo';
                }
            }
            update_option($option_name, array_values($urls), false);
            echo '<div class="updated"><p>Cambios guardados.</p></div>';
        }
    }
    procesar_actualizacion_gen('actualizar_g1', 'gestionar_g1_nonce', 'g1_eliminar', 'estado_g1', 'lector_feeds_g1_urls');
    procesar_actualizacion_gen('actualizar_g2', 'gestionar_g2_nonce', 'g2_eliminar', 'estado_g2', 'lector_feeds_g2_urls');
    
    $urls_g1 = get_option('lector_feeds_g1_urls', []);
    $urls_g2 = get_option('lector_feeds_g2_urls', []);
    $cache_g1 = count(get_option('lector_items_compilados_grupo_1', []));
    $cache_g2 = count(get_option('lector_items_compilados_grupo_2', []));
    ?>
    <div class="wrap">
        <h1>Gestión de Feeds</h1>
        <?php settings_errors('lector_mensajes_gen'); ?>
        <div style="display: flex; flex-wrap: wrap; gap: 20px; margin-top: 20px;">
            
            <!-- BLOQUE GRUPO 1 -->
            <div style="flex: 1; min-width: 450px; background: #fff; border: 1px solid #ccd0d4; padding: 20px;">
                <h2 style="margin-top: 0; border-bottom: 1px solid #eee; padding-bottom: 10px; display: flex; justify-content: space-between;">
                    <span>📁 Feeds - Grupo 1 (Lista con extracto)</span>
                    <span style="font-size: 13px; font-weight: normal; background: #e0e0e0; padding: 3px 10px; border-radius: 20px;">Total: <?php echo count($urls_g1); ?></span>
                </h2>
                <p>Shortcode: <code>[feeds_grupo_1]</code> | <span style="color: #0073aa; font-weight: bold;">Caché lista: <?php echo $cache_g1; ?> items.</span></p>
                
                <div style="margin-bottom: 15px; display:flex; gap:10px; border-bottom: 1px dashed #ccc; padding-bottom: 15px; flex-wrap: wrap;">
                    <form method="post" action="">
                        <?php wp_nonce_field('sinc_g1_nonce'); ?>
                        <button type="submit" name="sinc_g1" value="1" class="button button-primary">🔄 Sincronizar Lote</button>
                    </form>
                    <form method="post" action="">
                        <?php wp_nonce_field('vaciar_g1_nonce'); ?>
                        <button type="submit" name="vaciar_g1" value="1" class="button" style="color: #d63638; border-color: #d63638;">🗑️ Vaciar Datos</button>
                    </form>
                </div>
                
                <form method="post" action="" style="margin-bottom: 20px; margin-top: 20px;">
                    <?php wp_nonce_field('guardar_g1_nonce'); ?>
                    <input type="url" name="url_feed_g1" class="regular-text" required placeholder="https://..." style="width: 70%;" />
                    <?php submit_button('Añadir Feed', 'secondary', 'nuevo_feed_g1', false); ?>
                </form>

                <form method="post" action="">
                    <?php wp_nonce_field('gestionar_g1_nonce'); ?>
                    <?php if (!empty($urls_g1)) : ?>
                        <div style="margin-bottom: 10px; display:flex; gap: 5px;">
                            <button type="button" class="button button-small" onclick="marcarTodosGen('g1', 'activo')">✓ Todos Activos</button>
                            <button type="button" class="button button-small" onclick="marcarTodosGen('g1', 'pausado')">⏸ Todos Pausados</button>
                            <button type="button" class="button button-small" onclick="marcarBorrarGen('g1')">🗑 Invertir Borrado</button>
                        </div>
                        <table class="widefat striped">
                            <thead><tr><th>URL</th><th>Estado</th><th>Quitar</th></tr></thead>
                            <tbody>
                                <?php foreach ($urls_g1 as $index => $item) : ?>
                                    <tr>
                                        <td><?php echo esc_html($item['url']); ?></td>
                                        <td>
                                            <select name="estado_g1[<?php echo $index; ?>]" class="estado_g1_select">
                                                <option value="activo" <?php selected($item['estado'], 'activo'); ?>>Activo</option>
                                                <option value="pausado" <?php selected($item['estado'], 'pausado'); ?>>Pausado</option>
                                            </select>
                                        </td>
                                        <td><label><input type="checkbox" name="g1_eliminar[]" value="<?php echo $index; ?>" class="g1_chk_borrar"> Borrar</label></td>
                                    </tr>
                                <?php endforeach; ?>
                            </tbody>
                        </table>
                        <br><?php submit_button('Guardar Cambios Grupo 1', 'primary', 'actualizar_g1', false); ?>
                    <?php else: ?>
                        <p>No hay feeds en el Grupo 1.</p>
                    <?php endif; ?>
                </form>

                <div style="margin-top: 30px; padding-top: 15px; border-top: 1px dashed #ccc;">
                    <h4 style="margin-top:0;">Herramientas JSON y OPML</h4>
                    <div style="display:flex; flex-direction: column; gap: 10px;">
                        <form method="post" action="">
                            <?php wp_nonce_field('exportar_g1_nonce'); ?>
                            <input type="hidden" name="exportar_g1" value="1">
                            <button type="submit" class="button">⬇️ Exportar JSON</button>
                        </form>
                        <form method="post" action="" enctype="multipart/form-data" style="display:flex; gap:5px;">
                            <?php wp_nonce_field('importar_g1_nonce'); ?>
                            <input type="file" name="archivo_importar_g1" accept=".json" required style="max-width: 190px;">
                            <button type="submit" name="importar_g1" value="1" class="button">⬆️ JSON (Sobrescribir)</button>
                        </form>
                        <form method="post" action="" enctype="multipart/form-data" style="display:flex; gap:5px;">
                            <?php wp_nonce_field('importar_opml_g1_nonce'); ?>
                            <input type="file" name="archivo_importar_opml_g1" accept=".opml,.xml" required style="max-width: 190px;">
                            <button type="submit" name="importar_opml_g1" value="1" class="button">⬆️ OPML (Añadir)</button>
                        </form>
                    </div>
                </div>
            </div>

            <!-- BLOQUE GRUPO 2 -->
            <div style="flex: 1; min-width: 450px; background: #fff; border: 1px solid #ccd0d4; padding: 20px;">
                <h2 style="margin-top: 0; border-bottom: 1px solid #eee; padding-bottom: 10px; display: flex; justify-content: space-between;">
                    <span>🖼️ Feeds - Grupo 2 (Cuadrícula Visual)</span>
                    <span style="font-size: 13px; font-weight: normal; background: #e0e0e0; padding: 3px 10px; border-radius: 20px;">Total: <?php echo count($urls_g2); ?></span>
                </h2>
                <p>Shortcode: <code>[feeds_grupo_2]</code> | <span style="color: #0073aa; font-weight: bold;">Caché lista: <?php echo $cache_g2; ?> items.</span></p>

                <div style="margin-bottom: 15px; display:flex; gap:10px; border-bottom: 1px dashed #ccc; padding-bottom: 15px; flex-wrap: wrap;">
                    <form method="post" action="">
                        <?php wp_nonce_field('sinc_g2_nonce'); ?>
                        <button type="submit" name="sinc_g2" value="1" class="button button-primary">🔄 Sincronizar Lote</button>
                    </form>
                    <form method="post" action="">
                        <?php wp_nonce_field('vaciar_g2_nonce'); ?>
                        <button type="submit" name="vaciar_g2" value="1" class="button" style="color: #d63638; border-color: #d63638;">🗑️ Vaciar Datos</button>
                    </form>
                </div>
                
                <form method="post" action="" style="margin-bottom: 20px; margin-top: 20px;">
                    <?php wp_nonce_field('guardar_g2_nonce'); ?>
                    <input type="url" name="url_feed_g2" class="regular-text" required placeholder="https://..." style="width: 70%;" />
                    <?php submit_button('Añadir Feed', 'secondary', 'nuevo_feed_g2', false); ?>
                </form>

                <form method="post" action="">
                    <?php wp_nonce_field('gestionar_g2_nonce'); ?>
                    <?php if (!empty($urls_g2)) : ?>
                        <div style="margin-bottom: 10px; display:flex; gap: 5px;">
                            <button type="button" class="button button-small" onclick="marcarTodosGen('g2', 'activo')">✓ Todos Activos</button>
                            <button type="button" class="button button-small" onclick="marcarTodosGen('g2', 'pausado')">⏸ Todos Pausados</button>
                            <button type="button" class="button button-small" onclick="marcarBorrarGen('g2')">🗑 Invertir Borrado</button>
                        </div>
                        <table class="widefat striped">
                            <thead><tr><th>URL</th><th>Estado</th><th>Quitar</th></tr></thead>
                            <tbody>
                                <?php foreach ($urls_g2 as $index => $item) : ?>
                                    <tr>
                                        <td><?php echo esc_html($item['url']); ?></td>
                                        <td>
                                            <select name="estado_g2[<?php echo $index; ?>]" class="estado_g2_select">
                                                <option value="activo" <?php selected($item['estado'], 'activo'); ?>>Activo</option>
                                                <option value="pausado" <?php selected($item['estado'], 'pausado'); ?>>Pausado</option>
                                            </select>
                                        </td>
                                        <td><label><input type="checkbox" name="g2_eliminar[]" value="<?php echo $index; ?>" class="g2_chk_borrar"> Borrar</label></td>
                                    </tr>
                                <?php endforeach; ?>
                            </tbody>
                        </table>
                        <br><?php submit_button('Guardar Cambios Grupo 2', 'primary', 'actualizar_g2', false); ?>
                    <?php else: ?>
                        <p>No hay feeds en el Grupo 2.</p>
                    <?php endif; ?>
                </form>

                <div style="margin-top: 30px; padding-top: 15px; border-top: 1px dashed #ccc;">
                    <h4 style="margin-top:0;">Herramientas JSON y OPML</h4>
                    <div style="display:flex; flex-direction: column; gap: 10px;">
                        <form method="post" action="">
                            <?php wp_nonce_field('exportar_g2_nonce'); ?>
                            <input type="hidden" name="exportar_g2" value="1">
                            <button type="submit" class="button">⬇️ Exportar JSON</button>
                        </form>
                        <form method="post" action="" enctype="multipart/form-data" style="display:flex; gap:5px;">
                            <?php wp_nonce_field('importar_g2_nonce'); ?>
                            <input type="file" name="archivo_importar_g2" accept=".json" required style="max-width: 190px;">
                            <button type="submit" name="importar_g2" value="1" class="button">⬆️ JSON (Sobrescribir)</button>
                        </form>
                        <form method="post" action="" enctype="multipart/form-data" style="display:flex; gap:5px;">
                            <?php wp_nonce_field('importar_opml_g2_nonce'); ?>
                            <input type="file" name="archivo_importar_opml_g2" accept=".opml,.xml" required style="max-width: 190px;">
                            <button type="submit" name="importar_opml_g2" value="1" class="button">⬆️ OPML (Añadir)</button>
                        </form>
                    </div>
                </div>
            </div>
        </div>
    </div>

    <script>
        function marcarTodosGen(tipo, estado) {
            const selectores = document.querySelectorAll('.estado_' + tipo + '_select');
            selectores.forEach(select => select.value = estado);
        }
        function marcarBorrarGen(tipo) {
            const checkboxes = document.querySelectorAll('.' + tipo + '_chk_borrar');
            if(checkboxes.length === 0) return;
            const nuevoEstado = !checkboxes[0].checked;
            checkboxes.forEach(chk => chk.checked = nuevoEstado);
        }
    </script>
    <?php
}

// ========================================================================
// 4. SHORTCODES GENÉRICOS
// ========================================================================
add_shortcode('feeds_grupo_1', 'lector_feeds_g1_shortcode');
function lector_feeds_g1_shortcode($atts) {
    $rss_items = get_option('lector_items_compilados_grupo_1', []);
    if (empty($rss_items)) {
        return '<p>Procesando... Recarga la página en unos minutos.</p><script>fetch("'.admin_url('admin-ajax.php').'?action=lector_procesar_fondo_gen&tipo=grupo_1");</script>';
    }
    
    $por_pagina = 10;
    $pagina_actual = isset($_GET['pag_g1']) ? max(1, intval($_GET['pag_g1'])) : 1;
    $paged_items = array_slice($rss_items, ($pagina_actual - 1) * $por_pagina, $por_pagina);

    ob_start();
    echo '<div style="display:flex; flex-direction:column; gap:1.5rem;">';
    foreach ($paged_items as $item) {
        echo '<a href="' . esc_url($item['enlace']) . '" target="_blank" rel="noopener nofollow" style="display:flex; border:1px solid #eaeaea; border-radius:8px; overflow:hidden; background:#fff; text-decoration:none; color:inherit; box-shadow: 0 2px 5px rgba(0,0,0,0.03); transition: transform 0.2s ease;" onmouseover="this.style.transform=\'translateY(-3px)\'" onmouseout="this.style.transform=\'none\'">';
        echo '<div style="width:35%; min-width:250px; background:#f9f9f9; display:flex; align-items:center; justify-content:center;"><img src="' . esc_url($item['imagen']) . '" alt="img" loading="lazy" style="width:100%; height:100%; max-height:220px; object-fit:cover;" /></div>';
        echo '<div style="width:65%; padding:1.5rem; display:flex; flex-direction:column; justify-content:center;">';
        echo '<div style="font-size:0.85rem; color:#666; margin-bottom:0.5rem; display:flex; flex-wrap:wrap; gap:10px; align-items:center;">';
        if (!empty($item['fuente'])) { echo '<span style="background:#eee; padding:2px 8px; border-radius:4px; font-weight:600; color:#333;">' . esc_html($item['fuente']) . '</span>'; }
        echo '<span>' . wp_date('j \d\e F \d\e Y, H:i', $item['timestamp']) . '</span></div>';
        echo '<h3 style="font-size:1.3rem; margin:0 0 0.8rem 0; line-height:1.3; color:#222;">' . esc_html($item['titulo']) . '</h3>';
        if (isset($item['extracto'])) { echo '<p style="font-size:0.95rem; color:#555; margin:0; line-height:1.5;">' . $item['extracto'] . '</p>'; }
        echo '</div></a>';
    }
    echo '</div>';
    
    $total_paginas = ceil(count($rss_items) / $por_pagina);
    if ($total_paginas > 1) {
        $btn_style = "display:inline-block; padding:8px 16px; background:#0073aa; color:#fff; text-decoration:none; border-radius:4px; font-weight:600;";
        echo '<div style="display:flex; justify-content:space-between; margin-top:30px; padding-top:20px; border-top:1px solid #eaeaea;">';
        if ($pagina_actual > 1) { echo '<a href="' . esc_url(add_query_arg('pag_g1', $pagina_actual - 1)) . '" style="'.$btn_style.'">&laquo; Anteriores</a>'; } else { echo '<div></div>'; }
        if ($pagina_actual < $total_paginas) { echo '<a href="' . esc_url(add_query_arg('pag_g1', $pagina_actual + 1)) . '" style="'.$btn_style.'">Siguientes &raquo;</a>'; }
        echo '</div>';
    }
    
    echo '<script>document.addEventListener("DOMContentLoaded", function() { setTimeout(function() { fetch("'.admin_url('admin-ajax.php').'?action=lector_procesar_fondo_gen&tipo=grupo_1"); }, 2000); });</script>';
    return ob_get_clean();
}

add_shortcode('feeds_grupo_2', 'lector_feeds_g2_shortcode');
function lector_feeds_g2_shortcode($atts) {
    $rss_items = get_option('lector_items_compilados_grupo_2', []);
    if (empty($rss_items)) {
        return '<p>Procesando... Recarga la página en unos minutos.</p><script>fetch("'.admin_url('admin-ajax.php').'?action=lector_procesar_fondo_gen&tipo=grupo_2");</script>';
    }
    
    $por_pagina = 15;
    $pagina_actual = isset($_GET['pag_g2']) ? max(1, intval($_GET['pag_g2'])) : 1;
    $paged_items = array_slice($rss_items, ($pagina_actual - 1) * $por_pagina, $por_pagina);

    ob_start();
    echo '<div style="display:grid; grid-template-columns:repeat(auto-fill, minmax(280px, 1fr)); gap:1.5rem;">';
    foreach ($paged_items as $item) {
        echo '<a href="' . esc_url($item['enlace']) . '" target="_blank" rel="noopener nofollow" style="display:flex; flex-direction:column; border:1px solid #eaeaea; border-radius:8px; overflow:hidden; background:#fff; text-decoration:none; color:inherit; box-shadow: 0 2px 5px rgba(0,0,0,0.03); transition: transform 0.2s ease;" onmouseover="this.style.transform=\'translateY(-3px)\'" onmouseout="this.style.transform=\'none\'">';
        echo '<div style="width:100%; height:260px; background:#f5f5f5; display:flex; align-items:center; justify-content:center; border-bottom:1px solid #eaeaea;"><img src="' . esc_url($item['imagen']) . '" alt="img" loading="lazy" style="width:100%; height:100%; object-fit:contain;" /></div>';
        echo '<div style="padding:1rem; display:flex; flex-direction:column;">';
        if (!empty($item['fuente'])) { echo '<span style="font-size:0.75rem; font-weight:700; text-transform:uppercase; color:#555; margin-bottom:0.4rem; letter-spacing:0.5px;">' . esc_html($item['fuente']) . '</span>'; }
        echo '<h3 style="font-size:1.1rem; margin:0 0 0.5rem 0; line-height:1.3; color:#222; font-weight:600;">' . esc_html($item['titulo']) . '</h3>';
        echo '<span style="font-size:0.8rem; color:#888; margin-top:auto;">' . wp_date('j \d\e F \d\e Y', $item['timestamp']) . '</span>';
        echo '</div></a>';
    }
    echo '</div>';

    $total_paginas = ceil(count($rss_items) / $por_pagina);
    if ($total_paginas > 1) {
        $btn_style = "display:inline-block; padding:8px 16px; background:#0073aa; color:#fff; text-decoration:none; border-radius:4px; font-weight:600;";
        echo '<div style="display:flex; justify-content:space-between; margin-top:30px; padding-top:20px; border-top:1px solid #eaeaea;">';
        if ($pagina_actual > 1) { echo '<a href="' . esc_url(add_query_arg('pag_g2', $pagina_actual - 1)) . '" style="'.$btn_style.'">&laquo; Anteriores</a>'; } else { echo '<div></div>'; }
        if ($pagina_actual < $total_paginas) { echo '<a href="' . esc_url(add_query_arg('pag_g2', $pagina_actual + 1)) . '" style="'.$btn_style.'">Siguientes &raquo;</a>'; }
        echo '</div>';
    }

    echo '<script>document.addEventListener("DOMContentLoaded", function() { setTimeout(function() { fetch("'.admin_url('admin-ajax.php').'?action=lector_procesar_fondo_gen&tipo=grupo_2"); }, 2000); });</script>';
    return ob_get_clean();
}

Sobre el código: rendimiento, transients y opciones

Cuando se construye un agregador de feeds para WordPress, el gran enemigo es el tiempo de bloqueo y la saturación de la base de datos. Un script mal optimizado que lea 20 o 30 RSS en directo puede provocar que tu sitio tarde varios segundos en cargar (o peor, provocar un Timeout del servidor). Para evitarlo, este snippet aplica tres capas de optimización técnica:

1. El almacenamiento persistente (opciones de WordPress vs base de datos)

En lugar de crear tablas personalizadas (lo cual añadiría complejidad y mantenimiento innecesario), el script almacena los resultados ya procesados en la tabla wp_options mediante la función nativa update_option().

  • ¿Qué se guarda? Por un lado, las URLs de los feeds y los estados (activos/pausados). Por otro, la matriz gigante (array) con los últimos 100 o 150 elementos ya compilados, limpios y ordenados cronológicamente por su timestamp.

2. WPO, adiós al autoload

Por defecto, cualquier opción que guardes con update_option() en WordPress se crea con el parámetro autoload = yes. Esto obliga a MySQL a cargar esos datos en la memoria RAM global con cada sola petición que recibe tu web, aunque el usuario esté visitando la portada, cualquier post o página y no la URL donde se añadió el shortcode del lector de feeds.

  • La solución aplicada: Todos los guardados del script llevan explícitamente el tercer parámetro en false (update_option(..., false)).
  • El resultado: El peso de los datos compilados no entra en la carga automática global. La base de datos descansa y los datos solo se consultan bajo demanda exclusiva del shortcode.

3. Gestión de caché y la transitoriedad de RSS

WordPress incluye de forma nativa la librería SimplePie a través de fetch_feed()en su núcleo. Sin embargo, su gestión por defecto de la caché de los archivos XML externos puede ser caprichosa.

  • Timeouts estrictos: Forzamos un límite de espera de 3 segundos ($feed->set_timeout(3);). Si una fuente externa está caída o lenta, el script no cuelga tu servidor; aborta la conexión educadamente y continúa con el siguiente lote.
  • Caché temporal de extracción: Aplicamos un filtro para que la caché interna transitoria de WordPress para el RSS sea de 5 minutos (return 300;). Esto evita que múltiples visitas seguidas lancen peticiones masivas e innecesarias a los servidores de los autores originales.

4. Procesamiento asíncrono (AJAX Invisible)

Para que el usuario que visita la web no note absolutamente nada, la sincronización nunca se ejecuta en la carga visible de la página (Front-end).

  • Se usa un gancho AJAX silencioso (admin-ajax.php) que se dispara mediante un pequeño script de JavaScript asíncrono con un pequeño retraso (setTimeout).
  • Esto divide la carga en lotes de 5 en 5 fuentes, asegurando que el servidor procese la información de forma fraccionada y completamente transparente para el visitante.

Donar

Documentales sobre dibujantesTontolares, los titulares más tontos

Deja un comentario

¿Algo que decir?

Este blog se aloja en LucusHost

LucusHost, el mejor hosting

Tu WordPress puede volar

Servicio de optimización de WordPress
Quiero velocidad

Servicio de optimización

Suscripción por e-mail

Recibe gratis los artículos completos en tu correo sin publicidad en el momento que se publiquen. Se envía el contenido íntegro del feed sin herramientas externas y sin anuncios.