PHP y SEO: detectando contenidos duplicados

SEO PHP duplicados

Hoy traigo un howto o code-kata, para comenzar a hacer nuestra propia herramienta de detección de duplicados. Puede ser una tarea más o menos compleja, depende mucho de hasta dónde queramos llegar, pero sólo mediante scripting en PHP podemos implementarlo.

Es decir, podemos recorrer toda una web de la que estamos encargados, guardar los contenidos de sus URLs, y chequearlos para ver si tiene contenidos duplicados. Me remito a un post de hace un par de años para recorrer una web en anchura, o en profundidad, mediante un sencillo script en PHP: https://jnjsite.com/yo-robot-ii-white-gray-black-hat-seo/

Primero tendremos que poder recorrer la web, esto es lo que se llama crawlear. Después de recorrer el sitio web, lo siguiente será extraer la información que vayamos a utilizar para comparar los contenidos de cada página con los de las demás. Podremos chequear por títulos, párrafos, frases, palabras, etc.. aquí los criterios de detección varían mucho de una herramienta a otra. Vamos a ir jugueteando en este post con varios criterios.

Sacando todos los títulos de un sitio web

A modo de code-kata, mejorando el código del post antiguo, podemos obtener todos los títulos de una página así. O mejor dicho, todos los títulos de todas las páginas de una web así:

<?php

$dom = new DOMDocument();
$theSite = $argv[1];

$linksQueue = array($theSite => 0);
$visitedUrls = array(
    $theSite => 0,
);
$currentLevel = 0;

while (0 != count($linksQueue)) {
    $currentUrl = key($linksQueue);
    $currentLevel = array_shift($linksQueue);

    echo 'FOUND:'.count($visitedUrls).' QUEUE:'.count($linksQueue).' LEVEL:'.$currentLevel.' '.$currentUrl.PHP_EOL;

    @$dom->loadHTMLFile($currentUrl);

    for ($i = 1; $i <= 6; ++$i ) {
        foreach ($dom->getElementsByTagName('h'.$i) as $title) {
            echo '    h'.$i.': '.trim($title->nodeValue).PHP_EOL;
        }
    }

    foreach ($dom->getElementsByTagName('a') as $link) {
        $newUrl = $link->getAttribute('href');
        // if in-site and not yet visited then follow
        if (substr($newUrl, 0, strlen($theSite)) == $theSite and !array_key_exists($newUrl, $visitedUrls)) {
            $linksQueue[$newUrl] = $currentLevel + 1;
            $visitedUrls[$newUrl] = $currentLevel + 1;
        }
    }
}

asort($visitedUrls);
echo '// Results ////////////////////////////////////////////'.PHP_EOL;
foreach ($visitedUrls as $key => $value) {
    echo 'DEPTH:'.$value.' '.$key.PHP_EOL;
}
echo 'Total URLs found: '.count($visitedUrls).PHP_EOL;

Fíjate que recibe como parámetro la URL. Esto nos da unos resultados por pantalla como los siguientes:

..
h5: Lo + visto hoy
    h5: Etiquetas
    h5: Archivos
FOUND:292 QUEUE:264 LEVEL:1 https://jnjsite.com/magento-2-haciendo-scripts-externos-en-php/
    h1: Magento 2: haciendo scripts externos en PHP
    h2: Al grano, la carcasa del script, el código fuente
    h2: Otro código fuente de ejemplo, listando los productos con todos los atributos simples
    h2: Magento: listando precios de los productos
    h2: PrEDA: algoritmo para encontrar todos los cuadrados latinos de tamaño NxN
    h2: Magento: cambiar scope de los atributos de productos
    h2: Magento 1: sacando la información de pedido con la API Soap
    h2: ¡Suscríbete a las novedades!
    h2: Navegación de la entrada
    h3: También te puede interesar
    h3: Entradas recientes
    h3: Lo + visto hoy
    h4: Dejar un comentario Cancelar respuesta
    h5: Lo + visto hoy
    h5: Etiquetas
    h5: Archivos
FOUND:299 QUEUE:270 LEVEL:1 https://jnjsite.com/magento-2-automatizando-el-despliegue-continuo/
    h1: Magento 2: automatizando el despliegue continuo
    h2: Al grano, el código fuente de un despliegue
    h2: Compatibilidades del script
    h2: Terminando, aclaraciones y referencias
    h2: Cómo automatizar el despliegue de una aplicación web en Symfony 4
    h2: Sylius.org tienda online 100% Symfony: calidad, flexibilidad y escalabilidad
..

Sacando todas las frases de las páginas de un sitio web

Vamos ahora a modificar el script para sacar todas las frases de una web. Haciendo un poco de ejercicio mental, un poco de poner cera, pulir cera, al final que podemos sacar un script PHP como el siguiente. Modificando el anterior script, ponemos en el centro del bucle el siguiente código, que dada una página te saca todas las frases:

..
    // Removing scripts..
    while ($node = $dom->getElementsByTagName('script') and $node->length) {
        $node->item(0)->parentNode->removeChild($node->item(0));
    }
    // Removing styles..
    while ($node = $dom->getElementsByTagName('style') and $node->length) {
        $node->item(0)->parentNode->removeChild($node->item(0));
    }
    // Getting only the HTML of body..
    $body = $dom->saveHTML($dom->getElementsByTagName('body')->item(0));
    $body = str_replace('</', PHP_EOL.'</', $body);
    $body = str_replace('. ', '.'.PHP_EOL, $body);
    $lines = explode(PHP_EOL, strip_tags($body));
    $linesFiltered = array();
    foreach ($lines as $key => $value) {
        if ('' == trim($value)) {
            unset($lines[$key]);
        } else {
            $linesFiltered[] = trim($value);
        }
    }
    // Finally we get sentences with meaning..
    foreach ($linesFiltered as $line) {
        echo '    '.$line.PHP_EOL;
    }
..

Mira que el bucle externo es igual, sólo cambia el centro del bucle que he puesto aquí encima. Al ejecutarlo el script completo, si todo ha ido bien, tenemos que ver por línea de comandos algo como lo siguiente:

SEO PHP listando frases con sentido de una página web

Sacando todas las palabras de las páginas de un sitio web

Practicando, practicando, que podemos trabajar los datos para quedarnos con un listado de palabras de contenido que puede tener un website. De esta manera con el script siguiente ya podemos tener un listado de palabras por página web de un sitio:

..
    // Removing scripts..
    while ($node = $dom->getElementsByTagName('script') and $node->length) {
        $node->item(0)->parentNode->removeChild($node->item(0));
    }
    // Removing styles..
    while ($node = $dom->getElementsByTagName('style') and $node->length) {
        $node->item(0)->parentNode->removeChild($node->item(0));
    }
    // Getting only the text content of the website..
    $content = $dom->textContent;
    $contentLines = explode(PHP_EOL, $content);
    $words = array();
    foreach ($contentLines as $line) {
        $wordsInLine = explode(' ', $line);
        foreach ($wordsInLine as $word) {
            $word = str_replace(PHP_EOL, '', trim($word));
            if (isset($words[$word])) {
                ++$words[$word];
            } else {
                $words[$word] = 1;
            }
        }
    }
    foreach ($words as $key => $value) {
        echo $key.' => '.$value.PHP_EOL;
    }
..

Al ejecutar el script se verá para cada página un listado de palabras tal que así:

..
Gnome => 1
Symfony: => 3
tutorial => 3
15: => 1
los => 7
tests => 1
automáticos, => 1
funcionales => 1
unitarios => 1
Migrando => 1
Ubuntu => 2
Server => 1
16 => 1
a => 6
18 => 1
con => 10
Virtualmin => 1
Magento => 3
2: => 3
haciendo => 1
scripts => 1
externos => 1
PHP => 3
automatizando => 1
el => 3
despliegue => 1
continuo => 1
14: => 1
navegando => 1
DomCrawler, => 1
BrowserKit => 1
CssSelector => 1
GNU/Linux: => 1
terabytes => 1
nube => 3
privada => 1
Raspberry => 1
Pi => 1
Syncthing => 1
..

Un poco más de teoría, comprendiendo los contenidos

Si has seguido el tutorial completo, llegados a este punto ya tenemos la materia prima. Las ideas, los conceptos sobre los que va una página web ya los podemos tener analizando las palabras/frases que lo forman.

Viendo las frases de los contenidos, los títulos, podemos también automatizar el análisis con herramientas de análisis conceptual, con herramientas de Machine Learning, Inteligencia Artificial. Por ejemplo, podríamos trabajar estos contenidos pasándolos a un procesador de lenguaje natural (NLP), como Amazon Comprehend, y sabríamos mucha más información global sobre cada contenido. Aunque esto no es material para este post, si tuvieras que hacer una herramienta que comprendiera los conceptos, sacara frases clave, conceptos, estado de ánimo del escritor, etc.. todo eso lo tendrías servido listo para utilizar sin tener que reinventar la rueda trabajando con servicios IA que nos preveen.

Volviendo al origen del post, vamos a simplemente centrarnos en las principales funciones que nos da PHP para comparar contenidos. Es decir, habiendo desglosado anteriormente los contenidos, vamos ahora a ver cómo tratarlos.

Las funciones de comparación que nos brinda PHP

PHP nos trae las siguientes herramientas básicas:

  • similar_text: calcula el porcentaje de parecido de dos cadenas de texto.
  • levenshtein: calcula la distancia de Levenshtein entre dos cadenas de texto.
  • soundex: calcula una cadena resultado de cómo ‘suena’ la cedena. Dos cadenas escritas distintas, pero que suenan igual, tendrán la misma clave.
  • metaphone: igual que soundex, sólo que más exacto y ajustado para el habla inglesa.

Aparte de estas fuciones, podríamos comparar contenidos similares tanteando las palabras que componen los contenidos. Por ejemplo, si un porcentaje alto de palabras están en la misma cantidad, probablemente sea contenido duplicado, copiado o no es original.

Detectando finalmente duplicados

