Извлечение структурированных данных из HTML-страниц с помощью CSS-селекторов

Stanislav Korotky | 15 марта, 2019

Среда разработки MetaTrader позволяет интегрировать программы и внешние данные, в частности, получаемые из сети Интернет через WebRequest. Наиболее универсальным и часто применяемым форматом данных в сети является HTML. В тех случаях, когда тот или иной публичный сервис не предоставляет открытый API для запросов или его протокол трудно реализовать на MQL, в ход идет давно известное средство — парсинг (разбор) страниц HTML. В частности, среди трейдеров популярны различные экономические календари, и хотя с появлением встроенного календаря это уже не столь актуально, но все же кому-то могут потребоваться новости со специфического сайта. Также часто возникает необходимость проанализировать сделки из торгового HTML-отчета, полученного со стороны.

В экосистеме MQL5 можно найти различные варианты решения этой задачи, но они, как правило, специфичны и имеют свои ограничения. Вместе с тем, существует в некотором смысле "родной" и универсальный способ поиска и выделения данных из HTML. Речь идет о селекторах CSS. В данной статье мы рассмотрим их реализацию на MQL и приведем примеры использования на практике.

Для анализа HTML потребуется создать парсер, который преобразует внутренний текст страницы в иерархию некоторых объектов — они называются объектами DOM (Document Object Model), а затем в этой иерархии можно будет находить объекты с заданными параметрами. Данный подход использует служебную информацию о структуре документа, которая недоступна во внешнем представлении.

Таким образом, можно, например, выделить в документе строки конкретной таблицы, прочитать из них заданные столбцы и получить массив со значениями, который легко сохранить в csv-файл, отобразить на графике или использовать в расчетах эксперта.


Обзор технологий HTML/CSS и DOM

Сегодня уже трудно встретить человека, который бы не знал, что такое HTML. Поэтому подробно описывать синтаксис этого языка разметки гипертекста не имеет смысла.

Первоисточником технической информации в отрасли является IETF (инженерный совет Интернета) и его спецификации — так называемые RFC (Request For Comments). В частности, для HTML есть свои спецификации, и их — великое множество (вот для примера лишь одна). Также стандарты можно найти на сайте смежной организации - W3C (World Wide Web Consortium, HTML5.2).

Эти же организации разработали и регулируют технологию CSS (Cascading Style Sheets). Она нас интересует не столько из-за возможности, как следует из названия, описывать стили представления информации на веб-страницах, а потому что в её составе были созданы селекторы CSS — специальный язык запросов для поиска подходящих элементов внутри html-страниц.

И HTML, и CSS постоянно развиваются, накапливая вереницу версий. Например, на данный момент HTML5.2 и CSS4 являются актуальными. Но обновление и расширение возможностей соседствует с таким явлением как наследование особенностей старых версий — ведь Интернет очень большой, разнородный и инертный (даже если кто-то изменит оригинал документа, наверняка найдутся его прежние копии). В результате, при написании алгоритмов с использованием веб-технологий приходится весьма творчески смотреть на спецификации, чтобы, с одной стороны, учитывать возможные устоявшиеся отклонения, а с другой - намеренно вносить упрощения, дабы не утонуть в многочисленных вариациях.

В нашем проекте мы также будем рассматривать синтаксис HTML в упрощенном виде.

Как известно, html-документ состоит из тегов, заключенных в символы '<' и '>'. Внутри тега указывается его имя и опциональные атрибуты — строковые пары вида имя="значение", причем знак равно и значение могут отсутствовать. Например, тег:

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

— это тег с именем 'a' (что трактуется браузерами как гиперссылка) и двумя параметрами: href — адрес сайта для перехода по гиперссылке и target — опция открытия сайта (в данном случае, "_blank", т.е. в новом окне браузера).

Приведенный первый тег называется открывающим. После него следует текст, то есть фактически видимое содержимое — "HTML and CSS", и затем парный закрывающий тег — он должен иметь то же имя, что и открывающий, но помечен символом '/' после '<' (все вместе складывается в '</a>'). Иными словами, открывающие и закрывающие теги идут парами и могут включать в себя другие теги, но только целиком, не внахлест. Вот пример корректного вложения:

<group attribute1="value1">

  <name>text1</name>

  <name>text2</name>

</group>

А вот такое "пересечение" тегов запрещено:

<group id="id1">

<name>text1

</group>

</name>

Но запрет только теоретический. На практике теги частенько открываются или закрываются по ошибке в неверном месте документа. Парсер должен уметь справляться с такой ситуацией.

Допускается отсутствие содержимого внутри тега, т.е. пустая строка:

<p></p>

В соответствии со стандартами некоторые теги могут (а точнее - должны) вообще не иметь содержимого. Например, тег, описывающий изображение:

<img src="/ico20190101.jpg">

выглядит как открывающий, но не имеет парного ему закрывающего. Такие теги называются пустыми. Заметьте, что атрибуты хоть и относятся к тегу, но не являются его содержимым.

Определить, является ли тег пустым или следует ожидать его закрытия где-то далее по тексту документа, не всегда просто. Несмотря на то, что имена допустимых пустых тегов определены в спецификациях, бывают случаи, когда незакрытыми остаются и другие теги. Кроме того, в силу родства HTML с XML (и существования такой разновидности как XHTML), некоторые верстальщики веб-страниц оформляют пустые теги следующим образом:

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

