Extraindo dados estruturados de páginas HTML através de seletores CSS

22 abril 2019, 15:39
Stanislav Korotky
0
480

O ambiente de desenvolvimento do MetaTrader permite integrar programas e dados externos, nomeadamente obtidos da Internet via WebRequest. O formato de dados mais universal e usado na Web é HTML. Quando um serviço público não fornece uma API aberta para solicitações ou a API em si mesma, seu protocolo é difícil de implementar em MQL, e é nesse caso que a análise (parsing) de páginas HTML vem em nosso auxílio. Em particular, entre os traders, são populares variados calendários econômicos, e, embora com o advento do calendário embutido isso não seja mais tão importante, alguém pode precisar de notícias de um site específico. Adicionalmente, surge a necessidade de analisar operações de relatórios HTML de negociação de terceiros.

No ecossistema MQL5, podem-se encontrar várias soluções para esse problema, mas elas geralmente são específicas e têm suas limitações. Ao mesmo tempo, existe em certo sentido uma maneira nativa e universal de pesquisar e extrair dados a partir de HTML, neste caso, fala-se de seletores CSS. Neste artigo, veremos sua implementação em MQL e daremos exemplos de uso prático.

Para analisar HTML, é preciso criar um analisador que converta o texto interno da página numa hierarquia de objetos chamada de objetos DOM (Modelo de objeto de documento), é nessa hierarquia que é possível encontrar objetos com parâmetros especificados. Essa abordagem usa informações auxiliares sobre a estrutura do documento, que não é exibida externamente.

Assim, pode-se, por exemplo, num documento selecionar as linhas de uma tabela específica, ler as colunas especificadas a partir delas e obter um array com valores que podem ser facilmente salvos num arquivo csv, exibidos num gráfico ou usados nos cálculos do EA.


HTML/CSS e DOM — visão geral

Atualmente, já é difícil encontrar alguém que não saiba o que é HTML, por essa razão, não faz sentido descrever em detalhes a sintaxe dessa linguagem de marcação de hipertexto.

Neste âmbito, a principal fonte de informação técnica é a IETF (Internet Engineering Task Force) e suas especificações RFC (Request For Comments). Em particular, o HTML tem suas próprias especificações, e há muitas delas, eis aqui apenas um exemplo. Adicionalmente, as orientações podem ser encontradas no site da W3C (World Wide Web Consortium HTML5.2), que são um consórcio.

Essas mesmas organizações desenvolveram e regulamentaram a CSS (em português, Folha de Estilo em Cascata). Interessa-nos, não tanto pela possibilidade de descrever estilos para apresentar informações em páginas da Web, mas, sim, porque nela foram criados os seletores CSS — uma linguagem de consultas especial para encontrar elementos correspondentes dentro de páginas html.

HTML e CSS estão em constante evolução, acumulando versão trás versão. Atualmente, HTML5.2 e CSS4 são as mais relevantes, mas a atualização e a expansão de recursos acompanham a herança de capacidades das versões antigas, uma vez que a Internet é muito grande, heterogênea e sinérgica (mesmo que alguém mude o documento original, certamente haverá cópias anteriores). Como resultado, ao escrever algoritmos usando ferramentas da web, deve-se olhar de maneira bastante criativa as orientações para, por um lado, considerar possíveis variação e, por outro, introduzir simplificações intencionalmente para não se afogar nelas.

Em nosso projeto, também consideraremos a sintaxe HTML de forma simplificada.

Como é sabido, um documento html consiste em tags entre os caracteres '<' e '>'. Dentro da tag, são indicados seu nome e atributos opcionais (pares de strings com formato nome="valor", sendo que o sinal de igual e o de valor podem estar ausentes). Por exemplo, a tag:

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

Esta é uma tag com o nome 'a' (interpretado pelos navegadores como um hyperlink) e dois parâmetros: href — endereço do site para seguir o hyperlink e target — opção de abrir o site (neste caso, "_blank", ou seja, numa nova janela do navegador).

A primeira tag é chamada de tag de abertura. Ela é seguida pelo texto, ou seja, pelo conteúdo realmente visível "HTML and CSS", e, em seguida, a tag de fechamento par que deve ter o mesmo nome da tag de abertura, mas marcada com o símbolo '/' depois de '<' ' (todo dentro de '</a>'). Em outras palavras, as tags de abertura e fechamento vêm em pares e podem incluir outras tags, sem sobreposição. Aqui está um exemplo de um uso correto:

<group attribute1="value1">

  <name>text1</name>

  <name>text2</name>

</group>

Este tal "cruzamento" das tags é proibido:

<group id="id1">

<name>text1

</group>

</name>

Mas a proibição é apenas teórica. Na prática, no documento as tags geralmente são abertas ou fechadas por engano no lugar errado. O analisador deve ser capaz de lidar com essa situação.

A ausência de conteúdo dentro da tag é permitida, por exemplo, uma linha vazia:

<p></p>

De acordo com os padrões, algumas tags não devem ter conteúdo. Por exemplo, uma tag que descreve uma imagem:

<img src="/ico20190101.jpg">

Parece uma abertura, mas não tem sua dupla de fechamento. Essas tags são chamadas de vazias. Observe que os atributos não são o conteúdo da tag, embora estejam relacionados a ela.

Nem sempre é fácil determinar se uma tag está vazia ou se é necessário fechá-la em algum lugar mais adiante no texto de um documento. Apesar do fato de que os nomes das tags vazias válidas são definidos nas especificações, há casos em que outras tags permanecem não fechadas. Além disso, devido ao parentesco de HTML com XML (e a existência de variedades como XHTML), alguns paginadores formatam as tags vazias da seguinte forma:

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

Observe que a barra '/' antes de '>' é supérflua do ponto de vista do HTML5 estrito. Mas todos esses recursos são encontrados em páginas da Web reais e, portanto, no analisador de HTML, é preciso estar pronto para eles.

Em princípio, os nomes de tags e atributos interpretados pelos navegadores são padronizados, mas HTML também pode conter elementos personalizados, alguns dos quais são ignorados pelos navegadores se o desenvolvedor da página não os “liga” ao DOM usando uma API especial. Mas, em qualquer caso, todas as tags podem conter informações úteis.

