Неработающие ссылки приводят к утечке авторитета (link juice) и расходуют бюджет сканирования (crawl budget), поскольку боты поисковых систем тратят время на индексацию HTTP-ответов 404 вместо ценного контента. Кроме того, они ухудшают пользовательский опыт, прерывая процесс навигации, и повышают показатель отказов. Исправление таких ссылок с помощью перенаправлений 301 или обновления URL-адресов позволяет оптимизировать сканирование сайта, сохранить поток ссылочного веса на целевые страницы и укрепить архитектуру сайта, чтобы избежать косвенных штрафов в органическом ранжировании.
Проводя плановую проверку неработающих ссылок с помощью привычного плагина Broken Link Checker, я обнаружил, что локальное сканирование больше не работает, возможно, из-за какой-то защиты сервера. Поэтому я взял одну очень простую функцию, которая у меня где-то завалялась, и немного доработал её с помощью Gemini, чтобы сделать её более удобной в использовании и запустить сканирование и исправление неработающих ссылок.
Хотя неработающие ссылки появляются как грибы после дождя, в ходе этого исследования я заметил значительный отток ссылок из Twitter (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-адреса или вы не хотите его добавлять.
Сразу оговорюсь, что это функция одноразового использования, поэтому рекомендуется удалить её после завершения очистки. Не думаю, что буду её дальше дорабатывать, но если это произойдёт, я обновлю код прямо здесь.
Хотя в итоге код получился слишком длинным для фрагмента кода (snippet), который, как правило, должен быть коротким или представлять собой небольшой отрывок, он работает довольно хорошо. Конечно, его ещё можно доработать, чтобы избежать некоторых ложных срабатываний с кодом 404, и улучшить за счёт возможности выбора нескольких действий, но если у вас не так много ссылок и нет времени на их ручную проверку, он вполне работоспособен.
Для массовой замены ссылок можно воспользоваться классическими специализированными плагинами, такими как Better Search Replace и Search Regex.
Обновление от 23 августа 2026 года.
Улучшения:
1 — Слева добавлен элемент для множественного выбора и кнопка «Отменить выбранное», позволяющая удалять строки из списка пакетами.
2 — Время отклика для ссылок на Archive.org и Bluesky установлено на 7 секунд, чтобы избежать появления результатов с кодом 403 или сообщением «Timeout» при запросах к этим доменам.
3 — Оптимизирован процесс загрузки JSON-файла в разделе «Импорт JSON»
Обновление от 24 августа 2026 года
Новая система исключения доменов (защита IP): интегрирован надёжный массив исключений, позволяющий игнорировать крупные веб-сайты и социальные сети.
«Тихий пропуск»: отныне, если сканер обнаружит ссылку, ведущую на Google, YouTube, Twitter/X, Facebook, Instagram, LinkedIn, Archive.org, Amazon, Pinterest или TikTok, он автоматически пропустит её, не отправляя на эти сайты никаких HTTP-запросов. Поэтому вам придётся проверять эти ссылки с помощью другого инструмента или вручную.
Никаких ложных срабатываний и никаких блокировок: эта мера предотвращает временное попадание IP-адреса вашего сервера в черные списки из-за избыточного количества запросов (ограничение скорости, например, ошибки 429 в API Google). Кроме того, она очищает результаты от «ложных неработающих ссылок» (ошибок 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, '"').replace(/'/g, ''');
}
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;">×</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('&', '&', $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;
}