Обратите внимание на слэш '/' перед '>' — он лишний с точки зрения строгого HTML5. Но все эти особенности встречаются в реальных веб-страницах, и потому в парсере HTML нужно быть к ним готовыми.

В принципе, имена тегов и атрибутов, которые интерпретируются браузерами, стандартизованы, но HTML может содержать и кастомизированные элементы — браузерами они пропускаются, если разработчик страницы не "подключил" их к DOM с помощью специального скриптового API. Но в любом случае, во всех тегах может содержаться полезная информация.

Парсер можно рассматривать как автомат с конечным числом состояний, продвигающийся по тексту буква за буквой и меняющий свое состояние в соответствии с контекстом. В частности, из приведенного выше краткого описания структуры тегов ясно, что изначально парсер находится вне какого-либо тега (назовем это состояние - "blank"). Затем, если встретился символ '<', мы попадаем в открывающий тег (состояние "insideTagOpen") вплоть до момента, пока не встретится '>'. Если встретится последовательность '</', мы оказываемся в закрывающем теге (состояние "insideTagClose"), и так далее. Прочие состояния мы рассмотрим в разделе реализации парсера.

При переходе между состояниями мы получаем возможность выделить из текущего места в документе структурированную информацию, поскольку знаем смысл состояния. Например, при нахождении в открывающем теге можно выделить его имя как строку между последним '<' и последующим пробелом или '>' (в зависимости от наличия атрибутов). На основе полученных данных парсер будет создавать объекты некоего класса DomElement. Помимо имени, атрибутов и содержимого эти объекты будут формировать иерархию, повторяя структуру вложенности тегов. Иными словами, у каждого объекта будет родитель (за исключением корневого элемента, описывающего весь документ целиком) и опциональный массив дочерних объектов.

На выходе парсера мы получим полное дерево объектов, в котором один объект соответствует одному тегу в исходном документе.

Селекторы CSS описывают стандартные обозначения для условного отбора объектов на основе их параметров и взаимного размещения в иерархии. Полный перечень селекторов - довольно обширен. Мы поддержим лишь часть из них, входящую в стандарты CSS1, CSS2 и CSS3.

Вот перечень основных компонентов селекторов:

Они могут дополняться справа так называемыми псевдо-классами:

Наконец, одиночный селектор можно дополнить условием на атрибуты:

При необходимости допускается указывать несколько пар скобок с разными атрибутами.

Простой селектор — это селектор имени или универсальный селектор, за которым опционально следует класс, идентификатор, ноль или более атрибутов или псевдо-класс в любом порядке. Простой селектор отбирает элемент, когда все компоненты селектора совпадают со свойствами элемента.

CSS-селектор (или полный селектор) — это цепочка из одного или большего числа простых селекторов, объединенных символами-комбинаторами (' ' (пробел), '>', '+', '~'):

До сих пор мы изучали сухую теорию. Давайте рассмотрим, как она работает на практике.

В любом современном браузере можно увидеть HTML текущей открытой страницы. Например, в Chrome достаточно выполнить команду View page source контекстного меню или открыть окно разработчика (Developer tools, Ctrl+Shift+I). В окне разработчика имеется закладка Console, в которой можно попробовать находить элементы с помощью селекторов CSS. Чтобы применить селектор достаточно в консоли вызвать функцию document.querySelectorAll (она входит в программное API всех браузеров).

Например, на стартовой странице форумов https://www.mql5.com/ru/forum можно выполнить команду (код JavaScript):

document.querySelectorAll("div.widgetHeader")

И мы получим список элементов (тегов) 'div', у которых указан класс "widgetHeader". Разумеется, я выбрал этот селектор не наобум, а посмотрев исходный код страницы, из которого становится ясно, что таким стилем оформлены названия форумов.

Если расширить селектор следующим образом:

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

мы получим список заголовков форумов — они оформлены как гиперссылки 'a', являющиеся первыми дочерними элементами в каждом блоке 'div', отобранном на первом этапе. Вот как это может выглядеть (зависит от версии браузера):

Веб-страница MQL5 и результат отбора её HTML-элементов с помощью CSS-селекторов

Веб-страница MQL5 и результат отбора её HTML-элементов с помощью CSS-селекторов

Разумеется, в реальных задачах потребуется аналогичным образом анализировать HTML-код других сайтов, локализовывать элементы, которые представляют интерес, и выяснять для них CSS-селекторы. В окне разработчика имеется закладка Elements (или с похожим названием), где можно выделить любой тег в документе (он при этом будет подсвечен на странице) и посмотреть для него CSS-селекторы. Это позволит постепенно освоиться с селекторами и впоследствии составлять цепочки из них вручную. Чуть позже мы покажем пример подбора селекторов под конкретную веб-страницу.


Проектирование

