Publi

Analiza el contenido de una web directamente desde PHP. Web scraping sencillo y mucho más

coche

El web scraping es una técnica basada en el análisis del contenido de una web para extraer información útil. El objetivo es que, una web, que generalmente está pensada para ser visualizada por un usuario sea descargada por un programa informático y automáticamente se reconozca la información que queremos sacar de ella. Podemos extraer el título de las páginas, párrafos de texto, contenido de tablas, elementos ocultos y mucho más.

Es parecido a lo que hacen los motores de búsqueda cuando entran en una página y rastrean su contenido, aunque la técnica de web scraping se centra más en la detección y clasificación de las estructuras de información. Por ejemplo, podemos entrar en páginas biográficas de Wikipedia para extraer nombres, nacionalidades, fechas de nacimiento y muerte y profesiones de diferentes personajes directamente del contenido HTML de las diferentes páginas. Para ello es muy importante entender dónde está cada pedazo de información.

Esto lo podemos hacer en una URL determinada, o hacer que nuestro sistema navegue por cientos de URLs dentro de un servidor para extraer y clasificar datos de tiendas, hoteles, agencias de viaje, seguros, películas, música… en fin, cualquier cosa que podamos clasificar y almacenar en una base de datos y que, sea por el motivo que sea, dicha clasificación no está disponible.

¿Por qué no puedo hacer las cosas mejor?

Muchas veces podemos. Muchas webs proporcionan un servicio de descarga de datos, por ejemplo AEMET proporciona información meteorológica en CSV. Podemos cientos de servicios que proporcionan exportación de datos. En otros casos encontramos APIs para diferentes servicios web que nos permiten hacer consultas y obtener dicha información en un formato adecuado y todo clasificado. Podemos ver como ejemplo algunas tiendas online (o proveedores) que facilitan el catálogo completo a los socios a través de peticiones SOAP o API REST. Estas cosas nos ahorran tener que analizar el contenido de las webs, clasificar y filtrar los datos para que estén a nuestro gusto para poder almacenarlos.

Pero en muchos casos, esto no es posible o, al menos, no de forma gratuita (en caso de que una web proporcione un sistema de pago para obtener los datos debemos consultar que dicha web no prohíba esta técnica). Y, cuando no es posible obtener los datos de una forma elegante, tenemos que hallar la manera de hacerlo con la información que tenemos a nuestra disposición. Es decir, archivos HTML de la web.

¿Puedo o no puedo hacerlo?

Muchas webs actuales están preparadas para que otros servicios entren, registren y cataloguen la información que ofrecen. Por ejemplo, a través de microdatos, es decir, proporcionan ciertas etiquetas y atributos compatibles con HTML que nos ayudan a saber qué información se está mostrando en cada campo. Esto se utiliza mucho para saber quién es el autor de un post, cuál es la imagen asociada a un contenido, su título, las votaciones de los usuarios, las valoraciones, el lugar, la fecha, el motivo de celebración de un evento, director e intérpretes de una película, etc. Es decir, los microdatos nos ayudan a obtener información clasificable de un contenido de una página web. Estos microdatos, al ser atributos HTML serán muy fáciles de rastrear por nuestro Web Scraper.

Otro caso muy importante son los datos openGraph, utilizado por muchas webs para hacer una compartición enriquecida de contenidos a través de redes sociales. Es decir, para que cuando compartas una web en Facebook, éste sepa rápidamente cuál es el título del contenido, una pequeña descripción, una foto, etc. Aunque si Facebook no encuentra esta información, la busca de otra manera, aunque tal vez sea menos efectiva (si no utilizamos en nuestra página dichas etiquetas, puede que la imagen no se detecte correctamente, o si tenemos varios títulos en la misma página no se detecte el que corresponde con el contenido).

Muchas webs, por otro lado, en las condiciones de servicio especifican que no se pueden utilizar estas técnicas para sacar información de las mismas. Este es un tema controvertido, ya que si proporcionas un servicio público, que puede ser accedido por humanos a través de máquinas, las máquinas también podrían acceder solas y clasificar la información para humanos. Aunque en la historia ha habido juicios al respecto y los han ganado las webs víctimas del rastreo (como creo que ha pasado muchas veces con páginas que han podido ser accedidas a través de la caché de buscadores…). Aunque puede que los dueños de dichas webs sean celosos con el uso que se hace de su información.

