How to create an RSS feed aggregator in WordPress (without ruining your WPO)

03.09.2026|

Tiempo de lectura Lectura: 27 min, 39 s
Número de palabras Palabras: 5116
Número de visitas Visitas: 58
Icono de traducción
How to create an RSS feed aggregator in WordPress (without ruining your WPO)
The snippet allows you to add two groups of feeds: one arranged horizontally with an excerpt, and the other in a grid layout without an excerpt, designed for images.

As you may have already noticed, the‘News’ page has been restored and the‘Cartoons’ page – which previously relied on an external service (Elfsight) – has been redesigned. To achieve this, a small programme has been created to act as an aggregator/reader, and the page loads almost instantly. In this post, you’ll find the generic code, in case you’d like to give it a go. You can also use it on a private page as a simple reader.

In the case of the comic strips, pagination has been added with a limit of 10 pages at 15 strips per page, which is sufficient to view a history spanning around 4 days. For news items,the 10 most recent posts in total are displayed, sorted chronologically and with pagination so that previous posts can be read up to alimit of 10 pages, also covering approximately four days (depending on the publication flow of all sources).

 You can suggest new sources (provided they have an RSS feed). To check whether the URL you’ve suggested is valid, you cango to Simple Pieand test it first.

The Rise and Fall of Syndication: XML and Feeds

Before we get into the code, we need to understand what we’re using. An XML (eXtensible Markup Language) file is a markup language designed to store and transmit data in a structured format. Based on this, RSS (Really Simple Syndication) feeds were developed – standardised files that websites generate automatically whenever they publish content.

  • The golden age: In the 2000s, with the explosion of blogs, RSS reigned supreme. Tools that are now defunct, such as Google Reader, allowed users to subscribe to hundreds of sites and read them all in one place without having to visit each website individually; even so, there are still alternatives for reading feeds.
  • The current decline: These days, feeds are on the wane for a number of reasons. With blogs dying off in droves every day and the rise of social media, engagement algorithms and closed newsletters, as well as search engine AI scraping summaries (now known as ‘ Zero-Click Search ’), many media outlets have removed their RSS feeds to force users to browse their websites. This has made it increasingly difficult to find topic-specific sources that offer RSS feeds.

How this feed engine works in WordPress

This snippet is designed with an architecture that prioritises performance and WPO.

  • Asynchronous processing (invisible AJAX): Does not block page loading. The server downloads the feeds in the background, in small batches of five at a time, triggered by organic visitor traffic.
  • Zero impact on the database: The processed data is stored in the options table with the autoload = false setting. This means that it does not clutter the site’s global memory and is only loaded when the read page requests it.
  • Clean links: Link to external articles using the attributes `target="_blank"` and `rel="noopener nofollow"`, protecting your website’s SEO and preventing authority leakage.

Advice on using third-party content

Whilst the format is designed to display content on readers and web pages, when adding third-party content, it is essential to follow good ethical and technical practices so as not to harm either your server or the original authors:

  • Hotlinking and image blocking: The snippet loads images directly from the original server (hotlinking). This saves space on your hard drive, but authors or owners may impose limitations and restrictions; if they have anti-hotlinking measures in their .htaccess file, the images will appear broken.
  • Respect for bandwidth: Do not overuse the synchronisation frequency. Constantly sending requests to third-party servers may cause their firewall (such as Cloudflare) to block your server’s IP address, as it may be mistaken for a malicious bot.
  • Intellectual property: Extract only the title and a brief summary (the code limits this to 25 words). The aim of an ethical aggregator is to redirect traffic to the original author, not to steal their full content.
  • The content displayed may change. This is an important point to bear in mind. The source may change the content it displays or even remove the feed altogether. This could result in it disappearing from your site or something different from what you expected being displayed. You should check your sources from time to time.

Code

It’s very simple to use. Add the code to your theme’s functions.php file or to your snippets plugin, and the admin area will appear under Settings/Feed Manager, featuring the shortcodes and settings to add, pause or delete the links you’ve added, and to export and import a JSON file containing your lists.

Below the code, you’ll find some technical details about performance.