Рассмотрим на глобальном уровне, какие классы нам потребуются. Первичную обработку HTML-текста поручим классу HtmlParser. Он будет сканировать текст в поисках символов разметки '<', '/', '>' и некоторых других, и по правилам автомата, описанным в предыдущем разделе, создавать объекты класса DomElement: по одному на каждый пустой тег или пару открывающий-закрывающий теги. Внутри открывающего тега могут встретиться атрибуты, которые также необходимо прочитать и сохранить в текущем объекте DomElement — этим займется класс AttributesParser. Он также будет работать по принципу автомата с конечным числом состояний.

Объекты DomElement будут создаваться парсером с учетом иерархии, повторяющей вложенность тегов. Например, если в тексте встретится тег 'div', внутри которого размещено несколько параграфов, то есть тегов 'p', то они будут преобразованы в дочерние объекты для объекта, описывающего 'div'.

Начальный, корневой объект будет содержать весь документ. По аналогии с браузером (который предоставляет метод document.querySelectorAll) мы предусмотрим в классе DomElement метод для запроса объектов, соответствующих переданным CSS-селекторам. Но сами селекторы придется тоже предварительно проанализировать и преобразовать из строкового представления в объекты: одиночный компонент селектора будем хранить в классе SubSelector, а простой селектор целиком — в классе SubSelectorArray.

Когда мы будем иметь готовое дерево DOM на выходе парсера, то сможем запросить у корневого (или, в принципе, у любого другого) объекта DomElement все подчиненные ему элементы, совпадающие с параметрами селекторов. Все отобранные элементы будут помещены в итерируемый список DomIterator. Для простоты мы реализуем его как наследник DomElement, в котором массив дочерних узлов используется как хранилище найденных элементов.

Настройки с правилами обработки конкретных сайтов или файлов HTML, а также результаты работы алгоритма удобно хранить в классе, который объединяет в себе свойства карты (map, то есть предоставляет доступ к значениям по именам соответствующих атрибутов), а также массива (то есть доступ к элементам по индексу). Выберем для этого класса название IndexMap.

Предусмотрим возможность вкладывать IndexMap друг в друга: в частности, при сборе табличных данных из веб-страницы у нас формируется список строк, в каждой из которых содержится список столбцов, и по обоим измерениям мы можем сохранить имена исходных элементов. Это особенно полезно, когда некоторых искомых элементов в веб-документе не хватает (а это бывает часто), и тогда простая индексация по порядку упускает важную информацию о том, какие именно данные пропущены. В качестве бонуса, "научим" IndexMap сериализоваться в многострочный текст, включая формат CSV - это пригодится при конвертации HTML-страниц в табличные данные. При желании вы сможете заменить класс IndexMap на свой собственный без потери основного функционала.

Вот как описанные классы выглядят на UML-диаграмме.

UML-диаграмма классов, реализующих CSS-селекторы на MQL

UML-диаграмма классов, реализующих CSS-селекторы на MQL



Реализация

HtmlParser

В классе HtmlParser опишем переменные, которые потребуются для сканирования исходного текста и генерации дерева объектов, а также организации автоматного алгоритма.

Текущая позиция в тексте хранится в переменной offset. Корень результирующего дерева и текущий объект (в контексте которого идет сканирование) представлены указателями root и cursor. Их тип DomElement мы рассмотрим позже. Перечень тегов, которые могут быть пустыми по спецификации HTML, загрузим в карту empties (её инициализация производится в конструкторе, который будет приведен ниже). Наконец, для описания автоматных состояний предусмотрим переменную state, являющуюся перечислением типа StateBit.

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

class HtmlParser
{
  private:

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

Перечисление StateBit имеет элементы, описывающие следующие состояния парсера в зависимости от текущей позиции в тексте:

Помимо этого опишем в парсере строки-константы, которые будем использовать для поиска разметки:

    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;

Непосредственно конструктор парсера инициализирует все эти переменные:

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

Здесь используется массив строк empty_tags, который предварительно подключается из внешнего текстового файла:

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

Вот его содержимое (допустимые пустые теги, но список не исчерпывающий):

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

В деструкторе парсера не забываем удалить дерево DOM:

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

Основную работу выполняет метод parse:

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

На вход подается текст веб-страницы, создается пустой корневой DomElement, курсор устанавливается на него, а текущая позиция в тексте (offset) — на самое начало. Затем в цикле, пока не будет успешно прочитан весь текст, вызывается вспомогательный метод processText. В нем в дело вступает "автомат", который по умолчанию находится в состоянии 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);
      }

Алгоритм ищет в тексте символ '<', и если не находит, значит тегов больше нет - конец обработки (для этого возвращается false). Если символ найден, и существует фрагмент текста между найденным новым тегом и предыдущим положением (offset), то данный фрагмент является содержимым текущего тега (этот объект доступен по указателю cursor) - поэтому текст добавляется в объект с помощью вызова cursor.setText().

Далее позиция в тексте перемещается на начало нового найденного тега, и в зависимости от того, какая сигнатура следует после '<' (COMMENT_START, TAG_CLOSE_START, TAG_OPEN_START) парсер переключается в новое соответствующее состояние. Функция IsString — маленький вспомогательный метод сравнения строк, использующий StringSubstr.

В любом случае из метода processText возвращается true, что означает, что метод будет тут же вызван снова в цикле (вспоминаем вызывающий метод parse), но теперь парсер будет уже в другом состоянии. Если он в открывающем теге, срабатывает следующий код.

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

