Función para encontrar y corregir enlaces rotos en WordPress

Sin comentarios

22.08.2026|

Sin comentarios

Tiempo de lectura Lectura: 27 min, 34 s
Número de palabras Palabras: 5101
Número de visitas Visitas: 1
Icono de traducción

Los enlaces rotos provocan fugas de autoridad (link juice) y se comen el presupuesto de rastreo (crawl budget), ya que los bots de los motores de búsqueda pierden tiempo indexando respuestas HTTP 404 en lugar de contenido de valor. También degradan la experiencia de usuario al interrumpir el flujo de navegación e incrementan la tasa de rebote. Al arreglarlos con redirecciones 301 o la actualización de las URL, se optimiza el rastreo de la web, se preserva el flujo de equidad de enlaces hacia las páginas de destino y se consolida la arquitectura del sitio para evitar penalizaciones indirectas en el posicionamiento orgánico.

Haciendo la revisión rutinaria de enlaces rotos con el plugin de siempre, Broken Link Checker, descubrí que el escaneo local no funcionaba. Así que agarré una función muy simple que tenía por ahí y la mejoré un poco con la ayuda de Gemini para hacerla más usable y ejecutar el barrido y corrección de enlaces rotos.

Si bien los enlaces rotos se reproducen como setas, en este viaje he notado una sangría importante de enlaces de Twitter (X), que pierde cuentas a chorros. Aparecen ya cientos de cuentas y/o tuits que dejaron de existir, pero dejaré este asunto para una futura nota sobre la muerte de la web como la conocíamos.

Interfaz de la función de comprobación de enlaces rotos para WordPress con puntos para explicar cada una de sus partes

Para usar la función basta añadir el snippet al functions.php de tu plantilla o tema hijo o en tu plugin de snippets y en Herramientas/ Inspector Enlaces encontrarás la herramienta y sus opciones, que se detallan a continuación.

1 - Tipos de estado. Aquí puedes elegir entre más de 10 tipos de estado para un escaneo selectivo.

2 - El botón Iniciar Escaneo Continuo ejecuta un escaneo sin pausas. Aunque no debería, puede tener cierto impacto en la velocidad de carga del sitio. Lo he tenido funcionando durante horas sin incidencias.

3 - Escaneo por lotes. Se escanearán 10 entradas cada vez que pulses este botón.

4 - Exportar/Importar JSON. Como el plugin no escribe nada en la base de datos, si cierras el navegador perderás la lista y para recuperar todo aquello que no recuerdes que hayas hecho tendrás que guardar siempre tu lista de escaneo con "Exportar". Para recuperarla bastará importarla.

5 - Información del proceso de escaneo.

6 - Filtro por tipos. Una vez que hayas terminado el escaneo puedes filtrar la vista de la lista por estados.

7 - Post/Página. Aquí tendrás un enlace de edición del post y otro de (Ver) para visitarlo.

8 - El anchor del enlace para que lo localices más rápido.

9 - La URL del enlace roto.

10 - Código de estado HTTP del enlace.

11 - Acción. El aspa de cierre solo quita esa fila de la vista de la lista. No edita ni borra ni añade nada.

12 - URL de sustitución. Caja donde se añade el enlace de reemplazo.

13 - Buscar en Archive.org. Al pinchar aquí se abrirá Wayback Machine con la lista de fechas disponibles de las copias de tu url (si se conservan) para que elijas aquella que más te interese.

14 - Quitar enlace. Esta opción elimina el hipervínculo manteniendo el texto del enlace en el post o página por si no tienes URL alternativa o no quieres añadirla.

Vaya por delante que es una función de usar y tirar, se aconseja que la quites una vez termines con la limpieza. No creo que la siga modificando, pero si lo hiciera actualizaría el código aquí mismo.

Aunque al final haya salido un código demasiado largo para un snippet, que se supone que debe ser algo corto o un fragmento, funciona bastante bien. Eso sí, aún se puede pulir para evitar ciertos falsos positivos de 404 y mejorarlo con la selección múltiple de acciones, pero si no tienes muchos enlaces y tiempo para revisarlos manualmente, es completamente funcional.

Para sustituciones masivas de enlaces, puedes apoyarte en los clásicos plugins dedicados como Better Search Replace y Search Regex.

// Inspector de Enlaces Rotos (Con Reemplazo Directo AJAX, Desvinculación de Enlace, Filtro de Vista, Exportar/Importar JSON)

if (!defined('ABSPATH')) exit;

// 1. Menú en Herramientas
add_action('admin_menu', function() {
    add_management_page('Inspector Enlaces', 'Inspector Enlaces', 'manage_options', 'inspector-enlaces-rotos', 'ier_render_admin_page');
});

// 2. Registro AJAX
add_action('wp_ajax_ier_scan_single_post', 'ier_scan_single_post_callback');
add_action('wp_ajax_ier_replace_link', 'ier_replace_link_callback');
add_action('wp_ajax_ier_unlink_link', 'ier_unlink_link_callback');

