Extrayendo datos estructurados de las páginas HTML usando los selectores CSS

7 junio 2019, 08:59
Stanislav Korotky
0
192

El entorno de desarrollo MetaTrader permite integrar los programas y los datos externos, en particular, obtenidos de la Internet vía WebRequest. El formato de datos más universal y más usado en la red es HTML. Cuando un servicio público no proporciona una API abierta para las solicitudes o su protocolo es difícil de implementar en MQL, se usa el análisis sintáctico (parsing) de las páginas HTML. En particular, diferentes calendarios económicos son muy populares entre los traders, y aunque con la aparición del calendario incorporado eso no es tan importante, algunos pueden necesitar las noticias de una web específica. Además, a menudo surge la necesidad de analizar las transacciones en el informe HTML comercial obtenido de los terceros.

En el ecosistema MQL5, se puede encontrar varias soluciones de este problema, pero generalmente son específicas y tienen sus limitaciones. Al mismo tiempo, existe en cierto sentido una manera «nativa» y universal de buscar y extraer los datos desde HTML. Se trata de los selectores CSS. En este artículo, veremos su implementación en MQL y mostraremos los ejemplos del uso práctico.

Para analizar HTML, es necesario crear un analizador sintáctico (parser) que convierta el texto interno de la página en una jerarquía de algunos objetos llamados objetos DOM (Document Object Model), y luego, en esta jerarquía, se puede encontrar los objetos con parámetros especificados. Este enfoque utiliza una información auxiliar sobre la estructura del documento, que no está disponible en representación externa.

De esta manera, se puede, por ejemplo, seleccionar las filas de una tabla especificada en el documento, leer las columnas específicas desde ellas y obtener un array con valores que podemos guardar fácilmente en un archivo csv, mostrar en el gráfico o usar en los cálculos del EA.


Visión general de las tecnologías HTML/CSS y DOM

Hoy en día es difícil de encontrar a una persona que no sepa que es HTML. Por esta razón, no tiene sentido describir detalladamente la sintaxis de este lenguaje del marcado del hipertexto.

En este campo, la fuente originaria de la información técnica es IETF (Grupo de Trabajo de Ingeniería de Internet) y sus especificaciones RFC (Request For Comments). En particular, HTML tiene sus propias especificaciones, y son muchas (aquí tiene un ejemplo de una de ellas). Se puede encontrar los patrones en la web de W3C (World Wide Web Consortium, HTML5.2).

Estas mismas organizaciones han desarrollado y regulan la tecnología CSS (Cascading Style Sheets). Ella nos interesa no tanto por la posibilidad de describir los estilos de la presentación de la información en las páginas web, sino porque en ella fueron creados los selectores CSS, es decir, un lenguaje especial de las solicitudes para buscar los elementos correspondientes dentro de las páginas web.

HTML y CSS están en un constante desarrollo, acumulando una versión tras otra. Por ejemplo, HTML5.2 y CSS4 son más relevantes en este momento. La actualización y la expansión de las posibilidades se acompañan con la sucesión de las particularidades de las versiones antiguas, ya que la Internet es muy grande, heterogéneo e inerte (incluso si alguien cambia el original del documento, seguramente habrán copias anteriores). Como resultado, al escribir los algoritmos usando las tecnologías web, se debe observar las especificaciones de una manera bastante creativa con el fin de, por un lado, tomar en cuenta posibles desviaciones establecidas, y por otro lado, introducir simplificaciones intencionalmente para no ahogarse en las múltiples variaciones.

En nuestro proyecto, también consideraremos la sintaxis HTML en una forma simplificada.

Como se sabe, un documento html se compone de las etiquetas encerradas entre los símbolos '<' y '>'. Dentro de la etiqueta se indica su nombre y los atributos opcionales (pares de string tipo nombre=«valor», siendo que el signo igual y el valor pueden omitirse). Por ejemplo:

<a href="https://www.w3.org/standards/webdesign/htmlcss" target="_blank">HTML and CSS</a>

Es la etiqueta con el nombre 'a' (interpretado por los navegadores como un enlace) y dos parámetros: href es la dirección para seguir el enlace y target es la opción de la apertura de la web (en este caso, "_blank", es decir, en una nueva ventana del navegador).

La primera etiqueta se llama la etiqueta de apertura. Ella es seguida por el texto, o sea, por el contenido realmente visible "HTML and CSS", y en seguida va la etiqueta del cierre que debe tener el mismo nombre que la de apertura pero marcada con el símbolo '/' después de '<' (en total será '</a>'). En otras palabras, las etiquetas de apertura y de cierre van en pares y pueden incluir otras etiquetas, sin sobreposición. Es un ejemplo de un uso correcto:

<group attribute1="value1">

  <name>text1</name>

  <name>text2</name>

</group>

Mientras que este «cruzamiento» de las etiquetas está prohibido:

<group id="id1">

<name>text1

</group>

</name>

Pero la prohibición es sólo teórica. En la práctica, las etiquetas a menudo se abren y se cierran por error en un lugar incorrecto del documento. El analizador sintáctico tiene que ser capaz de solucionar esta situación.

Se admite la ausencia del contenido dentro de la etiqueta, es decir, una línea vacía:

<p></p>

De acuerdo con los patrones, algunas etiquetas no deben tener contenido. Por ejemplo, la etiqueta que describe una imagen:

<img src="/ico20190101.jpg">

parece de apertura, pero no tiene de cierre. Estas etiquetas se llaman vacías. Obsérvese que aunque los atributos corresponden a la etiqueta, pero no son su contenido.

No siempre es fácil de determinar que una etiqueta está vacía o se debe esperar su cierre a continuación del texto del documento. A pesar de que los nombres de las etiquetas vacías válidas están determinados en las especificaciones, hay casos cuando otras etiquetas también quedan sin cerrar. Además, debido al parentesco de HTML con XML (y la existencia de una variedad como XHTML), algunos compaginadores de las páginas web formatean las etiquetas vacías de la siguiente manera:

<img src="/ico20190101.jpg" />

Nótese que la barra '/' antes '>' es sobrante desde el punto de vista de HTML5 estricto. Pero todas estas particularidades están presentes en las páginas web reales, y por eso, tenemos que estar preparados para ellas en el parser HTML.

En principio, los nombres de las etiquetas y atributos que se interpretan por los navegadores están estandartizados, pero HTML puede contener también los elementos personalizados que se omiten por los navegadores si el desarrollador de la página no los «ha vinculado» a DOM a través de una API especial. Pero en cualquier caso, todas las etiquetas pueden contener alguna información útil.

El analizador sintáctico puede ser considerado como una máquina con el número finito de los estados que va avanzando por el texto letra por letra y cambiando sus estado según el contexto. En particular, desde la descripción breve de la estructura de las etiquetas arriba mencionada, queda claro que inicialmente el parser se encuentra fuera de alguna etiqueta (llamaremos este estado "blank"). Luego, si aparece el símbolo '<', estaremos en la etiqueta de apertura (estado "insideTagOpen") hasta que aparezca '>'. Si aparece la secuencia '</', estaremos en la etiqueta de cierre (estado "insideTagClose"), etc. Hablaremos de otros estados en la sección de la implementación del parser.

Durante la transición entre los estados, podemos seleccionar la información estructurada a partir de la localización actual en el documento, puesto que sabemos el significado del estado. Por ejemplo, al estar en la etiqueta de apertura, se puede seleccionar su nombre como una cadena entre el último '<' y el siguiente espacio o '>' (dependiendo de la presencia de los atributos). A base de los datos obtenidos, el parser va a crear los objetos de una determinada clase DomElement. Además del nombre, atributos y el contenido, estos objetos van a formar una jerarquía repitiendo la estructura del anexado de las etiquetas. En otras palabras, cada objeto tendrá su padre (excepto el elemento raíz que describe el documento entero) y un array opcional de objetos hijos.

En la salida del parser, obtendremos el árbol completo de objetos en el cual un objeto corresponde a una etiqueta en el documento original.

Los selectores CSS describen las notaciones estándar para la selección condicional de objetos a base de sus parámetros y colocación mutua en la jerarquía. La lista completa de los selectores es bastante extensa. Habilitamos sólo una parte de ellos incluida en los patrones CSS1, CSS2 y CSS3.

Aquí está la lista de los componentes principales de los selectores:

  • * - cualquier objeto (selector universal);
  • .value — objeto con el atributo 'class' con el valor "value"; ejmplo: <div class="example"></div>; selector adecuado: .example;
  • #id — objeto con el atributo 'id' con el valor "value"; para la etiqueta <div id="unique"></div> se dispara el selector: #unique;
  • tag — objeto con el nombre 'tag'; para encontrar todos los 'div', como los mencionados encima o <div>text</div>, usamos el selector: div;
Pueden ser completados a la derecha con pseudo clases:

  • :first-child — el objeto es el primer hijo dentro del padre;
  • :last-child — el objeto es el último hijo dentro del padre;
  • :nth-child(n) — el objeto va con el número especificado en la lista de nodos hijos de su padre;
  • :nth-last-child(n) — el objeto va con el número especificado en la lista de nodos hijos de su padre, en caso de la numeración inversa;