O analisador pode ser pensado como una máquina de estados finitos avançando no texto letra por letra e mudando de estado de acordo com o contexto. Graças à breve descrição da estrutura de tags mencionada acima, fica claro que inicialmente o analisador está fora de qualquer tag (chamamos esse estado de “blank”). Em seguida, se aparecer o caractere '<', estaremos na tag de abertura (estado "insideTagOpen") até ser encontrado '>'. Se houver uma sequência '</', estaremos na tag de fechamento (estado "insideTagClose"), e assim por diante. Consideraremos os outros estados na implementação do analisador.

Durante a transição entre estados, podemos selecionar informações estruturadas a partir da localização atual no documento, porque conhecemos o significado do estado. Por exemplo, se estivermos na tag de abertura, poderemos selecionar seu nome como uma linha entre o último '<' e o espaço subsequente ou '>' (dependendo da presença de atributos). Com base nos dados recebidos, o analisador cria objetos de uma determinada classe DomElement. Além do nome, atributos e conteúdo, esses objetos formam uma hierarquia repetindo a estrutura de anexado de tags. Em outras palavras, cada objeto tem um pai (com exceção do elemento raiz que descreve o documento inteiramente) e um array opcional de objetos-filhos.

No resultado do analisador, obtemos uma árvore de objetos completa na qual um objeto corresponde a uma tag no documento de origem.

Os seletores CSS descrevem a notação padrão para a seleção condicional de objetos com base em seus parâmetros e posicionamento mútuo na hierarquia. A lista completa de seletores é bastante extensa. Habilitamos apenas a parte deles incluída nos padrões CSS1, CSS2 e CSS3.

Aqui está a lista dos componentes principais dos seletores:

  • * — qualquer objeto (seletor universal);
  • .value — objeto com o atributo 'class' com valor "value"; exemplo: <div class="example"></div>; seletor adequado: .example;
  • #id — objeto com atributo 'id' com valor "value"; para a tag <div id="unique"></div> é ativado o seletor: #unique;
  • tag — objeto com nome 'tag'; para encontrar todos os 'div', como os mencionados acima ou <div>text</div>, usamos o seletor: div;
Eles podem ser complementados à direita com pseudo-classes:

  • :first-child — o objeto é o primeiro filho dentro do pai;
  • :last-child — o objeto é o último filho dentro do pai;
  • :nth-child(n) — o objeto vai com o número especificado na lista de nós filhos de seu pai;
  • :nth-last-child(n) — o objeto vai com o número especificado na lista de nós filhos do pai durante a numeração reversa;

Finalmente, o único seletor pode ser complementado pela condição dos atributos:
  • [attr] — o objeto tem um atributo 'attr' (não importa qual seu valor e se esse valor existe);
  • [attr=value] — o objeto tem um atributo 'attr' com valor 'value';
  • [attr*=text] — o objeto tem um atributo 'attr' com valor contento a substring 'text';
  • [attr^=start] — o objeto tem um atributo 'attr' com valor que começa com a string 'start';
  • [attr$=end] — o objeto tem um atributo 'attr' com valor que termina com a substring 'end';

Se necessário, é permitido especificar vários pares de colchetes com atributos diferentes.

O seletor simples é um seletor de nome ou seletor universal, opcionalmente seguido por uma classe, identificador, zero ou mais atributos ou pseudo-classe em qualquer ordem. Um seletor simples seleciona um elemento quando todos os componentes do seletor coincidem com as propriedades do elemento.

O seletor CSS (ou seletor completo) é uma cadeia de um ou mais seletores simples unidos por caracteres combinadores (' ' (espaço), '>', '+', '~'):
  • container element — objeto 'element' aninhado no objeto 'container' num nível arbitrário;
  • parent > element — objeto 'element' tem um pai direto 'parent' (o nível de aninhamento é 1);
  • e1 + element — objeto 'element' tem um pai comum com 'e1' e o segue imediatamente;
  • e1 ~ element — objeto 'element' tem um pai comum com 'e1' e o segue a qualquer distância;

Até agora, estudamos apenas a teoria. Vejamos como ela funciona na prática.

Em qualquer navegador moderno, pode-se ver o HTML da página aberta no momento. Por exemplo, no Chrome, basta executar o comando 'View page source' do menu de contexto ou abrir a janela do desenvolvedor (Developer tools, Ctrl+Shift+I). Na janela do desenvolvedor, há uma guia Console na qual você pode tentar encontrar elementos usando seletores CSS. Para usar um seletor, basta chamar a função document.querySelectorAll no console (ela está incluída na API de todos os navegadores).

Por exemplo, na página inicial dos fóruns https://www.mql5.com/pt/forum é possível executar o comando (código JavaScript):

document.querySelectorAll("div.widgetHeader")

Como resultado, temos uma lista de elementos (tags) 'div', que têm a classe "widgetHeader". Claro, eu não escolhi este seletor aleatoriamente, mas, sim, depois de olhar para o código fonte da página, a partir dela fica claro que os nomes dos fóruns são redigidos com esse estilo.

Se expandirmos o seletor da seguinte maneira:

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

Obteremos uma lista de cabeçalhos de fóruns, eles são formatados como hiperlinks 'a', que são os primeiros elementos filhos em cada bloco 'div' selecionado na primeira etapa. Vejamos como fica isso (depende da versão do navegador):

Página da Web MQL5 e resultado da seleção de seus elementos HTML usando seletores CSS

Página da Web MQL5 e resultado da seleção de seus elementos HTML usando seletores CSS

Naturalmente, na vida real, é necessário analisar de forma semelhante o código HTML de outros sites, localizar elementos e descobrir seletores CSS para eles. Na janela do desenvolvedor, está a guia Elements (ou com um nome semelhante), nela pode-se selecionar qualquer tag no documento (ela é destacada na página) e examinar seletores CSS para ela, permitindo que você aprenda gradualmente seletores e, consequentemente, faça cadeias manualmente. Um pouco mais tarde, mostraremos como escolher seletores de acordo com uma página da Web específica.


Desenho

