Les liens rompus entraînent des pertes d'autorité (link juice) et grèvent le budget d'exploration (crawl budget), car les robots des moteurs de recherche perdent du temps à indexer des réponses HTTP 404 au lieu de contenu de valeur. Ils nuisent également à l'expérience utilisateur en interrompant le flux de navigation et augmentent le taux de rebond. En les corrigeant à l’aide de redirections 301 ou en mettant à jour les URL, on optimise l’exploration du site, on préserve le flux de valeur des liens vers les pages de destination et on consolide l’architecture du site afin d’éviter des pénalités indirectes sur le référencement naturel.
En effectuant ma vérification habituelle des liens rompus avec le plugin habituel, Broken Link Checker, j’ai découvert que l’analyse locale ne fonctionnait pas. J'ai donc repris une fonction très simple que j'avais sous la main et je l'ai légèrement améliorée avec l'aide de Gemini afin de la rendre plus pratique et de pouvoir lancer l'analyse et la correction des liens rompus.
Même si les liens morts poussent comme des champignons, j’ai remarqué au cours de ce voyage une fuite importante de liens provenant de Twitter (X), qui perd des comptes à un rythme effréné. On compte déjà des centaines de comptes et/ou de tweets qui ont cessé d’exister, mais je réserverai ce sujet pour un prochain article sur la mort du Web tel que nous le connaissions.

Pour utiliser cette fonctionnalité, il suffit d'ajouter le snippet au fichier functions.php de votre modèle ou thème enfant, ou dans votre plugin de snippets. Vous trouverez ensuite l'outil et ses options dans Outils/Inspecteur de liens ; celles-ci sont détaillées ci-dessous.
1 - Types d'état. Vous pouvez ici choisir parmi plus de 10 types d'état pour une analyse sélective.
2 - Le bouton « Lancer l'analyse en continu » lance une analyse sans interruption. Même si cela ne devrait pas être le cas, cela peut avoir un certain impact sur la vitesse de chargement du site. Je l'ai laissé fonctionner pendant des heures sans aucun problème.
3 - Numérisation par lots. Chaque fois que vous cliquerez sur ce bouton, 10 entrées seront numérisées.
4 - Exporter/Importer au format JSON. Comme le plugin n'enregistre aucune donnée dans la base de données, si vous fermez votre navigateur, vous perdrez votre liste. Pour récupérer tout ce dont vous ne vous souvenez pas avoir fait, vous devrez toujours enregistrer votre liste d'analyse à l'aide de la fonction « Exporter ». Pour la récupérer, il suffira de l'importer.
5 - Informations sur le processus de numérisation.
6 - Filtrage par type. Une fois l'analyse terminée, vous pouvez filtrer l'affichage de la liste par état.
7 - Article/Page. Vous trouverez ici un lien pour modifier l'article et un autre (Voir) pour le consulter.
8 - L'ancrage du lien pour que tu puisses le trouver plus rapidement.
9 - L'URL du lien rompu.
10 - Code d'état HTTP du lien.
11 - Action. La croix de fermeture ne fait que masquer cette ligne dans la liste. Elle ne modifie, ne supprime ni n'ajoute rien.
12 - URL de remplacement. Champ dans lequel saisir le lien de remplacement.
13 - Effectuer une recherche sur Archive.org. En cliquant ici, vous ouvrirez Wayback Machine, qui affichera la liste des dates disponibles pour les copies de votre URL (si elles ont été conservées), afin que vous puissiez choisir celle qui vous intéresse le plus.
14 - Supprimer le lien. Cette option supprime le lien hypertexte tout en conservant le texte du lien dans l'article ou la page, au cas où vous n'auriez pas d'URL alternative ou ne souhaiteriez pas en ajouter une.
Précisons d'emblée qu'il s'agit d'une fonction à usage unique; il est conseillé de la supprimer une fois le nettoyage terminé. Je ne pense pas que je vais continuer à la modifier, mais si c'était le cas, je mettrais à jour le code ici même.
Même si, au final, le code s'est avéré trop long pour un « snippet » (qui est censé être court ou un extrait), cela fonctionne plutôt bien. Certes, on peut encore l'affiner pour éviter certains faux positifs de type 404 et l'améliorer grâce à la sélection multiple d'actions, mais si vous n'avez pas beaucoup de liens et pas le temps de les vérifier manuellement, il est tout à fait fonctionnel.
Pour les remplacements en masse de liens, tu peux t'appuyer sur les plugins classiques dédiés à cette fonction, tels que Better Search Replace et 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, '"').replace(/'/g, ''');
}
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;">×</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('&', '&', $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;
}