Finalmente, se puede completar un selector único con una condición para sus atributos:
  • [attr] — el objeto tiene un atributo 'attr' (no importa el valor que tiene y sis este valor existe);
  • [attr=value] — el objeto tiene un atributo 'attr' con el valor 'value';
  • [attr*=text] — el objeto tiene un atributo 'attr' con el valor que contiene la subcadena 'text';
  • [attr^=start] — el objeto tiene un atributo 'attr' con el valor que empieza con la cadena 'start';
  • [attr$=end] — el objeto tiene un atributo 'attr' con el valor que termina con la subcadena 'end';

Si hace falta, se permite especificar varios pares de corchetes con atributos diferentes.

El selector simple es un selector del nombre o un selector universal, opcionalmente seguido por una clase, identificador, cero o más atributos o una pseudo clase en cualquier orden. El selector simple selecciona un elemento cuando todos los componentes del selector coinciden con las propiedades del elemento.

El selector CSS (o selector completo) es una cadena compuesta por uno o más selectores simples unidos por caracteres combinadores (' ' (espacio), '>', '+', '~'):
  • container element — el objeto 'element' está incorporado en el objeto 'container' al nivel aleatorio;
  • parent > element — objeto 'element' tiene un padre directo 'parent' (el nivel del anexado es 1);
  • e1 + element — objeto 'element' tiene un padre común con 'e1' y le sigue inmediatamente;
  • e1 ~ element — objeto 'element' tiene un padre común con 'e1' y le sigue a cualquier distancia;

Hasta ahora, estábamos estudiando la pura teoría. Vamos a ver cómo funciona en la práctica.

Se puede ver HTML de la página actual abierta en cualquier navegador moderno. Por ejemplo, en Chrome basta con ejecutar el comando View page source del menú contextual o abrir la ventana del desarrollador (Developer tools, Ctrl+Shift+I). En la ventana del desarrollador hay pestaña Console, en la cual se puede intentar buscar los elementos usando selectores CSS. Para aplicar el selector basta con llamar a la función document.querySelectorAll en la consola (se incluye en API de todos los navegadores).

Por ejemplo, en la página inicial de los foros https://www.mql5.com/es/forum se puede ejecutar el comando (código JavaScript):

document.querySelectorAll("div.widgetHeader")

Obtenemos la lista de los elementos (etiquetas) 'div', que tienen la clase "widgetHeader". Desde luego, no he elegido este seleccionador aleatoriamente, sino después de repasar el código fuente de la página desde el cual se hace claro que los nombres de los foros tienen este estilo.

Si expandimos el selector de la siguiente manera:

document.querySelectorAll("div.widgetHeader a:first-child")

Obtendremos la lista de los encabezados de los foros, están formateados como enlaces 'a' que son los primeros elementos hijos en cada bloque 'div' seleccionado en la primera etapa. Veamos como se ve eso (depende de la versión del navegador):

Página de MQL5 y resultado de la selección de sus elementos HTML usando selectores CSS

Página de MQL5 y resultado de la selección de sus elementos HTML usando selectores CSS

Naturalmente, en vida real, es necesario analizar de la misma manera el código HTML de otras páginas web, localizar los elementos que representan interés y descubrir los selectores CSS para ellos. En la ventana del desarrollador hay pestaña Elements (o con el nombre parecido) donde se puede seleccionar cualquier etiqueta en el documento (se resalta en la página) y ver los selectores CSS para ella. Eso permitirá aprender gradualmente los selectores y luego componer las cadenas manualmente. Más tarde mostraremos un ejemplo de cómo escoger los selectores de acuerdo con una página web especificada.


Diseño

Consideraremos a nivel global qué clases vamos a necesitar. Atribuimos el procesamiento inicial del texto HTML a la clase HtmlParser. Va a escanear el texto buscando los símbolos del marcado '<', '/', '>' y algunos otros, y según las reglas de la máquina descritas en la sección anterior, va a crear los objetos de la clase DomElement: uno para cada etiqueta vacía o un par de las etiquetas de apertura y de cierre. Dentro de la etiqueta de apertura, pueden aparecer los atributos que también tienen que ser leídos y guardados en el objeto actual DomElement (la clase AttributesParser se encarga de ello). También va a trabajar según el principio de la máquina con el número finito de estados.

Los objetos DomElement van a crearse por el parser considerando la jerarquía que repite el anexado de las etiquetas. Por ejemplo, si el texto contiene la etiqueta 'div' dentro de la cual se encuentra varios párrafos, es decir, las etiquetas 'p', entonces serán convertidos en los objetos hijos para el objeto que describe 'div'.

El objeto raíz inicial va a contener el documento completo. Por la analogía con el navegador (que representa el método document.querySelectorAll), en la clase DomElement, vamos a prever un método para solicitar objetos correspondientes a los traspasados a los selectores CSS. Además, habrá que analizar previamente los selectores y convertirlos del formato string en los objetos: el único componente del selector se guarda en la clase SubSelector, y el selector simple enteramente, en la clase SubSelectorArray.

Cuando vamos a tener un árbol DOM hecho en la salida del parser, podremos solicitar al objeto raíz DomElement (o a cualquier otro) todos sus elementos subordinados que corresponden a los parámetros de los selectores. Todos los elementos seleccionados serán desplazados en la lista iterable DomIterator. Para simplificar, los vamos a implementar como un heredero de DomElement en el que el array de nodos hijos se usa como repositorio de elementos encontrados.

Las configuraciones con las reglas del procesamiento de unos determinados sitios web o archivos HTML, así como los resultados del trabajo del algoritmo pueden guardarse convenientemente en la clase que combina las propiedades del mapa (map, es decir, proporciona el acceso a los valores por los nombres de los atributos correspondientes), así como del array (es decir, el acceso a los elementos por el índice). Seleccionamos el nombre IndexMap para esta clase.

Prevemos la posibilidad de colocar IndexMap uno dentro de otro: en particular, al recopilar los datos de la tabla de las páginas web, se forma una lista de líneas cada una de las cuales contiene una lista de columnas. Según ambas dimensiones, podemos guardar los nombres de los elementos originales. Eso es especialmente útil cuando faltan algunos elementos buscados en el documento web (eso ocurre con frecuencia), y entonces, la indexación simple por orden omite la información importante sobre los datos que faltan. Como un bono, «enseñaremos» a IndexMap a serializarse en un texto multilíneas incluyendo el formato CSV. Eso será útil durante la conversión de las páginas HTML en los datos de tabla. Si desea, puede reemplazar la clase IndexMap por la suya sin perder la funcionalidad principal.

Así se muestran las clases en el diagrama UML:

Diagrama UML de las clases que implementan los selectores CSS en MQL

Diagrama UML de las clases que implementan los selectores CSS en MQL



Implementación

HtmlParser

En la clase HtmlParser, describimos las variables que serán necesarias para escanear el texto original y generar el árbol de objetos, así como para organizar el algoritmo del autómata.

La posición actual en el texto se guarda en la variable offset. La raíz del árbol resultante y el objeto actual (en el contexto del cual se ejecuta el escaneo) se representan por los punteros root y cursor. Consideraremos su tipo DomElement más tarde. Cargaremos la lista de las etiquetas que pueden estar vacíos según la especificación de HTML en el mapa empties (su inicialización se realiza en el constructor que se mostrará a continuación). Finalmente, para la descripción de los estados del autómata, prevemos la variable state que es una enumeración tipo StateBit.

enum StateBit
{
  blank,
  insideTagOpen,
  insideTagClose,
  insideComment,
  insideScript
};

class HtmlParser
{
  private:

    StateBit state;
    
    int offset;
    DomElement *root;
    DomElement *cursor;
    IndexMap empties;
    ...

La enumeración StateBit tiene los elementos que describen el siguiente estado del parser, dependiendo de la posición actual en el texto:

  • blank — fuera de la etiqueta;
  • insideTagOpen — dentro de la etiqueta de apertura;
  • insideTagOpen — dentro de la etiqueta de apertura;
  • insideComment — dentro del comentario (el comentario en el código HTML se encierra entre las etiquetas tipo <!-- comentario -->); mientras que el parser se encuentra dentro del comentario, los objetos no se generan, sin importar que etiquetas se encuentran ahí;
  • insideScript — dentro del script; este estado debe ser resaltado porque en el código javascript también a menudo aparecen las subcadenas que se interpretan como las etiquetas HTML, pero no son elementos de DOM, sino forman parte del script);

Además de eso, en el parser, describiremos las cadenas constantes que serán usados para buscar el marcado:

    const string TAG_OPEN_START;
    const string TAG_OPEN_STOP;
    const string TAG_OPENCLOSE_STOP;
    const string TAG_CLOSE_START;
    const string TAG_CLOSE_STOP;
    const string COMMENT_START;
    const string COMMENT_STOP;
    const string SCRIPT_STOP;