Consideremos em nível global de quais classes precisamos. Atribuímos o processamento inicial do texto HTML à classe HtmlParser. Ela varre o texto em busca de caracteres de marcação '<', '/', '>' (e de outros) e, segundo as regras da máquina descrita na seção anterior, cria objetos da classe DomElement: um para cada tag vazia ou um par de tags de abertura-fechamento. Dentro da tag de abertura, pode haver atributos que também é preciso ler e salvar no objeto DomElement atual, isso é feito pela classe AttributesParser. Ela também funciona com o princípio da máquina de estados finitos.

Os objetos DomElement são criados pelo analisador levando em conta a hierarquia que repete o aninhamento de tags. Por exemplo, se o texto contiver uma tag 'div' contendo vários parágrafos (isto é, tags 'p'), eles serão convertidos em objetos filhos para um objeto descrevendo 'div'.

O objeto raiz inicial contém o documento inteiro. Por analogia com o navegador (que fornece o método document.querySelectorAll), na classe DomElement reservamos um método para consultar objetos correspondentes aos transferidos pelos seletores CSS. No entanto, também devemos pré-analisar os próprios seletores e convertê-los de formato de string em objetos: armazenamos o único componente do seletor na classe SubSelector e o seletor simples inteiramente, na classe SubSelectorArray.

Quando tenhamos uma árvore DOM finalizada na saída do analisador, podemos solicitar ao objeto raiz (ou, em princípio, a qualquer outro) DomElement todos os seus elementos subordinados que correspondem aos parâmetros dos seletores. Todos os elementos selecionados são colocados numa lista iterável DomIterator. Por simplicidade, nós a implementamos como um herdeiro de DomElement, no qual um array de nós filhos é usado como o repositório dos elementos encontrados.

Configurações com regras para processamento de sites específicos ou arquivos HTML, bem como resultados do algoritmo podem ser convenientemente armazenados numa classe que combina propriedades de mapa (map, isto é, fornece acesso a valores segundo os nomes dos atributos correspondentes) e também de array (acesso a valores segundo índice). Escolhemos o nome IndexMap para esta classe.

Consideramos a possibilidade de colocar IndexMap juntamente, pois, em particular ao coletar dados de tabela de páginas da Web, geramos uma lista de linhas, cada uma contendo uma lista de colunas. Segundo ambas as dimensões podemos salvar os nomes dos elementos originais. Isso é especialmente útil quando faltam alguns dos elementos num documento da Web (acontece com frequência), o que faz com que durante uma indexação simples sejam ignoradas informações importantes sobre quais faltam dados. Como um bônus, vamos "ensinar" o IndexMap a serializar em texto de múltiplas linhas, incluindo o formato CSV. Isso é útil ao converter páginas HTML em dados tabulares. Se desejar, você pode substituir a classe IndexMap pela sua sem perder a funcionalidade principal.

Aqui estão as classes descritas num diagrama UML:

Diagrama UML de classes implementando seletores CSS em MQL

Diagrama UML de classes implementando seletores CSS em MQL



Implementação

HtmlParser

Na classe HtmlParser, descrevemos as variáveis necessárias para varrer o texto fonte e gerar a árvore de objetos, além de organizar o algoritmo do autômato.

A posição atual no texto é armazenada na variável offset. A raiz da árvore resultante e o objeto atual (no contexto do qual a varredura está sendo executada) são representados pelos ponteiros root e cursor. Nós veremos seu tipo DomElement posteriormente. Carregamos a lista de tags (que podem estar vazias de acordo com a especificação HTML) no mapa empties (é inicializado no construtor, mostrado abaixo). Finalmente, para a descrição dos estados do autômato, consideramos a variável state, que é uma enumeração do tipo StateBit.

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

class HtmlParser
{
  private:

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

A enumeração StateBit possui elementos que descrevem os seguintes estados do analisador, dependendo da posição atual no texto:

  • blank — fora da tag;
  • insideTagOpen — dentro da tag de abertura;
  • insideTagClose — dentro da tag de fechamento;
  • insideComment — dentro do comentário (comentários no código HTML são colocados em tags do tipo <!-- comentário -->); enquanto o analisador está dentro do comentário, nenhum objeto é gerado, não importa quais tags são encontradas;
  • insideScript — dentro do script; este estado deve ser destacado, porque no código javascript também existem subsequências que são interpretadas como tags HTML, sem serem elementos DOM, mas, sim, parte de um script);

Além disso, no analisador descrevemos strings constantes que usamos para pesquisar marcação:

    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;

Diretamente, o construtor do analisador inicializa todas essas variáveis:

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

Aqui é usado o array de strings empty_tags, que é pré-conectado a partir de um arquivo de texto externo:

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

Este é o seu conteúdo (tags vazias são válidas, mas a lista não é exaustiva):

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

No destruidor do analisador, não se esqueça de excluir a árvore DOM:

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

O trabalho principal é executado pelo método parse:

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

É inserido o texto da página da Web, é criada a raiz vazia DomElement, o cursor é posicionado nela e a posição atual no texto (offset) é colocada no início. Em seguida, num ciclo, até que todo o texto seja lido com êxito, é chamado o método auxiliar processText. Nele entra em ação o "autômato", que por padrão está no 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);
      }

O algoritmo procura no texto o símbolo '<' e, se não o encontrar, é porque não há mais tags, o que indica fim do processamento (para isso, é retornado false). Se o símbolo for encontrado e houver um fragmento de texto entre a nova tag encontrada e a posição anterior (offset), esse fragmento é o conteúdo da tag atual (esse objeto é acessível pelo ponteiro cursor), consequentemente, o texto é adicionado ao objeto chamando cursor.setText().

Em seguida, a posição no texto é movida para o início da tag recém-encontrada e, dependendo da assinatura que segue '<' (COMMENT_START, TAG_CLOSE_START, TAG_OPEN_START), o analisador alterna para o novo estado correspondente. A função IsString é um método de comparação de string auxiliar pequeno usando StringSubstr.

Em qualquer caso, o método processText retorna true, o que significa que o método é imediatamente chamado novamente num ciclo (lembramo-nos do método de chamada parse), mas agora o analisador está num estado diferente. Se estiver na tag de abertura, o código a seguir é acionado.

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

