Audit de cannibalisation SEO : détecteur de titres dupliqués et similaires pour WordPress

Aucun commentaire

23.09.2026|

Aucun commentaire

Tiempo de lectura Lectura: 24 min, 27 s
Número de palabras Palabras: 4526
Número de visitas Visitas: 38
Icono de traducción

Cet outil, sous forme d'extrait de code, de plugin (ou de script PHP léger), analyse par lots l'ensemble des contenus publiés sur votre site afin de détecter les conflits de cannibalisation entre les titres SEO et les titres natifs.

Je m'en suis servi pour améliorer un peu un site web qui comportait une multitude de titres exactement identiques, dont beaucoup ne comportaient qu'un seul mot. Je l'ai maintenant un peu peaufiné pour pouvoir le partager. L'analyse du pourcentage de similitude pourrait être améliorée, n'hésite pas à le faire.

Cet extrait de code compare les titres de tous vos articles publiés afin de détecter les doublons exacts (correspondance à 100 %) ou les similitudes lexicales élevées (égales ou supérieures à 80 %).

Si un article ne dispose pas d'un titre SEO spécifique configuré dans le plugin correspondant, le script utilise automatiquement le titre natif de WordPress comme solution de secours. Je n'ai pas encore pu le tester sur tous les plugins SEO, il m'en manquait un. Si vous constatez une erreur, n'hésitez pas à me le signaler.

En quoi est-ce bénéfique et à quoi cela sert-il ?

  • Évitez la cannibalisation : lorsque deux articles ou plus se font concurrence pour les mêmes mots-clés dans leurs titres, les moteurs de recherche sont déroutés et répartissent l'autorité entre eux, ce qui nuit au référencement global des deux.
  • Gagnez du temps lors des audits : affichez dans un tableau de bord unique et clair tous les conflits potentiels, sans avoir à recourir à des outils externes payants ou à des recherches fastidieuses.
  • Permet d'apporter des corrections à la volée : propose des liens d'édition directs pour résoudre immédiatement les chevauchements de contenu.

Les slugs des URL ne sont ni analysés, ni modifiés, ni altérés.

Guide des fonctionnalités de l'interface

Audit de cannibalisation SEO : détecteur de titres dupliqués et similaires pour WordPress
  1. Origine des titres à analyser : sélecteur dynamique qui détecte automatiquement le plugin SEO actif sur votre installation. Il est nativement compatible avec The SEO Framework (TSF), Yoast SEO, Rank Math, SEOPress et All in One SEO (Legacy). S'il n'en détecte aucun ou si vous sélectionnez l'option native, il évaluera les titres par défaut de WordPress.
  2. Lancer l'audit : lance le processus d'indexation et d'analyse par lots en arrière-plan, en affichant une barre de progression en temps réel.
  3. Exporter tout (.txt) / Exporter les résultats filtrés : permet de télécharger un rapport complet au format texte brut contenant toutes les paires en conflit détectées, leurs liens d'édition et le pourcentage de similitude, afin de pouvoir le traiter hors ligne ou de l'enregistrer comme historique.
  4. Charger un rapport (.txt) : importe un fichier de rapport généré précédemment. Cela vous permet de consulter instantanément les audits précédents sans avoir à relancer l'analyse de la base de données.
  5. Actions groupées (Éliminer / Vérifier à nouveau les éléments sélectionnés) : permet de cocher plusieurs cases pour éliminer d'un seul coup les faux positifs de la liste ou de lancer une nouvelle vérification par lots uniquement sur les lignes sélectionnées.
  6. Pagination : Sélecteur du nombre d'éléments affichés par page (10, 25, 50, 100 ou tous) afin de ne pas surcharger l'affichage du navigateur en cas de centaines de résultats.
  7. Tri des résultats : permet de trier la liste par ordre de détection ou par pourcentage de similitude (de la plus grande à la plus petite similitude, ou inversement).
  8. Moteur de recherche en temps réel : filtrez instantanément les résultats en saisissant n'importe quel mot ou terme-clé figurant dans les titres de l'article A ou de l'article B.
  9. Motif de la correspondance : indique la raison du conflit signalé : « Titre SEO exact (100 % identique) » ou « Forte similitude lexicale dans les titres ».
  10. Pourcentage de similitude : indique, à l'aide d'une étiquette visuelle, le degré de concordance calculé entre les deux titres.
  11. Vérifier à nouveau (ligne individuelle) : interroge à nouveau la base de données uniquement pour cette paire spécifique. Si vous avez corrigé le titre de l'un des deux articles dans un autre onglet, cliquer ici met à jour le taux de similitude ou supprime automatiquement la ligne si le conflit a déjà été résolu.
  12. Ignorer (ligne unique) : supprime cette correspondance particulière de la liste actuelle si vous estimez qu'il s'agit d'un faux positif ou qu'elle ne nécessite aucune modification.

Pourquoi est-il léger et n'affecte-t-il pas les performances ?

Contrairement à de nombreux plugins SEO lourds qui surchargent la base de données ou exécutent des tâches en continu en arrière-plan, cet extrait de code a été conçu selon les principes d'efficacité suivants :

  • Sans tables ni options persistantes : il s'agit d'un outil volatile. Il ne stocke pas de données inutiles dans la table wp_options et ne crée pas de tables personnalisées. Tout le traitement s'effectue en mémoire RAM pendant la session.
  • Traitement par lots via AJAX : l'analyse ne bloque pas le serveur. La liste complète des articles en espagnol est consultée rapidement grâce à une seule requête optimisée (WP_Query + ids), et la comparaison lexicale (similar_text) s'effectue en divisant les données en petits lots (blocs de 25) envoyés de manière asynchrone.
  • Consommation nulle hors utilisation : le script n'enregistre ni tâches cron, nihooks d'enregistrement, ni moniteurs en temps réel. Il ne consomme des ressources du serveur que pendant les quelques secondes où vous cliquez sur « Lancer l'audit » ou « Vérifier à nouveau ».

Il s'agit d'un code à usage unique. Il est conseillé de désactiver cet extrait de code ou de le supprimer de l'endroit où vous l'avez ajouté une fois la tâche terminée. Et de toujours le tester au préalable dans un environnement de test.

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

Laisser un commentaire

Vous avez quelque chose à dire ?

Este blog se aloja en LucusHost

LucusHost,el mejor hosting