Esta herramienta en forma de snippet, plugin (o script ligero en PHP) analiza por lotes todos los contenidos publicados en tu sitio para encontrar conflictos de canibalización entre títulos SEO o títulos nativos.
Lo usé para arreglar un poco una web que tenía un montón de títulos exactamente iguales, muchos de ellos de una sola palabra. Ahora, lo he adecentado un poco para compartirlo. Se podría mejorar el análisis del porcentaje de similitud, eres libre de hacerlo.
El snippet compara los títulos de todas tus entradas publicadas para encontrar duplicidades exactas (100% de coincidencia) o similitudes léxicas altas (iguales o superiores al 80%).
Si un post no tiene configurado un título SEO específico en el plugin correspondiente, el script utiliza automáticamente el título nativo de WordPress como fallback. No he podido probarlo aún en todos los plugins de SEO, me faltó uno. Si encuentras algún error puedes hacérmelo saber.
¿Por qué es beneficioso y para qué sirve?
- Evita la canibalización: Cuando dos o más entradas compiten por las mismas palabras clave en sus títulos, los motores de búsqueda se confunden y dividen la autoridad entre ellas, lo que empeora el posicionamiento global de ambas.
- Ahorra tiempo en auditorías: Muestra en un único panel ordenado todos los posibles conflictos sin tener que recurrir a herramientas externas de pago o rastreos pesados.
- Permite corregir sobre la marcha: Ofrece enlaces directos de edición para resolver los solapamientos de contenido inmediatamente.
No se analizan ni se tocan o alteran los slug de las URL
Guía de funciones de la interfaz