Se no texto não for encontrado nem o espaço nem o caractere '>', a sintaxe do HTML é infringida e retornamos false. A essência do que acontece está a seguir, na seleção do nome da tag.

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

Aqui nós criamos um novo objeto com o nome detectado, além disso, o objeto atual (cursor) é usado como o nó pai para ele.

Agora precisamos processar os atributos, se houver.

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

O método parseAttributes "mora" diretamente na classe DomElement, que discutiremos mais adiante.

Se a tag não estiver fechada, é preciso verificar se é uma daquelas que podem estar vazias e, em caso afirmativo, "fechá-la" implicitamente.

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

Dependendo de se a tag está fechada ou não, nós nos movemos "em profundidade" através da hierarquia de objetos, fazendo com que o recém criado (e) seja o objeto atual ou permaneça no contexto do objeto anterior. Em qualquer caso, a posição no texto (offset) é movida para o último caractere lido, isto é, '>'.

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

Um caso especial é o script. Se encontramos a tag <script>, o analisador muda para o estado insideScript, caso contrário, para o já conhecido estado blank.

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

No estado da tag de fechamento, o código a seguir é acionado.

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

Novamente, procuramos '>', que deve necessariamente estar de acordo com a sintaxe HTML, e se não estiver, interrompemos o processo. Em caso de sucesso, selecionamos o nome da tag. Ele é necessário para verificar se a tag de fechamento corresponde à tag de abertura. Se não corresponder, será necessário engolir esse erro de maneira astuta e tentar continuar analisando.

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

Como estamos processando a tag de fechamento, o contexto do objeto atual é finalizado e o analisador retorna para o DomElement pai:

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

Em caso de sucesso, o estado do analisador novamente se tornará igual a blank.

Quando o analisador está dentro de um comentário, obviamente procura o final do comentário.

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

Quando o analisador está dentro de um script, procura o final do 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);
    }

Aqui, na verdade, é toda a classe HtmlParser. Agora conheçamos o DomElement.


DomElement, início

A classe DomElement possui variáveis para armazenar o nome (obrigatório), conteúdo, atributos, referências ao pai e array de elementos filhos (ele é protegido protected porque é usado na classe derivada DomIterator).

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

  protected:
    DomElement *children[];

O conjunto de construtores dificilmente requer explicação:

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

Certamente, na classe existem os métodos "setter" e "getter" (eles são omitidos no artigo), bem como um conjunto de métodos para trabalhar com elementos filhos (damos apenas protótipos):

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

O método parseAttributes usado anteriormente no estágio de análise delega todo o trabalho â classe auxiliar AttributesParser.

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

Recebida uma cadeia simples data na entrada, o método preenche o mapa attributes com as propriedades encontradas.

O código completo da classe AttributesParser pode ser encontrado nos arquivos anexados. A classe em si é pequena e funciona no mesmo princípio de autômato que o HtmlParser, embora o número de estados seja apenas dois:

enum AttrBit
{
  name,
  value
};

Como a lista de atributos é uma string que consiste em pares do tipo name="value", AttributesParser está sempre no nome ou no valor. Este analisador poderia ser implementado usando a função StringSplit, mas devido a possíveis desvios na formatação (por exemplo, a presença ou ausência de aspas, o uso de espaços dentro das aspas, etc.), foi escolhida a abordagem do autômato.

Retornando à classe DomElement, notamos que a parte principal do trabalho deve ser realizada por métodos para selecionar elementos filhos correspondentes aos seletores CSS especificados. Mas antes que possamos começar a considerar essa funcionalidade, é necessário descrever as classes de seletores.

SubSelector e SubSelectorArray

A classe SubSelector descreve um componente de um seletor simples. Por exemplo, no seletor simples "td[align=left][width=325]" existem três componentes:

  • nome da tag — td
  • condição do atributo align — [align=left]
  • condição do atributo width — [width=325]
No seletor simples "td:first-child" existem dois componentes:
  • nome da tag — td
  • condição do índice filho usando a pseudo-classe :first-child
No seletor simples "span.main[id^=calendarTip]" existem novamente três:
  • nome da tag — span
  • classe — main
  • o atributo id deve começar com a string calendarTip

Aqui está a classe em si:

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

A variável type contém o primeiro caractere do seletor ('.', '#', '[') ou, por padrão, 0, que corresponde ao seletor de nome. A variável value contém a substring após o caractere, ou seja, o realmente procurado. Se uma pseudo-classe for encontrada na string do seletor, seu identificador será gravado no campo modifier. Finalmente, ao descrever os seletores ":nth-child" e ":nth-last-child" entre parênteses, é indicado o índice do elemento a ser procurado, portanto, devemos salvá-lo no campo param (na implementação atual ele só pode ser um número, mas, em princípio, são permitidas fórmulas especiais, assim, o campo é declarado string).

A classe SubSelectorArray é composta por muitos componentes, por isso, nós declaramos um array de seletores nela:

class SubSelectorArray
{
  private:
    SubSelector *selectors[];

SubSelectorArray é um seletor simples inteiramente. Para seletores CSS completos, a classe não é necessária porque eles são processados sequencialmente, passo a passo, isto é, um seletor simples em cada nível da hierarquia.

Juntamos seletores de pseudo-classes suportáveis no mapa mod para poder obter imediatamente o modificador correspondente a partir do 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);
    }

A classe TypeContainer é um wrapper de template para valores adicionados ao IndexMap.

Lembre-se de que os membros estáticos, neste caso, objetos para um mapa, devem ser inicializados após a descrição da classe:

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

Mas voltemos à classe SubSelectorArray.

Quando é necessário adicionar um componente de seletor simples a um array, é chamada a função 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);
      }
    }

Para ela é transferido o primeiro caractere (tipo) e a próxima linha, que analisa o nome do objeto desejado, uma pseudo-classe opcional e um parâmetro. Em seguida, tudo isso é passado para o construtor SubSelector, e um novo componente seletor é adicionado ao array selectors.

A função add é usada indiretamente a partir do construtor do seletor simples da seguinte maneira:

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