Если в тексте не найдено ни пробела, ни символа '>', синтаксис HTML нарушен, и мы возвращаем false. Суть происходящего далее — в выделении имени тега.

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

Здесь мы создали новый объект с обнаруженным именем, причем в качестве родительского узла для него используется текущий объект (cursor).

Теперь нужно обработать атрибуты, если они есть.

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

Метод parseAttributes "живет" непосредственно в классе DomElement, который мы рассмотрим позднее.

Если тег не закрыт, требуется проверить, не относится ли он к числу тех, которые могут быть пустыми, и если да - то "закрыть" его неявно.

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

В зависимости от того, закрыт тег или нет, мы либо перемещаемся "вглубь" по иерархии объектов, делая текущим объектом вновь созданный (e), либо остаемся в контексте прежнего объекта. В любом случае позиция в тексте (offset) перемещается на последний прочитанный символ, то есть за '>'.

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

Особый случай - скрипт. Если нам встретился тег <script>, парсер переходит в состояние insideScript, в противном случае - в уже знакомое состояние blank.

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

В состоянии закрывающего тега срабатывает следующий код.

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

Опять же, мы ищем '>', который обязательно должен быть согласно синтаксису HTML, и если его нет — прерываем процесс. В случае успеха выделяем имя тега. Оно требуется для того, чтобы проверить совпадение закрывающего тега с открывающим. А если соответствие нарушено, необходимо хитрым образом поглотить эту ошибку верстки и попытаться продолжить парсинг.

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

Поскольку мы обрабатываем закрывающий тег, значит, контекст текущего объекта закончился, и парсер переключается обратно на родительский DomElement:

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

В случае успеха состояние парсера вновь становится равным blank.

Когда парсер внутри комментария, он, очевидно, ищет конец комментария.

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

Когда парсер внутри скрипта, он ищет конец скрипта.

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

Вот, собственно и весь класс HtmlParser. Теперь познакомимся с DomElement.


DomElement, начало

Класс DomElement имеет переменные для хранения имени (обязательно), содержимого, атрибутов, ссылки на родителя и массив дочерних элементов (он сделал защищенным protected, потому что будет использован в производном классе DomIterator).

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

  protected:
    DomElement *children[];

Набор конструкторов вряд ли требует пояснений:

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

Разумеется, в классе есть методы "setter"-ы и "getter"-ы полей (в статье они опущены), а также набор методов для работы с дочерними элементами (приведем только прототипы):

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

Использованный ранее на стадии парсинга метод parseAttributes делегирует всю работу вспомогательному классу AttributesParser.

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

Получая на входе простую строку data, метод заполняет карту attributes найденными свойствами.

Полный код класс AttributesParser можно найти в прилагаемых файлах. Сам класс небольшой и работает по похожему автоматному принципу, что и HtmlParser, правда число состояний всего два:

enum AttrBit
{
  name,
  value
};

Поскольку список атрибутов, это строка, состоящая из пар вида name="value", AttributesParser всегда находится либо на имени, либо на значении. Этот парсер можно было бы реализовать с помощью функции StringSplit, но из-за потенциальных отклонений в форматировании (например, наличие кавычек или их отсутствие, использование пробелов внутри кавычек и т.д.) был выбран автоматный подход.

Возвращаясь к классу DomElement, отметим, что основную часть работы в нем должны выполнять методы по подбору дочерних элементов, соответствующих заданным CSS-селекторам. Но прежде чем мы сможем приступить к рассмотрению этого функционала, необходимо описать классы селекторов.

SubSelector и SubSelectorArray

Класс SubSelector описывает один компонент простого селектора. Например, в простом селекторе "td[align=left][width=325]" три компонента:

В простом селекторе "td:first-child" два компонента: А в простом селекторе "span.main[id^=calendarTip]" - опять три:

Вот и сам класс:

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

Переменная type содержит первый символ селектора ('.', '#', '[') или по-умолчанию 0, что соответствует селектору имени. Переменная value хранит следующую за символом подстроку, т.е. фактически искомое. Если в строке селектора встретится псевдо-класс, его идентификатор записывается в поле modifier. Наконец, при описании селекторов ":nth-child" и ":nth-last-child" в скобках указывается индекс искомого элемента — его будем сохранять в поле param (в текущей реализации это может быть только число, но в принципе допускаются специальные формулы, поэтому поле объявлено строковым).

Класс SubSelectorArray представляет собой всю связку компонентов, поэтому декларируем в нем массив selectors:

class SubSelectorArray
{
  private:
    SubSelector *selectors[];

SubSelectorArray - это и есть один простой селектор целиком. Для полных CSS-селекторов класс не нужен, потому что они обрабатываются последовательно шаг за шагом - по одному простому селектору на каждом уровне иерархии.

Поддерживаемые селекторы псевдо-классов сведем в карте mod, чтобы иметь возможность по строке сразу получать соответствующий модификатор из 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);
    }

Класс TypeContainer — это шаблонизированная обертка для значений, складываемых в IndexMap.

Напомним, что статические члены, в данном случае объекты для карты, должны быть проинициализированы после описания класса:

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

Но вернемся к классу SubSelectorArray.