Algunas herramientas que he visto, chequean el contenido completo haciendo un hash que lo identifica. Entonces si dos páginas tienen el mismo hash, las dan como duplicadas. Pero éste método de sacar un hash por página es muy simple, porque sólo detectará páginas con exactamente el mismo hash.

La idea es sacar similitud entre contenidos por porcentajes, es decir, por porcentaje de parecidos. Esto lo podemos conseguir inicialmente muestreando las palabras anteriores y contando si dos páginas tienen las mismas palabras, o no, o mejor dicho, cuánto porcentaje de palabras se repiten en dos páginas. Por ejemplo podemos elegir que pasado un 90% de igualdad detectarlos como contenidos duplicados.

Vamos a centrarnos en el título para hacer unos experimientos..

Usando similar_text para comparar los títulos

Por ejemplo, cogiendo dos títulos del listado anterior:

$titulo1 = 'Magento 1: listar todas las alertas por vuelta a stock de producto';
$titulo2 = 'Magento 1: dejar que los clientes se suscriban a alertas de vuelta a stock';

Podemos chequear así entonces:

<?php

$titulo1 = 'Magento 1: listar todas las alertas por vuelta a stock de producto';
$titulo2 = 'Magento 1: dejar que los clientes se suscriban a alertas de vuelta a stock';

$coincidents = similar_text($titulo1, $titulo2, $percent);

echo 'Tenemos '.$coincidents.' caracteres coincidentes, con un porcentaje de similaridad de '.$percent.'%'.PHP_EOL;

..teniendo una salida en línea de comandos como la siguiente:

Tenemos 43 caracteres coincidentes, con un porcentaje de similaridad de 61.428571428571%

Cuidado con esta función porque si le cambiamos el orden así:

$coincidents = similar_text($titulo2, $titulo1, $percent);

..obtendremos el siguiente porcentaje:

Tenemos 41 caracteres coincidentes, con un porcentaje de similaridad de 58.571428571429%

Además, la función es case sensitive.

Usando la distancia de levenshtein

Podemos obtenerla añadiendo el siguiente código al script anterior:

$levenshtein = levenshtein($titulo1, $titulo2, $cost_ins = 1, $cost_rep = 1, $cost_del = 1);

echo 'Tenemos una distancia de Levenshtein entre las dos cadenas de '.$levenshtein.PHP_EOL;

Que para este experimiento nos dará 43. La distancia de Levenshtein es el número de caracteres que hay que cambiar para, partiendo de $titulo1, lleguemos al $titulo2.

Usando soundex

Para esto podemos hacer lo siguiente en PHP:

$keySoundex1 = soundex($titulo1);
$keySoundex2 = soundex($titulo2);
echo 'La clave soundex de $titulo1 es: '.$keySoundex1.PHP_EOL
    .'La clave soundex de $titulo2 es: '.$keySoundex2.PHP_EOL;

De nuevo cuidadín con éste método porque nos puede dar falso positivo fácilmente, por ejemplo ahora devuelve:

La clave soundex de $titulo1 es: M253
La clave soundex de $titulo2 es: M253

Esta función soundex devuelve un valor de 4 caracteres para cualquier cadena.

Y usando metaphone

Finalmente tenemos metaphone, que parecido a soundex, nos devuelve valores de pronunciación. Está bien adaptado a inglés, en este experimiento podemos añadir al script anterior lo siguiente:

$keyMetaphone1 = metaphone($titulo1);
$keyMetaphone2 = metaphone($titulo2);
echo 'La clave metaphone de $titulo1 es: '.$keyMetaphone1.PHP_EOL
    .'La clave metaphone de $titulo2 es: '.$keyMetaphone2.PHP_EOL;

..que nos devuelve lo siguiente:

La clave metaphone de $titulo1 es: MJNTLSTRTTSLSLRTSPRFLTSTKTPRTKT
La clave metaphone de $titulo2 es: MJNTTJRKLSKLNTSSSSKRBNLRTSTFLTSTK

Terminando, bibliografía

Finalmente, ya con los ingredientes de aquí arriba ya podemos generar un crawler que revise toda una web. Podemos ir guardando los títulos, palabras, o textos completos en arrays de contenidos. Lo ideal sería guardarlo todo con cada revisión, pero esto ya cada uno según lo que necesites. Luego podemos comparar dichos contenidos entre sí y así finalmente tendremos un buen detector de contenidos duplicados 😉

A modo de ejercicio, también podríamos así tratar de sacar las palabras clave, las más repetidas. Quizá filtrar y sacar los artículos, preposiciones, conjunciones.. sería buena práctica.

Sólo me queda remitirme a la documentación oficial:
https://www.php.net/manual/es/ref.strings.php

Y decir que he subido a Github el código fuente completo:
https://github.com/jaimenj/seo-php-detect-duplicates
..por si alguien lo necesita o quiere curiosearlo.

Otro día más.. ¡Un saludo!

Compartir..

Dejar un comentario

Tu dirección de correo electrónico no será publicada. Los campos obligatorios están marcados con *