Otras páginas, puede que no lo prohíban expresamente, pero tampoco nos facilitan la tarea, y estas también podemos rastrearlas, aunque nos costará un poco. En definitiva, la información es accesible, aunque tal vez por desconocimiento de técnicas para clasificación de la información, por no considerarlo importante, o por cualquier otro motivo (tacañería de los que han encargado la web, por ejemplo), no se facilita la web de los rastreadores. Así que, intentemos hacerlo lo mejor posible para extraer información.

Unos apuntes básicos

Si vamos a explorar una sola página, no hay ningún problema, nos descargamos la web y procedemos a analizar su contenido. Pero si el motor debe iniciar una interacción con la web, tenemos que intentar hacer que el comportamiento sea lo más humano posible. Por un lado, porque muchas webs requieren que el usuario dé una serie de pasos, y acceda a una serie de sitios antes de acceder a un dato, por lo que tenemos que intentar dar todos los pasos, hacer que nuestro sistema emita todas las cabeceras HTTP pertinentes, almacene todas las cookies que se producen, etc.

Por otro lado, aunque hay algunos sistemas que están programados sobre un navegador a modo de extensión. En este caso hablaré de cómo hacer un script PHP para generar el rastreo de información. El primer método, está bien porque será capaz de ejecutar el Javascript de la página en un navegador e incluso simular mucho mejor el comportamiento humano, ya que algunas webs ejecutan muchos scripts necesarios para la visualización o la carga de información. Lo malo es que necesitamos un ordenador personal constantemente ejecutando un navegador con esa extensión y navegando por webs, y eso puede ser muy muy pesado para el sistema (no digo que no podamos hacer nada más, pero un Chrome así puede llegar a ocupar varios Gb de RAM y muchos Javascript consumen mucha CPU).
El segundo método, al estar hecho en un lenguaje de servidor puede servir para hacer escaneos rápidos de información en webs y presentarlos en nuestra página web. Tal vez, extraer pequeñas porciones de información, o comprobar el estado de ciertos servicios y mostrarlos de forma apropiada. También nos serviría para generar un catálogo de productos de una tienda (que puede tardar varias horas), y poder compararlos con los precios de otras tiendas, y todo eso dejarlo ejecutándose en segundo plano, o en otro servidor en la misma red que la web donde queremos mostrar la información. Hay muchas posibilidades.

Peticiones web

Al final estos sistemas se basan en hacer peticiones a una web mientras nuestro trabajo no haya finalizado… como si fuera un do…while(), eso sí, nuestro trabajo, si los sitios web son muy grandes pueden llevarnos muchas horas. Por un lado, las peticiones web, ya que se trata de máquinas que acceden a máquinas, podemos hacerlas de la manera más rápida posible, es más podemos pedir cientos de páginas sin parar aunque corremos un riesgo: el servidor donde se aloja la web que intentamos rastrear puede que expulse nuestra IP de forma temporal o permanente, o incluso retrase nuestras peticiones. Y esto puede arruinar nuestro trabajo. Por eso, personalmente creo que debemos introducir un retraso entre petición y petición. Además de simular un comportamiento humano, como decíamos antes, evita que la web que estamos explorando nos expulse, o se sature, aunque la cantidad de peticiones que haremos será muy grande.

Para hacer peticiones en la web podemos utilizar cURL, incluso podemos utilizar una clase como esta:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
<?php

function av($array, $key, $default=null)
{
  return (isset($array[$key]))?$array[$key]:$default;
}

class fsh
{
  protected $sleepTime;
  protected $config;
  protected $tempPath;
  protected $referer = null;
  protected $userAgent = 'MiPropioRastreador/0.2';
  protected $cookieJar = array();
  protected $lastFetch = 0;
  protected $filters = array();

  function __construct($sleepTime, array $options=array())
  {
    $this->sleepTime = $sleepTime;
    $this->tempPath = av($options, 'tempPath', 'tmp/');
    if (av($options, 'debug', true))
      define('FETCH_DEBUG', 1);
  }

  public function addFilter($function, $argument=null)
  {
    if (!is_callable($function))
      throw new Exception ('Filter function is not callable');

    if (!$argument)
      $argument = array();
    else if(!is_array($argument))
      $argument = array($argument);

    $this->filters[] = array('f' => $function, 'a'=>$argument);
  }