Когда требуется добавить компонент простого селектора в массив, вызывается функция 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);
      }
    }

В неё передается первый символ (тип) и последующая строка, которая разбирается на имя искомого объекта, опциональный псевдо-класс и параметр. Затем это всё передается в конструктор SubSelector, и новый компонент селектора добавляется в массив selectors.

Функция add используется опосредованно из конструктора простого селектора следующим образом:

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

Функция createFromString получает текстовое представление CSS-селектора и в цикле просматривает его в поисках стартовых спецсимволов '.', '#' или '[', определяет, где в тексте заканчивается данный компонент и вызывает для выделенной информации метод add. Цикл продолжается, пока в тексте продолжается цепочка компонентов.

Полный текст класса SubSelectorArray можно найти в приложении.

Теперь настало время вернуться к классу DomElement. И здесь начинается наиболее сложная для понимания часть.


DomElement, продолжение

Для поиска элементов, отвечающих заданным селекторам (в их текстовом представлении), применяется метод querySelect. Именно в нем полный CSS-селектор "разрезается" на простые селекторы, которые последовательно преобразуются в объект SubSelectorArray. Для каждого простого селектора ищется список подходящих элементов, потом относительно этих элементов ищутся другие элементы, подходящие для следующего простого селектора, и так далее вплоть до последнего простого селектора или пока список найденных элементов не окажется пустым.

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

В качестве возвращаемого значения мы видим незнакомый класс DomIterator, но, как уже было отмечено выше, это - наследник DomElement. Он предоставляет небольшой дополнительный функционал по сравнению с DomElement (в частности, позволяет "пролистывать" дочерние элементы), поэтому пока оставим DomIterator за кадром. Сложность же заключается в другом.

Анализ строки селекторов производится посимвольно, для чего используется несколько локальных переменных. Текущий символ хранится в переменной c (от character). Прерыдущий символ - в переменной p (от previous). Если символ является одним из символов-комбинаторов (' ', '+', '>', '~'), он запоминается в переменной (a), но не используется до тех пор, пока не будет определен следуюший за ним простой селектор.

Как вы помните, комбинаторы стоят между простыми селекторами, а определяемую ими операцию можно выполнить только тогда, когда будет прочитан до конца селектор, стоящий справа. Поэтому последний прочитанный комбинатор (a) сперва проходит фазу "передержки" - переменная (a) не используется, пока не появится следующий комбинатор или не закончится строка - в обоих случаях это сигнал, что селектор сформирован. Только в тот момент "старый" комбинатор (b) вступает в дело и затем заменяется на новый (a). Вероятно, это тот случай, когда сам код будет понятнее, чем его описание.

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

Переменная cursor всегда указывает на первый символ, с которого начинается подстрока с простым селектором (т.е. на символ сразу после предыдущего комбинатора или на начало строки). Когда мы обнаруживаем очередной комбинатор, то копируем в переменную selector подстроку от cursor до текущего символа (i).

Иногда подряд идет несколько комбинаторов - как правило это бывает, когда пробелами окружают другие символы-комбинаторы, но ведь пробел - тоже комбинатор. Например, записи "td>span" и "td > span" эквивалентны, но во второй вставлены пробелы для улучшения читабельности. Такие ситуации обрабатывает строка:

a = MathMax(c, p);

Она сравнивает текущий и предыдущий символы, когда оба являются комбинаторами. Затем, пользуясь тем, что пробел имеет наименьший код, мы всегда выбираем более "старший" комбинатор. Массив комбинаторов, очевидно, определен таким образом:

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

Проверка символа на вхождение в данный массив производится простой вспомогательной функцией isCombinator.

Если подряд идет два комбинатора, отличных от пробела, то это ошибочный селектор, и поведение не определено спецификациями, однако наш код не теряет работоспособности и предлагает консистентное поведение.

Если текущий символ - не комбинатор, а предыдущий символ был комбинатором, исполнение попадает в ветвь, помеченную комментарием action. Здесь мы запоминаем текущий размер массива отобранных к данному моменту DomElement-ов с помощью вызова:

index = result.getChildrenCount();

Изначально массив, разумеется, пустой, и index равен 0.

Создаем массив объектов-селекторов, соответствующий текущему простому селектору - строке selector:

SubSelectorArray selectors(selector);

Далее вызываем метод find, который еще предстоит рассмотреть.

find(b, &selectors, result);

Внутрь мы передаем символ-комбинатор (причем предпоследний, из переменной b), простой селектор для поиска элементов и массив, куда складывать результаты.

После этого мы двигаем "очередь" комбинаторов вперед, копируя последний найденный (но еще не обработанный) символ-комбинатор из переменной a в переменную b, и удаляем из результатов всё, что там было до вызова find с помощью:

result.removeFirst(index);

Метод removeFirst определен в DomIterator и выполняет простую задачу согласно своему названию - удаляет из массива все первые элементы вплоть до указанного номера. Это делается потому, что в процессе обработки каждого очередного простого селектора, мы накладываем все более узкие условия на отбор элементов, и всё, что было отобрано ранее, перестает соответствовать, а вновь добавленные элементы (удовлетворяющие более строгим условиям) идут, начиная с номера index.