A função createFromString obtém uma representação textual do seletor CSS e, num ciclo, o verifica em busca de caracteres especiais iniciais '.', '#' ou '[', determina onde dado componente termina no texto e chama o método add para as informações selecionadas. O ciclo continua enquanto a cadeia de componentes continua.

O texto completo da classe SubSelectorArray pode ser encontrado no adendo.

Agora é hora de retornar à classe DomElement. Aqui começa a parte mais difícil de entender.


DomElement, continuação

Para procurar elementos que correspondam aos seletores especificados (em suas representações textuais), é usado o método querySelect. É nele que o seletor CSS completo é "cortado" em seletores simples, que são convertidos sequencialmente no objeto SubSelectorArray. Para cada seletor simples, é pesquisada uma lista de elementos adequados, em seguida, em relação a esses elementos são pesquisados outros elementos que são adequados para o próximo seletor simples e assim por diante, até o último seletor simples ou até que a lista de elementos encontrados esteja vazia.

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

Como um valor de retorno, vemos a classe desconhecida DomIterator, mas, como observado acima, ela é a herdeira de DomElement. Ela fornece uma funcionalidade extra em comparação com DomElement (permite que você possa "folhear" os elementos filhos), assim, por enquanto, deixamos o DomIterator nos bastidores. A dificuldade está em outro lugar.

A análise da string do seletor é executada caractere por caractere, para o qual são usadas diversas variáveis locais. O caractere atual é armazenado na variável c (character), enquanto o caractere anterior, na variável p (previous). Se um caractere for um dos caracteres combinadores ('', '+', '>', '~'), ele será armazenado na variável (a), mas não será usado até que seja definido o próximo seletor simples.

Como você se lembra, os combinadores estão entre seletores simples, e a operação que eles definem só pode ser executada quando o seletor à direita é totalmente lido. Consequentemente, o último combinador lido (a) passa primeiro pela fase de "superexposição": variável (a) não é usada até que o próximo combinador apareça ou a string termine, em ambos os casos é um sinal de que o seletor é formado. É somente naquele momento que o combinador "antigo" (b) entra em ação e depois é substituído por um novo (a). Provavelmente este é o caso quando o próprio código se torna mais claro do que sua descrição.

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

A variável cursor sempre aponta para o primeiro caractere a partir do qual começa a subsequência com um seletor simples (ou seja, para o caractere imediatamente após o combinador anterior ou para o início da string). Quando encontramos outro combinador, copiamos para a variável selector a substring do cursor ao caractere atual (i).

Às vezes existem vários combinadores em sucessão. Como regra, isso acontece quando outros caracteres combinadores cercam os espaços, mas o espaço também é um combinador. Por exemplo, as entradas "td> span" e "td > span" são equivalentes, mas os espaços são inseridos no segundo para melhorar a legibilidade. Tais situações são processadas pela string:

a = MathMax(c, p);

Ela compara os caracteres atuais e anteriores quando ambos são combinadores. Em seguida, usando o fato de que o espaço tem o menor código, sempre escolhemos um combinador mais "antigo". O array combinador é obviamente definido da seguinte forma:

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

A verificação da entrada do caractere neste array é realizada pela função auxiliar simples isCombinator.

Se houver dois combinadores além de um espaço, trata-se de um seletor incorreto, e o comportamento não é definido pelas especificações, no entanto, nosso código não perde desempenho e sugere um comportamento consistente.

Se o caractere atual não é um combinador e o caractere anterior era um combinador, a execução cai numa ramificação marcada com o comentário action. Aqui nós memorizamos o tamanho atual do array de DomElements selecionados para este momento chamando:

index = result.getChildrenCount();

Inicialmente, o array é, obviamente, vazio e o índice é 0.

Criamos um array de objetos seletores correspondente ao seletor simples atual - string selector:

SubSelectorArray selectors(selector);

Em seguida, chamamos o método find, que ainda precisa ser considerado.

find(b, &selectors, result);

Por dentro, passamos o caractere combinador (o penúltimo, da variável b), um seletor simples para procurar elementos e um array aonde adicionar os resultados.

Depois disso, movemos a "fila" de combinadores para frente, copiando o último caractere combinador encontrado (mas ainda não processado) da variável a para a variável b, e removemos dos resultados tudo o que estava antes de chamar find com:

result.removeFirst(index);

O método removeFirst é definido no DomIterator e simplesmente remove todos os primeiros elementos do array até o número especificado. Isso é feito porque, durante o processamento de cada seletor simples, impomos condições cada vez mais restritas à seleção de elementos, e tudo que selecionado anteriormente deixa de estar em conformidade, assim, os elementos recém-adicionados (que satisfazem as condições mais rigorosas) começam com o número index.

Processamento semelhante (marcado com o comentário action) também é executado quando chegamos ao final da linha de entrada. Neste caso, é necessário processar o último combinador, aguardando a sua vez, em conjunto com o resto da linha (a partir da posição cursor).

Agora, vejamos dentro do método find.

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

Se um dos combinadores ('', '>') que impõe condições ao aninhamento de tags é passado para a entrada, é necessário chamar recursivamente verificações para todos os elementos filhos. Também nessa ramificação, levamos em conta o combinador especial '/', usado no início da pesquisa no método de chamada.

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

O método match será discutido abaixo. Por enquanto, é importante saber que ele retorna true se o objeto corresponder aos seletores passados e, caso contrário, false. Quando a pesquisa está apenas começando (o combinador op = '/'), ainda não há “combinações” e, portanto, todas as tags que satisfazem os seletores são adicionadas ao 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 o combinador '', verifica-se se o DomElement atual ou qualquer um de seus ancestrais em qualquer geração já está presente nos resultados (output). Isso significa que um novo elemento filho que satisfaz os critérios de pesquisa está aninhado no pai. Essa é a tarefa desse combinador.

O combinador '>' funciona de maneira semelhante, mas deve rastrear apenas "parentes" imediatos, portanto, verificamos apenas se o DomElement atual está presente nos resultados intermediários. Se este é o caso, significa que ele foi previamente selecionado na saída de acordo com as condições do seletor à esquerda do combinador, enquanto seu i-ésimo elemento filho acaba de satisfazer o seletor à direita do combinador.

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

