This tool, which takes the form of a snippet, plugin (or lightweight PHP script), analyses all the content published on your site in batches to identify cannibalisation issues between SEO titles and native titles. I used it to tidy up a website that had loads of titles that were exactly the same, many of them consisting of just a single word. I’ve now tidied it up a bit so I can share it. The similarity percentage analysis could be improved – feel free to do so.
The snippet compares the titles of all your published posts to find exact duplicates (100% match) or high lexical similarity (80% or more). If a post does not have a specific SEO title configured in the relevant plugin, the script automatically uses the native WordPress title as a fallback.
Why is it beneficial, and what is it used for?
- Avoid cannibalisation: When two or more posts compete for the same keywords in their titles, search engines become confused and split the authority between them, which worsens the overall ranking of both.
- Save time on audits: View all potential conflicts in a single, organised dashboard without having to resort to external paid tools or cumbersome searches.
- Allows you to make corrections on the fly: It provides direct editing links to resolve content overlaps immediately.
URL slugs are not analysed, modified or altered
Interface Features Guide

- Source of the titles to be analysed: A dynamic selector that automatically detects the active SEO plugin on your site. It is natively compatible with The SEO Framework (TSF), Yoast SEO, Rank Math, SEOPress and All in One SEO (Legacy). If it does not detect any of these or you select the native option, it will evaluate the default WordPress titles.
- Start audit: Launches the batch indexing and analysis process in the background, displaying a real-time progress bar.
- Export All (.txt) / Export Filtered Results: Allows you to download a complete plain-text report containing all the conflicting pairs found, their edit links and the similarity percentage, so that you can work on it offline or save it for your records.
- Upload report (.txt): Import a previously generated report file. This allows you to review past audits instantly without having to run the database scan again.
- Bulk actions (Discard / Re-check selected items): Allows you to tick several boxes to remove false positives from the list in one go or to run a batch re-check on the selected rows only.
- Pagination: A selector for the number of items displayed per page (10, 25, 50, 100 or all) to prevent the browser view from becoming cluttered if there are hundreds of results.
- Sorting results: Allows you to sort the list by detection order or by percentage of similarity (from highest to lowest similarity, or vice versa).
- Real-time search: Filter results instantly by typing any word or keyword contained in the titles of Post A or Post B.
- Reason for match: Indicates the reason for the detected conflict: “Exact SEO title (100% match)” or “High lexical similarity in titles”.
- Percentage of similarity: Displays the calculated degree of similarity between the two titles by means of a visual label.
- Re-check (Single row): Queries the database again for that specific pair only. If you have corrected the title of one of the two posts in another tab, clicking here will update the similarity score or automatically remove the row if the conflict has already been resolved.
- Discard (Single row): Removes that particular match from the current list if you consider it to be a false positive or if it does not require any changes.
Why is it lightweight and doesn’t affect performance?
Unlike many resource-intensive SEO plugins that overload the database or run continuous background tasks, this snippet has been designed in accordance with the following principles of efficiency:
- No tables or persistent options: It is a volatile tool. It does not store junk in the
`wp_options` table or create custom tables. All processing takes place in RAM during the session. - Batch processing via AJAX: The scan does not block the server. The complete list of entries in Spanish is retrieved quickly via a single optimised query (
WP_Query+ids), and the lexical comparison (similar_text) is carried out by dividing the data into small batches (chunks of 25) sent asynchronously. - Zero resource usage when not in use: The script does not run cron jobs, savehooks or real-time monitors. It only uses server resources for the few seconds it takes to click ‘Start Audit’ or ‘Re-check’.
This is a one-off code snippet. We recommend disabling the snippet or removing it from wherever you’ve added it once you’ve completed the task. And always test it in a test environment first.
Code
/**
* 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));
});