  protected function extractCookies($headers)
  {
    $lines = explode("\n", $headers);
    foreach ($lines as $l)
      {
    $dp = strpos($l, ':');
    if ($dp === false)
      continue;
    $part1 = trim(substr($l, 0, $dp));
    $part2 = trim(substr($l, $dp+1));
    if ($part1 == 'Set-Cookie')
      {
        $eq = strpos($part2, '=');
        if ($eq === false)
          {
        $this->cookieJar[] = $part2;
        continue;
          }

        $key = trim(substr($part2, 0, $eq));
        $val = trim(substr($part2, $eq+1));
        if (!$key)
          $this->cookieJar[] = $val;
        else
          $this->cookieJar[$key] = $val;
      }
      }
  }

  protected function getCookies()
  {
    $resv = array();

    foreach ($this->cookieJar as $key => $val)
      {
    $eval = explode(';', $val);
    if (is_numeric($key))
      $resv[] = $eval[0];
    else
      $resv[] = $key.'='.$eval[0];
      }

    if ( (defined('FETCH_DEBUG')) && (FETCH_DEBUG) )
      echo "\nCOOKIES: ".implode('; ', $resv)."\n";

    return implode('; ', $resv);
  }

  public function fetchUrl($url, $curlData = array())
  {
    $headers = array();
    $headers[] = "Accept: text/html,application/xhtml+xml,application/xml;q=0.9,*/*;q=0.8";
    $headers[] = "Connection: Keep-Alive";
    $unid = uniqid().'.html';

    if ( (defined('FETCH_DEBUG')) && (FETCH_DEBUG) )
      echo "Entrando en ".$url."... (".$unid.").";
    else
      echo "Entrando en ".$url."... ";

    $sleepSecs = $this->sleepTime / 1000;
    if (microtime(true)-$this->lastFetch < $sleepSecs)
      usleep(( $sleepSecs - (microtime(true)-$this->lastFetch) ) * 1000000);
    $try=0;
    do
      {
    if ($try>0)
      {
        echo "[reintento]";
        usleep(100000);
      }
    $curlBase = array(CURLOPT_COOKIE => $this->getCookies(),
              /* CURLOPT_COOKIEFILE => av($this->config, 'cookiejar'), */
              /* CURLOPT_COOKIEJAR => av($this->config, 'cookiejar'), */
              CURLOPT_RETURNTRANSFER => true,
              CURLOPT_USERAGENT => $this->userAgent,
              CURLOPT_FOLLOWLOCATION => true,
              CURLOPT_MAXREDIRS => 5,
              CURLOPT_VERBOSE => 0,
              CURLOPT_HEADER => 1,
              CURLOPT_HTTPHEADER => $headers,
              CURLOPT_HEADER, 0
              );
    if ($this->referer)
      $curlBase[CURLOPT_REFERER] = $this->referer;

    if ( (defined('FETCH_DEBUG')) && (FETCH_DEBUG) && av($curlBase, CURLOPT_REFERER))
      echo "Referer: ".$curlBase[CURLOPT_REFERER]."\n";

    $ch = curl_init();
    $curlopts = $curlBase + array(CURLOPT_URL => $url) + $curlData;

    curl_setopt_array($ch, $curlopts);
    $res = curl_exec($ch);
    $header_size = curl_getinfo($ch, CURLINFO_HEADER_SIZE);
    $headers = substr($res, 0, $header_size);
    $res = substr($res, $header_size);
    $this->extractCookies($headers);
    $this->referer = $url;

    if (curl_getinfo($ch, CURLINFO_HTTP_CODE)!=200)
      {
        $try++;
        continue;
      }
    if ( (defined('FETCH_DEBUG')) && (FETCH_DEBUG) )
      file_put_contents($this->tempPath.'/'.$unid, $res);

    echo "OK\n";
    $ok = true;
    curl_close($ch);
      } while ( (!$ok) ||  ($try>10) );
    if (!$ok)
      {
    echo "ERROR\n";
    file_put_contents($this->tempPath.'/error.html', $res);
    throw new Exception("La página " . $url . " devolvió un error ".curl_getinfo($ch, CURLINFO_HTTP_CODE));
      }

    $this->lastFetch = microtime(true);
    if (count($this->filters)>0)
      {
    foreach ($this->filters as $filter)
      {
        $res = call_user_func_array(av($filter, 'f'), array_merge(array($res), av($filter, 'a')));
      }
      }
    return $res;
  }

};

