워드프레스에서 깨진 링크를 찾아 수정하는 기능

No comments

11.09.2026|

No comments

Tiempo de lectura Lectura: 26 min, 9 s
Número de palabras Palabras: 4840
Número de visitas Visitas: 35
Icono de traducción

깨진 링크는‘링크 주스(link juice)’의 유출을 유발하고 크롤링 예산(crawl budget)을 낭비하게 만듭니다. 검색 엔진 봇이 가치 있는 콘텐츠 대신 HTTP 404 응답을 색인화하는 데 시간을 낭비하기 때문입니다. 또한 탐색 흐름을 방해하여 사용자 경험을 저하시킬 뿐만 아니라 이탈률을 높입니다. 301 리디렉션이나 URL 업데이트를 통해 이를 수정하면 웹사이트 크롤링이 최적화되고, 대상 페이지로 향하는 링크 권한의 흐름이 유지되며, 사이트 아키텍처가 강화되어 자연 검색 순위에서 간접적인 페널티를 방지할 수 있습니다.

평소 사용하던 ‘Broken Link Checker’ 플러그인으로 깨진 링크에 대한 정기 점검을 하던 중, 아마도 서버의 보안 설정 때문인지 로컬 스캔이 더 이상 작동하지 않는다는 사실을 발견했습니다. 그래서 여기저기 뒀던 아주 간단한 함수를 꺼내서, Gemini의 도움을 받아 조금 개선하여 더 사용하기 편리하게 만들고, 깨진 링크를 스캔하고 수정하는 작업을 수행할 수 있게 했습니다.

깨진 링크가 우후죽순처럼 생겨나고 있지만, 이번 조사 과정에서 트위터(X) 링크의 급격한 감소가 눈에 띄었는데, 계정이 쉴 새 없이 사라지고 있습니다. 이미 수백 개의 계정과/또는 트윗이 사라진 상태이지만, 이 문제는 우리가 알고 있던 웹의 죽음에 관한 향후 글에서 다루기로 하겠습니다.

워드프레스용 깨진 링크 확인 기능의 인터페이스와 각 부분을 설명하는 설명 사항

이 기능을 사용하려면 템플릿이나 자식 테마의 functions.php 파일에 스니펫을 추가하거나 스니펫 플러그인에 추가하기만 하면 됩니다. 그러면 ‘도구’ > ‘링크 검사기’에서 해당 도구와 옵션을 확인할 수 있으며, 자세한 내용은 다음과 같습니다.

1 - 상태 유형. 여기에서 선택적 스캔을 위해 10가지 이상의 상태 유형 중에서 선택할 수 있습니다.

2 - ‘지속 스캔 시작’ 버튼을 누르면 중단 없이 스캔이 실행됩니다. 비록 그럴 이유는 없지만, 사이트 로딩 속도에 어느 정도 영향을 미칠 수 있습니다. 저는 이 기능을 몇 시간 동안 문제 없이 실행해 본 적이 있습니다.

3 - 일괄 스캔. 이 버튼을 누를 때마다 10개의 항목이 스캔됩니다.

4 - JSON 내보내기/가져오기. 이 플러그인은 데이터베이스에 아무것도 저장하지 않으므로, 브라우저를 닫으면 목록이 사라집니다. 따라서 자신이 수행한 작업을 모두 기억하지 못하는 경우, “내보내기” 기능을 통해 스캔 목록을 항상 저장해 두어야 합니다. 나중에 다시 불러오려면 가져오기만 하면 됩니다.

5 - 스캔 과정 정보.

6 - 유형별 필터링. 스캔이 완료되면 목록 보기를 상태별로 필터링할 수 있습니다.

7 - 게시물/페이지. 여기에는 게시물을 편집할 수 있는 링크와 게시물을 볼 수 있는 (보기) 링크가 있습니다.

8 - 링크의 앵커 텍스트로, 더 빨리 찾을 수 있도록.

9 - 깨진 링크의 URL.

10 - 링크의 HTTP 상태 코드.

11 - 동작. 닫기 아이콘은 목록 보기에서 해당 행만 숨길 뿐입니다. 내용을 수정하거나 삭제하거나 추가하지는 않습니다.

12 - 대체 URL. 대체 링크를 입력하는 입력란입니다.

13 - Archive.org에서 검색하기. 여기를 클릭하면 Wayback Machine이 열리며, 해당 URL의 사본(보존되어 있는 경우)에 대해 이용 가능한 날짜 목록이 표시되므로, 가장 관심 있는 날짜를 선택할 수 있습니다.

14 - 링크 제거. 이 옵션을 선택하면 대체 URL이 없거나 추가하고 싶지 않은 경우를 대비해, 게시물이나 페이지에 링크 텍스트는 그대로 유지한 채 하이퍼링크만 제거합니다.

우선 이 기능은 일회용이라는 점을 미리 말씀드리며, 청소가 끝나면 제거하는 것이 좋습니다. 앞으로도 이 기능을 수정할 일은 없을 것 같지만, 만약 수정하게 된다면 바로 이곳에서 코드를 업데이트하겠습니다.