- Origen de los títulos a analizar: Selector dinámico que autodetecta el plugin SEO activo en tu instalación. Es compatible de forma nativa con The SEO Framework (TSF), Yoast SEO, Rank Math, SEOPress y All in One SEO (Legacy). Si no detecta ninguno o seleccionas la opción nativa, evaluará los títulos por defecto de WordPress.
- Iniciar auditoría: Dispara el proceso de indexación y análisis por lotes en segundo plano, mostrando una barra de progreso en tiempo real.
- Exportar Todos (.txt) / Exportar Filtrados: Permite descargar un informe completo en texto plano con todos los pares en conflicto encontrados, sus enlaces de edición y el porcentaje de similitud para trabajarlo offline o guardarlo como registro.
- Cargar informe (.txt): Importa un archivo de informe generado previamente. Esto te permite revisar auditorías pasadas al instante sin necesidad de ejecutar de nuevo el escaneo en la base de datos.
- Acciones masivas (Descartar / Re-comprobar seleccionados): Permite marcar varias casillas para eliminar falsos positivos de la lista de un plumazo o lanzar una re-comprobación en lote únicamente sobre las filas seleccionadas.
- Paginación: Selector de elementos visibles por página (10, 25, 50, 100 o todos) para no saturar la vista del navegador si hay cientos de resultados.
- Ordenación de resultados: Permite ordenar el listado por orden de detección o por porcentaje de similitud (de mayor a menor coincidencia o viceversa).
- Buscador en tiempo real: Filtra las coincidencias al instante escribiendo cualquier palabra o término clave contenido en los títulos del Post A o Post B.
- Motivo de coincidencia: Indica la razón del conflicto registrado: “Título SEO exacto (100% igual)” o “Similitud léxica alta en títulos”.
- Porcentaje de similitud: Muestra mediante una etiqueta visual el grado de coincidencia calculado entre ambos títulos.
- Re-comprobar (Fila individual): Vuelve a consultar la base de datos solo para ese par específico. Si has corregido el título de uno de los dos posts en otra pestaña, al pulsar aquí se actualiza la similitud o se elimina la fila automáticamente si el conflicto ya se ha resuelto.
- Descartar (Fila individual): Quita esa coincidencia particular de la lista actual si consideras que es un falso positivo o no requiere cambios.
¿Por qué es ligero y no afecta al rendimiento?
A diferencia de muchos plugins SEO pesados que saturan la base de datos o ejecutan tareas continuas en segundo plano, este snippet se ha diseñado bajo los siguientes principios de eficiencia:
- Sin tablas ni opciones persistentes: Es una herramienta volátil. No guarda basura en la tabla
wp_optionsni crea tablas personalizadas. Todo el trabajo se procesa en la memoria RAM durante la sesión. - Procesamiento por lotes mediante AJAX: El escaneo no bloquea el servidor. La lista completa de entradas en español se consulta de forma rápida mediante una única consulta optimizada (
WP_Query+ids), y la comparación léxica (similar_text) se realiza dividiendo los datos en lotes pequeños (chunks de 25 en 25) enviados de forma asíncrona. - Cero consumo fuera de uso: El script no registra cron jobs, ni ganchos (hooks) de guardado, ni monitores en tiempo real. Solo consume recursos del servidor durante los segundos precisos en los que pulsas "Iniciar Auditoría" o "Re-comprobar".
Se trata de un código de usar y tirar. Se aconseja desactivar el snippet o eliminarlo de donde lo hayas añadido una vez hayas completado la tarea. Y siempre testearlo antes en un entorno de pruebas.
Código
/**
* Auditoría de Canibalización SEO (Soporte Multi-Plugin)
* Descripción: Detecta canibalización por lotes en títulos SEO (TSF, Yoast, Rank Math, SEOPress, etc) o nativos.
* Version: 3.1 (Re-comprobación en lote)
*/
if (!defined('ABSPATH')) exit;
// 1. Crear menú
add_action('admin_menu', function() {
add_management_page(
'Auditoría Canibalización',
'Canibalización SEO',
'manage_options',
'seo-canibalizacion-audit',
'render_seo_canibalizacion_page'
);
});
// Función auxiliar, detecta el plugin SEO activo y devuelve sus datos
function get_seo_audit_plugins_data() {
$plugins = array(
'tsf' => array('name' => 'The SEO Framework', 'key' => '_genesis_title'),
'yoast' => array('name' => 'Yoast SEO', 'key' => '_yoast_wpseo_title'),
'rankmath' => array('name' => 'Rank Math', 'key' => 'rank_math_title'),
'seopress' => array('name' => 'SEOPress', 'key' => '_seopress_titles_title'),
'aioseo' => array('name' => 'All in One SEO (Legacy)', 'key' => '_aioseop_title'),
'native' => array('name' => 'Ninguno (Títulos nativos de WP)', 'key' => '') // Sin meta_key, usa post_title
);
$detected_key = 'native';
if ( ! function_exists( 'is_plugin_active' ) ) {
include_once( ABSPATH . 'wp-admin/includes/plugin.php' );
}
if ( is_plugin_active( 'autodescription/autodescription.php' ) ) {
$detected_key = 'tsf';
} elseif ( is_plugin_active( 'wordpress-seo/wp-seo.php' ) || is_plugin_active( 'wordpress-seo-premium/wp-seo-premium.php' ) ) {
$detected_key = 'yoast';
} elseif ( is_plugin_active( 'seo-by-rank-math/rank-math.php' ) || is_plugin_active( 'seo-by-rank-math-pro/rank-math-pro.php' ) ) {
$detected_key = 'rankmath';
} elseif ( is_plugin_active( 'wp-seopress/seopress.php' ) || is_plugin_active( 'wp-seopress-pro/seopress-pro.php' ) ) {
$detected_key = 'seopress';
} elseif ( is_plugin_active( 'all-in-one-seo-pack/all_in_one_seo_pack.php' ) ) {
$detected_key = 'aioseo';
}
return array('options' => $plugins, 'detected' => $detected_key);
}
// 2. Interfaz de Usuario
function render_seo_canibalizacion_page() {
$seo_data = get_seo_audit_plugins_data();
$seo_options = $seo_data['options'];
$detected = $seo_data['detected'];
?>
<style>
.seo-audit-header-actions { display: flex; gap: 10px; align-items: center; margin-bottom: 15px; flex-wrap: wrap; }
.seo-audit-box { background: #fff; padding: 20px; border: 1px solid #ccd0d4; box-shadow: 0 1px 1px rgba(0,0,0,.04); margin-bottom: 20px; }
.audit-search-box { float: right; margin-bottom: 10px; }
.progress-wrapper { margin-top: 15px; padding: 15px; background: #f0f0f1; border-left: 4px solid #2271b1; }
#audit-progress { width: 100%; height: 20px; border-radius: 3px; overflow: hidden; }
#audit-progress::-webkit-progress-bar { background-color: #e2e4e7; }
#audit-progress::-webkit-progress-value { background-color: #2271b1; transition: width 0.3s ease; }
.conflict-actions-btn { display: flex; gap: 5px; }
</style>
<div class="wrap">
<h1 class="wp-heading-inline">Auditoría de Canibalización SEO</h1>
<p>Analiza títulos y metadatos SEO en español por lotes para detectar duplicidades o similitudes extremas.</p>
<hr class="wp-header-end">
<div class="seo-audit-box">
<!-- Selector Multi-Plugin -->
<div style="margin-bottom: 20px; padding-bottom: 15px; border-bottom: 1px solid #eee;">
<label for="seo-plugin-source" style="font-weight: 600; margin-right: 10px;">Origen de los Títulos a analizar:</label>
<select id="seo-plugin-source" style="max-width: 300px;">
<?php foreach($seo_options as $key => $data): ?>
<option value="<?php echo esc_attr($data['key']); ?>" <?php selected($detected, $key); ?>>
<?php echo esc_html($data['name']); ?>
<?php echo ($detected === $key) ? ' (Detectado)' : ''; ?>
</option>
<?php endforeach; ?>
</select>
<p class="description" style="display:inline-block; margin-left:10px; margin-top:0;">
Selecciona qué plugin SEO quieres evaluar. Si el título SEO está vacío, usará el título nativo del post.
</p>
</div>
<div class="seo-audit-header-actions">
<button id="start-seo-audit" class="button button-primary button-large">▶ Iniciar Auditoría</button>
<button id="stop-seo-audit" class="button button-secondary button-large" style="display:none; color: #d63638; border-color: #d63638;">⏹ Detener Escaneo</button>
<span style="border-left: 1px solid #ccc; height: 30px; margin: 0 5px;"></span>
<button id="export-all-txt-audit" class="button button-secondary" style="display:none;">⬇ Exportar Todos (.txt)</button>
<button id="export-filtered-txt-audit" class="button button-secondary" style="display:none; color: #2271b1; border-color: #2271b1;">⬇ Exportar Filtrados (.txt)</button>
<label for="import-txt-file" class="button button-secondary" style="cursor: pointer;">
📂 Cargar informe (.txt)
</label>
<input type="file" id="import-txt-file" accept=".txt" style="display: none;">
</div>
<div id="audit-progress-container" class="progress-wrapper" style="display:none;">
<progress id="audit-progress" value="0" max="100"></progress>
<p id="audit-status" style="margin: 8px 0 0 0; font-weight: 600; color: #1d2327;">Preparando indexación...</p>
</div>
</div>
<div id="audit-tools-bar" class="tablenav top" style="display:none;">
<div class="alignleft actions bulkactions">
<button id="recheck-selected" class="button button-secondary" disabled>
🔄 Re-comprobar seleccionados
</button>
<button id="discard-selected" class="button" style="color: #d63638; border-color: #d63638;" disabled>
🗑️ Descartar seleccionados
</button>
</div>
<div class="alignleft actions">
<select id="items-per-page">
<option value="10">10 por página</option>
<option value="25" selected>25 por página</option>
<option value="50">50 por página</option>
<option value="100">100 por página</option>
<option value="999999">Todos</option>
</select>
<select id="sort-results">
<option value="default">Orden de detección</option>
<option value="sim_desc">Mayor coincidencia primero (100% arriba)</option>
<option value="sim_asc">Menor coincidencia primero</option>
</select>
</div>
<div class="audit-search-box">
<input type="search" id="search-conflicts" placeholder="Buscar por título..." style="width: 250px;">
</div>
<div class="tablenav-pages">
<span class="displaying-num" id="total-count-info">0 elementos</span>
<span class="pagination-links">
<button class="button" id="prev-page" disabled>‹</button>
<span class="paging-input">
<span class="tablenav-paging-text">Página <span id="current-page-info">1</span> de <span id="total-pages-info">1</span></span>
</span>
<button class="button" id="next-page" disabled>›</button>
</span>
</div>
</div>
<div id="audit-results" style="margin-top: 10px;"></div>
</div>
<script>
jQuery(document).ready(function($) {
let offset = 0;
let batchSize = 25;
let allPosts = [];
let totalPosts = 0;
let isRunning = false;
let activeMetaKey = '';
let exportedConflicts = [];
let displayConflicts = [];
let currentPage = 1;
let itemsPerPage = 25;
function initTableStructure() {
$('#audit-results').html(
'<table class="wp-list-table widefat fixed striped">' +
'<thead><tr>' +
'<td id="cb" class="manage-column column-cb check-column"><input id="cb-select-all" type="checkbox"></td>' +
'<th>Post A</th>' +
'<th>Post B</th>' +
'<th>Motivo Coincidencia</th>' +
'<th style="width:100px;">Similitud</th>' +
'<th style="width:190px;">Acciones</th>' +
'</tr></thead>' +
'<tbody id="conflict-rows"></tbody>' +
'</table>'
);
}
function updateView() {
let term = $('#search-conflicts').val().toLowerCase().trim();
let sortType = $('#sort-results').val();
if (term !== "") {
displayConflicts = exportedConflicts.filter(item =>
item.post_a_title.toLowerCase().includes(term) ||
item.post_b_title.toLowerCase().includes(term) ||
item.reason.toLowerCase().includes(term)
);
} else {
displayConflicts = [...exportedConflicts];
}
if (sortType === 'sim_desc') {
displayConflicts.sort((a, b) => b.similarity - a.similarity);
} else if (sortType === 'sim_asc') {
displayConflicts.sort((a, b) => a.similarity - b.similarity);
}
let totalPages = Math.ceil(displayConflicts.length / itemsPerPage) || 1;
if (currentPage > totalPages) currentPage = totalPages;
if (currentPage < 1) currentPage = 1;
renderTablePage();
}
function renderTablePage() {
$('#cb-select-all').prop('checked', false);
$('#discard-selected, #recheck-selected').prop('disabled', true);
if (exportedConflicts.length === 0) {
$('#audit-tools-bar').hide();
$('#export-all-txt-audit, #export-filtered-txt-audit').hide();
$('#conflict-rows').html('<tr><td colspan="6" style="text-align:center; padding: 20px;">No se encontraron conflictos.</td></tr>');
return;
}
$('#audit-tools-bar, #export-all-txt-audit').show();
if (displayConflicts.length < exportedConflicts.length) {
$('#export-filtered-txt-audit').show();
} else {
$('#export-filtered-txt-audit').hide();
}
let totalPages = Math.ceil(displayConflicts.length / itemsPerPage) || 1;
let start = (currentPage - 1) * itemsPerPage;
let end = start + itemsPerPage;
let pageItems = displayConflicts.slice(start, end);
let $tbody = $('#conflict-rows');
$tbody.empty();
if (pageItems.length === 0) {
$tbody.html('<tr><td colspan="6" style="text-align:center; padding: 20px;">No hay resultados para la búsqueda.</td></tr>');
} else {
pageItems.forEach(function(item) {
$tbody.append(buildRowHtml(item));
});
}
$('#current-page-info').text(currentPage);
$('#total-pages-info').text(totalPages);
$('#total-count-info').text(displayConflicts.length + ' elementos');
$('#prev-page').prop('disabled', currentPage === 1);
$('#next-page').prop('disabled', currentPage === totalPages || totalPages === 0);
}
function buildRowHtml(item) {
return '<tr data-index="' + item.uid + '">' +
'<th scope="row" class="check-column"><input type="checkbox" class="row-select-cb" value="' + item.uid + '"></th>' +
'<td><strong><a href="' + item.post_a_edit + '" target="_blank" class="row-title">' + item.post_a_title + '</a></strong></td>' +
'<td><strong><a href="' + item.post_b_edit + '" target="_blank" class="row-title">' + item.post_b_title + '</a></strong></td>' +
'<td>' + item.reason + '</td>' +
'<td><span class="badge" style="background:#e0e0e0; padding:3px 8px; border-radius:12px; font-weight:bold;">' + item.similarity + '%</span></td>' +
'<td class="conflict-actions-btn">' +
'<button type="button" class="button button-small recheck-row" data-id-a="' + item.post_a_id + '" data-id-b="' + item.post_b_id + '">Re-comprobar</button> ' +
'<button type="button" class="button button-small discard-row" style="color:#d63638; border-color:#d63638;">Descartar</button>' +
'</td>' +
'</tr>';
}
$('#search-conflicts').on('input', function() { currentPage = 1; updateView(); });
$('#items-per-page').on('change', function() { itemsPerPage = parseInt($(this).val()); currentPage = 1; updateView(); });
$('#sort-results').on('change', function() { currentPage = 1; updateView(); });
$('#prev-page').on('click', function() { if (currentPage > 1) { currentPage--; updateView(); } });
$('#next-page').on('click', function() { let totalPages = Math.ceil(displayConflicts.length / itemsPerPage); if (currentPage < totalPages) { currentPage++; updateView(); } });
$(document).on('change', '.row-select-cb, #cb-select-all', function() {
let selectedCount = $('.row-select-cb:checked').length;
$('#discard-selected, #recheck-selected').prop('disabled', selectedCount === 0);
});
$(document).on('change', '#cb-select-all', function() {
$('.row-select-cb').prop('checked', $(this).is(':checked'));
});
$('#start-seo-audit').on('click', function() {
$(this).prop('disabled', true);
$('#stop-seo-audit').show().prop('disabled', false);
$('#export-all-txt-audit, #export-filtered-txt-audit, #audit-tools-bar').hide();
$('#seo-plugin-source').prop('disabled', true);
$('#audit-progress-container').show();
$('#audit-progress').val(0);
$('#audit-status').text('Obteniendo índice de posts en español...');
initTableStructure();
offset = 0;
exportedConflicts = [];
displayConflicts = [];
currentPage = 1;
isRunning = true;
activeMetaKey = $('#seo-plugin-source').val(); // Capturamos la opción seleccionada
$('#search-conflicts').val('');
fetchSpanishPostsIndex();
});
$('#stop-seo-audit').on('click', function() {
isRunning = false;
$(this).prop('disabled', true);
$('#audit-status').text('Escaneo detenido por el usuario.');
$('#start-seo-audit').prop('disabled', false);
$('#seo-plugin-source').prop('disabled', false);
updateView();
});
$('#discard-selected').on('click', function() {
let selectedUids = [];
$('.row-select-cb:checked').each(function() { selectedUids.push($(this).val()); });
if (selectedUids.length === 0) return;
if (confirm('¿Seguro que deseas descartar ' + selectedUids.length + ' conflicto(s) seleccionado(s)?')) {
exportedConflicts = exportedConflicts.filter(item => !selectedUids.includes(item.uid));
updateView();
}
});
// Re-comprobar seleccionados por lote
$('#recheck-selected').on('click', function() {
let selectedUids = [];
$('.row-select-cb:checked').each(function() { selectedUids.push($(this).val()); });
if (selectedUids.length === 0) return;
let itemsToRecheck = exportedConflicts.filter(item => selectedUids.includes(item.uid));
let totalToRecheck = itemsToRecheck.length;
let processed = 0;
let resolvedCount = 0;
let $btn = $(this);
$btn.prop('disabled', true).text('Re-comprobando (0/' + totalToRecheck + ')...');
$('#discard-selected').prop('disabled', true);
function processNextRecheck(index) {
if (index >= totalToRecheck) {
$btn.text('🔄 Re-comprobar seleccionados');
updateView();
alert('Re-comprobación finalizada.\n\n- Procesados: ' + totalToRecheck + '\n- Resueltos/Eliminados: ' + resolvedCount + '\n- Persisten: ' + (totalToRecheck - resolvedCount));
return;
}
let item = itemsToRecheck[index];
$btn.text('Re-comprobando (' + (index + 1) + '/' + totalToRecheck + ')...');
$.post(ajaxurl, {
action: 'seo_audit_recheck_pair',
id_a: item.post_a_id,
id_b: item.post_b_id,
meta_key: activeMetaKey
}, function(response) {
if (response.success) {
if (response.data.has_conflict) {
let target = exportedConflicts.find(i => i.uid === item.uid);
if (target) {
target.post_a_title = response.data.title_a;
target.post_b_title = response.data.title_b;
target.reason = response.data.reason;
target.similarity = response.data.similarity;
}
} else {
resolvedCount++;
exportedConflicts = exportedConflicts.filter(i => i.uid !== item.uid);
}
}
processNextRecheck(index + 1);
}).fail(function() {
processNextRecheck(index + 1);
});
}
processNextRecheck(0);
});
$(document).on('click', '.discard-row', function() {
exportedConflicts = exportedConflicts.filter(item => item.uid !== $(this).closest('tr').data('index'));
updateView();
});
$(document).on('click', '.recheck-row', function() {
let $btn = $(this);
let uid = $btn.closest('tr').data('index');
$btn.prop('disabled', true).text('...');
$.post(ajaxurl, {
action: 'seo_audit_recheck_pair',
id_a: $btn.data('id-a'),
id_b: $btn.data('id-b'),
meta_key: activeMetaKey
}, function(response) {
if (response.success) {
if (response.data.has_conflict) {
let item = exportedConflicts.find(i => i.uid === uid);
if (item) {
item.post_a_title = response.data.title_a;
item.post_b_title = response.data.title_b;
item.reason = response.data.reason;
item.similarity = response.data.similarity;
}
updateView();
alert('El conflicto persiste (' + response.data.similarity + '%).');
} else {
alert('¡Solucionado! Los títulos ya no entran en conflicto.');
exportedConflicts = exportedConflicts.filter(i => i.uid !== uid);
updateView();
}
} else {
alert('Error al verificar.');
$btn.prop('disabled', false).text('Re-comprobar');
}
}).fail(function() {
alert('Error de conexión.');
$btn.prop('disabled', false).text('Re-comprobar');
});
});
$('#import-txt-file').on('change', function(e) {
let file = e.target.files[0];
if (!file) return;
let reader = new FileReader();
reader.onload = function(evt) { parseTxtReport(evt.target.result); };
reader.readAsText(file);
$(this).val('');
});
function parseTxtReport(text) {
let blocks = text.split('---------------------------------------------------');
let imported = [];
blocks.forEach(function(block) {
if (!block.trim()) return;
let headerMatch = block.match(/Coincidencia\s+\((\d+(?:\.\d+)?)%\s*-\s*(.*?)\)/i);
let postAMatch = block.match(/Post A:\s+(.*?)\n\s*Editar:\s*(?:.*?)(?:post=|\/post\/|\?p=)(\d+)/i);
let postBMatch = block.match(/Post B:\s+(.*?)\n\s*Editar:\s*(?:.*?)(?:post=|\/post\/|\?p=)(\d+)/i);
if (headerMatch && postAMatch && postBMatch) {
imported.push({
uid: 'row_' + parseInt(postAMatch[2]) + '_' + parseInt(postBMatch[2]),
post_a_id: parseInt(postAMatch[2]),
post_a_title: postAMatch[1].trim(),
post_a_edit: 'post.php?post=' + parseInt(postAMatch[2]) + '&action=edit',
post_b_id: parseInt(postBMatch[2]),
post_b_title: postBMatch[1].trim(),
post_b_edit: 'post.php?post=' + parseInt(postBMatch[2]) + '&action=edit',
reason: headerMatch[2].trim(),
similarity: parseFloat(headerMatch[1])
});
}
});
if (imported.length > 0) {
exportedConflicts = imported;
$('#search-conflicts').val('');
currentPage = 1;
initTableStructure();
updateView();
alert('Informe cargado con éxito: ' + imported.length + ' conflictos importados.');
} else {
alert('No se pudieron extraer datos válidos del archivo .txt.');
}
}
function downloadTxtReport(dataToExport, typeLabel) {
if (dataToExport.length === 0) return;
let txtContent = "===================================================\n";
txtContent += "INFORME DE CANIBALIZACIÓN SEO (AUDITORÍA VOLÁTIL) - " + typeLabel + "\n";
txtContent += "Fecha: " + new Date().toLocaleString() + "\n";
txtContent += "Total conflictos listados: " + dataToExport.length + "\n";
txtContent += "===================================================\n\n";
dataToExport.forEach(function(item, index) {
txtContent += "[" + (index + 1) + "] Coincidencia (" + item.similarity + "% - " + item.reason + ")\n";
txtContent += " - Post A: " + item.post_a_title + "\n Editar: " + item.post_a_edit + "\n";
txtContent += " - Post B: " + item.post_b_title + "\n Editar: " + item.post_b_edit + "\n";
txtContent += "---------------------------------------------------\n";
});
let blob = new Blob([txtContent], { type: 'text/plain;charset=utf-8' });
let link = document.createElement('a');
link.href = URL.createObjectURL(blob);
let suffix = typeLabel === 'COMPLETO' ? 'completo' : 'filtrado';
link.download = 'auditoria_canibalizacion_' + suffix + '_' + new Date().toISOString().slice(0,10) + '.txt';
link.click();
}
$('#export-all-txt-audit').on('click', function() { downloadTxtReport(exportedConflicts, 'COMPLETO'); });
$('#export-filtered-txt-audit').on('click', function() { downloadTxtReport(displayConflicts, 'FILTRADO'); });
function fetchSpanishPostsIndex() {
$.post(ajaxurl, {
action: 'seo_audit_get_index',
meta_key: activeMetaKey // Enviamos el meta key dinámico
}, function(response) {
if (response.success) {
allPosts = response.data.posts;
totalPosts = allPosts.length;
if (totalPosts === 0) {
$('#audit-status').text('No se encontraron publicaciones en español.');
resetButtons();
return;
}
processBatch();
} else {
$('#audit-status').text('Error al obtener índice.');
resetButtons();
}
}).fail(function() {
$('#audit-status').text('Error AJAX inicial.');
resetButtons();
});
}
function processBatch() {
if (!isRunning) return;
let percentage = Math.round((offset / totalPosts) * 100);
$('#audit-progress').val(percentage);
$('#audit-status').text('Analizando ' + offset + ' de ' + totalPosts + ' posts (' + percentage + '%)...');
let currentBatch = allPosts.slice(offset, offset + batchSize);
$.post(ajaxurl, {
action: 'seo_audit_process_batch',
batch: JSON.stringify(currentBatch),
all_posts: JSON.stringify(allPosts)
}, function(response) {
if (!isRunning) return;
if (response.success) {
let results = response.data.conflicts;
if (results && results.length > 0) {
results.forEach(function(item) {
item.uid = 'row_' + item.post_a_id + '_' + item.post_b_id;
exportedConflicts.push(item);
});
updateView();
}
offset += batchSize;
if (offset < totalPosts) {
processBatch();
} else {
$('#audit-progress').val(100);
$('#audit-status').text('Completado. Se encontraron ' + exportedConflicts.length + ' posibles conflictos.');
resetButtons();
updateView();
}
} else {
$('#audit-status').text('Error en el lote actual.');
resetButtons();
}
}).fail(function() {
$('#audit-status').text('Error de servidor en lote.');
resetButtons();
});
}
function resetButtons() {
$('#start-seo-audit').prop('disabled', false);
$('#seo-plugin-source').prop('disabled', false);
$('#stop-seo-audit').hide();
isRunning = false;
}
});
</script>
<?php
}
// 3. Obtención del índice adaptado a Multi-Plugin
add_action('wp_ajax_seo_audit_get_index', function() {
if (!current_user_can('manage_options')) {
wp_send_json_error('Permisos insuficientes');
}
$meta_key = isset($_POST['meta_key']) ? sanitize_text_field($_POST['meta_key']) : '';
$args = array(
'post_type' => 'post',
'post_status' => 'publish',
'posts_per_page' => -1,
'fields' => 'ids',
'lang' => 'es'
);
$query = new WP_Query($args);
$spanish_ids = $query->posts;
if (empty($spanish_ids)) {
wp_send_json_success(array('posts' => array()));
}
global $wpdb;
$ids_placeholder = implode(',', array_map('intval', $spanish_ids));
if ( empty( $meta_key ) ) {
// Consulta nativa: Si no hay plugin SEO o se forzó ignorarlos
$results = $wpdb->get_results("
SELECT p.ID, p.post_title, p.post_title as seo_title
FROM {$wpdb->posts} p
WHERE p.ID IN ({$ids_placeholder})
");
} else {
// Consulta para cualquier plugin SEO basado en postmeta
$query_string = "
SELECT p.ID, p.post_title, m.meta_value as seo_title
FROM {$wpdb->posts} p
LEFT JOIN {$wpdb->postmeta} m ON (p.ID = m.post_id AND m.meta_key = %s)
WHERE p.ID IN ({$ids_placeholder})
";
$results = $wpdb->get_results( $wpdb->prepare( $query_string, $meta_key ) );
}
$posts_data = array();
foreach ($results as $row) {
// Si el título SEO está vacío, usamos el nativo
$clean_title = !empty($row->seo_title) ? $row->seo_title : $row->post_title;
$posts_data[] = array(
'id' => (int) $row->ID,
'title' => $row->post_title,
'clean' => strtolower(trim($clean_title))
);
}
wp_send_json_success(array('posts' => $posts_data));
});
// 4. Comparación por lote en RAM
add_action('wp_ajax_seo_audit_process_batch', function() {
if (!current_user_can('manage_options')) {
wp_send_json_error('Permisos insuficientes');
}
$current_batch = isset($_POST['batch']) ? json_decode(stripslashes($_POST['batch']), true) : array();
$all_posts = isset($_POST['all_posts']) ? json_decode(stripslashes($_POST['all_posts']), true) : array();
if (empty($current_batch) || empty($all_posts)) {
wp_send_json_success(array('conflicts' => array()));
}
$conflicts = array();
foreach ($current_batch as $post_a) {
foreach ($all_posts as $post_b) {
if ($post_a['id'] >= $post_b['id']) continue;
if (!empty($post_a['clean']) && $post_a['clean'] === $post_b['clean']) {
$conflicts[] = array(
'post_a_id' => $post_a['id'],
'post_a_title' => $post_a['title'],
'post_a_edit' => get_edit_post_link($post_a['id']),
'post_b_id' => $post_b['id'],
'post_b_title' => $post_b['title'],
'post_b_edit' => get_edit_post_link($post_b['id']),
'reason' => 'Título SEO exacto (100% igual)',
'similarity' => 100
);
continue;
}
similar_text($post_a['clean'], $post_b['clean'], $percent);
if ($percent >= 80) {
$conflicts[] = array(
'post_a_id' => $post_a['id'],
'post_a_title' => $post_a['title'],
'post_a_edit' => get_edit_post_link($post_a['id']),
'post_b_id' => $post_b['id'],
'post_b_title' => $post_b['title'],
'post_b_edit' => get_edit_post_link($post_b['id']),
'reason' => 'Similitud léxica alta en títulos',
'similarity' => round($percent, 1)
);
}
}
}
wp_send_json_success(array('conflicts' => $conflicts));
});
// 5. Re-comprobar par específico en tiempo real
add_action('wp_ajax_seo_audit_recheck_pair', function() {
if (!current_user_can('manage_options')) {
wp_send_json_error('Permisos insuficientes');
}
$id_a = isset($_POST['id_a']) ? intval($_POST['id_a']) : 0;
$id_b = isset($_POST['id_b']) ? intval($_POST['id_b']) : 0;
$meta_key = isset($_POST['meta_key']) ? sanitize_text_field($_POST['meta_key']) : '';
if (!$id_a || (!$id_b)) {
wp_send_json_error('IDs inválidos');
}
$title_a = get_the_title($id_a);
$seo_a = !empty($meta_key) ? get_post_meta($id_a, $meta_key, true) : '';
$clean_a = strtolower(trim(!empty($seo_a) ? $seo_a : $title_a));
$title_b = get_the_title($id_b);
$seo_b = !empty($meta_key) ? get_post_meta($id_b, $meta_key, true) : '';
$clean_b = strtolower(trim(!empty($seo_b) ? $seo_b : $title_b));
if (!empty($clean_a) && $clean_a === $clean_b) {
wp_send_json_success(array(
'has_conflict' => true,
'title_a' => $title_a,
'title_b' => $title_b,
'reason' => 'Título SEO exacto (100% igual)',
'similarity' => 100
));
}
similar_text($clean_a, $clean_b, $percent);
if ($percent >= 80) {
wp_send_json_success(array(
'has_conflict' => true,
'title_a' => $title_a,
'title_b' => $title_b,
'reason' => 'Similitud léxica alta en títulos',
'similarity' => round($percent, 1)
));
}
wp_send_json_success(array('has_conflict' => false));
});