Аналогичная обработка (помеченная комментарием action) выполняется и в случае, когда мы достигли конца входной строки. В этом случае требуется обработать последний комбинатор, ожидающий своей очереди, в связке с остатком строки (от позиции cursor).

Заглянем теперь внутрь метода find.

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

Если на вход передан один из комбинаторов (' ', '>'), накладывающих условия на вложенность тегов, требуется рекурсивно вызывать проверки для всех дочерних элементов. Также в этой ветви учтем специальный комбинатор '/', который используется в самом начале поиска в вызывающем методе.

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

Метод match рассмотрим чуть ниже. Пока важно знать, что он возвращает true, если объект соответствует переданным селекторам, и false - в противном случае. Когда поиск только начинается (комбинатор op = '/'), никаких "комбинаций" еще нет, и потому все теги, которые удовлетворили селекторам, добавляются в результат (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;
              }
            }

Для комбинатора ' ' выполняется проверка, что текущий DomElement или любой его предок в любом поколении уже присутствует в результатах (output). Это означает, что удовлетворяющий условиям поиска новый дочерний элемент вложен в родительский. А это и есть задача данного комбинатора.

Комбинатор '>' работает похоже, но должен отслеживать только непосредственных "родственников", поэтому проверяем только текущий DomElement на присутствие в промежуточных результатах. Если это так, значит он был ранее отобран в output по условиям селектора слева от комбинатора, а его i-й дочерний элемент только что удовлетворил селектору справа от комбинатора.

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

И далее аналогичные проверки нужно выполнять в глубине дерева DOM, потому рекурсивно вызываем метод find для дочерних элементов.

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

Комбинаторы '+' и '~' накладывают условия на принадлежность двух элементов одному и тому же родителю.

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

Один из элементов должен быть уже отобранным в результаты с помощью селектора, стоящего слева от комбинатора. Когда это условие выполнено, остается проверить "братьев и сестер" на правый селектор ("братья и сестры" - это дочерние элементы родителя текущего узла).

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

Разница в обработке комбинаторов '+' и '~' заключается лишь в том, что в случае '+' элементы должны быть непосредственными соседями, а в случае '~' — между ними может быть произвольное количество других "братьев и сестер". Поэтому для '+' цикл выполняется лишь один раз — для следующего элемента в массиве дочерних. Внутри цикла мы снова видим вызов функции match (о ней - двумя абзацами ниже).

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

После всех проверок следует опуститься на следующий уровень иерархии дерева элементов DOM, и вызвать find для дочерних узлов.

Вот, собственно, и весь метод find. Теперь рассмотрим функцию match. Это последний пункт в нашей слегка затянувшейся истории про реализацию селекторов.

Данная функция проверяет текущий объект на всю цепочку компонентов простого селектора, передаваемого через входной параметр. Если в цикле хоть один компонент не совпадет со свойствами элемента, проверка закончится неудачей.

    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
          }

Селектор типа 0 — это имя тега или псевдо-класс. Если в селекторе стоит звездочка, любой тег подходит, иначе сравниваем строку в селекторе с именем тега:

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

Реализованные на данный момент псевдо-классы накладывают ограничение на номер текущего элемента в массиве дочерних элементов своего родителя, поэтому анализируем индексы:

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

Селектор '.' накладывает ограничение на атрибут "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;
          }
        }

Селектор '#' накладывает ограничение на атрибут "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;
          }
        }

Селектор '[' предоставляет возможность указать произвольный набор требуемых атрибутов, причем сравнение значений может производиться не только строго, но на вхождение подстроки (суффикс '*'), начало ('^') и концовку ('$').

        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;

    }

Обратите внимание, что атрибут "class" здесь тоже поддерживается и обрабатывается, причем как и в случае селектора '.', сравнение ведется не на строгое соответствие, а на присутствие искомого класса среди вероятного множества других. В HTML очень часто используется механизм, когда элементу назначается сразу несколько классов — они указываются в атрибуте class через пробел.

Итак, подведем промежуточный итог. Мы реализовали в классе DomElement метод querySelect, который принимает в качестве параметра строку с полным CSS-селектором и возвращает объект DomIterator - фактически массив найденных подходящих элементов. Внутри querySelect происходит разбивка строки CSS-селектора на последовательность простых селекторов и символов-комбинаторов между ними. Для каждого простого селектора вызывается метод find с указанием комбинатора, и данный метод обновляет список результатов, рекурсивно вызывая самого себя для дочерних элементов. Сравнение компонентов простого селектора со свойствами конкретного элемента производится в методе match.

С помощью метода querySelect мы можем отобрать, например, строки из таблицы, используя один CSS-селектор, а затем вызвать querySelect для каждой строки с другим CSS-селектором, чтобы вычленить конкретные ячейки. Поскольку работа с таблицами широко востребована, создадим в классе DomElement метод tableSelect, в котором реализуем описанный подход. Его код приведен в упрощенном виде.

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

Селектор для строк указывается в параметре rowSelector, селекторы для ячеек — в массиве columSelectors.

После того как элементы документа будут выбраны, нам потребуется взять из них некоторую информацию - например, текст или значение атрибута. Для определения того, откуда именно из элемента "вырезать" информацию, используется массив dataSelectors — для каждой колонки таблицы способ извлечения данных может быть свой.