El constructor del parser inicializa directamente todas estas variables:

  public:
    HtmlParser():
      TAG_OPEN_START("<"),
      TAG_OPEN_STOP(">"),
      TAG_OPENCLOSE_STOP("/>"),
      TAG_CLOSE_START("</"),
      TAG_CLOSE_STOP(">"),
      COMMENT_START("<!--"),
      COMMENT_STOP("-->"),
      SCRIPT_STOP("/script>"),
      state(blank)
    {
      for(int i = 0; i < ArraySize(empty_tags); i++)
      {
        empties.set(empty_tags[i]);
      }
    }

Aquí, se usa el array de cadenas empty_tags que se conecta previamente desde un archivo de texto externo:

string empty_tags[] =
{
  #include <empty_strings.h>
};

Este es su contenido (las etiquetas vacías válidas, pero la lista no es exhaustiva):

//  header
"isindex",
"base",
"meta",
"link",
"nextid",
"range",
// body
"img",
"br",
"hr",
"frame",
"wbr",
"basefont",
"spacer",
"area",
"param",
"keygen",
"col",
"limittext"

No olvidemos eliminar el árbol DOM en el destructor del parser:

    ~HtmlParser()
    {
      if(root != NULL)
      {
        delete root;
      }
    }

El método parse ejecuta el trabajo principal:

    DomElement *parse(const string &html)
    {
      if(root != NULL)
      {
        delete root;
      }
      root = new DomElement("root");
      cursor = root;
      offset = 0;
      
      while(processText(html));
      
      return root;
    }

A la entrada, se pasa el texto de la página web, se crea DomElement vacío de raíz, el cursor se coloca en él, y la posición actual en el texto (offset) se coloca al principio. Luego, se invoca cíclicamente el método auxiliar processText hasta que no sea leído el texto completo con éxito. Ahí entra en acción el «autómata» que por defecto se encuentra en el estado blank.

    bool processText(const string &html)
    {
      int p;
      if(state == blank)
      {
        p = StringFind(html, "<", offset);
        if(p == -1) // no more tags
        {
          return(false);
        }
        else if(p > 0)
        {
          if(p > offset)
          {
            string text = StringSubstr(html, offset, p - offset);
            StringTrimLeft(text);
            StringTrimRight(text);
            StringReplace(text, "&nbsp;", "");
            if(StringLen(text) > 0)
            {
              cursor.setText(text);
            }
          }
        }
        
        offset = p;
        
        if(IsString(html, COMMENT_START)) state = insideComment;
        else
        if(IsString(html, TAG_CLOSE_START)) state = insideTagClose;
        else
        if(IsString(html, TAG_OPEN_START)) state = insideTagOpen;
        
        return(true);
      }

El algoritmo busca el símbolo '<' en el texto, y si lo encuentra, entonces, no hay más etiquetas, eso significa el fin del procesamiento (para eso, se devuelve false). Si el símbolo ha sido encontrado y hay un fragmento del texto entre la etiqueta nueva encontrada y la posición anterior (offset), este fragmento es el contenido de la etiqueta actual (este objeto está disponible por el puntero cursor), por eso, el texto se añade al objeto a través de la llamada a cursor.setText().

Luego, la posición en el texto se mueve al principio de la nueva etiqueta encontrada, y dependiendo de la signatura que va después de '<' (COMMENT_START, TAG_CLOSE_START, TAG_OPEN_START), el parser se conmuta al nuevo estado correspondiente. La función IsString es un método auxiliar pequeño de comparación de cadenas que utiliza StringSubstr.

En cualquier caso, el método processText devuelve true, lo que significa que el método será llamado inmediatamente de nuevo en el ciclo (recordamos el método de la llamada parse), pero ahora el parser estará en otro estado. Si se encuentra en la etiqueta de apertura, se activa el siguiente código.

      else
      if(state == insideTagOpen)
      {
        offset++;
        int pspace = StringFind(html, " ", offset);
        int pright = StringFind(html, ">", offset);
        p = MathMin(pspace, pright);
        if(p == -1)
        {
          p = MathMax(pspace, pright);
        }
        
        if(p == -1 || pright == -1) // no tag closing
        {
          return(false);
        }

Si en el texto no ha sido encontrado el espacio ni el símbolo '>', la sintaxis de HTML ha sido infringida y devolvemos false. La esencia de lo que ocurre a continuación consiste en la selección del nombre de la etiqueta.

        if(pspace > pright)
        {
          pspace = -1; // outer space, disregard
        }

        bool selfclose = false;
        if(IsString(html, TAG_OPENCLOSE_STOP, pright - StringLen(TAG_OPENCLOSE_STOP) + 1))
        {
          selfclose = true;
          if(p == pright) p--;
          pright--;
        }
        
        string name = StringSubstr(html, offset, p - offset);
        
        StringToLower(name);
        StringTrimRight(name);
        DomElement *e = new DomElement(cursor, name);

Aquí, hemos creado un nuevo objeto con el nombre detectado, además, el objeto actual (cursor) se usa como nodo padre para él.

Ahora, hay que procesar los atributos, si existen.

        if(pspace != -1)
        {
          string txt;
          if(pright - pspace > 1)
          {
            txt = StringSubstr(html, pspace + 1, pright - (pspace + 1));
            e.parseAttributes(txt);
          }
        }

El método parseAttributes «vive» directamente en la clase DomElement, que será considerado más tarde.

Si la etiqueta no está cerrada, hay que comprobar si pertenece a las que pueden estar vacías. En este caso, hay que «cerrarla» implícitamente.

        bool softSelfClose = false;
        if(!selfclose)
        {
          if(empties.isKeyExisting(name))
          {
            selfclose = true;
            softSelfClose = true;
          }
        }

Dependiendo de que si la etiqueta está cerrada o no, nos movemos «en la profundidad» a través de la jerarquía de los objetos haciendo que el objeto recién creado (e) sea actual, o permanecemos en el contexto del objeto anterior. En cualquier caso, la posición en el texto (offset) se mueve al último carácter leído, es decir después de '>'.

        pright++;
        if(!selfclose)
        {
          cursor = e;
        }
        else
        {
          if(!softSelfClose) pright++;
        }
        
        offset = pright;

Un caso especial es el script. Si encontramos la etiqueta <script>, el parser pasa al estado insideScript, en caso contrario, al estado ya conocido blank.

        if((name == "script") && !selfclose)
        {
          state = insideScript;
        }
        else
        {
          state = blank;
        }
        
        return(true);
        
      }

En el estado de la etiqueta de cierre, se acciona el siguiente código.

      else
      if(state == insideTagClose)
      {
        offset += StringLen(TAG_CLOSE_START);
        p = StringFind(html, ">", offset);
        if(p == -1)
        {
          return(false);
        }

Nuevamente, buscamos '>' que necesariamente debe estar de acuerdo con la sintaxis HTML, si no hay, interrumpimos el proceso. En caso del éxito, seleccionamos el nombre de la etiqueta. Es necesario para comprobar si la etiqueta de cierre corresponde a la de apertura. Si no corresponden, es necesario «tragar» este error de manera astuta e intentar continuar analizando.

        string tag = StringSubstr(html, offset, p - offset);
        StringToLower(tag);
        
        DomElement *rewind = cursor;
        
        while(StringCompare(cursor.getName(), tag) != 0)
        {
          string previous = cursor.getName();
          cursor = cursor.getParent();
          if(cursor == NULL)
          {
            // orphan closing tag
            cursor = rewind;
            state = blank;
            offset = p + 1;
            return(true);
          }
        }

Puesto que procesamos la etiqueta de cierre, entonces, el contexto del objeto actual ha finalizado, y el parser vuelve a DomElement padre:

        cursor = cursor.getParent();
        if(cursor == NULL) return(false);
        
        state = blank;
        offset = p + 1;
        
        return(true);
      }

En caso del éxito, el estado del parser vuelve a ser igual a blank.

Cuando el parser está dentro del comentario, obviamente busca el final del comentario.

      else
      if(state == insideComment)
      {
        offset += StringLen(COMMENT_START);
        p = StringFind(html, COMMENT_STOP, offset);
        if(p == -1)
        {
          return(false);
        }
        
        offset = p + StringLen(COMMENT_STOP);
        state = blank;
        
        return(true);
      }

Cuando el parser está dentro del script, busca el final del script.

      else
      if(state == insideScript)
      {
        p = StringFind(html, SCRIPT_STOP, offset);
        if(p == -1)
        {
          return(false);
        }
        
        offset = p + StringLen(SCRIPT_STOP);
        state = blank;
        
        cursor = cursor.getParent();
        if(cursor == NULL) return(false);
        
        return(true);
      }
      return(false);
    }

Pues, es toda la clase HtmlParser. Ahora, vamos a conocer DomElement.


DomElement, inicio

La clase DomElement tiene las variables para almacenar el nombre (obligatoriamente), contenido, atributos, referencia al padre y el array de los elementos hijos (es protegido protected, porque va a usarse en la clase derivada DomIterator).