Em seguida, verificações semelhantes precisam ser realizadas na profundidade da árvore DOM, por isso, chamamos recursivamente o método find para os elementos filhos.

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

Combinadores '+' e '~' impõem condições indicando que dois elementos pertencem ao mesmo pai.

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

Um dos elementos já deve estar selecionado nos resultados usando o seletor à esquerda do combinador. Quando esta condição é cumprida, resta verificar se os “irmãos e irmãs” são o seletor certo (“irmãos e irmãs” são elementos filhos do pai do nó atual).

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

A diferença no processamento de combinadores '+' e '~' está apenas no fato de que, no caso de '+', os elementos devem ser vizinhos imediatos e, no caso de '~', pode haver um número arbitrário de outros "irmãos e irmãs" entre eles. Portanto, para '+' o ciclo é executado apenas uma vez, para o próximo elemento no array de filhos. Dentro do ciclo, vemos novamente a chamada para a função match (sobre ela falaremos dois parágrafos abaixo).

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

Depois de todas as verificações, é necessário ir para o próximo nível na hierarquia da árvore de elementos DOM e chamar find para os nós filhos.

Esse é todo o método find. Agora consideremos a função match. Este é o último ponto de nossa ligeiramente prolongada história sobre a implementação de seletores.

Essa função verifica se o objeto atual corresponde a toda a cadeia de componentes de um seletor simples passado pelo parâmetro de entrada. Se pelo menos um componente no ciclo não corresponder às propriedades do elemento, a verificação falhará.

    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
          }

O seletor de tipo 0 é um nome de tag ou pseudo-classe. Se houver um asterisco no seletor, qualquer tag é apropriada, caso contrário, comparamos a string no seletor com o nome da tag:

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

As pseudo-classes atualmente implementadas impõem um limite no número do elemento atual no array de elementos filhos de seu pai, por isso, analisamos os í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;
            }
          }
        }

Seletor '.' impõe uma restrição no 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;
          }
        }

O seletor '#' impõe uma restrição no 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;
          }
        }

Selector '[' fornece a capacidade de especificar um conjunto arbitrário de atributos, além disso, a comparação de valores pode ser feita não apenas estritamente, mas, também, pela ocorrência de uma substring (sufixo '*'), início ('^') e 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;

    }

Observe que o atributo "class" também é suportado e processado aqui e, como no caso do seletor '.', a comparação é feita para saber da presença da classe desejada entre as muitos outras prováveis. Em HTML, é frequentemente usado um mecanismo em que um elemento recebe várias classes de uma só vez (indicadas no atributo class com um espaço).

Apresentemos o resultado intermediário. Na classe DomElement, implementamos o método querySelect, que toma como parâmetro uma string com um seletor CSS completo e retorna um objeto DomIterator (na verdade, um array de elementos correspondentes encontrados). Dentro de querySelect, a string do seletor CSS é dividida numa sequência de seletores simples e caracteres combinadores entre eles. Para cada seletor simples, é chamado o método find com uma indicação do combinador, adicionalmente, este método atualiza a lista de resultados, invocando-se recursivamente para elementos filhos. No método match, os componentes do seletor simples são comparados com as propriedades de um determinado elemento.

Com a ajuda do método querySelect, podemos selecionar, por exemplo, linhas de uma tabela usando um seletor CSS e, em seguida, chamar querySelect para cada linha com outro seletor CSS para isolar células específicas. Como o trabalho com tabelas é amplamente exigido, criamos o método tableSelect na classe DomElement, na qual implementamos a abordagem descrita. Seu código é dado de forma simplificada.

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

O seletor para linhas é especificado no parâmetro rowSelector, os seletores para células são especificados no array columSelectors.

Depois que os elementos do documento são selecionados, precisamos obter algumas informações deles, por exemplo, o texto ou o valor do atributo. Para determinar onde cortar as informações do elemento, é usado o array dataSelectors, uma vez que para cada coluna da tabela, o método de extração de dados pode ser diferente.

Se dataSelectors[i] for uma linha vazia, leremos o conteúdo de texto da tag (o que está entre as partes de abertura e fechamento, por exemplo, obtemos "100%" da tag "<p>100%</p>"). Se dataSelectors[i] for uma linha, supomos que é o nome de atributo e, portanto, tenha seu valor.

Vejamos implementação linha por linha:

      DomIterator *r = querySelect(rowSelector);

Aqui, obtemos a lista de elementos resultante pelo seletor de linha.

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

Aqui criamos um mapa vazio, ao qual adicionamos os dados tabulares, e preparamos um ciclo de objetos de linha. Aqui está o ciclo em si:

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

Recebemos a próxima linha (e), criamos para ela um mapa-contêiner (row), de onde adicionamos as células e executamos o ciclo por colunas:

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

Em cada objeto de linha, usando o seletor apropriado, selecionamos a lista de objetos célula (d). De cada célula encontrada, selecionamos os dados e salvamos no 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);
          }

Aqui, para simplificar o código, são usadas chaves inteiras, mas o código fonte completo permite tomar identificadores de elemento como chaves.

Se a célula apropriada não for encontrada, marcamo-la como vazia.

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

Anexamos a linha row preenchida à tabela data.

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

Assim, na saída, obtemos um mapa de mapas (map of map), ou seja, uma tabela com números de linha para a primeira dimensão e números de coluna para a segunda. Se necessário, a função tableSelect pode ser adaptada a outros contêineres de dados.

Para aplicar todas as classes acima descritas, foi criado um especialista em EA-utilitário que não opera.

EA-utilitário WebDataExtractor

Esse EA é projetado para converter dados de páginas da Web numa estrutura tabular, com a capacidade de salvar o resultado num arquivo CSV.

Como parâmetros de entrada, o EA recebe uma referência para a fonte de dados (pode ser um arquivo local ou uma página na Internet, que para baixarmos usamos WebRequest), seletores para linhas e colunas, bem como o nome do arquivo CSV. Os principais parâmetros de entrada estão listados abaixo:

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