De esta forma, podemos crear una instancia y hacer peticiones de forma sencilla. Moviendo la web desde la que procede el usuario, enviando el agente de usuario (userAgent), y reintentando si la web presenta algún error.

1
2
3
4
5
6
<?php

$fetcher = new fsh(1000, './tmp/');
$contenidos = $fetcher->fetchUrl("http://imdb.com/");

echo $contents;

Como nota, quiero aclarar que las cookies son capturadas por funciones de PHP ya que algunas versiones de cURL, debido a un bug no capturan la cookie cuando ésta no tiene valor (es decir, sólo el nombre de la cookie… y alguna página he encontrado que lo necesita, ¿por qué? ni idea, pero si no, no me dejaba rastrearla adecuadamente y siempre redirigía a la página principal.). Por otro lado, este script, además de introducir esperas entre peticiones, nos permite almacenar los archivos HTML dentro de nuestro directorio (si hemos declarado la constante FETCH_DEBUG a un valor verdadero).

Análisis de su contenido

Está muy bien que podamos extraer el HTML de las páginas que rastreamos, pero ahora toca meternos en su contenido y extraer información. Para eso existe una biblioteca para PHP muy buena llamada SimpleHTMLDom que, aunque ya tiene algunos años sigue funcionando muy bien. Esta biblioteca es capaz de encontrar elementos gracias a selectores dentro del contenido de la página. Básicamente:

  • etiqueta
  • .clase
  • #id
  • [atributo]

Pudiendo combinar todos estos para encontrar exactamente un elemento dentro de la web. Imaginemos que queremos encontrar un div de clase username dentro de un li de clase user dentro de un ul con id navbar, debemos utilizar: «ul#navbar li.user div.username». Por supuesto podríamos haber buscado directamente .username aunque se buscaría por todo el esquema de la web elementos con clase .username (no sólo divs, y no tienen por qué estar dentro de user), lo mismo pasaría con .user, tal vez haya algo más de la misma clase dentro de #navbar aunque tanto por especificidad como por rendimiento viene bien utilizar la ruta lo más completa posible.

Y utilizar esta biblioteca es muy sencillo (aunque la misma te permite hacer peticiones HTTP, lo hará con file_get_contents() por lo que muchas cosas escaparían de nuestro control. Creo que mejor con cURL o cualquier otra biblioteca). Sólo tenemos que cargar el texto del HTML dentro, y la misma se encargará de procesar la estructura de la página incluso si ésta es defectuosa (incluye etiquetas que no están cerradas correctamente, por ejemplo), además de muchos otros elementos como detectar el juego de caracteres que resultarán de gran ayuda.

Tras ello, sólo tenemos que utilizar el método find() que nos devolverá objetos tipo simple_html_dom_node con información sobre los nodos seleccionados o un array de objetos de este tipo si el selector ha encontrado varios objetos coincidentes. Podemos utilizar este ejemplo:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
<?php
require_once('simple_html_dom.php');
require_once('fsh.php');

function analiza_tabla(&$tabla, $cabecera)
{
  if (!$tabla)
    return '';
  if (is_array($tabla))
    return analiza_tabla($tabla[0], $cabecera); /* Si hay varias tablas, analiza la primera */

  foreach ($tabla->find('th') as $enc)
    {
      if (trim($enc->plaintext) == $cabecera)
    {
      $seldato = $enc->next_sibling();
      /* El dato estará en el siguiente objeto */
      return $seldato->plaintext;
    }
    }
  return '';
}

$f = new fsh(1000);
$contenido = $f->fetchUrl('https://es.wikipedia.org/wiki/Albert_Einstein');

$nodos = str_get_html($contenido);

$_nombre = $nodos->find('h1#firstHeading', 0);
$infobox = $nodos->find('table.infobox', 0);

$nombre = ($_nombre)?$_nombre->plaintext:'No encontrado';

$_nacimiento = analiza_tabla($infobox, 'Nacimiento');
$_muerte = analiza_tabla($infobox, 'Fallecimiento');
$_nacionalidad = analiza_tabla($infobox, 'Nacionalidad');

/* Sabemos que el nacimiento pueden ser dos líneas */
$nacimiento = explode("\n", $_nacimiento); /* Dividimos las líneas */
$nacimiento = array_map('trim', $nacimiento);        /* Eliminamos espacios al principio y al final
                            de cada elemento. */