class DomElement
{
  private:
    string name;
    string content;
    IndexMap attributes;
    DomElement *parent;

  protected:
    DomElement *children[];

El conjunto de constructores difícilmente requiere explicaciones:

  public:
    DomElement(): parent(NULL) {}
    DomElement(const string n): parent(NULL)
    {
      name = n;
    }

    DomElement(DomElement *p, const string &n, const string text = "")
    {
      p.addChild(&this);
      parent = p;
      name = n;
      if(text != "") content = text;
    }

Está claro que en la clase hay métodos "setter" y "getter" de los campos (se omiten en el artículo), así como, un conjunto de los métodos para trabajar con los elementos hijos (damos sólo los prototipos):

    void addChild(DomElement *child)
    int getChildrenCount() const;
    DomElement *getChild(const int i) const;
    void addChildren(DomElement *p)
    int getChildIndex(DomElement *e) const;

El método parseAttributes utilizado anteriormente en la fase del análisis delega todo el trabajo a la clase auxiliar AttributesParser.

    void parseAttributes(const string &data)
    {
      AttributesParser p;
      p.parseAll(data, attributes);
    }

Recibiendo una cadena simple data en la entrada, el método rellena el mapa attributes con propiedades encontradas.

El código completo de la clase AttributesParser se encuentra en los archivos adjuntos. La clase en sí no es grande y funciona según el principio parecido de autómata que HtmlParser, pero hay sólo dos estados:

enum AttrBit
{
  name,
  value
};

Puesto que la lista de los atributos es una cadena que se compone de los pares tipo name="value", AttributesParser siempre se encuentra en el nombre o en el valor. Se podría implementar este parser a través de la función StringSplit, pero debido a las desviaciones potenciales en el formateo (por ejemplo, la presencia o la falta de las llaves, uso de los espacios dentro las llaves, etc.), ha sido elegido el enfoque de autómata.

Volviendo a la clase DomElement, notamos que la parte principal del trabajo debes ser realizada por los métodos para seleccionar los elementos hijos correspondientes a los selectores CSS especificados. Pero antes de proceder a la consideración de esta funcionalidad, hay que describir las clases de los selectores.

SubSelector y SubSelectorArray

La clase SubSelector describe un componente de un selector simple. Por ejemplo, en el selector simple "td[align=left][width=325]" hay tres componentes:

  • nombre de la etiqueta - td
  • condición para el atributo align - [align=left]
  • condición para el atributo width - [width=325]
En el selector simple "td:first-child" hay dos componentes:
  • nombre de la etiqueta - td
  • condición para el índice hijo usando la pseudo clase - :first-child
El selector simple "span.main[id^=calendarTip]" hay tres otra vez:
  • nombre de la etiqueta - span
  • clase — main
  • el atributo id debe empezar con la cadena calendarTip

Aquí está la clase:

class SubSelector
{
  enum PseudoClassModifier
  {
    none,
    firstChild,
    lastChild,
    nthChild,
    nthLastChild
  };
  