결국 스니펫(짧은 코드 조각) 치고는 코드가 너무 길어졌지만, 꽤 잘 작동합니다. 물론, 404 오류의 특정 오탐을 방지하고 여러 작업을 한 번에 선택할 수 있도록 개선할 여지는 있지만, 링크가 많지 않고 수동으로 검토할 시간이 없다면 이대로도 충분히 사용할 수 있습니다.

링크를 대량으로 일괄 교체하려면 Better Search Replace나 Search Regex와 같은 대표적인 전용 플러그인을 활용할 수 있습니다.

2026년 8월 23일 업데이트.

개선 사항:

1 - 왼쪽에 다중 선택 상자가 추가되었으며, 목록에서 행을 일괄적으로 제거할 수 있는“선택 항목 제거” 버튼이 추가되었습니다.

2 - Archive.org 및 Bluesky 링크에 대한 응답 시간을 7초로 조정하여, 해당 도메인에서 403 오류나 "타임아웃" 결과가 과도하게 표시되는 것을 방지합니다.

3 - “JSON 가져오기”의 JSON 파일 불러오기 프로세스가 최적화되었습니다.

2026년 8월 24일 업데이트

새로운 도메인 제외 시스템 (IP 보호): 대규모 웹사이트와 소셜 미디어를 제외하기 위한 강력한 제외 배열 이 통합되었습니다.

무음 건너뛰기: 이제부터 스캐너가 Google, YouTube, Twitter/X, Facebook, Instagram, LinkedIn, Archive.org, Amazon, Pinterest 또는 TikTok으로 연결되는 링크를 감지하면, 해당 사이트로 HTTP 요청을 보내지 않고 자동으로 건너뜁니다. 따라서 이러한 링크는 다른 도구를 사용하거나 수동으로 확인해야 합니다.

오탐이 전혀 없고 차단도 없습니다: 이 조치는 과도한 요청(Google API의 429 차단과 같은속도 제한)으로 인해 서버 IP가 일시적으로 블랙리스트에 등재되는 것을 방지합니다. 또한, 해당 플랫폼의 강력한 봇 방지 방화벽으로 인해 발생하는 “가짜 끊어진 링크”(403 오류) 결과를 제거하여, 전체 스캔 속도를 훨씬 더 빠르고 효율적으로 만들어 줍니다.