/* Repetimos con el fallecimiento */
$muerte = explode("\n", $_muerte);
$muerte = array_map('trim', $muerte);

/* Las nacionalidades las separamos por comas */
$__nacionalidad = explode("\n", $_nacionalidad);
$__nacionalidad = array_map('trim', $__nacionalidad);
$nacionalidad = implode(', ', $__nacionalidad);

echo "Nombre: ".$nombre."\n";
echo "Nacimiento: ".av($nacimiento, 0)."(".av($nacimiento, 1)."\n";
echo "Muerte: ".av($muerte, 0)." en ".av($muerte, 1)."\n";
echo "Nacionalidad: ".$nacionalidad."\n";

Y mucho más sencillo este ejemplo con microdatos:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
<?php
require_once('simplehtml/simple_html_dom.php');
require_once('efe.php');

$f = new fsh(1000);
$contenido = $f->fetchUrl('http://www.imdb.com/title/tt0470752/');

$nodos = str_get_html($contenido);

$titulo = $nodos->find('[itemprop="name"]', 0);
$duracion = $nodos->find('[itemprop="duration"]', 0);
$director = $nodos->find('[itemprop="director"] [itemprop="name"]', 0);

echo "Titulo: ".trim(($titulo)?$titulo->plaintext:'No encontrado')."\n";
echo "Duración: ".trim(($duracion)?$duracion->plaintext:'No encontrado')."\n";
echo "Director: ".trim(($director)?$director->plaintext:'No encontrado')."\n";

En el segundo ejemplo vemos cómo los datos se encuentran fácilmente con los microdatos, ya que la propia página facilita esa información dentro de la estructura del HTML. Aunque en la primera, tenemos que tener en cuenta que un mínimo cambio en el diseño de la web, puede hacer que dejemos de encontrar contenidos en la misma, por lo que tenemos que tener cuidado. Lo malo en este método es que si la web tiene cierto comportamiento en Javascript que necesite ser replicado para extraer la información, tendremos que programarlo aparte, aunque no es muy común verlo.

Foto principal : Keith Wickramasekara

También podría interesarte....

There are 8 comments left Ir a comentario

  1. Pingback: Analiza el contenido de una web directamente desde PHP. Web scrapping sencillo y mucho más | PlanetaLibre /

  2. irradiah.com /
    Usando Mozilla Firefox Mozilla Firefox 61.0 en Mac OS X Mac OS X 10

    Para comenzar file_get_contents es muy bueno, luego CURL es lo más recomendado

    1. Gaspar Fernández / Post Author
      Usando Mozilla Firefox Mozilla Firefox 61.0 en Ubuntu Linux Ubuntu Linux

      Cierto, file_get_contents() muchas veces nos puede salvar la vida, pero como siempre, las funciones fáciles y rápidas nos hacen perder algo de control.

  3. Pedro Caro /
    Usando Google Chrome Google Chrome 76.0.3809.132 en Windows Windows 7

    Catchable fatal error: Argument 2 passed to fsh::__construct() must be of the type array, string given, called in E:\wamp64\www\scraping\imdb.php on line 3 and defined in E:\wamp64\www\scraping\curl_class.php on line 19

    El constructor pide un array como parámetro y se le envía un string, que se debe enviar ?
    Probaste el ejemplo …?
    Saludos

  4. doodle jump /
    Usando Google Chrome Google Chrome 120.0.0.0 en Windows Windows NT

    ¿El constructor solicita un conjunto como parámetro y recibe un string, que se debe enviar?

  5. Usando Google Chrome Google Chrome 120.0.0.0 en Windows Windows NT

    I found this post very interesting and informative. Thank you for sharing your special thoughts with us. My site: Philippine Boxing

  6. mapquest driving directions /
    Usando Google Chrome Google Chrome 122.0.0.0 en Windows Windows NT

    The suggestions you mentioned above are impressive. Although the topic may not be new, the article presents reasonable and modern arguments

  7. word wipe /
    Usando Google Chrome Google Chrome 122.0.0.0 en Windows Windows NT

    What a dazzling blog! Taking the time to read your writings was relaxing. For me, this was a book that I found to be quite enjoyable. I have it bookmarked, and I am looking forward to reading more of what it has to offer. Never stop making such a wonderful effort!

Leave a Reply to irradiah.com Cancle Reply