// 3. Interfaz HTML + JavaScript
function ier_render_admin_page() {
    global $wpdb;
    $ajax_url = admin_url('admin-ajax.php');
    $total_posts = (int) $wpdb->get_var("SELECT COUNT(ID) FROM {$wpdb->posts} WHERE post_type IN ('post', 'page') AND post_status = 'publish'");
    
    echo '<div class="wrap">';
    echo '<h1>Inspector de Enlaces Rotos</h1>';
    echo '<p>Escaneo ultra-ligero con reemplazo directo, desvinculación de enlaces, filtro por estados, exportación/importación JSON y auto-recuperación.</p>';
    
    echo '<div style="background: #fff; padding: 12px 15px; border: 1px solid #ccc; max-width: 800px; border-radius: 4px; margin-bottom: 15px;">';
    echo '<strong style="display: block; margin-bottom: 8px;">Tipos de estado a escanear:</strong>';
    echo '<div style="display: flex; flex-wrap: wrap; gap: 12px; align-items: center;" id="ier-status-filters">';
    echo '<label style="font-weight: bold; margin-right: 10px;"><input type="checkbox" id="ier-check-all" checked> [ Todos ]</label>';
    echo '<label><input type="checkbox" class="ier-status-cb" value="404" checked> 404</label>';
    echo '<label><input type="checkbox" class="ier-status-cb" value="404 Interno" checked> 404 Interno</label>';
    echo '<label><input type="checkbox" class="ier-status-cb" value="Timeout / Error Red" checked> Timeout / Error Red</label>';
    echo '<label><input type="checkbox" class="ier-status-cb" value="403" checked> 403</label>';
    echo '<label><input type="checkbox" class="ier-status-cb" value="400" checked> 400</label>';
    echo '<label><input type="checkbox" class="ier-status-cb" value="500" checked> 500</label>';
    echo '<label><input type="checkbox" class="ier-status-cb" value="502" checked> 502</label>';
    echo '<label><input type="checkbox" class="ier-status-cb" value="503" checked> 503</label>';
    echo '<label><input type="checkbox" class="ier-status-cb" value="520" checked> 520</label>';
    echo '<label><input type="checkbox" class="ier-status-cb" value="Otros / Desconocido" checked> Otros</label>';
    echo '</div>';
    echo '</div>';

    echo '<div style="margin-bottom: 15px; display: flex; gap: 10px; align-items: center; flex-wrap: wrap;">';
    echo '<button id="ier-start-btn" type="button" class="button button-primary">Iniciar Escaneo Continuo</button>';
    echo '<button id="ier-next-10-btn" type="button" class="button button-secondary">Escanear 10 Entradas</button>';
    echo '<button id="ier-stop-btn" type="button" class="button button-secondary" style="display:none;">Pausar</button>';
    echo '<span style="border-left: 1px solid #ccc; height: 24px; margin: 0 5px;"></span>';
    echo '<button id="ier-export-btn" type="button" class="button button-secondary">Exportar JSON</button>';
    echo '<button id="ier-import-trigger-btn" type="button" class="button button-secondary">Importar JSON</button>';
    echo '<input type="file" id="ier-import-file" accept=".json" style="display: none;">';
    echo '</div>';
    
    echo '<div style="margin-top: 15px; background: #fff; padding: 12px; border: 1px solid #ccc; max-width: 600px; border-radius: 4px;">';
    echo '<strong>Estado:</strong> <span id="ier-status-text">Listo para comenzar</span><br>';
    echo '<strong>Progresión:</strong> Procesadas <span id="ier-processed-count">0</span> de <strong>' . $total_posts . '</strong><br>';
    echo '<strong>Última entrada analizada:</strong> <em id="ier-current-title" style="color: #2271b1;">-</em><br>';
    echo '<strong>Enlaces rotos localizados:</strong> <span id="ier-count-broken" style="color: #d63638; font-weight: bold;">0</span>';
    echo '</div>';

    echo '<div style="margin-top: 20px; display: flex; align-items: center; gap: 10px; background: #f6f7f7; padding: 10px; border: 1px solid #c3c4c7; border-radius: 4px;">';
    echo '<strong>Filtrar vista por tipo:</strong> ';
    echo '<select id="ier-view-filter-select" style="max-width: 250px;"><option value="ALL">Mostrar todos los estados</option></select> ';
    echo '<span id="ier-view-filter-count" style="color: #646970; font-style: italic;"></span>';
    echo '</div>';

    echo '<table class="widefat fixed striped" style="margin-top: 10px;">';
    echo '<thead><tr>';
    echo '<th style="width: 18%;">Post / Página</th>';
    echo '<th style="width: 18%;">Texto / Anchor</th>';
    echo '<th style="width: 24%;">URL Rota</th>';
    echo '<th style="width: 12%;">Estado</th>';
    echo '<th style="width: 23%;">Sustituir por (Wayback/Nuevo)</th>';
    echo '<th style="width: 5%; text-align: center;">Acción</th>';
    echo '</tr></thead>';
    echo '<tbody id="ier-results-body"><tr><td colspan="6">Haz clic en un botón de arriba para comenzar o importa un JSON guardado.</td></tr></tbody>';
    echo '</table>';
    echo '</div>';

    ?>
    <script>
    jQuery(document).ready(function($) {
        const totalPosts = <?php echo $total_posts; ?>;
        const ajaxUrl = '<?php echo esc_url($ajax_url); ?>';
        let lastId = 0;
        let processedCount = 0;
        let scanning = false;
        let totalBroken = 0;
        let targetBatchCount = 0;
        let currentBatchProgress = 0;

        $('#ier-check-all').on('change', function() {
            $('.ier-status-cb').prop('checked', $(this).is(':checked'));
        });

        $('.ier-status-cb').on('change', function() {
            if (!$(this).is(':checked')) {
                $('#ier-check-all').prop('checked', false);
            } else {
                if ($('.ier-status-cb:not(:checked)').length === 0) {
                    $('#ier-check-all').prop('checked', true);
                }
            }
        });

        function getSelectedStatuses() {
            const selected = [];
            $('.ier-status-cb:checked').each(function() {
                selected.push($(this).val());
            });
            return selected;
        }

        function updateViewFilterDropdown() {
            const currentSelected = $('#ier-view-filter-select').val() || 'ALL';
            const statusSet = new Set();

            $('#ier-results-body tr').each(function() {
                const status = $(this).attr('data-status');
                if (status) statusSet.add(status);
            });

            const $select = $('#ier-view-filter-select');
            $select.empty();
            $select.append('<option value="ALL">Mostrar todos los estados</option>');

            Array.from(statusSet).sort().forEach(function(status) {
                $select.append('<option value="' + status + '">' + status + '</option>');
            });

            if (statusSet.has(currentSelected)) {
                $select.val(currentSelected);
            } else {
                $select.val('ALL');
            }

            applyViewFilter();
        }

        function applyViewFilter() {
            const selectedStatus = $('#ier-view-filter-select').val();
            let visibleCount = 0;

            $('#ier-results-body tr').each(function() {
                const $tr = $(this);
                const rowStatus = $tr.attr('data-status');

                if (!rowStatus) return;

                if (selectedStatus === 'ALL' || rowStatus === selectedStatus) {
                    $tr.show();
                    visibleCount++;
                } else {
                    $tr.hide();
                }
            });

            if (selectedStatus === 'ALL') {
                $('#ier-view-filter-count').text('');
            } else {
                $('#ier-view-filter-count').text('(' + visibleCount + ' de ' + totalBroken + ' visibles)');
            }
        }

        $('#ier-view-filter-select').on('change', function() {
            applyViewFilter();
        });

        $(document).on('click', '.ier-replace-btn', function(e) {
            e.preventDefault();
            const $btn = $(this);
            const $tr = $btn.closest('tr');
            
            let postId = parseInt($tr.attr('data-post-id'), 10) || 0;
            if (!postId) {
                const editUrl = $tr.find('.ier-edit-link').attr('href') || '';
                const match = editUrl.match(/post=(\d+)/);
                if (match) {
                    postId = parseInt(match[1], 10);
                }
            }

            const oldUrl = $tr.attr('data-old-url');
            const $input = $tr.find('.ier-new-url-input');
            const newUrl = $input.val().trim();

            if (!postId) {
                alert('Error: ID del Post no válido o ausente.');
                return;
            }

            if (!oldUrl) {
                alert('Error: URL original no encontrada en el elemento.');
                return;
            }

            if (!newUrl) {
                alert('Introduce una nueva URL para reemplazar.');
                return;
            }

            $btn.prop('disabled', true).text('Guardando...');
            $input.prop('disabled', true);

            $.ajax({
                url: ajaxUrl,
                type: 'POST',
                data: {
                    action: 'ier_replace_link',
                    post_id: postId,
                    old_url: oldUrl,
                    new_url: newUrl
                },
                success: function(response) {
                    if (response.success) {
                        $tr.attr('data-old-url', newUrl);
                        $input.prop('disabled', false);
                        $tr.css({'background-color': '#d4edda'});

                        $btn.text('¡Guardado!')
                            .css({'background': '#28a745', 'border-color': '#28a745', 'color': '#fff'})
                            .prop('disabled', false);

                        setTimeout(function() {
                            $btn.text('Guardar')
                                .css({'background': '', 'border-color': '', 'color': ''});
                            $tr.css({'background-color': ''});
                        }, 2000);
                    } else {
                        alert('Error al reemplazar: ' + (response.data || 'No se encontró la URL en la entrada.'));
                        $btn.prop('disabled', false).text('Guardar');
                        $input.prop('disabled', false);
                    }
                },
                error: function() {
                    alert('Error en la solicitud AJAX.');
                    $btn.prop('disabled', false).text('Guardar');
                    $input.prop('disabled', false);
                }
            });
        });

        $(document).on('click', '.ier-unlink-btn', function(e) {
            e.preventDefault();
            const $btn = $(this);
            const $tr = $btn.closest('tr');

            let postId = parseInt($tr.attr('data-post-id'), 10) || 0;
            if (!postId) {
                const editUrl = $tr.find('.ier-edit-link').attr('href') || '';
                const match = editUrl.match(/post=(\d+)/);
                if (match) {
                    postId = parseInt(match[1], 10);
                }
            }

            const urlToUnlink = $tr.attr('data-old-url');

            if (!postId || !urlToUnlink) {
                alert('Error: Datos del post o URL no encontrados.');
                return;
            }

            if (!confirm('¿Quieres eliminar la etiqueta de enlace mantención el texto ancla intacto?')) {
                return;
            }

            $btn.prop('disabled', true).text('Quitando...');

            $.ajax({
                url: ajaxUrl,
                type: 'POST',
                data: {
                    action: 'ier_unlink_link',
                    post_id: postId,
                    url: urlToUnlink
                },
                success: function(response) {
                    if (response.success) {
                        $tr.css({
                            'background-color': '#f8d7da',
                            'transition': 'background-color 0.4s ease'
                        });
                        $btn.text('Enlace quitado').css({'background': '#d63638', 'border-color': '#d63638', 'color': '#fff'});
                        setTimeout(function() {
                            $tr.fadeOut(300, function() {
                                $(this).remove();
                                if (totalBroken > 0) {
                                    totalBroken--;
                                    $('#ier-count-broken').text(totalBroken);
                                }
                                updateViewFilterDropdown();
                            });
                        }, 1000);
                    } else {
                        alert('Error al desvincular: ' + (response.data || 'No se pudo eliminar el enlace.'));
                        $btn.prop('disabled', false).text('Quitar enlace');
                    }
                },
                error: function() {
                    alert('Error en la solicitud AJAX.');
                    $btn.prop('disabled', false).text('Quitar enlace');
                }
            });
        });

        $('#ier-start-btn').on('click', function(e) {
            e.preventDefault();
            if (getSelectedStatuses().length === 0) {
                alert('Selecciona al menos un tipo de estado para escanear.');
                return;
            }
            scanning = true;
            targetBatchCount = totalPosts;
            currentBatchProgress = 0;
            toggleButtons(true);
            if (processedCount === 0) $('#ier-results-body').empty();
            processNext();
        });

        $('#ier-next-10-btn').on('click', function(e) {
            e.preventDefault();
            if (getSelectedStatuses().length === 0) {
                alert('Selecciona al menos un tipo de estado para escanear.');
                return;
            }
            scanning = true;
            targetBatchCount = 10;
            currentBatchProgress = 0;
            toggleButtons(true);
            if (processedCount === 0) $('#ier-results-body').empty();
            processNext();
        });

        $('#ier-stop-btn').on('click', function(e) {
            e.preventDefault();
            scanning = false;
            toggleButtons(false);
            $('#ier-status-text').text('Pausado por el usuario.');
        });

        $(document).on('click', '.ier-dismiss-row', function(e) {
            e.preventDefault();
            $(this).closest('tr').fadeOut(200, function() {
                $(this).remove();
                if (totalBroken > 0) {
                    totalBroken--;
                    $('#ier-count-broken').text(totalBroken);
                }
                updateViewFilterDropdown();
                if ($('#ier-results-body tr').length === 0) {
                    $('#ier-results-body').append('<tr><td colspan="6">No hay enlaces rotos pendientes en el listado.</td></tr>');
                }
            });
        });

        $('#ier-export-btn').on('click', function(e) {
            e.preventDefault();
            const rows = [];

            $('#ier-results-body tr').each(function() {
                const $tr = $(this);
                if ($tr.find('td').length < 6) return;

                const $postLink = $tr.find('.ier-edit-link');
                const postTitle = $postLink.text();
                const postEditUrl = $postLink.attr('href') || '';
                const postViewUrl = $tr.find('.ier-view-link').attr('href') || '';
                
                let postId = parseInt($tr.attr('data-post-id'), 10) || 0;
                if (!postId && postEditUrl) {
                    const match = postEditUrl.match(/post=(\d+)/);
                    if (match) postId = parseInt(match[1], 10);
                }

                const anchorHtml = $tr.find('td:nth-child(2)').html();
                const url = $tr.attr('data-old-url') || $tr.find('td:nth-child(3) a').text();
                const statusHtml = $tr.attr('data-status') || $tr.find('td:nth-child(4) strong').text();

                rows.push({
                    post_id: postId,
                    post_title: postTitle,
                    post_edit_url: postEditUrl,
                    post_view_url: postViewUrl,
                    anchor: anchorHtml,
                    url: url,
                    status: statusHtml
                });
            });

            if (rows.length === 0) {
                alert('No hay enlaces rotos en la lista para exportar.');
                return;
            }

            const exportData = {
                created_at: new Date().toISOString(),
                last_id: lastId,
                processed_count: processedCount,
                total_broken: rows.length,
                broken_links: rows
            };

            const dataStr = "data:text/json;charset=utf-8," + encodeURIComponent(JSON.stringify(exportData, null, 2));
            const downloadAnchor = document.createElement('a');
            const dateSuffix = new Date().toISOString().slice(0, 10);
            
            downloadAnchor.setAttribute("href", dataStr);
            downloadAnchor.setAttribute("download", "enlaces-rotos-" + dateSuffix + ".json");
            document.body.appendChild(downloadAnchor);
            downloadAnchor.click();
            downloadAnchor.remove();
        });

        $('#ier-import-trigger-btn').on('click', function(e) {
            e.preventDefault();
            $('#ier-import-file').click();
        });

        $('#ier-import-file').on('change', function(e) {
            const file = e.target.files[0];
            if (!file) return;

            const reader = new FileReader();
            reader.onload = function(evt) {
                try {
                    const data = JSON.parse(evt.target.result);
                    if (!data.broken_links || !Array.isArray(data.broken_links)) {
                        alert('El archivo JSON no tiene un formato válido.');
                        return;
                    }

                    $('#ier-results-body').empty();
                    totalBroken = data.broken_links.length;
                    $('#ier-count-broken').text(totalBroken);

                    if (typeof data.last_id !== 'undefined') lastId = data.last_id;
                    if (typeof data.processed_count !== 'undefined') {
                        processedCount = data.processed_count;
                        $('#ier-processed-count').text(processedCount);
                    }

                    data.broken_links.forEach(function(item) {
                        appendRow(item);
                    });

                    updateViewFilterDropdown();
                    $('#ier-status-text').text('Datos importados correctamente desde JSON.');
                    $('#ier-import-file').val('');
                } catch (err) {
                    alert('Error al leer el archivo JSON: ' + err.message);
                }
            };
            reader.readAsText(file);
        });

        function escapeAttr(str) {
            return String(str || '').replace(/"/g, '&quot;').replace(/'/g, '&#39;');
        }

        function appendRow(item) {
            const cleanStatus = $('<div>').html(item.status).text();
            
            let postId = parseInt(item.post_id, 10) || 0;
            if (!postId && item.post_edit_url) {
                const match = item.post_edit_url.match(/post=(\d+)/);
                if (match) {
                    postId = parseInt(match[1], 10);
                }
            }

            let rawUrl = item.url;
            try {
                rawUrl = decodeURIComponent(item.url);
            } catch (e) {
                rawUrl = item.url;
            }

            const archiveSearchUrl = 'https://web.archive.org/web/*/' + rawUrl;
            const viewUrl = (item.post_view_url && item.post_view_url !== '#') ? item.post_view_url : '<?php echo esc_js(home_url('/?p=')); ?>' + postId;

            $('#ier-results-body').append(
                '<tr data-status="' + escapeAttr(cleanStatus) + '" data-post-id="' + postId + '" data-old-url="' + escapeAttr(item.url) + '">' +
                    '<td>' +
                        '<a href="' + escapeAttr(item.post_edit_url) + '" target="_blank" class="ier-edit-link">' + item.post_title + '</a> ' +
                        '<a href="' + escapeAttr(viewUrl) + '" target="_blank" class="ier-view-link" style="font-size: 11px; text-decoration: none; color: #2271b1;" title="Ver entrada pública">(Ver)</a>' +
                    '</td>' +
                    '<td>' + item.anchor + '</td>' +
                    '<td><a href="' + escapeAttr(item.url) + '" target="_blank" style="color:#d63638;">' + item.url + '</a></td>' +
                    '<td><strong>' + item.status + '</strong></td>' +
                    '<td>' +
                        '<div style="display: flex; gap: 4px;">' +
                            '<input type="url" class="ier-new-url-input" placeholder="https://..." style="width: 100%; font-size: 11px; padding: 2px 4px;">' +
                            '<button type="button" class="button button-small ier-replace-btn" title="Reemplazar URL en la BD">Guardar</button>' +
                        '</div>' +
                        '<div style="margin-top: 4px; display: flex; justify-content: space-between; align-items: center;">' +
                            '<a href="' + escapeAttr(archiveSearchUrl) + '" target="_blank" style="font-size: 10px; color: #2271b1; text-decoration: none;">🔍 Buscar en Archive.org</a>' +
                            '<button type="button" class="button-link ier-unlink-btn" style="font-size: 10px; color: #d63638; text-decoration: underline;" title="Quitar enlace y dejar solo texto">Quitar enlace</button>' +
                        '</div>' +
                    '</td>' +
                    '<td style="text-align: center;"><button type="button" class="button-link ier-dismiss-row" title="Descartar de la lista" style="color: #a7aaad; text-decoration: none; font-size: 18px; font-weight: bold; line-height: 1;">&times;</button></td>' +
                '</tr>'
            );
        }

        function processNext() {
            if (!scanning || currentBatchProgress >= targetBatchCount || processedCount >= totalPosts) {
                scanning = false;
                toggleButtons(false);
                $('#ier-status-text').text(processedCount >= totalPosts ? 'Escaneo completado.' : 'Lote procesado correctamente.');
                return;
            }

            $('#ier-status-text').text('Consultando ID superior a ' + lastId + '...');

            $.ajax({
                url: ajaxUrl,
                type: 'POST',
                data: {
                    action: 'ier_scan_single_post',
                    last_id: lastId,
                    allowed_statuses: getSelectedStatuses()
                },
                success: function(response) {
                    if (response.success) {
                        const data = response.data;

                        if (data.finished) {
                            scanning = false;
                            toggleButtons(false);
                            $('#ier-status-text').text('Fin de la base de datos.');
                            return;
                        }

                        lastId = data.post_id;
                        processedCount++;
                        currentBatchProgress++;

                        $('#ier-processed-count').text(processedCount);
                        $('#ier-current-title').text(data.post_title + ' (ID ' + data.post_id + ')');

                        if (data.broken_links && data.broken_links.length > 0) {
                            if ($('#ier-results-body tr td').length === 1) {
                                $('#ier-results-body').empty();
                            }

                            totalBroken += data.broken_links.length;
                            $('#ier-count-broken').text(totalBroken);

                            data.broken_links.forEach(function(item) {
                                appendRow(item);
                            });

                            updateViewFilterDropdown();
                        }

                        if (scanning) {
                            setTimeout(processNext, 20);
                        }
                    } else {
                        $('#ier-status-text').text('Error del servidor: ' + (response.data || 'Respuesta vacía'));
                        scanning = false;
                        toggleButtons(false);
                    }
                },
                error: function(xhr, status, error) {
                    console.warn('Fallo en ID > ' + lastId + '. Forzando salto al siguiente ID...', status, error);
                    $('#ier-status-text').text('Timeout en ID > ' + lastId + '. Saltando entrada problemática...');
                    
                    lastId++;
                    processedCount++;
                    currentBatchProgress++;
                    
                    if (scanning) {
                        setTimeout(processNext, 1000);
                    }
                }
            });
        }

        function toggleButtons(isScanning) {
            if (isScanning) {
                $('#ier-start-btn, #ier-next-10-btn, #ier-export-btn, #ier-import-trigger-btn').hide();
                $('.ier-status-cb, #ier-check-all').prop('disabled', true);
                $('#ier-stop-btn').show();
            } else {
                $('#ier-stop-btn').hide();
                $('.ier-status-cb, #ier-check-all').prop('disabled', false);
                $('#ier-start-btn, #ier-next-10-btn, #ier-export-btn, #ier-import-trigger-btn').show();
                $('#ier-start-btn').text('Continuar Escaneo');
            }
        }
    });
    </script>
    <?php
}

// 4. Servidor: Analizar entrada individual
function ier_scan_single_post_callback() {
    if (!current_user_can('manage_options')) {
        wp_send_json_error('Sin permisos');
    }

    if (function_exists('wp_ob_end_flush_all')) {
        @wp_ob_end_flush_all();
    }

    @set_time_limit(15);

    global $wpdb;
    $last_id = isset($_POST['last_id']) ? intval($_POST['last_id']) : 0;
    $allowed_statuses = isset($_POST['allowed_statuses']) && is_array($_POST['allowed_statuses']) ? $_POST['allowed_statuses'] : [];

    $post = $wpdb->get_row($wpdb->prepare(
        "SELECT ID, post_title, post_content FROM {$wpdb->posts} WHERE post_type IN ('post', 'page') AND post_status = 'publish' AND ID > %d ORDER BY ID ASC LIMIT 1",
        $last_id
    ));

    if (!$post) {
        wp_send_json_success(['finished' => true]);
    }

    $title = $post->post_title;
    $content = $post->post_content;
    $broken_links = [];

    $post_view_url = get_permalink($post->ID);

    if (!empty($content)) {
        $regex = '/<a\s+[^>]*href=["\']([^"\']+)["\'][^>]*>(.*?)<\/a>/is';

        if (preg_match_all($regex, $content, $matches, PREG_SET_ORDER)) {
            $site_url = site_url();
            $site_host = parse_url($site_url, PHP_URL_HOST);
            $start_time = microtime(true);

            foreach ($matches as $match) {
                if ((microtime(true) - $start_time) > 3.0) {
                    break;
                }

                $url = trim($match[1]);
                $anchor = wp_strip_all_tags($match[2]);

                if (empty($url) || strpos($url, '#') === 0 || strpos($url, 'mailto:') === 0 || strpos($url, 'tel:') === 0 || strpos($url, 'javascript:') === 0) {
                    continue;
                }

                $url_host = parse_url($url, PHP_URL_HOST);

                // 1. COMPROBACIÓN NATIVA DE ENLACES INTERNOS
                if (empty($url_host) || $url_host === $site_host) {
                    $target_post_id = url_to_postid($url);
                    if (!$target_post_id && !ier_is_internal_url_valid($url)) {
                        $status_key = '404 Interno';
                        if (ier_is_status_allowed($status_key, $allowed_statuses)) {
                            $broken_links[] = [
                                'post_id'       => $post->ID,
                                'post_title'    => $title,
                                'post_edit_url' => get_edit_post_link($post->ID, ''),
                                'post_view_url' => $post_view_url,
                                'anchor'        => !empty($anchor) ? $anchor : '<em>[Sin texto / Imagen]</em>',
                                'url'           => $url,
                                'status'        => $status_key
                            ];
                        }
                    }
                    continue;
                }

                // 2. COMPROBACIÓN RÁPIDA VÍA HEAD
                $args = [
                    'timeout'     => 1,
                    'redirection' => 1,
                    'sslverify'   => false,
                    'user-agent'  => 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/124.0.0.0 Safari/537.36'
                ];

                $response = wp_remote_head($url, $args);
                $status_code = is_wp_error($response) ? 0 : wp_remote_retrieve_response_code($response);

                if (is_wp_error($response) || $status_code === 405 || $status_code === 403) {
                    $response = wp_remote_get($url, $args);
                    $status_code = is_wp_error($response) ? 'Timeout / Error Red' : wp_remote_retrieve_response_code($response);
                }

                if (intval($status_code) === 498) {
                    continue;
                }

                if (is_wp_error($response) || (is_numeric($status_code) && $status_code >= 400) || $status_code === 'Timeout / Error Red') {
                    $status_str = (string) $status_code;
                    
                    if (ier_is_status_allowed($status_str, $allowed_statuses)) {
                        $broken_links[] = [
                            'post_id'       => $post->ID,
                            'post_title'    => $title,
                            'post_edit_url' => get_edit_post_link($post->ID, ''),
                            'post_view_url' => $post_view_url,
                            'anchor'        => !empty($anchor) ? $anchor : '<em>[Sin texto / Imagen]</em>',
                            'url'           => $url,
                            'status'        => $status_str
                        ];
                    }
                }
            }
        }
    }

    wp_send_json_success([
        'post_id'      => $post->ID,
        'post_title'   => $title,
        'broken_links' => $broken_links,
        'finished'     => false
    ]);
}

// 5. Servidor: Reemplazar URL directamente en el contenido del post
function ier_replace_link_callback() {
    if (!current_user_can('manage_options')) {
        wp_send_json_error('Sin permisos');
    }

    global $wpdb;
    $post_id = isset($_POST['post_id']) ? intval($_POST['post_id']) : 0;
    
    $old_url_raw = isset($_POST['old_url']) ? wp_unslash(trim($_POST['old_url'])) : '';
    $new_url_raw = isset($_POST['new_url']) ? wp_unslash(trim($_POST['new_url'])) : '';

    if (!$post_id || empty($old_url_raw) || empty($new_url_raw)) {
        wp_send_json_error('Parámetros incompletos.');
    }

    $content = $wpdb->get_var($wpdb->prepare("SELECT post_content FROM {$wpdb->posts} WHERE ID = %d", $post_id));

    if (!$content) {
        wp_send_json_error('No se encontró el contenido de la entrada.');
    }

    $old_url_decoded = html_entity_decode($old_url_raw, ENT_QUOTES | ENT_HTML5, 'UTF-8');
    $old_url_entities = htmlentities($old_url_decoded, ENT_QUOTES | ENT_HTML5, 'UTF-8');
    $old_url_amp = str_replace('&', '&amp;', $old_url_decoded);
    $old_url_sanitized = esc_url_raw($old_url_raw);

    $target_old_url = false;

    if (strpos($content, $old_url_raw) !== false) {
        $target_old_url = $old_url_raw;
    } elseif (strpos($content, $old_url_decoded) !== false) {
        $target_old_url = $old_url_decoded;
    } elseif (strpos($content, $old_url_amp) !== false) {
        $target_old_url = $old_url_amp;
    } elseif (strpos($content, $old_url_entities) !== false) {
        $target_old_url = $old_url_entities;
    } elseif (strpos($content, $old_url_sanitized) !== false) {
        $target_old_url = $old_url_sanitized;
    }

    if ($target_old_url !== false) {
        $new_url = esc_url_raw($new_url_raw);
        $updated_content = str_replace($target_old_url, $new_url, $content);
        
        $result = $wpdb->update(
            $wpdb->posts,
            ['post_content' => $updated_content],
            ['ID' => $post_id],
            ['%s'],
            ['%d']
        );

        if ($result !== false) {
            clean_post_cache($post_id);
            wp_send_json_success();
        } else {
            wp_send_json_error('No se pudo actualizar la base de datos.');
        }
    } else {
        wp_send_json_error('La URL original no coincide exactamente con el texto en BD.');
    }
}

// 6. Servidor: Eliminar enlace conservando el texto ancla (Unlink)
function ier_unlink_link_callback() {
    if (!current_user_can('manage_options')) {
        wp_send_json_error('Sin permisos');
    }

    global $wpdb;
    $post_id = isset($_POST['post_id']) ? intval($_POST['post_id']) : 0;
    $url_raw = isset($_POST['url']) ? wp_unslash(trim($_POST['url'])) : '';

    if (!$post_id || empty($url_raw)) {
        wp_send_json_error('Parámetros incompletos.');
    }

    $content = $wpdb->get_var($wpdb->prepare("SELECT post_content FROM {$wpdb->posts} WHERE ID = %d", $post_id));

    if (!$content) {
        wp_send_json_error('No se encontró la entrada.');
    }

    $url_decoded = html_entity_decode($url_raw, ENT_QUOTES | ENT_HTML5, 'UTF-8');
    $url_sanitized = esc_url_raw($url_raw);
    
    // Lista de variaciones posibles de la URL para que la expresión regular la capture bien
    $urls_to_check = array_unique([$url_raw, $url_decoded, $url_sanitized]);
    $replaced = false;
    $updated_content = $content;

    foreach ($urls_to_check as $target_url) {
        $quoted_url = preg_quote($target_url, '/');
        $pattern = '/<a\s+[^>]*href=["\']' . $quoted_url . '["\'][^>]*>(.*?)<\/a>/is';

        if (preg_match($pattern, $updated_content)) {
            $updated_content = preg_replace($pattern, '$1', $updated_content);
            $replaced = true;
            break;
        }
    }

    if ($replaced) {
        $result = $wpdb->update(
            $wpdb->posts,
            ['post_content' => $updated_content],
            ['ID' => $post_id],
            ['%s'],
            ['%d']
        );

        if ($result !== false) {
            clean_post_cache($post_id);
            wp_send_json_success();
        } else {
            wp_send_json_error('No se pudo actualizar la base de datos.');
        }
    } else {
        wp_send_json_error('No se pudo encontrar el enlace exacto en el contenido del post.');
    }
}

// Funciones auxiliares
function ier_clean_raw_url($url) {
    $url = wp_unslash(trim($url));
    if (empty($url)) return '';
    return esc_url_raw($url);
}

function ier_is_status_allowed($status, $allowed_statuses) {
    if (empty($allowed_statuses)) return true;
    
    $status_str = (string) $status;
    
    if (in_array($status_str, $allowed_statuses, true)) {
        return true;
    }
    
    if (in_array('Otros / Desconocido', $allowed_statuses, true)) {
        $known_statuses = ['404', '404 Interno', 'Timeout / Error Red', '403', '400', '500', '502', '503', '520'];
        if (!in_array($status_str, $known_statuses, true)) {
            return true;
        }
    }
    
    return false;
}

function ier_is_internal_url_valid($url) {
    // Si es solo una ruta con query (ej: ?p=123) o raíz
    $path = parse_url($url, PHP_URL_PATH);
    if (empty($path) || $path === '/') return true;

    // 1. Comprobar si existe físicamente en el servidor (ej: /archivo/documento.pdf, /archivo/letras.doc, etc.)
    $site_host = parse_url(site_url(), PHP_URL_HOST);
    $url_host  = parse_url($url, PHP_URL_HOST);
    
    if (empty($url_host) || $url_host === $site_host) {
        $local_path = ABSPATH . ltrim($path, '/');
        if (file_exists($local_path) || is_dir($local_path)) {
            return true;
        }
    }

    // 2. Comprobar si es un Post, Página, Adjunto o Custom Post Type por Slug/Path (soporta .htm/.html si el slug lo incluye)
    $clean_path = ltrim($path, '/');
    $page_or_post = get_page_by_path($clean_path, OBJECT, ['post', 'page', 'attachment']);
    if (!$page_or_post && (str_ends_with($clean_path, '.htm') || str_ends_with($clean_path, '.html'))) {
        $path_without_ext = preg_replace('/\.html?$/i', '', $clean_path);
        $page_or_post = get_page_by_path($path_without_ext, OBJECT, ['post', 'page', 'attachment']);
    }
    if ($page_or_post) return true;

    // 3. Comprobar si es una Taxonomía (etiquetas /tag/, categorías /category/, etc.)
    $term_path = trim($path, '/');
    $segments  = explode('/', $term_path);
    if (count($segments) >= 2) {
        $taxonomy_slug = $segments[0];
        $term_slug     = end($segments);
        
        // Mapeo común de slugs de taxonomía
        $taxonomies = [
            'tag'       => 'post_tag',
            'category'  => 'category',
            'etiqueta'  => 'post_tag',
            'categoria' => 'category'
        ];

        $target_tax = isset($taxonomies[$taxonomy_slug]) ? $taxonomies[$taxonomy_slug] : $taxonomy_slug;

        if (taxonomy_exists($target_tax) && term_exists($term_slug, $target_tax)) {
            return true;
        }
    }

    // 4. Intento de resolución nativa vía Rewrite Rules de WordPress
    $query_vars = [];
    if (class_exists('WP')) {
        $wp = new WP();
        $query_vars = $wp->parse_request($url);
    }
    
    if (!empty($query_vars)) {
        return true;
    }

    // 5. Comprobar directorio de subidas (/wp-content/uploads/)
    $upload_dir = wp_upload_dir();
    $base_url   = parse_url($upload_dir['baseurl'], PHP_URL_PATH);
    if ($base_url && strpos($path, $base_url) === 0) {
        $relative_file = str_replace($base_url, '', $path);
        $file_path     = $upload_dir['basedir'] . $relative_file;
        if (file_exists($file_path)) return true;
    }

    return false;
}

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.