// ========================================================================
// 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;
    }

    // Aumentamos a 10 para acelerar el barrido
    $batch = array_slice($active_urls, $current_idx, 10);
    update_option($idx_opt, $current_idx + 10, 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);
    
    // Caché a 4 horas (14400 segundos)
    $custom_cache = function(){ return 14400; }; 
    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);
    }
    
    // Guardamos la última hora de ejecución
    update_option('lector_ultimo_ajax_gen_' . $tipo, wp_date('d/m/Y H:i:s'), 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() {
    // Usamos $_REQUEST para aceptar POST
    $tipo = isset($_REQUEST['tipo']) ? sanitize_text_field($_REQUEST['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;

    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;
    }

    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.');

    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><br>
                <span style="color: #0073aa; font-weight: bold;">Caché lista: <?php echo $cache_g1; ?> items.</span><br>
                <span style="color: #d63638; font-size: 0.9em;">⏱️ Última ejecución silenciosa: <strong><?php echo esc_html(get_option('lector_ultimo_ajax_gen_grupo_1', 'Nunca')); ?></strong></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 (10)</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><br>
                <span style="color: #0073aa; font-weight: bold;">Caché lista: <?php echo $cache_g2; ?> items.</span><br>
                <span style="color: #d63638; font-size: 0.9em;">⏱️ Última ejecución silenciosa: <strong><?php echo esc_html(get_option('lector_ultimo_ajax_gen_grupo_2', 'Nunca')); ?></strong></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 (10)</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>
                document.addEventListener("DOMContentLoaded", function() {
                    fetch("'.admin_url('admin-ajax.php').'", {
                        method: "POST",
                        headers: { "Content-Type": "application/x-www-form-urlencoded" },
                        body: "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) {
        $estilo_img = (strpos($item['imagen'], 'emoji') !== false) ? 'object-fit: contain; padding: 2rem;' : 'object-fit: cover;';
        
        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; ' . $estilo_img . '" /></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').'", {
                    method: "POST",
                    headers: { "Content-Type": "application/x-www-form-urlencoded" },
                    body: "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>
                document.addEventListener("DOMContentLoaded", function() {
                    fetch("'.admin_url('admin-ajax.php').'", {
                        method: "POST",
                        headers: { "Content-Type": "application/x-www-form-urlencoded" },
                        body: "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').'", {
                    method: "POST",
                    headers: { "Content-Type": "application/x-www-form-urlencoded" },
                    body: "action=lector_procesar_fondo_gen&tipo=grupo_2"
                });
            }, 2000);
        });
    </script>';
    
    return ob_get_clean();
}

About the code: performance, transients and options

When building a feed aggregator for WordPress, the main challenges are lock-in time and database overload. A poorly optimised script that reads 20 or 30 live RSS feeds can cause your site to take several seconds to load (or worse, cause a server timeout ). To prevent this, this snippet applies three layers of technical optimisation:

1. Persistent storage (WordPress options vs a database)

Rather than creating custom tables (which would add unnecessary complexity and maintenance), the script stores the processed results in the wp_options table using the native update_option() function.

  • What is stored? On the one hand, the URLs of the feeds and their statuses (active/paused). On the other, the huge array containing the latest 100 or 150 items, which have already been compiled, cleaned up and sorted chronologically by their timestamp.

2. WPO: goodbye to autoload

By default, any option you save using ` update_option()` in WordPress is created with the parameter ` autoload = yes`. This forces MySQL to load that data into global RAM with every single request your website receives, even if the user is visiting the homepage, any post or page, and not the URL where the feed reader shortcode was added.

  • The solution implemented: All saves in the script explicitly set the third parameter to false (update_option(..., false)).
  • The result: The size of the compiled data is not included in the overall automatic load. The database remains idle and the data is only retrieved upon explicit request via the shortcode.

3. Cache management and the transient nature of RSS

WordPress natively includes the SimplePie library via ` fetch_feed()`in its core. However, its default handling of the cache for external XML files can be erratic.

  • Strict timeouts: We enforce a 3-second timeout ($feed->set_timeout(3);). If an external feed is down or slow, the script won’t crash your server; it will gracefully terminate the connection and move on to the next batch.
  • Temporary fetch cache: We have applied a filter so that WordPress’s internal temporary cache for RSS feeds is set to 5 minutes (return 300;). This prevents multiple consecutive visits from sending a massive number of unnecessary requests to the original authors’ servers.

4. Asynchronous processing (Invisible AJAX)

To ensure that users visiting the website are completely unaware of it, synchronisation never takes place during the visible loading of the page (front-end).

  • A silent AJAX hook (admin-ajax.php) is used, which is triggered by a small asynchronous JavaScript script with a short delay (setTimeout).
  • This divides the load into batches of 5 across 5 sources, ensuring that the server processes the information in stages, in a way that is completely transparent to the visitor.

Este blog se aloja en LucusHost

LucusHost,el mejor hosting