Если dataSelectors[i] — пустая строка, прочитаем текстовое содержимое тега (то, что находится между открывающей и закрывающей частями, например, получим "100%" из тега "<p>100%</p>"). Если dataSelectors[i] — строка, считаем, что это имя атрибута, и потому берем его значение.

Посмотрим построчно на реализацию:

      DomIterator *r = querySelect(rowSelector);

Здесь мы получили результирующий список элементов по селектору строк.

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

Здесь мы создали пустую карту, куда будем складывать табличные данные, и приготовились к циклу по объектам-строкам. А вот и сам цикл:

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

Получили очередную строку (e), создали под неё карту-контейнер (row), куда будем складывать ячейки, и запускаем цикл по колонкам:

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

В каждом объекте-строке с помощью соответствующего селектора выделяем список объектов-ячеек (d). Из каждой найденной ячейки выделяем данные и сохраняем в карту 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);
          }

Здесь для упрощения кода используются целочисленные ключи, но полный исходный код позволяет брать идентификаторы элементов в качестве ключей.

Если подходящая ячейка не найдена, помечаем её как пустую.

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

Прикрепляем заполненную строку row в таблицу data.

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

Таким образом, на выходе мы получим карту карт (map of map), т.е. фактически таблицу с номерами строк по первому измерению и номерами колонок по второму. При необходимости, функцию tableSelect можно адаптировать под другие контейнеры данных.

Для того чтобы применить все вышеописанные классы в действии был создан неторгующий эксперт-утилита.

Эксперт-утилита WebDataExtractor

Данный эксперт предназначен для конвертации данных из веб-страниц в табличную структуру, с возможностью сохранения результата в CSV-файл.

В качестве входных параметров эксперт получает ссылку на источник данных (это может быть локальный файл или страница в интернете, для скачивания которой используем WebRequest), селекторы для строк и столбцов, а также имя CSV-файла. Основные входные параметры приведены ниже:

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

В параметре URL нужно указать адрес веб-страницы (начинается с http:// или https://) или имя локального html-файла.

В параметре SaveName, в обычном режиме, указывается имя CSV-файла с результатами. Однако его можно использовать для другой цели — сохранения скачанной страницы для целей последующей отладки селекторов. Для работы в таком режиме требуется оставить пустым следующий параметр — RowSelector, в котором обычно задается CSS-селектор строк.

Поскольку селекторов колонок несколько, они задаются в отдельном настроечном CSV-файле, имя которого указывается в параметре ColumnSettingsFile. Формат файла следующий.

Первая строка — заголовок, каждая последующая — описывает отдельное поле (колонку с данными в строке таблицы).

В файле должно быть 3 колонки: имя, CSS-селектор, "локатор" данных:

Параметры TestQuery и TestSubQuery позволяют протестировать селекторы для строки и одной колонки с выводом в лог, без сохранения в CSV-файл и без файлов настроек для всех колонок.

Вот как выглядит основная рабочая функция эксперта в сокращенном виде.

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

Здесь мы прочитали HTML-страницу из файла или скачали из Интернета. Теперь для преобразования документа в иерархию объектов DOM создадим объект HtmlParser и запустим парсинг:

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

Если указаны тестовые селекторы, обрабатываем их с помощью вызовов 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);
  }

В штатном режиме работы, считываем файл настроек колонок и вызываем знакомую функцию tableSelect:

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

Если указан файл для сохранения результатов в CSV, делегируем эту задачу карте 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);
}

Попробуем применить эксперт на практике.


Практическое использование

Трейдерам хорошо знакомы некоторые стандартные HTML-файлы, например, отчеты тестирования и торговые отчеты, которые генерирует MetaTrader. Иногда мы получаем такие файлы от знакомых или скачиваем из интернета и хотим проанализировать на графике, для чего необходимо преобразовать данные из HTML в табличный вид, в простейшем случае — в CSV-формат.

CSS-селекторы в нашей утилите позволяют это сделать автоматически.

Заглянем внутрь HTML-файлов. Вот внешний вид и часть HTML-кода торгового отчета MetaTrader 5 (файл ReportHistory.html, прилагается).

Внешний вид и часть HTML-кода торгового отчета

Внешний вид и часть HTML-кода торгового отчета

А вот внешний вид и часть HTML-кода файла тестера MetaTrader 5 (файл Tester.html, прилагается).

Внешний вид и часть HTML-кода отчета тестера

Внешний вид и часть HTML-кода отчета тестера

Согласно внешнему представлению в торговом отчете имеется 2 таблицы: с приказами (Orders) и сделками (Deals). Однако если посмотреть на внутреннюю верстку, оказывается, что это единая таблица. Все видимые заголовки и разделительная полоса сформированы за счет управления стилями ячеек таблицы. Однако нам нужно каким-то образом научиться различать приказы и сделки и сохранить каждую из подтаблиц в свой CSV-файл.