No parâmetro URL, deve-se especificar o endereço da página da Web (inicia com http:// ou https://) ou o nome do arquivo html local.

No parâmetro SaveName, no modo normal, é especificado o nome do arquivo CSV com os resultados. No entanto, ele pode ser usado para salvar a página baixada com a finalidade de depuração subsequente de seletores. Para trabalhar neste modo, é preciso deixar o próximo parâmetro vazio - RowSelector - que geralmente define o seletor de linha CSS.

Como existem vários seletores de coluna, eles são especificados num arquivo CSV separado, cujo nome é especificado no parâmetro ColumnSettingsFile. O formato do arquivo é o seguinte:

A primeira linha - o título, cada um subsequente - descreve um campo separado (uma coluna com dados numa linha da tabela).

O arquivo deve ter 3 colunas: nome, seletor CSS, "localizador" de dados:

  • o nome é o da i-ésima coluna no arquivo CSV de saída;
  • o seletor CSS é o seletor para a seleção do elemento a partir do qual os dados são coletados para a i-ésima coluna do arquivo CSV de saída; esse seletor é usado dentro de cada elemento DOM selecionado anteriormente na página da Web usando o seletor RowSelector; para selecionar um elemento diretamente, é necessário especificar '.';
  • "localizador" de dados determina de qual parte do elemento devem ser tomados dados, pode-se especificar o nome do atributo ou deixá-lo vazio para obter o conteúdo de texto da tag.

Os parâmetros TestQuery e TestSubQuery permitem testar seletores para uma linha e uma coluna com saída para o log, sem salvar no arquivo CSV e sem definir arquivos para todas as colunas.

Aqui está a principal função de trabalho do EA resumidamente.

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

Aqui lemos a página HTML do arquivo ou baixamos da Internet. Agora, para converter o documento numa hierarquia de objetos DOM, criamos um objeto HtmlParser e iniciamos a análise:

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

Se os seletores de teste forem especificados, nós os processamos com as chamadas 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);
  }

No modo normal de operação, lemos o arquivo das configurações das colunas e chamamos a função tableSelect:

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

Se um arquivo for especificado para salvar os resultados em CSV, delegamos essa tarefa ao mapa data.

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

Tentemos colocar o EA em prática.


Uso prático

Os traders estão familiarizados com alguns arquivos HTML padrão, como relatórios de teste e relatórios de negociação gerados pelo MetaTrader. Às vezes, recebemos esses arquivos de conhecidos ou os baixamos da Internet e queremos analisar num gráfico, para o qual precisamos converter dados de HTML para uma exibição de tabela, no caso mais simples, para o formato CSV.

Os seletores CSS em nosso utilitário permitem fazer isso automaticamente.

Olhemos dentro dos arquivos HTML. Aqui está a aparência e parte do código HTML de um relatório de negociação do MetaTrader 5 (arquivo ReportHistory.html, em anexo).

Aparência e parte do código HTML do relatório de negociação

Aparência e parte do código HTML do relatório de negociação

Aqui está a aparência e parte do código HTML de um arquivo do testador MetaTrader 5 (arquivo Tester.html, em anexo).

Aparência e parte do código HTML do relatório do testador

Aparência e parte do código HTML do relatório do testador

De acordo com a apresentação externa no relatório de negociação, há duas tabelas: uma contendo ordens (Orders) e outra, operações (Deals). No entanto, se olharmos para o layout interno, verifica-se que se trata de uma única tabela. Todos os cabeçalhos visíveis e linhas divisórias são formados controlando os estilos das células da tabela. No entanto, precisamos aprender de alguma forma a distinguir entre ordens e operações e salvar cada uma das sub-tabelas num arquivo CSV.

A diferença entre a primeira e a segunda parte está no número de colunas: nas suas ordens há 11 e nas operações, 13. Infelizmente, o padrão CSS não permite impor condições na seleção de elementos pai (no nosso caso, linhas de uma tabela, tag tr) pelo número ou conteúdo de elementos filhos (no nosso caso, células de tabela, tag td). De fato, as possibilidades de seletores não são ilimitadas e, em alguns casos, é impossível selecionar os elementos requeridos por meios padronizados. Mas como nós mesmos estamos desenvolvendo nossa própria implementação de seletores, podemos adicionar um seletor especial não padrão para o número de elementos filhos. Este será uma nova pseudo-classe. Denotamos por ":has-n-children (n)", por analogia com ":nth-child (n)".

Neste caso, para seleção de linhas com ordens, é adequado o seletor:

tr:has-n-children(11)

No entanto, o problema ainda não foi resolvido, porque esse seletor seleciona, além das linhas com dados, o cabeçalho da tabela. Para eliminar isso, preste atenção ao design de cores das linhas de dados, pois o atributo bgcolor é definido para elas e o valor de cor alterna para as linhas pares e ímpares (#FFFFFF e # F7F7F7). Cor, isto é, o atributo bgcolor também é usado para o cabeçalho, mas o valor é # E5F0FC. Assim, as linhas de dados têm cores claras com bgcolor começando com "#F". Adicionamos esta condição ao seletor:

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

Ele identifica corretamente todas as linhas com ordens.

Os parâmetros de cada ordem são lidos a partir das células da linha. Para fazer isso, escrevemos o arquivo de configuração 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),

Nele, todos os campos são identificados simplesmente pelo seu número de sequência. Em outros casos, podem ser necessários seletores mais inteligentes com atributos e classes.

Para obter uma tabela de operações, basta substituir o número de elementos filhos de 11 para 13 no seletor de linha:

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

O arquivo de configuração ReportHistoryDeals.cfg.csv está anexado, é semelhante ao mencionado acima.

Se você agora executar o WebDataExtractor e especificar os seguintes parâmetros de entrada (o arquivo webdataex-report1.set está anexado):

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

Nós obteremos o arquivo ReportOrders.csv correspondente ao relatório HTML original:

Arquivo CSV obtido como resultado da aplicação de seletores CSS ao relatório de negociação

Arquivo CSV obtido como resultado da aplicação de seletores CSS ao relatório de negociação

Para obter uma tabela de operações, usamos as configurações anexadas webdataex-report2.set.

A boa notícia é que os seletores que criamos também são adequados para relatórios do testador. Os arquivos webdataex-tester1.set e webdataex-tester2.set anexados permitem converter um exemplo de relatório HTML Tester.html em arquivos CSV.