  public:
    ushort type;
    string value;
    PseudoClassModifier modifier;
    string param;
};

La variable type contiene el primer símbolo del selector ('.', '#', “[“), o 0 por defecto, lo que corresponde al selector del nombre. La variable value guarda la subcadena después del carácter, es decir, lo buscado realmente. Si una pseudo clase ha sido encontrada en la cadena del selector, su identificador se escribe en el campo modifier. Finalmente, al describir los selectores ":nth-child" y ":nth-last-child", el índice del elemento buscado se indica entre paréntesis, lo vamos a guardar en el campo param (en la implementación actual, eso puede ser sólo un número, pero, en principio, se admiten unas fórmulas especiales, por eso, el campo está declarado como string).

La clase SubSelectorArray representa un conjunto de componentes, por eso, declaramos el array selectors dentro de él:

class SubSelectorArray
{
  private:
    SubSelector *selectors[];

SubSelectorArray es un selector simple integramente. Para los selectores CSS completos, la clase no es necesaria porque se procesan consecutivamente paso a paso (un selector simple en cada nivel de la jerarquía).

Juntamos los selectores soportados de las pseudo clases en el mapa mod, para poder obtener inmediatamente el modificador correspondiente a partir de PseudoClassModifier:

    IndexMap mod;
    
    static TypeContainer<PseudoClassModifier> first;
    static TypeContainer<PseudoClassModifier> last;
    static TypeContainer<PseudoClassModifier> nth;
    static TypeContainer<PseudoClassModifier> nthLast;
    
    void init()
    {
      mod.add(":first-child", &first);
      mod.add(":last-child", &last);
      mod.add(":nth-child", &nth);
      mod.add(":nth-last-child", &nthLast);
    }

La clase TypeContainer es un envoltorio (wrapper) de plantilla para los valores adicionados a IndexMap.

Recordaré que los miembros estáticos (en este caso, los objetos para el mapa) tienen que ser inicializados después de la descripción de la clase:

TypeContainer<PseudoClassModifier> SubSelectorArray::first(PseudoClassModifier::firstChild);
TypeContainer<PseudoClassModifier> SubSelectorArray::last(PseudoClassModifier::lastChild);
TypeContainer<PseudoClassModifier> SubSelectorArray::nth(PseudoClassModifier::nthChild);
TypeContainer<PseudoClassModifier> SubSelectorArray::nthLast(PseudoClassModifier::nthLastChild);

Pero volvamos a la clase SubSelectorArray.

Cuando es necesario añadir un componente del selector simple al array, se llama a la función add:

    void add(const ushort t, string v)
    {
      int n = ArraySize(selectors);
      ArrayResize(selectors, n + 1);
      
      PseudoClassModifier m = PseudoClassModifier::none;
      string param;
      
      for(int j = 0; j < mod.getSize(); j++)
      {
        int p = StringFind(v, mod.getKey(j));
        if(p > -1)
        {
          if(p + StringLen(mod.getKey(j)) < StringLen(v))
          {
            param = StringSubstr(v, p + StringLen(mod.getKey(j)));
            if(StringGetCharacter(param, 0) == '(' && StringGetCharacter(param, StringLen(param) - 1) == ')')
            {
              param = StringSubstr(param, 1, StringLen(param) - 2);
            }
            else
            {
              param = "";
            }
          }
        
          m = mod[j].get<PseudoClassModifier>();
          v = StringSubstr(v, 0, p);
          
          break;
        }
      }
      
      if(StringLen(param) == 0)
      {
        selectors[n] = new SubSelector(t, v, m);
      }
      else
      {
        selectors[n] = new SubSelector(t, v, m, param);
      }
    }

A ella se le pasa el primer símbolo (tipo) y la siguiente cadena que se descompone en el nombre del objeto buscado, la pseudo clase opcional y el parámetro. Luego, todo eso se pasa al constructor SubSelector, y el nuevo componente del selector se añade al array selectors.

La función add se usa indirectamente desde el constructor del selector simple de la siguiente manera:

  private:
    void createFromString(const string &selector)
    {
      ushort p = 0; // previous/pending type
      int ppos = 0;
      int i, n = StringLen(selector);
      for(i = 0; i < n; i++)
      {
        ushort t = StringGetCharacter(selector, i);
        if(t == '.' || t == '#' || t == '[' || t == ']')
        {
          string v = StringSubstr(selector, ppos, i - ppos);
          if(i == 0) v = "*";
          if(p == '[' && StringLen(v) > 0 && StringGetCharacter(v, StringLen(v) - 1) == ']')
          {
            v = StringSubstr(v, 0, StringLen(v) - 1);
          }
          add(p, v);
          p = t;
          if(p == ']') p = 0;
          ppos = i + 1;
        }
      }
      
      if(ppos < n)
      {
        string v = StringSubstr(selector, ppos, n - ppos);
        if(p == '[' && StringLen(v) > 0 && StringGetCharacter(v, StringLen(v) - 1) == ']')
        {
          v = StringSubstr(v, 0, StringLen(v) - 1);
        }
        add(p, v);
      }
    }

  public:
    SubSelectorArray(const string selector)
    {
      init();
      createFromString(selector);
    }

La función createFromString obtiene la representación textual del selector CSS y lo repasa ciclicamente en busca de los símbolos especiales iniciales '.', '#' o '[', determina dónde este componente termina en el texto, y llama el método add para la información seleccionada. El ciclo continua mientras que la cadena de componentes continua en el texto.

El texto completo de la clase SubSelectorArray se adjunta al artículo.

Ahora ha llegado el momento para volver a la clase DomElement. Aquí, empieza la parte más complicada para entender.


DomElement, continuación

Para buscar los elementos que corresponden a los selectores establecidos (en su representación textual), se aplica el método querySelect. Precisamente ahí el selector CSS completo «se divide» en los selectores simples que se transforman consecutivamente en el objeto SubSelectorArray. Para cada selector simple se busca una lista de los elementos convenientes, luego respecto a estos elementos se buscan otros elementos convenientes para el siguiente selector simple, y así, consecutivamente hasta que se alcance el último selector o hasta que la lista de los elementos encontrados se quede vacía.

    DomIterator *querySelect(const string q)
    {
      DomIterator *result = new DomIterator();

Como valor devuelto, vemos la clase desconocido DomIterator, pero como ha sido mencionado antes, es la clase heredada de DomElement. Ella proporciona una pequeña funcionalidad adicional en comparación con DomElement (en particular, permite «hojear» los elementos hijos), por eso, dejamos por ahora DomIterator aparte. La dificultad consiste en otra cosa.

El análisis de la cadena de los selectores se ejecuta carácter por carácter. Para eso, se usan varias variables locales. El carácter actual se almacena en la variable c (de character). El carácter anterior se almacena en la variable p (de previous). Si el carácter es uno de los símbolos combinadores (' ', '+', '>', '~'), se almacena en la variable (a), pero no se usa hasta que sea determinado el selector simple que le sigue.

Como puede recordar, los combinadores se encuentran entre los selectores simples, y la operación que ellos definen puede realizarse solamente cuando el selector de la derecha será leído hasta el final. Por eso, el último combinador leído (a) primero pasa por la fase de «superexposición»: la variable (a) no se usa hasta que aparezca el siguiente combinador o se termine la cadena. En ambos casos, es una señal de que el selector haya sido formado. Sólo en este momento el combinador «antiguo» (b) entra en acción, y luego se reemplaza por el nuevo (a). Probablemente éste es el caso cuando el propio código se entiende mejor que su descripción.

      int cursor = 0; // where selector string started
      int i, n = StringLen(q);
      ushort p = 0;   // previous character
      ushort a = 0;   // next/pending operator
      ushort b = '/'; // current operator, 'root' notation from the start
      string selector = "*"; // current simple selector, 'any' by default
      int index = 0;  // position in the resulting array of objects

      for(i = 0; i < n; i++)
      {
        ushort c = StringGetCharacter(q, i);
        if(isCombinator(c))
        {
          a = c;
          if(!isCombinator(p))
          {
            selector = StringSubstr(q, cursor, i - cursor);
          }
          else
          {
            // suppress blanks around other combinators
            a = MathMax(c, p);
          }
          cursor = i + 1;
        }
        else
        {
          if(isCombinator(p)) // action
          {
            index = result.getChildrenCount();
            
            SubSelectorArray selectors(selector);
            find(b, &selectors, result);
            b = a;
            
            // now we can delete outdated results in positions up to 'index'
            result.removeFirst(index);
          }
        }
        p = c;
      }
      
      if(cursor < i) // action
      {
        selector = StringSubstr(q, cursor, i - cursor);
        
        index = result.getChildrenCount();
        
        SubSelectorArray selectors(selector);
        find(b, &selectors, result);
        result.removeFirst(index);
      }
      
      return result;
    }

La variable cursor siempre indica en el primer carácter a partir del cual se empieza la subcadena con el selector simple (es decir, en el carácter inmediatamente después del combinador anterior o en el inicio de la cadena). Cuando encontramos otro combinador, copiamos la subcadena desde cursor hasta el carácter actual (i) a la variable selector.

A veces existen varios combinadores seguidos. Normalmente, eso ocurre cuando otros caracteres combinadores se rodean con espacios, pero un espacio también es un combinador. Por ejemplo, las entradas "td>span" y "td > span" son idénticas, pero la segunda entrada contiene espacios para mejorar la legibilidad. Estas situaciones se procesan por la cadena:

a = MathMax(c, p);

Ella compara el carácter actual con el anterior, cuando ambos son combinadores. Luego, usando el hecho de que el espacio tiene el menor código, siempre seleccionamos el combinador «mayor». El array de los combinadores se define obviamente de la siguiente forma:

ushort combinators[] =
{
  ' ', '+', '>', '~'
};

La verificación de la entrada del carácter en este array se realiza por la función auxiliar simple isCombinator.

Si hay dos combinadores seguidos diferentes del espacio, se trata de un selector incorrecto, y el comportamiento no se define por las especificaciones. Sin embargo, nuestro código no pierde su funcionalidad y proporciona un comportamiento consistente.

Si el carácter actual no es un combinador y el carácter anterior era un combinador, la ejecución pasa a la rama marcada con el comentario action. Aquí, memorizamos el tamaño actual del array con los DomElement seleccionados para este momento usando la llamada:

index = result.getChildrenCount();

Inicialmente, está claro que el array está vacío y el index es igual a 0.

Creamos un array de los objetos selectores correspondiente al selector simple actual - cadena selector:

SubSelectorArray selectors(selector);

Luego, llamamos al método find que también será considerado.

find(b, &selectors, result);

Para dentro, pasamos el carácter combinador (el penúltimo, desde la variable b), selector simple para buscar los elementos y el array para colocar los resultados.

Después de eso, movemos la «cola» de los combinadores hacia adelante, copiando el último carácter combinador encontrado (pero todavía sin procesar) desde la variable a a la variable b, y eliminamos desde los resultados todo lo que había antes de la llamada a find usando:

result.removeFirst(index);

El método removeFirst está definido en DomIterator y ejecuta una tarea simple según su nombre, es decir, elimina todos los primeros elementos desde el array hasta el número especificado. Eso se hace porque, durante el procesamiento de cada selector simple, imponemos las condiciones cada vez más estrictas a la selección de los elementos, y todo lo que ha sido seleccionado antes deja de existir, mientras que los elementos recién añadidos (que satisfacen a las condiciones más rigurosas) comienzan con el número index.

El procesamiento semejante (con el comentario action) se realiza cuando llegamos al final de la cadena de entrada. En este caso es necesario procesar el último combinador, que espera a su turno, en conjunto con el resto de la cadena (desde la posición cursor).

Echamos un vistazo adentro del método find.

    bool find(const ushort op, const SubSelectorArray *selectors, DomIterator *output)
    {
      bool found = false;
      int i, n;

Si uno de dos combinadores (' ', '>') que imponen condiciones al anexado de las etiquetas se pasa a la entrada, es necesario llamar recursivamente las verificaciones para todos los elementos hijos. Además, en esta rama, tenemos en cuenta un combinador especial '/', que se usa al principio de la búsqueda en el método de la llamada.

      if(op == ' ' || op == '>' || op == '/')
      {
        n = ArraySize(children);
        for(i = 0; i < n; i++)
        {
          if(children[i].match(selectors))
          {
            if(op == '/')
            {
              found = true;
              output.addChild(GetPointer(children[i]));
            }

El método match será analizado más abajo. Por ahora, es importante saber que devuelve true si el objeto corresponde a los selectores pasados, en caso contrario devuelve false. Cuando la búsqueda apenas comienza (combinador op = '/'), todavía no hay ningunas «combinaciones», y por eso, todas las etiquetas que han satisfecho los selectores se añaden al resultado (output.addChild).

            else
            if(op == ' ')
            {
              DomElement *p = &this;
              while(p != NULL)
              {
                if(output.getChildIndex(p) != -1)
                {
                  found = true;
                  output.addChild(GetPointer(children[i]));
                  break;
                }
                p = p.parent;
              }
            }

Para el combinador ' ' se ejecuta la verificación de que DomElement actual o cualquier antecesor suyo en cualquier generación ya está presente en los resultados (output). Eso significa que un nuevo elemento hijo que satisface los criterios de la búsqueda está incorporado en el padre. Esta es la tarea de este combinador.

El combinador '>' funciona de manera semejante, pero debe rastrear sólo los «parientes» inmediatos, por tanto, comprobamos sólo si DomElement actual está presente en los resultados intermedios. Si es así, entonces ha sido seleccionado antes en output según las condiciones del selector a la izquierda del combinador, mientras que su elemento hijo inésimo acaba de satisfacer el selector a la derecha del combinador.

            else // op == '>'
            {
              if(output.getChildIndex(&this) != -1)
              {
                found = true;
                output.addChild(GetPointer(children[i]));
              }
            }
          }

Luego, es necesario realizar las comprobaciones parecidas en la profundidad del árbol DOM, por tanto, llamamos recursivamente al método find para los elementos hijos.

          children[i].find(op, selectors, output);
        }
      }

Los combinadores '+' y '~' imponen las condiciones indicando que dos elementos pertenecen al mismo padre.

      else
      if(op == '+' || op == '~')
      {
        if(CheckPointer(parent) == POINTER_DYNAMIC)
        {
          if(output.getChildIndex(&this) != -1)
          {

Uno de los elementos ya tiene que estar seleccionado en los resultados a través del selector a la izquierda del combinador. Cuando esta condición ha sido cumplida, nos queda comprobar los «hermanos y hermanas» en el selector derecho («hermanos y hermanas» son los elementos hijos del padre del nodo actual).

            int q = parent.getChildIndex(&this);
            if(q != -1)
            {
              n = (op == '+') ? (q + 2) : parent.getChildrenCount();
              if(n > parent.getChildrenCount()) n = parent.getChildrenCount();
              for(i = q + 1; i < n; i++)
              {
                DomElement *m = parent.getChild(i);
                if(m.match(selectors))
                {
                  found = true;
                  output.addChild(m);
                }
              }
            }

La diferencia en el procesamiento de los combinadores '+' y '~' consiste sólo en el hecho de que, en caso de '+', los elementos tienen que ser vecinos inmediatos, y en el caso de '~', entre ellos puede haber un número aleatorio de otros «hermanos y hermanas». Por tanto, para '+' el ciclo se ejecuta sólo una vez, para el siguiente elemento en el array de los hijos. Dentro del ciclo, vemos nuevamente la llamada a la función match (sobre ella hablaremos dos párrafos más abajo).

          }
        }
        for(i = 0; i < ArraySize(children); i++)
        {
          found = children[i].find(op, selectors, output) || found;
        }
      }
      return found;
    }

Después de todas las comprobaciones, hay que bajar al siguiente nivel de la jerarquía del árbol de los elementos DOM y llamar find para los nodos hijos.

Pues, eso es todo sobre el método find. Ahora, hablaremos de la función match. Es el último punto en nuestra historia ligeramente prolongada sobre la implementación de los selectores.

Esta función comprueba si el objeto actual corresponde a toda la cadena de los componentes del selector simple, que se pasa a través del parámetro de entrada. Si por lo menos en el ciclo un componente no corresponde a las propiedades del elemento, la comprobación falla.

    bool match(const SubSelectorArray *u)
    {
      bool matched = true;
      int i, n = u.size();
      for(i = 0; i < n && matched; i++)
      {
        if(u[i].type == 0) // tag name and pseudo-classes
        {
          if(u[i].value == "*")
          {
            // any tag
          }

El selector tipo 0 es el nombre de una etiqueta o una pseudo clase. Si en el selector hay un asterisco, conviene cualquier etiqueta, de lo contrario, comparamos la cadena en el selector con el nombre de la etiqueta:

          else
          if(StringCompare(name, u[i].value) != 0)
          {
            matched = false;
          }

Las pseudo clases implementadas para este momento imponen una limitación en cuanto al número del elemento actual en el array de los elementos hijos de su padre, por tanto, analizamos los índices:

          else
          if(u[i].modifier == PseudoClassModifier::firstChild)
          {
            if(parent != NULL && parent.getChildIndex(&this) != 0)
            {
              matched = false;
            }
          }
          else
          if(u[i].modifier == PseudoClassModifier::lastChild)
          {
            if(parent != NULL && parent.getChildIndex(&this) != parent.getChildrenCount() - 1)
            {
              matched = false;
            }
          }
          else
          if(u[i].modifier == PseudoClassModifier::nthChild)
          {
            int x = (int)StringToInteger(u[i].param);
            if(parent != NULL && parent.getChildIndex(&this) != x - 1) // children are counted starting from 1
            {
              matched = false;
            }
          }
          else
          if(u[i].modifier == PseudoClassModifier::nthLastChild)
          {
            int x = (int)StringToInteger(u[i].param);
            if(parent != NULL && parent.getChildrenCount() - parent.getChildIndex(&this) - 1 != x - 1)
            {
              matched = false;
            }
          }
        }

El selector '.' impone una limitación al atributo "class":

        else
        if(u[i].type == '.')
        {
          if(attributes.isKeyExisting("class"))
          {
            Container *c = attributes["class"];
            if(c == NULL || StringFind(" " + c.get<string>() + " ", " " + u[i].value + " ") == -1)
            {
              matched = false;
            }
          }
          else
          {
            matched = false;
          }
        }

El selector '#' impone una limitación al atributo "id":

        else
        if(u[i].type == '#')
        {
          if(attributes.isKeyExisting("id"))
          {
            Container *c = attributes["id"];
            if(c == NULL || StringCompare(c.get<string>(), u[i].value) != 0)
            {
              matched = false;
            }
          }
          else
          {
            matched = false;
          }
        }

El selector '[' proporciona la posibilidad de indicar un conjunto aleatorio de los atributos necesarios, además, la comprobación de los valores puede realizarse no sólo de manera estricta, sino también por la ocurrencia de la subcadena (sufijo '*'), inicio ('^') y final ('$').

        else
        if(u[i].type == '[')
        {
          AttributesParser p;
          IndexMap hm;
          p.parseAll(u[i].value, hm);
          // attributes are selected one by one: element[attr1=value][attr2=value]
          // the map should contain only 1 valid pair at a time
          if(hm.getSize() > 0)
          {
            string key = hm.getKey(0);
            ushort suffix = StringGetCharacter(key, StringLen(key) - 1);
            
            if(suffix == '*' || suffix == '^' || suffix == '$') // contains, starts with, or ends with
            {
              key = StringSubstr(key, 0, StringLen(key) - 1);
            }
            else
            {
              suffix = 0;
            }
            
            if(hasAttribute(key) && attributes[key] != NULL)
            {
              string v = hm[0] != NULL ? hm[0].get<string>() : "";
              if(StringLen(v) > 0)
              {
                if(suffix == 0)
                {
                  if(key == "class")
                  {
                    matched &= (StringFind(" " + attributes[key].get<string>() + " ", " " + v + " ") > -1);
                  }
                  else
                  {
                    matched &= (StringCompare(v, attributes[key].get<string>()) == 0);
                  }
                }
                else
                if(suffix == '*')
                {
                  matched &= (StringFind(attributes[key].get<string>(), v) != -1);
                }
                else
                if(suffix == '^')
                {
                  matched &= (StringFind(attributes[key].get<string>(), v) == 0);
                }
                else
                if(suffix == '$')
                {
                  string x = attributes[key].get<string>();
                  if(StringLen(x) > StringLen(v))
                  {
                    matched &= (StringFind(x, v, StringLen(x) - StringLen(v)) == StringLen(v));
                  }
                }
              }
            }
            else
            {
              matched = false;
            }
          }
        }
      }
      
      return matched;

    }

Obsérvese que el atributo "class" también se soporta y se procesa aquí, como en el caso del selector '.', la comparación no se realiza respecto a la correspondencia estricta, sino respecto a la presencia de la clase buscada entre muchas otras probables. En HTML, a menudo se usa el mecanismo cuando al elemento se le asigna varias clases al mismo tiempo (se indican en el atributo class separadas con espacio).

Pues bien, hagamos un resumen intermedio. En la clase DomElement, hemos implementado el método querySelect que recibe una cadena con el selector CSS completo como parámetro y devuelve el objeto DomIterator (prácticamente, el array de los elementos correspondientes encontrados). Dentro de querySelect se realiza la división de la cadena del selector CSS en una secuencia de los selectores simples y caracteres combinadores entre ellos. Para cada selector simple, se invoca el método find con indicación del combinador, y este método actualiza la lista de los resultados invocándose recursivamente para los elementos hijos. La comparación de los componentes del selector simple con las propiedades de un determinado elemento se realiza en el método match.

Con la ayuda del método querySelect, podemos seleccionar, por ejemplo, las filas desde una tabla usando un selector CSS, y luego, llamar a querySelect para cada fila con otro selector CSS para extraer las celdas específicas. Puesto que el trabajo con las tablas es apliamente requerido, vamos a crear el método tableSelect en la clase DomElement, en el cual implementaremos el enfoque descrito. Su código se muestra en forma simplificada.

    IndexMap *tableSelect(const string rowSelector, const string &columSelectors[], const string &dataSelectors[])
    {

El selector para las filas se especifica en el parámetro rowSelector, los selectores para las celdas, en el array columSelectors.

Después de que los elementos del documento estén seleccionados, tendremos que obtener alguna información desde ellos, por ejemplo, el texto o el valor del atributo. Para determinar el lugar del elemento desde el cual hay que «recortar» la información, se usa el array dataSelectors (para cada columna de la tabla, el método de la extracción de los datos puede ser diferente).

Si dataSelectors[i] es una fila vacía, leemos el contenido de texto de la etiqueta (es decir, lo que se encuentra entre la parte de apertura y de cierre, por ejemplo, obtenemos "100%" desde la etiqueta "<p>100%</p>"). Si dataSelectors[i] es una fila, consideramos que se trata del nombre del atributo, por tanto, obtenemos su valor.

Vamos a ver la implementación línea por línea.

      DomIterator *r = querySelect(rowSelector);

Aquí, hemos obtenido la lista resultante de los elementos por el selector de las filas.

      IndexMap *data = new IndexMap('\n');
      int counter = 0;
      r.rewind();

Aquí, hemos creado un mapa vacío a donde vamos a colocar los datos de la tabla, y hemos preparado para el ciclo por los objetos-filas. Aquí está el ciclo:

      while(r.hasNext())
      {
        DomElement *e = r.next();
        
        string id = IntegerToString(counter);
        
        IndexMap *row = new IndexMap();

Recibimos la próxima fila (e), creamos un mapa-contenedor (row) para ella a donde vamos a colocar las celdas, e iniciamos el ciclo por las columnas:

        for(int i = 0; i < ArraySize(columSelectors); i++)
        {
          DomIterator *d = e.querySelect(columSelectors[i]);

En cada objeto-fila, seleccionamos la lista de los objetos-celdas (d) usando el selector correspondiente. Desde cada celda encontrada, seleccionamos los datos y los guardamos en el mapa row:

          string value;
          
          if(d.getChildrenCount() > 0)
          {
            if(dataSelectors[i] == "")
            {
              value = d[0].getText();
            }
            else
            {
              value = d[0].getAttribute(dataSelectors[i]);
            }
            
            StringTrimLeft(value);
            StringTrimRight(value);
            
            row.setValue(IntegerToString(i), value);
          }

Aquí, para simplificar el código, se usan las claves enteras, pero el código fuente completo permite tomar los identificadores de los elementos como claves.

Si no encontramos una celda apropiada, la marcamos como vacía.

          else // field not found
          {
            row.set(IntegerToString(i));
          }
          delete d;
        }

Añadimos la fila rellena row a la tabla date.

        if(row.getSize() > 0)
        {
          data.set(id, row);
          counter++;
        }
        else
        {
          delete row;
        }
      }
      
      delete r;
    
      return data;
    }

De esta manera, obtenemos un mapa de los mapas (map of map) en la salida, es decir, una tabla con los números de las filas por la primera dimensión y con los números de las columnas por la segunda. Si hace falta, se puede adaptar la función tableSelect para otros contenedores de los datos.

Para aplicar todas las clases arriba descritas, fue creada una utilidad-EA no negociable.

Utilidad-EA WebDataExtractor

Este EA fue diseñado para la conversión de los datos de las páginas web en un estructura tabular, con posibilidad de guardar el resultado en un archivo CSV.

Como parámetro de entrada, el EA recibe una referencia a la fuente de los datos (puede ser un archivo local o una página en Internet, para bajar la cual se usa WebRequest), los selectores para las filas y columnas, y el nombre del archivo CSV. Principales parámetros de entrada:

input string URL = "";
input string SaveName = "";
input string RowSelector = "";
input string ColumnSettingsFile = "";
input string TestQuery = "";
input string TestSubQuery = "";

En el parámetro URL, hay que indicar la dirección de la web (comenzando de http:// o https://) o el nombre del archivo html local.

En el parámetro SaveName, en el modo normal, se indica el nombre del archivo CSV con los resultados. Sin embargo, se puede usarlo para otro propósito, para guardar la página bajada para la depuración posterior de los selectores. Para trabajar en este modo, hay que dejar vacío el siguiente parámetro, RowSelector, en el que, normalmente, se define el selector CSS de las filas.

Puesto que hay varios selectores de las columnas, se especifican en un archivo CSV de configuración separado, cuyo nombre se especifica en el parámetro ColumnSettingsFile. El formato del archivo es el siguiente:

La primera línea es el encabezado, cada línea subsiguiente describe un campo separado (columna con los datos en una fila de la tabla).

El archivo debe tener tres columnas: nombre, selector CSS, y «localizador» de los datos:

  • nombre — nombre de la inésima columna en el archivo CSV de salida;
  • selector CSS — selector para seleccionar el elemento a partir del cual van a cogerse los datos para la inésima columna del archivo CSV de salida; este selector se usa dentro de cada elemento DOM seleccionado anteriormente desde la página web a través del selector RowSelector; para seleccionar un elemento-fila, hay que indicar '.';
  • «localizador» de datos — determina la parte del elemento desde la cual se toman los datos. Se puede indicar el nombre del atributo, o dejarlo vacío para obtener el contenido textual de la etiqueta.

Los parámetros TestQuery y TestSubQuery permiten testear los selectores para una fila y una columna con la salida en el log, sin guardar en el archivo CSV y sin los archivos de las configuraciones para todas las columnas.

Aquí está la principal función del EA en forma resumida:

int process()
{
  string xml;
  
  if(StringFind(URL, "http://") == 0 || StringFind(URL, "https://") == 0)
  {
    xml = ReadWebPageWR(URL);
  }
  else
  {
    Print("Reading html-file ", URL);
    int h = FileOpen(URL, FILE_READ|FILE_TXT|FILE_SHARE_WRITE|FILE_SHARE_READ|FILE_ANSI, 0, CP_UTF8);
    if(h == INVALID_HANDLE)
    {
      Print("Error reading file '", URL, "': ", GetLastError());
      return -1;
    }
    StringInit(xml, (int)FileSize(h));
    while(!FileIsEnding(h))
    {
      xml += FileReadString(h) + "\n";
    }
    // xml = FileReadString(h, (int)FileSize(h)); - has 4095 bytes limit in binary files!
    FileClose(h);
  }
  ...

Aquí, leemos la página HTML desde el archivo o bajamos desde Internet. Ahora, para convertir el documento en una jerarquía de objetos DOM, creamos el objeto HtmlParser e iniciamos el analizador:

  HtmlParser p;
  DomElement *document = p.parse(xml);

Si los selectores textuales están especificados, los procesamos usando las llamadas de querySelect:

  if(TestQuery != "")
  {
    Print("Testing query, subquery: '", TestQuery, "', '", TestSubQuery, "'");
    DomIterator *r = document.querySelect(TestQuery);
    r.printAll();
    
    if(TestSubQuery != "")
    {
      r.rewind();
      while(r.hasNext())
      {
        DomElement *e = r.next();
        DomIterator *d = e.querySelect(TestSubQuery);
        d.printAll();
        delete d;
      }
    }
    
    delete r;
    return(0);
  }

En el modo normal, leemos el archivo de configuraciones de las columnas y llamamos a la función tableSelect:

  string columnSelectors[];
  string dataSelectors[];
  string headers[];
  
  if(!loadColumnConfig(columnSelectors, dataSelectors, headers)) return(-1);
  
  IndexMap *data = document.tableSelect(RowSelector, columnSelectors, dataSelectors);

Si el archivo para guardar los resultados en CSV está especificado, le delegamos esta tarea al mapa date.

  if(StringLen(SaveName) > 0)
  {
    Print("Saving data as CSV to ", SaveName);
    int h = FileOpen(SaveName, FILE_WRITE|FILE_CSV|FILE_ANSI, '\t', CP_UTF8);
    if(h == INVALID_HANDLE)
    {
      Print("Error writing ", data.getSize() ," rows to file '", SaveName, "': ", GetLastError());
    }
    else
    {
      FileWriteString(h, StringImplodeExt(headers, ",") + "\n");
      
      FileWriteString(h, data.asCSVString());
      FileClose(h);
      Print((string)data.getSize() + " rows written");
    }
  }
  else
  {
    Print("\n" + data.asCSVString());
  }
  
  delete data;
  
  return(0);
}

Intentamos aplicar el EA en la práctica.


Uso práctico

Los traders conocen muy bien algunos archivos HTML estándar, por ejemplo, los informes de la simulación e informes comerciales generados por MetaTrader. A veces recibimos estos archivos de los conocidos o los bajamos de Internet, y queremos analizarlos en el gráfico, para lo cual necesitamos convertir los datos de HTML en una tabla, o en el caso más simple, en formato CSV.

Los selectores CSS en nuestra utilidad no permiten hacerlo automáticamente.

Echamos un vistazo adentro de los archivos HTML. Aquí está la apariencia y una parte del código HTML de un informe de MetaTrader 5 (el archivo ReportHistory.html se adjunta).

Apariencia y parte del código HTML del informe comercial

Apariencia y parte del código HTML del informe comercial

Aquí está la apariencia y una parte del código HTML de un archivo del Simulador de estrategias de MetaTrader 5 (el archivo Tester.html se adjunta).

Apariencia y parte del código HTML del informe del Simulador

Apariencia y parte del código HTML del informe del Simulador

De acuerdo con la representación externa, el informe comercial tiene dos tablas: con órdenes (Orders) y con transacciones (Deals). No obstante, si nos fijamos en layout interno, resulta que es la única tabla. Todos los encabezados visibles y las líneas separadoras se forman debido al control de los estilos de las celdas de la tabla. Pero nosotros necesitamos de alguna manera aprender a distinguir las órdenes y las transacciones, y guardar cada subtabla en su archivo CSV.

La diferencia entre la primera y la segunda parte consiste en el número de las columnas: en las órdenes son 11, en las transacciones son 13. Lamentablemente, el estándar CSS no permite imponer las condiciones a la selección de los elementos padre (en nuestro caso, las filas de la tabla, etiqueta tr) a base del número o contenido de los elementos hijos (en nuestro caso, las celdas de la tabla, etiqueta td). Así, las posibilidades de los selectores no son ilimitadas y, en algunas ocasiones, es imposible seleccionar los elementos necesarios usando medios estándar. Pero como nosotros mismos estamos desarrollando nuestra propia implementación de los selectores, podemos añadir un selector especial no estándar para el número de los elementos hijos. Será una pseudo clase nueva. La denotamos como ":has-n-children(n)", por analogía con ":nth-child(n)".

Entonces, para seleccionar las filas con las órdenes, podemos usar el selector:

tr:has-n-children(11)

No obstante, el problema aún no está resuelto, porque este selector, aparte de las filas con los datos, también selecciona el encabezado de la tabla. Para evitar eso, nos fijamos en la representación de color de las filas con datos: tienen establecido el atributo bgcolor, y el valor del color se alterna para las filas pares e impares (#FFFFFF и #F7F7F7). El color, es decir, el atributo bgcolor, también se usa para el encabezado, pero su valor es #E5F0FC. Así, las filas con datos tienen colores claros con bgcolor que comienza de "#F". Añadimos esta condición al selector:

tr:has-n-children(11)[bgcolor^="#F"]

Él identifica correctamente todas las filas con órdenes.

Los parámetros de cada orden se leerán desde las celdas de la fila. Para eso, escribiremos el siguiente archivo de configuración ReportHistoryOrders.cfg.csv::

Name,Selector,Data
Time,td:nth-child(1),
Order,td:nth-child(2),
Symbol,td:nth-child(3),
Type,td:nth-child(4),
Volume,td:nth-child(5),
Price,td:nth-child(6),
S/L,td:nth-child(7),
T/P,td:nth-child(8),
Time,td:nth-child(9),
State,td:nth-child(10),
Comment,td:nth-child(11),

En él, todos los campos se identifican simplemente por el número de la secuencia. En otras ocasiones, pueden ser necesarios los selectores más «inteligentes» con los atributos y las clases.

Para obtener la tabla de las transacciones, basta con sustituir el número de los elementos hijos en el selector de las filas de 11 por 13:

tr:has-n-children(13)[bgcolor^="#F"]

El archivo de configuración ReportHistoryDeals.cfg.csv se adjunta (parece al mostrado más arriba).

Si ahora iniciamos WebDataExtractor y especificamos los siguientes parámetros de entrada (archivo webdataex-report1.set se adjunta):

URL=ReportHistory.html
SaveName=ReportOrders.csv
RowSelector=tr:has-n-children(11)[bgcolor^="#F"]
ColumnSettingsFile=ReportHistoryOrders.cfg.csv

como resultado, obtenemos el archivo ReportOrders.csv correspondiente al informe HTML de salida:

archivo CSV obtenido como resultado de la aplicación de los selectores CSS al informe comercial

archivo CSV obtenido como resultado de la aplicación de los selectores CSS al informe comercial

Para obtener la tabla de transacciones, usamos las configuraciones adjuntas webdataex-report2.set.

La buena noticia es que los selectores creados también convienen para los informes del Simulador de Estrategias. Los archivos adjuntos webdataex-tester1.set y webdataex-tester2.set permiten convertir el informe HTML de demostración Tester.html en los archivos CSV.

¡Atención! El layout de muchas páginas web, así como, de los archivos HTML generados en MetaTrader se cambian de vez en cuando. Eso puede causar que los selectores anteriores dejan de ejecutar su trabajo, incluso si la apariencia de las páginas casi no se ha cambiado. En estos casos, es necesario volver a analizar el código HTML y modificar los selectores CSS.

Ahora, veamos la conversión para el informe del Simulador de MetaTrader 4; él permite demostrar algunas técnicas interesantes en la selección de los selectores CSS. Como ejemplo, se puede usar el archivo adjunto StrategyTester-ecn-1.htm.

En estos archivos hay dos tablas: en la primera figuran los resultados de la simulación, la segunda contiene las operaciones comerciales. Para seleccionar la segunda tabla, usaremos el selector "table ~ table". Dentro de la tabla de las operaciones, tenemos que descartar la primera fila, porque contiene el encabezado. Para eso, se usa el selector "tr + tr".

Entonces, al unirlos juntos, obtenemos un selector para seleccionar las filas de trabajo:

table ~ table tr + tr

En realidad, significa: seleccione la tabla después de la tabla (es decir, la segunda), y dentro de esta tabla, seleccione cada fila que tiene una fila anterior (es decir, todas a excepción de la primera con el encabezado).

Las configuraciones para extraer los parámetros de las transacciones desde la celda se adjuntan en el archivo test-report-mt4.cfg.csv. Obsérvese que el campo con la fecha se procesa por el selector de la clase:

DateTime,td.msdate,

es decir, las etiquetas td que tienen el atributo class="msdate" son apropiadas.

El archivo completo para las configuraciones de la utilidad es webdataex-tester-mt4.set.

Puede encontrar los ejemplos adicionales del uso y configuración de los selectores CSS en la página de la discusión WebDataExtractor.

Cabe mencionar que la utilidad sabe hacer más cosas:
  • realizar la sustitución automática de las cadenas (por ejemplo, cambiar el nombre del país por los símbolos de las divisas o la descripción de la prioridad de la noticia por un número);
  • mostrar el árbol DOM en el log para encontrar los selectores apropiados sin navegador;
  • bajar y convertir las páginas web según el temporizador o por la solicitud de una variable global;

Si necesita ayuda para configurar los selectores CSS para convertir una determinada página web, Usted puede adquirir WebDataExtractor (para MetaTrader 4, para MetaTrader 5) y obtener recomendaciones como parte el soporte del producto. Sin embargo, la disponibilidad de los códigos fuente permite usar toda la funcionalidad y ampliarla absolutamente gratis.


Conclusión

Hemos considerado la tecnología de los selectores CSS que representa uno de los estándar en el campo de la interpretación de los documentos web. Gracias a la implementación de los selectores CSS más usados en MQL, hemos obtenido la posibilidad de configurar y convertir con flexibilidad cualquier página HTML, inclusive muchos documentos estándar de MetaTrader, en los datos estructurizados, sin aplicar software de terceros.

Algunas otras tecnologías capaces de proporcionar las herramientas igualmente universales para procesar los documentos web se han quedado sin mencionar, especialmente teniendo en cuenta que MetaTrader usa ampliamente no sólo HTML, sino también XML. En particular, XPath y XSLT puede ser de interés potencial para los traders. Todo eso son las próximas etapas que pueden desarrollar la idea de automatizar los sistemas comerciales a base de los patrones web. El soporte de los selectores CSS en MQL es sólo el primer paso hacia este objetivo.

Traducción del ruso hecha por MetaQuotes Software Corp.
Artículo original: https://www.mql5.com/ru/articles/5706

Archivos adjuntos |
html2css.zip (35.94 KB)
Creando un EA gradador multiplataforma Creando un EA gradador multiplataforma

En este artículo, vamos a prender a escribir asesores que funcionan tanto en MetaTrader 4, como en MetaTrader 5. Para ello, trataremos de escribir un asesor que funcione según el principio de creación de cuadrículas de órdenes. Un gradador es un experto cuyo principal principio de trabajo consiste en colocar simultáneamente varias órdenes límite por encima del precio actual, y la misma cantidad por debajo.

Desarrollando las interfaces gráficas para los Asesores Expertos e indicadores a base de .Net Framework и C# Desarrollando las interfaces gráficas para los Asesores Expertos e indicadores a base de .Net Framework и C#

Presentamos una manera simple y rápida de crear las ventanas gráficas usando el editor Visual Studio, con la integración posterior en el código MQL del Asesor Experto. Este artículo está destinado para un vasto círculo de lectores y no requiere ningunos conocimientos de C# y tecnología .Net.

Integración de MetaTrader 5 y Python: recibiendo y enviando datos Integración de MetaTrader 5 y Python: recibiendo y enviando datos

En nuestra época, el procesamiento de datos requiere un extenso instrumental y muchas veces no se limita al entorno protegido (sandbox) de alguna determinada aplicación. Existen los lenguajes de programación especializados y universalmente reconocidos para procesar y analizar los datos, para la estadística y el aprendizaje automático. Python es el líder en este campo. En este artículo, se describe un ejemplo de la integración de MetaTrader 5 y Python a través de los sockets, así como, la obtención de las cotizaciones por medio de la API del terminal.

Biblioteca para el desarrollo rápido y sencillo de programas para MetaTrader (Parte III): Colección de órdenes y posiciones de mercado, búsqueda y filtrado Biblioteca para el desarrollo rápido y sencillo de programas para MetaTrader (Parte III): Colección de órdenes y posiciones de mercado, búsqueda y filtrado

En el primer artículo, comenzamos la creación de una gran biblioteca multiplataforma para construir con facilidad programas en las plataformas MetaTrader 5 y MetaTrader 4. Acto seguido, continuamos el desarrollo de la biblioteca y corregimos las órdenes y transacciones históricas. En esta ocasión, vamos a crear una clase que nos permita elegir y filtrar cómodamente órdenes y posiciones en las listas de colecciones; en concreto, crearemos un objeto básico de la biblioteca, llamado Engine, y añadiremos a la biblioteca una colección de órdenes y posiciones de mercado.