// Actualizado 24 de agosto 2026 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 '<style>.ier-hidden-row { display: none !important; }</style>';
    echo '<div class="wrap">';
    echo '<h1>Inspector de Enlaces Rotos</h1>';
    echo '<p>Escaneo ultra-ligero con reemplazo directo, desvinculación, filtro por estados, exportación/importación JSON y protección anti-bloqueos (excluye RRSS y Google).</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 '<span style="border-left: 1px solid #ccc; height: 20px; margin: 0 5px;"></span>';
    echo '<button id="ier-bulk-dismiss-btn" type="button" class="button button-secondary">Descartar seleccionados</button>';
    echo '</div>';

    echo '<table class="widefat fixed striped" style="margin-top: 10px;">';
    echo '<thead><tr>';
    echo '<th style="width: 3%; text-align: center;"><input type="checkbox" id="ier-bulk-select-all" title="Seleccionar todos los visibles"></th>';
    echo '<th style="width: 17%;">Post / Página</th>';
    echo '<th style="width: 16%;">Texto / Anchor</th>';
    echo '<th style="width: 23%;">URL Rota</th>';
    echo '<th style="width: 12%;">Estado</th>';
    echo '<th style="width: 24%;">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 class="ier-empty-row"><td colspan="7">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;
        }

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

        $('#ier-bulk-dismiss-btn').on('click', function(e) {
            e.preventDefault();
            const $checked = $('.ier-bulk-cb:checked:visible');
            if ($checked.length === 0) {
                alert('No hay enlaces seleccionados en la vista actual.');
                return;
            }
            if (!confirm('¿Seguro que quieres descartar ' + $checked.length + ' enlaces de la lista?')) {
                return;
            }

            $checked.each(function() {
                $(this).closest('tr').remove();
                if (totalBroken > 0) totalBroken--;
            });

            $('#ier-count-broken').text(totalBroken);
            $('#ier-bulk-select-all').prop('checked', false);
            updateViewFilterDropdown();
            
            if ($('#ier-results-body tr:not(.ier-empty-row)').length === 0) {
                $('#ier-results-body').html('<tr class="ier-empty-row"><td colspan="7">No hay enlaces rotos pendientes en el listado.</td></tr>');
            }
        });

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

            $('#ier-results-body tr:not(.ier-empty-row)').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;
            const $rows = $('#ier-results-body tr:not(.ier-empty-row)');

            if (selectedStatus === 'ALL') {
                $rows.removeClass('ier-hidden-row');
                visibleCount = $rows.length;
                $('#ier-view-filter-count').text('');
            } else {
                $rows.each(function() {
                    const $tr = $(this);
                    if ($tr.attr('data-status') === selectedStatus) {
                        $tr.removeClass('ier-hidden-row');
                        visibleCount++;
                    } else {
                        $tr.addClass('ier-hidden-row');
                    }
                });
                $('#ier-view-filter-count').text('(' + visibleCount + ' de ' + totalBroken + ' visibles)');
            }
            
            $('#ier-bulk-select-all').prop('checked', false);
        }

        $('#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();
                                if ($('#ier-results-body tr:not(.ier-empty-row)').length === 0) {
                                    $('#ier-results-body').html('<tr class="ier-empty-row"><td colspan="7">No hay enlaces rotos pendientes en el listado.</td></tr>');
                                }
                            });
                        }, 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();
            const $tr = $(this).closest('tr');
            $tr.fadeOut(150, function() {
                $(this).remove();
                if (totalBroken > 0) {
                    totalBroken--;
                    $('#ier-count-broken').text(totalBroken);
                }
                updateViewFilterDropdown();
                if ($('#ier-results-body tr:not(.ier-empty-row)').length === 0) {
                    $('#ier-results-body').html('<tr class="ier-empty-row"><td colspan="7">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:not(.ier-empty-row)').each(function() {
                const $tr = $(this);
                if ($tr.find('td').length < 7) 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(3)').html();
                const url = $tr.attr('data-old-url') || $tr.find('td:nth-child(4) a').text();
                const statusHtml = $tr.attr('data-status') || $tr.find('td:nth-child(5) 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);
                    }

                    let allRowsHtml = '';
                    data.broken_links.forEach(function(item) {
                        allRowsHtml += buildRowHtml(item);
                    });
                    
                    $('#ier-results-body').html(allRowsHtml);

                    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 buildRowHtml(item) {
            const cleanStatus = typeof item.status === 'string' ? item.status.replace(/<[^>]*>?/gm, '') : item.status;
            
            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;

            return '<tr data-status="' + escapeAttr(cleanStatus) + '" data-post-id="' + postId + '" data-old-url="' + escapeAttr(item.url) + '">' +
                '<td style="text-align: center;"><input type="checkbox" class="ier-bulk-cb"></td>' +
                '<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 appendRow(item) {
            $('.ier-empty-row').remove();
            $('#ier-results-body').append(buildRowHtml(item));
        }

        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) {
                            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(20);

    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) > 8.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. EXCLUSIÓN DE DOMINIOS GIGANTES (Protección de IP y reducción de falsos positivos)
                $excluded_domains = [
                    'google.com', 'google.es', 'googleapis.com',
                    'youtube.com', 'youtu.be',
                    'twitter.com', 'x.com',
                    'facebook.com', 'fb.com', 'instagram.com', 'linkedin.com',
                    'archive.org', 'web.archive.org',
                    'pinterest.com', 'tiktok.com', 
                    'amazon.com', 'amazon.es', 'amzn.to'
                ];
                
                $is_excluded = false;
                foreach ($excluded_domains as $ex_domain) {
                    if ($url_host === $ex_domain || substr($url_host, -strlen('.' . $ex_domain)) === '.' . $ex_domain) {
                        $is_excluded = true;
                        break;
                    }
                }

                if ($is_excluded) {
                    continue; // Saltamos este enlace silenciosamente sin hacer petición HTTP
                }

                // 3. AJUSTES DINÁMICOS PARA BLUESKY (Archive ya está excluido arriba)
                $timeout_limit = 2; 
                if (strpos($url_host, 'bsky.app') !== false) {
                    $timeout_limit = 7; 
                }

                // 4. COMPROBACIÓN RÁPIDA VÍA HEAD
                $args = [
                    'timeout'     => $timeout_limit,
                    'redirection' => 2,
                    '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',
                    'headers'     => [
                        'Accept'          => 'text/html,application/xhtml+xml,application/xml;q=0.9,image/avif,image/webp,*/*;q=0.8',
                        'Accept-Language' => 'es-ES,es;q=0.8,en-US;q=0.5,en;q=0.3',
                    ]
                ];

                $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;
                }
                
                // Excepción específica para falsos positivos de Bluesky (bloqueo antibots 403)
                if (strpos($url_host, 'bsky.app') !== false && in_array($status_code, [403, 'Timeout / Error Red'], true)) {
                    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);
    
    $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) {
    $path = parse_url($url, PHP_URL_PATH);
    if (empty($path) || $path === '/') return true;

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

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

    $term_path = trim($path, '/');
    $segments  = explode('/', $term_path);
    if (count($segments) >= 2) {
        $taxonomy_slug = $segments[0];
        $term_slug     = end($segments);
        
        $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;
        }
    }

    $query_vars = [];
    if (class_exists('WP')) {
        $wp = new WP();
        $query_vars = $wp->parse_request($url);
    }
    
    if (!empty($query_vars)) {
        return true;
    }

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

댓글 남기기

할 말이 있나요?

Este blog se aloja en LucusHost

LucusHost, el mejor hosting

이메일 구독

기사가 게시되는 즉시 광고 없이 받은 편지함에서 전체 기사를 무료로 받아보세요. 피드의 전체 콘텐츠는 외부 서비스를 통해 광고 없이 전송됩니다.