Atenção! O layout de muitas páginas da Web, bem como os arquivos HTML gerados no MetaTrader mudam de tempos em tempos. Isso pode levar ao fato de que os seletores anteriores deixam de fazer seu trabalho, mesmo que a apresentação externa das páginas não tenha sido alterada. Nesses casos, é preciso analisar novamente o código HTML e modificar os seletores CSS.

Agora vejamos a conversão para o relatório do testador do MetaTrader 4, pois ele permite mostrar algumas técnicas interessantes na seleção de seletores CSS. Pode-se usar o StrategyTester-ecn-1.htm anexado como um arquivo de verificação.

Existem duas tabelas nesses arquivos: a primeira contém os resultados do teste e a segunda contém as operações de negociação. Para selecionar a segunda tabela, usamos o seletor "table ~ table". Dentro da tabela de operações, é preciso descartar a primeira linha porque contém um cabeçalho. Para isso, usamos o seletor "tr + tr".

Assim, juntando-os, nós temos um seletor para a seleção de linhas de trabalho:

table ~ table tr + tr

Na verdade, significa: selecione a tabela após a tabela (ou seja, a segunda) e, dentro dessa tabela, selecione cada linha que tenha a linha anterior (ou seja, todas, exceto a primeira, com cabeçalhos).

As configurações para extrair os parâmetros das operações das células são anexadas num arquivo test-report-mt4.cfg.csv. Vale a pena notar que o campo de data é processado pelo seletor de classe:

DateTime,td.msdate,

Ou seja, as tags td que possuem o atributo class="msdate" são apropriadas.

O arquivo de configuração completo para o utilitário é webdataex-tester-mt4.set.

Exemplos adicionais de uso e personalização de seletores CSS podem ser encontrados na página discussões WebDataExtractor.

Note-se que o utilitário pode fazer muito mais:
  • realizar a substituição automática de strings (por exemplo, alterar os nomes de países para símbolos monetários ou uma descrição da prioridade da notícia para um número);
  • apresentar a árvore DOM no log para encontrar seletores adequados sem um navegador;
  • baixar e converter páginas da Web segundo temporizador ou segundo solicitação de uma variável global;

Se você precisar de ajuda para configurar seletores CSS para converter uma página da Web específica, pode comprar o WebDataExtractor (para MetaTrader 4, para MetaTrader 5) e obter recomendações como parte do suporte ao produto. No entanto, a disponibilidade de códigos fonte permite usar todas as funcionalidades e as expandir arbitrariamente de forma absolutamente gratuita.


Fim do artigo

Consideramos a abordagem de seletores CSS, que é um dos principais padrões na interpretação de documentos da Web. Graças à implementação dos seletores CSS usados com mais frequência em MQL, conseguimos personalizar e converter com flexibilidade páginas HTML, incluindo documentos padrão do MetaTrader, em dados estruturados sem usar software de terceiros.

Algumas outras tecnologias que fornecem ferramentas igualmente versáteis para o processamento de documentos da Web estão nos bastidores, especialmente considerando o fato de que o MetaTrader faz uso amplo não apenas de HTML, mas também de XML. Em particular, XPath e XSLT podem ser de interesse potencial para os traders. Tudo isto são os próximos estágios que podem desenvolver a ideia de automatizar sistemas de negociação baseados em padrões da Web. O suporte aos seletores CSS em MQL é apenas o primeiro passo para esse objetivo.

Traduzido do russo pela MetaQuotes Software Corp.
Artigo original: https://www.mql5.com/ru/articles/5706

Arquivos anexados |
html2css.zip (35.94 KB)
Biblioteca para desenvolvimento fácil e rápido de programas para a MetaTrader (parte I). Conceito, gerenciamento de dados e primeiros resultados Biblioteca para desenvolvimento fácil e rápido de programas para a MetaTrader (parte I). Conceito, gerenciamento de dados e primeiros resultados

Ao analisar um grande número de estratégias de negociação, pedidos de desenvolvimento de aplicativos para os terminais MetaTrader 5 e MetaTrader 4 e vários sites sobre MetaTrader, eu cheguei à conclusão de que toda essa diversidade é baseada principalmente nas mesmas funções elementares, ações e valores que aparecem regularmente em diferentes programas. Isso resultou na biblioteca multi-plataforma DoEasy para o desenvolvimento fácil e rápido de aplicativos para a МetaТrader 5 e МetaТrader 4.

Criando um EA gradador multiplataforma Criando um EA gradador multiplataforma

Neste artigo, aprenderemos como escrever EAs que funcionam tanto no MetaTrader 4 quanto no MetaTrader 5. Para fazer isso, tentaremos escrever um que trabalhe com o princípio de criação de grades de ordens. Um gradador é um Expert Advisor cujo trabalho fundamental consiste em colocar simultaneamente e na mesma quantidade ordens limitadas tanto acima como abaixo do preço atual.

Biblioteca para desenvolvimento fácil e rápido de programas para a MetaTrader (parte II). Coleção do histórico de ordens e negócios Biblioteca para desenvolvimento fácil e rápido de programas para a MetaTrader (parte II). Coleção do histórico de ordens e negócios

Na primeira parte, nós começamos a criar uma grande biblioteca multi-plataforma, simplificando o desenvolvimento de programas para as plataformas MetaTrader 5 e MetaTrader 4. Nós criamos o objeto abstrato COrder, que é um objeto base para o armazenamento de dados do histórico de ordens e negócios, bem como as ordens à mercado e posições. Agora nós vamos desenvolver todos os objetos necessários para o armazenamento de dados do histórico da conta em coleções.

Estudo de técnicas de análise de velas (parte III): Biblioteca para trabalhar com os padrões Estudo de técnicas de análise de velas (parte III): Biblioteca para trabalhar com os padrões

O objetivo deste artigo é criar uma ferramenta personalizada que permita aos usuários receber e usar todo o array de informações sobre os padrões discutidos anteriormente. Nós vamos criar uma biblioteca de funções relacionadas aos padrões que você poderá usar em seus próprios indicadores, painéis de negociação, Expert Advisors, etc.