Отличие первой части от второй — в количестве колонок: в приказах их 11, а в сделках — 13. К сожалению, стандарт CSS не позволяет накладывать условия на отбор родительских элементов (в нашем случае строк таблицы, тег tr) по количеству или содержимому дочерних элементов (в нашем случае ячейки таблицы, тег td). Действительно, возможности селекторов не безграничны и в некоторых случаях выделение требуемых элементов стандартными средствами невозможно. Но поскольку мы сами разрабатываем свою реализацию селекторов, мы можем добавить специальный нестандартный селектор на количество дочерних элементов. Это будет новый псевдо-класс. Обозначим его ":has-n-children(n)", по аналогии с ":nth-child(n)".

Тогда для отбора строк с приказами подойдет селектор:

tr:has-n-children(11)

Однако задача еще не решена, потому что этот селектор отбирает помимо строк с данными еще и заголовок таблицы. Чтобы его отсеять, обратим внимание на цветовое оформление строк с данными — для них установлен атрибут bgcolor, причем значение цвета чередуется для четных, и нечетных строк (#FFFFFF и #F7F7F7). Цвет, т.е. атрибут bgcolor, используется и для заголовка, но там значение равно #E5F0FC. Таким образом, строки с данными имеют светлые тона с bgcolor, начинающимся с "#F". Добавим это условие в селектор:

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

Он правильно определяет все строки с приказами.

Параметры каждого приказа будем читать из ячеек строки. Для этого напишем конфигурационный файл 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),

В нем все поля идентифицируются просто по порядковому номеру. В других случаях могут потребоваться более "умные" селекторы с атрибутами и классами.

Для получения таблицы сделок достаточно заменить в селекторе строк количество дочерних элементов с 11 на 13:

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

Конфигурационный файл ReportHistoryDeals.cfg.csv прилагается — он похож на приведенный выше.

Если теперь запустить WebDataExtractor и указать следующие входные параметры (файл webdataex-report1.set прилагается):

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

мы получим в результате файл ReportOrders.csv, соответствующий исходному HTML-отчету:

CSV-файл, полученный в результате применения CSS-селекторов к торговому отчету

CSV-файл, полученный в результате применения CSS-селекторов к торговому отчету

Для получения таблицы по сделкам воспользуйтесь прилагаемыми настройками webdataex-report2.set.

Хорошая новость заключается в том, что созданные нами селекторы подходят и для отчетов тестера. Прилагаемые файлы webdataex-tester1.set и webdataex-tester2.set позволяют конвертировать в CSV-файлы демонстрационный HTML-отчет Tester.html.

Внимание! Верстка многих веб-страниц, а также генерируемых HTML-файлов в MetaTrader время от времени меняется. Это может привести к тому, что прежние селекторы перестанут выполнять свою работу, даже если внешнее представление страниц почти не изменилось. В таких случаях требуется заново проанализировать HTML-код и модифицировать CSS-селекторы.

Теперь рассмотрим конвертацию для отчета тестера MetaTrader 4 — он позволяет продемонстрировать некоторые интересные приемы в подборе CSS-селекторов. В качестве файла для проверки можно использовать прилагаемый StrategyTester-ecn-1.htm.

В данных файлах — две таблицы: в первой указываются результаты теста, а во второй — торговые операции. Для выбора второй таблицы будем использовать селектор "table ~ table". Внутри таблицы операций необходимо отбросить первую строку, потому что в ней содержится заголовок. Для этой цели используем селектор "tr + tr".

Итак, соединив их вместе, получим селектор для отбора рабочих строк:

table ~ table tr + tr

Он фактически означает: выберите таблицу после таблицы (т.е. вторую), и внутри этой таблицы выберите каждой строку, у которой есть предыдущая строка (т.е. все за исключением первой, с заголовками).

Настройки для извлечения параметров сделок из ячеек сведены в файл test-report-mt4.cfg.csv. В нем стоит обратить внимание, что поле с датой обрабатывается селектором класса:

DateTime,td.msdate,

т.е. подходящими являются теги td, у которых есть атрибут class="msdate".

Полный файл настроек для утилиты — webdataex-tester-mt4.set.

Дополнительные примеры использования и настройки CSS-селекторов можно найти на странице обсуждения WebDataExtractor.

Стоит отметить, что утилита умеет и многое другое:

Если Вам потребуется помощь в настройке CSS-селекторов для конвертации конкретной веб-страницы, Вы можете приобрести WebDataExtractor (для MetaTrader 4, для MetaTrader 5) и получить рекомендации в рамках поддержки продукта. Однако наличие исходных кодов позволяет использовать весь функционал и произвольно расширять его абсолютно бесплатно.


Заключение

Мы рассмотрели технологию CSS-селекторов, которая является одним из основных стандартов в области интерпретации веб-документов. Благодаря реализации наиболее употребительных CSS-селекторов на MQL, мы получили возможность гибко настраивать и проводить конвертацию любых HTML-страниц, включая многие стандартные документы MetaTrader, в структурированные данные без привлечения стороннего программного обеспечения.

За кадром остались некоторые другие технологии, способные представить столь же универсальные инструменты для обработки веб-документов, особенно учитывая тот факт, что MetaTrader широко использует не только HTML, но и XML. В частности, потенциальный интерес для трейдеров может представлять XPath и XSLT. Всё это — следующие этапы, способные развить идею автоматизации торговых систем на базе веб-стандартов. Поддержка CSS-селекторов на MQL — лишь первый шаг к этой цели.