Extrahieren von strukturierten Daten aus HTML-Seiten mit Hilfe von CSS-Selektoren

Stanislav Korotky | 7 Mai, 2019

Die Entwicklungsumgebung MetaTrader ermöglicht die Integration von Anwendungen mit externen Daten, insbesondere mit den über die Funktion WebRequest aus dem Internet gewonnenen Daten. HTML ist das universellste und am häufigsten verwendete Datenformat im Web. Wenn ein öffentlicher Dienst keine offene API für Anfragen bereitstellt oder sein Protokoll in MQL schwer zu implementieren ist, können die gewünschten HTML-Seiten analysiert werden. Insbesondere Händler verwenden oft verschiedene Wirtschaftskalender. Obwohl die Aufgabe jetzt nicht so relevant ist, da die Plattform über den integrierten Kalender verfügt, benötigen einige Händler möglicherweise spezifische Nachrichten von bestimmten Websites. Außerdem müssen wir manchmal Transaktionen aus einem HTML-Handelsbericht analysieren, der von Dritten erhalten wurde.

Das MQL5-System bietet verschiedene Lösungen für das Problem, die jedoch meist spezifisch sind und ihre Grenzen haben. Auf der anderen Seite gibt es eine Art "native" und universelle Methode, um Daten aus HTML zu suchen und zu analysieren. Diese Methode ist mit der Verwendung von CSS-Selektoren verbunden. In diesem Artikel werden wir die MQL5-Implementierung dieser Methode sowie Beispiele für deren praktischen Einsatz betrachten.

Um HTML zu analysieren, müssen wir einen Parser erstellen, der internen Seitentext in eine Hierarchie einiger Objekte umwandeln kann, die Document Object Model oder DOM genannt werden. Aus dieser Hierarchie heraus können wir Objekte mit spezifizierten Parametern finden. Dieser Ansatz basiert auf der Verwendung von Service-Informationen über die Dokumentenstruktur, die in der externen Seitenansicht nicht verfügbar sind.

So können wir beispielsweise Zeilen einer bestimmten Tabelle in einem Dokument auswählen, die benötigten Spalten auslesen und ein Array mit Werten erhalten, das einfach in eine CSV-Datei gespeichert, in einem Diagramm angezeigt oder in Berechnungen des Expertenberaters verwendet werden kann.


Überblick über die HTML/CSS- und DOM-Technologie

HTML ist ein beliebtes Format, das fast jedem bekannt ist. Daher werde ich die Syntax dieser Hypertext-Markup-Sprache nicht im Detail beschreiben.

Die primäre Quelle der zugehörigen technischen Informationen ist IETF (Internet Engineering Task Force) und seine Spezifikationen, der sogenannte RFC (Request For Comments). Es gibt viele HTML-Spezifikationen (hier ist ein Beispiel). Standards sind auch auf der Website der entsprechenden Organisation, W3C (World Wide Web Consortium, HTML5.2) verfügbar.

Diese Organisationen haben die CSS-Technologie (Cascading Style Sheets) entwickelt und regeln sie. Wir sind jedoch nicht daran interessiert, dass diese Technologie Informationsdarstellungsstile auf Webseiten beschreibt, sondern wegen der darin enthaltenen CSS-Selektoren, d.h. einer speziellen Suchsprache, die die Suche nach Elementen innerhalb von HTML-Seiten ermöglicht.

Sowohl HTML als auch CSS entwickeln sich ständig weiter und neue Versionen werden erstellt. So sind beispielsweise die aktuell relevanten Versionen HTML5.2 und CSS4. Allerdings ist das Update und die Erweiterung immer mit der Vererbung alter Versionsfunktionen verbunden. Das Web ist so groß, heterogen und oft inert, und so existieren neben den alten Versionen auch neue Versionen. Daher sollten Sie beim Schreiben von Algorithmen, die den Einsatz von Web-Technologien beinhalten, die Spezifikationen sorgfältig verwenden: Einerseits sollten Sie mögliche traditionelle Abweichungen berücksichtigen und andererseits einige Vereinfachungen hinzufügen, die helfen, Probleme mit mehreren Variationen zu vermeiden.

In diesem Projekt werden wir die vereinfachte HTML-Syntax berücksichtigen.

Ein html-Dokument besteht aus Tags innerhalb der Zeichen '<' und '>'. Der Tag-Name und die optionalen Attribute werden innerhalb des Tags angegeben. Optionale Attribute sind Zeichenkettenpaare von name="value", wobei das Zeichen'=' manchmal weggelassen werden kann. Hier ist ein Beispiel für ein Tag:

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

— dies ist ein Tag namens "a" (der von Webbrowsern als Hyperlink interpretiert wird), mit zwei Parametern: "href" für die Adresse der Website unter dem angegebenen Hyperlink und "target" für die Option zum Öffnen der Website (in diesem Fall ist es gleich "_blank", d.h. die Website sollte in einem neuen Browser-Tab geöffnet werden).

Dieses erste Tag ist das öffnende Tag. Es folgt der Text, der tatsächlich auf der Webseite sichtbar ist: "HTML und CSS", und das passende abschließende Tag, das den gleichen Namen wie das öffnende Tag und einen zusätzlichen Schrägstrich '/' nach der spitzen Klammer '<' aufweist. (alle Zeichen zusammen bilden das Tag '</a>'). Mit anderen Worten, das Öffnen und Schließen von Tags wird paarweise verwendet und kann andere Tags beinhalten, aber nur ganze Tags, ohne sich zu überlappen. Hier ist ein Beispiel für eine korrekte Verschachtelung:

<group attribute1="value1">

  <name>text1</name>

  <name>text2</name>

</group>

die folgende "Überlappung" ist nicht zulässig:

<group id="id1">

<name>text1

</group>

</name>

Die Nutzung ist jedoch nicht nur theoretisch erlaubt. In der Praxis können Tags oft versehentlich an der falschen Stelle des Dokuments geöffnet oder geschlossen werden. Der Parser sollte in der Lage sein, mit dieser Situation umzugehen.

Einige Tags können leer sein, d.h. dies kann eine leere Zeile sein:

<p></p>

In Übereinstimmung mit den Standards können (oder dürfen) einige Tags überhaupt keinen Inhalt haben. Zum Beispiel das Tag, das ein Bild beschreibt:

<img src="/ico20190101.jpg">

Es sieht aus wie ein öffnender Tag, hat aber keinen passenden schließenden Tag. Solche Tags werden als leer bezeichnet. Bitte beachten Sie, dass die zum Tag gehörenden Attribute nicht der Tag-Inhalt sind.

Es ist nicht immer einfach zu bestimmen, ob ein Tag leer ist und ob es weiterhin einen abschließenden Tag geben sollte. Obwohl die Namen der gültigen leeren Tags in den Spezifikationen definiert sind, werden einige andere Tags nicht geschlossen. Auch weil HTML- und XML-Formate nahe beieinander liegen (und es gibt eine andere Variante: XHTML), erstellen einige Webseiten-Designer leere Tags wie folgt:

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

Achten Sie auf den Schrägstrich '/' vor dem Winkel '>'. Dieser Schrägstrich gilt als übertrieben im Sinne der strengen HTML5-Regeln. Alle diese spezifischen Fälle können in normalen Webseiten erfüllt werden, so dass der Parser in der Lage sein muss, sie zu verarbeiten.

Tag- und Attributnamen, die von Webbrowsern interpretiert werden, sind Standard, aber HTML kann benutzerdefinierte Elemente enthalten. Solche Elemente werden von Browsern übersprungen, es sei denn, die Entwickler "verbinden" sie mit DOM über die spezielle Skript-API. Wir sollten bedenken, dass jedes Tag nützliche Informationen enthalten kann.

Ein Parser kann als Endlicher Automat betrachtet werden, der Buchstabe für Buchstabe vorrückt und seinen Zustand entsprechend dem Kontext ändert. Aus der obigen Tag-Strukturbeschreibung geht hervor, dass sich der Parser zunächst außerhalb eines Tags befindet (nennen wir diesen Zustand "blank"). Dann gelangen wir nach dem Auftreffen auf die Öffnungswinkel '<' in einen Öffnungstag (Zustand "insideTagOpen"), der bis zur Schließwinkel '<' reicht. Die Kombination der Zeichen '</' deutet darauf hin, dass wir uns in einem schließenden Tag (dem Zustand "insideTagClose") befinden, und so weiter. Andere Zustände werden im Abschnitt zur Implementierung von Parsern berücksichtigt.

Beim Umschalten zwischen den Zuständen können wir strukturierte Informationen von der aktuellen Position im Dokument auswählen, da wir die Bedeutung des Zustands kennen. Wenn sich die aktuelle Position beispielsweise innerhalb eines öffnenden Tags befindet, kann der Tag-Name als Zeile zwischen dem letzten '<' und dem nachfolgenden Leerzeichen oder '>' ausgewählt werden (je nachdem, ob das Tag Attribute enthält). Der Parser extrahiert Daten und erstellt Objekte einer bestimmten Klasse DomElement. Zusätzlich zu Name, Attributen und Inhalt wird die Hierarchie der Objekte basierend auf der verschachtelten Struktur der Tags beibehalten. Mit anderen Worten, jedes Objekt hat einen Elternteil (mit Ausnahme des Wurzelelements, das das gesamte Dokument beschreibt) und ein optionales Array von Unterobjekten.

Der Parser gibt den vollständigen Baum der Objekte aus, in dem ein Objekt einem Tag im Quelldokument entspricht.

CSS-Selektoren beschreiben Standardnotationen für die bedingte Auswahl von Objekten basierend auf ihren Parametern und ihrer Position in der Hierarchie. Die vollständige Liste der Selektoren ist recht umfangreich. Wir werden einige von ihnen unterstützen, die in den Standards CSS1, CSS2 und CSS3 enthalten sind.

Hier ist eine Liste der wichtigsten Auswahlkomponenten:

Sie können von den so genannten Pseudoklassen begleitet werden, die auf der rechten Seite hinzugefügt werden:

Ein einzelner Selektor kann durch die Bedingung bezüglich der Attribute ergänzt werden:

Bei Bedarf ist es möglich, mehrere Klammerpaare mit unterschiedlichen Attributen anzugeben.

Einfacher Selektor ist der Namensselektor oder ein Universalselektor, dem optional eine Klasse, ein Identifikator, null oder mehrere Attribute oder eine Pseudoklasse in beliebiger Reihenfolge folgen können. Ein einfacher Selektor wählt ein Element aus, wenn alle Komponenten des Selektors mit den Element-Eigenschaften übereinstimmen.

CSS-Selektor (oder Vollselektor) ist eine Kette von einem oder mehreren einfachen Selektoren, die durch die Kombination von Zeichen (' ' (Leerzeichen), '>', '+', '+', '~') verbunden sind:

Bisher haben wir reine Theorie betrieben. Lassen Sie uns sehen, wie die oben genannten Ideen in der Praxis funktionieren.

Jeder moderne Webbrowser ermöglicht die Anzeige von HTML der aktuell geöffneten Seite. In Chrome können Sie beispielsweise den Befehl "Seitenquelltext anzeigen" aus dem Kontextmenü ausführen oder das Entwicklerfenster öffnen (Entwicklerwerkzeuge, Strg+Shift+I). Das Entwicklerfenster hat die Registerkarte Console, in der wir versuchen können, Elemente mit Hilfe von CSS-Selektoren zu finden. Um einen Selektor anzuwenden, rufen Sie einfach die Funktion document.querySelectorAll über die Konsole auf (sie ist in der Software-API aller Browser enthalten).

Auf der Startseite des Startforums https://www.mql5.com/de/forum können wir beispielsweise den folgenden Befehl (JavaScript-Code) ausführen:

document.querySelectorAll("div.widgetHeader")

Als Ergebnis erhalten wir eine Liste von 'div' Elementen (Tags), in denen die Klasse "widgetHeader" angegeben ist. Ich habe mich nach dem Betrachten des Quellseitencodes für diesen Selektor entschieden, woraus sich ergibt, dass die Forenthemen auf diese Weise gestaltet sind.

Der Wahlschalter kann wie folgt erweitert werden:

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

um die Liste der Diskussionsüberschriften zu erhalten: Sie sind als Hyperlinks 'a' verfügbar, die erste untergeordnete Elemente in jedem in der ersten Phase ausgewählten 'div' Block sind. Und das könnte so aussehen (abhängig von der Browserversion):

Die MQL5-Webseite und das Ergebnis der Auswahl von HTML-Elementen mit Hilfe von CSS-Selektoren.

Die MQL5-Webseite und das Ergebnis der Auswahl von HTML-Elementen mit Hilfe von CSS-Selektoren.

Sie sollten ebenfalls den HTML-Code der gewünschten Seiten analysieren, die Elemente von Interesse erkennen und geeignete CSS-Selektoren auswählen. Das Entwicklerfenster verfügt über die Registerkarte Elemente (oder ähnliches), in der Sie jedes beliebige Tag im Dokument auswählen können (dieses Tag wird hervorgehoben) und geeignete CSS-Selektoren für dieses Tag finden. So werden Sie schrittweise den Umgang mit Selektoren üben und lernen, Selektorketten manuell zu erstellen. Weiterhin werden wir prüfen, wie man geeignete Selektoren für eine bestimmte Webseite auswählt.


Entwurf

Betrachten wir die Klassen, die wir benötigen können, auf globaler Ebene. Die anfängliche HTML-Textverarbeitung wird von der Klasse HtmlParser durchgeführt. Die Klasse scannt den Text nach Markup-Zeichen '<', '/', '>' und einigen anderen, und sie erstellt Objekte der DomElement-Klasse nach den oben beschriebenen Regeln des endlichen Automaten: Ein Objekt wird für jedes leere Tag oder ein Paar von öffnenden und schließenden Tags erstellt. Das öffnende Tag kann Attribute haben, die wir im aktuellen Objekt DomElement lesen und speichern müssen. Dies wird von der Klasse AttributesParser durchgeführt. Die Klasse wird auch nach dem Prinzip des endlichen Automaten arbeiten.

Der Parser erstellt die Objekte DomElement unter Berücksichtigung der Hierarchie, die mit der verschachtelten Reihenfolge der Tags identisch ist. Wenn der Text beispielsweise das Tag "div" enthält, in dem mehrere Absätze platziert sind (was das Vorhandensein von "p"-Tags bedeutet), werden diese Abschnitte in Unterobjekte des Objekts umgewandelt, das "div" beschreibt.

Das anfängliche Wurzelobjekt enthält das gesamte Dokument. Ähnlich wie der Browser (der die Methode document.querySelectorAll bereitstellt), sollten wir in DomElement eine Methode zum Anfordern von Objekten bereitstellen, die den übergebenen CSS-Selektoren entsprechen. Die Selektoren sollten ebenfalls vorab analysiert und von der Zeichenkettendarstellung in Objekte umgewandelt werden: Eine einzelne Selektorkomponente wird in der Klasse SubSelector gespeichert und der gesamte einfache Selektor wird in SubSelectorArray gespeichert.

Sobald wir den fertigen DOM-Baum als Ergebnis der Parseroperation haben, können wir vom Wurzelobjekt DomElement (oder einem anderen Objekt) alle seine untergeordneten Elemente anfordern, die den Auswahlparametern entsprechen. Alle ausgewählten Elemente werden in die iterierbare DomIterator-Liste aufgenommen. Der Einfachheit halber implementieren wir die Liste als Kind von DomElement, in dem ein Array von Kindknoten zum Speichern der gefundenen Elemente verwendet wird.

Einstellungen mit bestimmten Verarbeitungsregeln für Site- oder HTML-Dateien und das Ergebnis der Algorithmusausführung können bequem in einer Klasse gespeichert werden, die Map-Eigenschaften (d.h. Zugriff auf Werte basierend auf den Namen der entsprechenden Attribute) und Array-Eigenschaften (d.h. Zugriff auf Elemente durch Index) kombiniert. Nennen wir diese Klasse IndexMap.

Lassen Sie uns die Möglichkeit bieten, IndexMap ineinander zu verschachteln: Wenn wir tabellarische Daten von einer Webseite sammeln, erhalten wir eine Liste von Zeilen, die jeweils die Liste der Spalten enthalten. Für beide Datentypen können wir die Namen der Quellelemente speichern. Dies kann besonders nützlich sein, wenn einige der erforderlichen Elemente im Quelldokument fehlen (was sehr häufig vorkommt) - in solchen Fällen ignoriert die einfache Indexierung wichtige Informationen darüber, welche Daten fehlen. Als Bonus lassen Sie uns IndexMap "trainieren", um in einen mehrzeiligen Text, einschließlich CSV-Format, serialisiert zu werden. Diese Funktion ist nützlich bei der Konvertierung von HTML-Seiten in tabellarische Daten. Bei Bedarf können Sie die Klasse IndexMap durch eine eigene ersetzen, wobei die Hauptfunktionalität erhalten bleibt.

Das folgende UML-Diagramm zeigt die beschriebenen Klassen.

UML-Diagramm der Klassen, die CSS-Selektoren in MQL implementieren.

UML-Diagramm der Klassen, die CSS-Selektoren in MQL implementieren.



Umsetzung

HtmlParser

In der Klasse HtmlParser beschreiben wir die Variablen, die notwendig sind, um den Quelltext zu scannen und den Objektbaum zu generieren sowie den Algorithmus des endlichen Automaten anzuordnen.

Die aktuelle Position im Text wird in der Variablen 'offset' gespeichert. Die resultierende Wurzel des Baums und das aktuelle Objekt (das Scannen wird in diesem Objektkontext durchgeführt) werden durch die Zeiger 'root' und 'cursor' dargestellt. Ihr DomElement-Typ wird später berücksichtigt. Die Liste der Tags, die gemäß der HTML-Spezifikation leer sein können, wird in die Map 'empties' geladen (die im Konstruktor initialisiert wird, siehe unten). Schließlich stellen wir die Variable 'state' für die Beschreibung von endlichen Maschinenzuständen zur Verfügung. Die Variable ist die Enumeration StateBit.

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

class HtmlParser
{
  private:

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

Die Enumeration StateBit enthält Elemente, die die folgenden Parserzustände in Abhängigkeit von der aktuellen Position im Text beschreiben:

Darüber hinaus beschreiben wir konstante Zeichenketten, die für die Suche nach Markup verwendet werden:

    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;

Der Parser-Konstruktor initialisiert alle diese Variablen:

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

Ein Array von empty_tags Zeichenketten wird hier verwendet. Dieses Array ist vorläufig aus einer externen Textdatei verbunden:

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

Siehe den folgenden Inhalt (gültige leere Tags, aber die Liste ist nicht vollständig):

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

Man vergesse nicht, den Baum des DOM wieder zu löschen

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

Die Hauptoperationen werden mit der Parse-Methode durchgeführt:

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

Die Webseite wird eingegeben, ein leeres Wurzel-DomElement erstellt, der Cursor darauf gesetzt, während die aktuelle Position im Text (Offset) ganz am Anfang steht. Dann wird die processText-Helfer-Methode in einer Schleife aufgerufen, bis der gesamte Text erfolgreich gelesen wurde. Der Finite-Zustand-Maschine wird dann in diesem Verfahren ausgeführt. Der Standardzustand der Maschine ist leer.

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

Der Algorithmus sucht im Text nach der spitzen Klammer '<'. Wenn sie nicht gefunden wird, gibt es keine Tags mehr, so dass die Verarbeitung unterbrochen werden sollte (es wird false zurückgegeben). Wenn die Klammer gefunden wird und sich zwischen dem neu gefundenen Tag und der vorherigen Position (Offset) ein Textfragment befindet, wird das Fragment als der Inhalt des aktuellen Tags betrachtet (das Objekt ist am Cursorzeiger verfügbar) - dieser Text wird also dem Objekt mit dem Aufruf von cursor.setText() hinzugefügt.

Dann wird die Position im Text an den Anfang des neu gefundenen Tags verschoben und abhängig von der Signatur, die auf '<' folgt. (COMMENT_START, TAG_CLOSE_START, TAG_OPEN_START) wird der Parser in den entsprechenden neuen Zustand versetzt. Die Funktion IsString ist eine kleine Hilfszeichenkettenvergleichsmethode, die StringSubstr verwendet.

Auf jeden Fall wird true von der Methode processText zurückgegeben, was bedeutet, dass die Methode in der Schleife erneut aufgerufen wird, aber der Parserzustand jetzt ein anderer ist. Befindet sich die aktuelle Position im öffnenden Tag, wird der folgende Code ausgeführt.

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

Wenn der Text weder Leerzeichen noch '>' enthält, ist die HTML-Syntax unterbrochen, so dass false zurückgegeben wird. In den weiteren Schritten wählen Sie den Tag-Namen aus.

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

Hier haben wir ein neues Objekt mit dem gefundenen Namen angelegt. Das aktuelle Objekt (Cursor) wird als Elternobjekt verwendet.

Jetzt müssen wir, falls vorhanden, die Attribute verarbeiten.

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

Die parseAttributes-Methode "lebt" direkt in der Klasse DomElement, die wir später betrachten werden.

Wenn das Tag nicht geschlossen ist, sollten Sie überprüfen, ob es nicht dasjenige ist, das leer sein kann. Wenn ja, sollte es implizit "geschlossen" werden.

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

Je nachdem, ob das Tag geschlossen ist oder nicht, bewegen wir uns entweder in der Objekthierarchie kontinuierlich nach unten und setzen das neu erstellte Objekt als das aktuelle (e), oder wir bleiben im Kontext des vorherigen Objekts. In jedem Fall wird die Position im Text (Offset) auf das letzte gelesene Zeichen verschoben, d.h. über '>' hinaus.

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

Ein Sonderfall ist das Skript. Wenn wir das Tag <script> verwenden, wechselt der Parser in den Zustand insideScript, ansonsten in den Zustand 'blank'.

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

Der folgende Code wird im Status des schließenden Tags ausgeführt.

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

Suchen wir erneut nach '>', das gemäß der HTML-Syntax verfügbar sein muss. Wird die Klammer nicht gefunden, sollte der Prozess unterbrochen werden. Der Tag-Name wird im Erfolgsfall hervorgehoben. Dies geschieht, um zu überprüfen, ob das schließende Tag mit dem öffnenden übereinstimmt. Und wenn die Übereinstimmung unterbrochen ist, ist es notwendig, diesen Layoutfehler irgendwie zu überwinden und zu versuchen, das Parsen fortzusetzen.

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

Wir verarbeiten das schließende Tag, was bedeutet, dass der Kontext des aktuellen Objekts beendet ist und der Parser zurück zum übergeordneten DomElement wechselt:

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

Wenn dies erfolgreich ist, wird der Parserzustand wieder 'blank'.

Wenn sich der Parser in einem Kommentar befindet, sucht er offensichtlich nach dem Ende des Kommentars.

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

Wenn sich der Parser in einem Skript befindet, sucht er nach dem Ende des Skripts.

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

Dies war eigentlich die ganze HtmlParser-Klasse. Betrachten wir nun DomElement.


DomElement - Der Anfang

Die Klasse DomElement verfügt über Variablen zum Speichern von Namen (obligatorisch), Inhalten, Attributen, Links zu Eltern- und Kind-Elementen (erstellt als "geschützt", da sie in der Kindklasse DomIterator verwendet werden).

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

  protected:
    DomElement *children[];

Eine Reihe von Konstruktoren bedarf keiner Erläuterung:

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

Natürlich hat die Klasse Feldmethoden "setter" und "getter" (sie werden im Artikel weggelassen), sowie eine Reihe von Methoden für Operationen mit untergeordneten Elementen (nur Prototypen werden im Artikel gezeigt):

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

Die Methode parseAttributes, die beim Parsen verwendet wurde, delegiert die weitere Arbeit an die Hilfsklasse AttributesParser.

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

'data' wird als einfache Zeichenkette zurückgegeben, auf deren Grundlage die Methode die Map 'attributes' mit den gefundenen Eigenschaften befüllt.

Der vollständige Code AttributesParser ist in den folgenden Anhängen verfügbar. Die Klasse ist nicht groß und arbeitet nach dem Finite-Zustand-Maschinenprinzip, ähnlich wie bei HtmlParser. Aber es hat nur zwei Staaten:

enum AttrBit
{
  name,
  Wert
};

Da die Liste der Attribute eine Zeichenkette ist, die aus Paaren name="value" besteht, ist AttributesParser immer entweder beim Namen oder beim Wert. Dieser Parser könnte mit der Funktion StringSplit implementiert werden, aber wegen möglicher Formatierungsabweichungen (wie z.B. das Vorhandensein oder Fehlen von Anführungszeichen, die Verwendung von Leerzeichen in den Anführungszeichen, usw.) wurde der Maschinenansatz gewählt.

Was die Klasse DomElement betrifft, so sollte der größte Teil der Arbeit in ihr von Methoden geleistet werden, die untergeordnete Elemente auswählen, die den gegebenen CSS-Selektoren entsprechen. Bevor wir mit dieser Funktion fortfahren, ist es notwendig, die Selektorklassen zu beschreiben.

SubSelector und SubSelectorArray

Die Klasse SubSelector beschreibt eine Komponente eines einfachen Selektors. So hat beispielsweise der einfache Selektor "td[align=left][width=325]" drei Komponenten:

Der einfache Selektor "td:first-child" besteht aus zwei Komponenten: Der einfache Selektor "span.main[id^=calendarTip]" besteht wiederum aus drei Komponenten:

Hier ist die Klasse:

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

Die Variable 'type' enthält das erste Zeichen des Wahlschalters ('.', '#', '[') oder den Standard 0, die dem Selektor 'name' entspricht. Die Variable 'value' speichert die Teilzeichenkette, die dem Zeichen folgt, d.h. das aktuell gesuchte Element. Wenn der Selektor 'string' eine Pseudoklasse hat, wird ihre ID in das Feld 'modifier' geschrieben. In der Beschreibung der Selektoren ":nth-child" und ":nth-last-child" wird der Index des gesuchten Elements in Klammern angegeben. Diese wird im Feld 'param' gespeichert (es kann nur eine Zahl in der aktuellen Implementierung sein, aber es sind auch spezielle Formeln erlaubt und daher wird das Feld als Zeichenkette deklariert).

Die Klasse SubSelectorArray stellt einen Haufen von Komponenten zur Verfügung, daher lassen Sie uns das Array 'selectors' darin deklarieren:

class SubSelectorArray
{
  private:
    SubSelector *selectors[];

SubSelectorArray ist ein einfacher Selektor als Ganzes. Für vollständige Selektoren CSS wird keine Klasse benötigt, da sie sequentiell und Schritt für Schritt verarbeitet werden, d.h. ein Selektor in jeder Hierarchieebene.

Fügen wir die unterstützten Pseudoklassenselektoren der Map 'mod' hinzu. Dies ermöglicht das sofortige Abrufen des entsprechenden Modifikators aus PseudoClassModifier für diese Zeichenkette:

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

Die Klasse TypeContainer ist ein Template-Wrapper für die Werte, die IndexMap hinzugefügt werden.

Beachten Sie, dass statische Elemente (in diesem Fall Objekte für die Map) nach der Klassenbeschreibung initialisiert werden müssen:

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

Kommen wir zurück zur Klasse SubSelectorArray.

Wenn es notwendig ist, eine einfache Auswahlkomponente zum Array hinzuzufügen, wird die Funktion add aufgerufen:

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

Das erste Zeichen (Typ) und die nächste Zeichenkette werden an sie übergeben. Die Zeichenkette wird auf den gesuchten Objektnamen, optional eine Pseudoklasse und einen Parameter analysiert. All dies wird dann an dem Konstruktor SubSelector übergeben, während dem Array 'selectors' eine neue Selektorkomponente hinzugefügt wird.

Die Funktion Add wird indirekt aus dem einfachen Selektor-Konstruktor verwendet:

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

Die Funktion createFromString erhält eine Textdarstellung des Selektors CSS und analysiert sie in einer Schleife, um Sonderanfangszeichen '.', '#' oder '[' zu finden, bestimmt dann, wo die Komponente endet und ruft die Methode 'add' für die ausgewählte Information auf. Die Schleife dauert so lange, wie die Kette der Komponenten weiterläuft.

Der vollständige Code des SubSelectorArrays ist unten angehängt.

Jetzt ist es an der Zeit, zur Klasse DomElement zurückzukehren. Dies ist der schwierigste Teil.


DomElement - Die Fortsetzung

Die Methode querySelect dient der Suche nach Elementen, die den angegebenen Selektoren (in der textuellen Darstellung) entsprechen. Bei dieser Methode wird der vollständige Selektor CSS in einfache Selektoren unterteilt, die dann in das Objekt SubSelectorArray umgewandelt werden. Die Liste der passenden Elemente haben wir nach jedem einfachen Selektor durchsucht. Andere Elemente, die dem nächsten, einfachen Selektor entsprechen, werden in Bezug auf die gefundenen Elemente durchsucht. Dies wird fortgesetzt, bis der letzte einfache Selektor erfüllt ist oder bis die Liste der gefundenen Elemente leer wird.

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

Hier ist der Rückgabewert die unbekannte Klasse DomIterator, die das Kind von DomElement ist. Es bietet zusätzlich zu DomElement zusätzliche Funktionen (insbesondere das "Scrollen" von untergeordneten Elementen), so dass wir DomIterator jetzt nicht im Detail analysieren werden. Es gibt noch einen weiteren komplizierten Teil.

Die Auswahlzeichenkette wird zeichenweise analysiert. Zu diesem Zweck werden mehrere lokale Variablen verwendet. Das aktuelle Zeichen wird in der Variablen c gespeichert (Abk. von 'character'). Das vorherige Zeichen wird in der Variablen p gespeichert (Abk. von 'previous'). Wenn es sich bei einem Zeichen um eines der Kombinationszeichen (' ', '+', '>','~') handelt, wird es in einer Variablen gespeichert (a), aber erst bei der Bestimmung des nächsten einfachen Selektors verwendet.

Kombinatoren befinden sich zwischen einfachen Wahlschaltern, während der von den Kombinatoren definierte Vorgang erst nach dem Lesen des gesamten Wahlschalters auf der rechten Seite ausgeführt werden kann. Daher durchläuft der letzte Lesekombinator (a) zunächst den Zustand "Warten": Die Variable a wird erst verwendet, wenn der nächste Kombinator erscheint oder das Zeichenkettenende erreicht ist, während beide Fälle bedeuten, dass der Selektor vollständig gebildet wurde. Nur in diesem Moment wird der "alte" Kombinator (b) angewendet und durch einen neuen ersetzt (a). Der Code selbst ist klarer als seine Beschreibung:

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

Die Variable 'cursor' zeigt immer auf das erste Zeichen, von dem aus die Zeichenkette mit dem einfachen Wahlschalter beginnt (d.h. auf das Zeichen, das unmittelbar auf den vorherigen Kombinator folgt oder auf den Anfang der Zeichenkette). Wenn ein anderer Kombinator gefunden wird, kopieren Sie die Teilzeichenkette von 'cursor' zum aktuellen Zeichen (i) in die Variable 'selector'.

Manchmal gibt es mehrere Kombinatoren hintereinander: Dies passiert normalerweise, wenn andere Kombinatorzeichen Leerzeichen umgeben, während das Leerzeichen selbst ebenfalls ein Kombinator ist. Beispielsweise sind die Einträge "td>span" und "td > span" gleichwertig, aber im zweiten Fall wurden Leerzeichen eingefügt, um die Lesbarkeit zu verbessern. Solche Situationen werden zeilenweise behandelt:

a = MathMax(c, p);

Das vergleicht das aktuelle und das vorherige Zeichen, wenn beide Kombinatoren sind. Dann wählen wir, basierend auf der Tatsache, dass das Leerzeichen den kleinsten Code hat, einen "älteren" Kombinator. Das Kombinatorarray ist offensichtlich wie folgt definiert:

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

Die Überprüfung, ob das Zeichen in dieses Array aufgenommen wird, erfolgt durch die einfache Helferfunktion isCombinator.

Wenn es hintereinander zwei Kombinatoren gibt, die nicht ein Leerzeichen sind, dann ist der Selektor fehlerhaft und das Verhalten ist in den Spezifikationen nicht definiert. Unser Code verliert jedoch nicht an Leistung und regt ein einheitliches Verhalten an.

Wenn das aktuelle Zeichen kein Kombinator ist, während das vorherige Zeichen ein Kombinator war, fällt die Ausführung in einen Zweig, der mit einem Kommentar zur Aktion markiert ist. Speichern Sie nun die aktuelle Größe des zu diesem Zeitpunkt ausgewählten Arrays von DomElementen durch Aufruf:

index = result.getChildrenCount();

Das Array ist zunächst leer und der Index = 0.

Erstellen wir ein Array von Selektorobjekten, die dem aktuellen einfachen Selektor entsprechen, d.h. der Zeichenkette 'selector':

SubSelectorArray selectors(selector);

Dann rufen wir die Methode 'find' auf, auf die wir später zurückkommen werden.

find(b, &selectors, result);

Wir übergeben das Kombinatorzeichen (dies sollte der vorhergehende Kombinator sein, d.h. aus der Variablen b), sowie den einfachen Selektor und ein Array, in das die Ergebnisse ausgegeben werden.

Danach verschieben wir die Warteschlange der Kombinatoren nach vorne, kopieren den zuletzt gefundenen Kombinator (der noch nicht verarbeitet wurde) aus der Variablen a in b und löschen aus den Ergebnissen alles, was vor dem Aufruf von 'find' verfügbar war:

result.removeFirst(index);

Die Methode removeFirst ist in DomIterator definiert. Sie führt eine einfache Aufgabe aus: Sie löscht aus einem Array alle ersten Elemente bis zur angegebenen Anzahl. Dies geschieht, weil wir bei jeder nachfolgenden einfachen Selektorverarbeitung die Elementauswahlbedingungen einschränken und alles, was früher ausgewählt wurde, nicht mehr gültig ist, während die neu hinzugefügten Elemente (die diese engen Bedingungen erfüllen) mit 'index' beginnen.

Eine ähnliche Verarbeitung (markiert mit dem Kommentar 'action') wird auch nach Erreichen des Endes der Eingabezeichenkette durchgeführt. In diesem Fall sollte der letzte ausstehende Kombinator in Verbindung mit dem Rest der Zeile (ab der Position 'cursor') verarbeitet werden.

Betrachten wir nun die Methode 'find'.

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

Wenn eine der Kombinatoren, die verschachtelten Tag-Bedingungen (' ', '>') festlegen, eingegeben wird, sollten für alle untergeordneten Elemente rekursiv Prüfungen aufgerufen werden. In diesem Zweig müssen wir auch den speziellen Kombinator '/' berücksichtigen, der bei der Suche beginnend mit der aufrufenden Methode verwendet wird.

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

Die Methode 'match' wird später besprochen. Sie gibt true zurück, wenn das Objekt dem übergebenen Selektor entspricht, andernfalls false. Ganz am Anfang der Suche (Combinator op = '/') gibt es noch keine Kombinationen, so dass alle Tags, die den Regeln des Selektors entsprechen, dem Ergebnis hinzugefügt werden (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;
              }
            }

Für den Kombinator ' ' wird geprüft, ob das aktuelle DomElement oder ein Elternelement bereits in 'output' vorhanden ist. Das bedeutet, dass die neuen untergeordneten Elemente, die die Suchbedingungen erfüllen, bereits in das Elternelement verschachtelt sind. Genau das ist die Aufgabe des Kombinators.

Der Kombinator '>' arbeitet ähnlich, muss aber nur unmittelbare "Verwandte" verfolgen und somit nur prüfen, ob das aktuelle DomElement in Zwischenergebnissen verfügbar ist. Wenn ja, dann wurde es zuvor durch Bedingungen des Wahlschalters auf der linken Seite des Kombinators zur 'output' ausgewählt, und sein i-tes untergeordnetes Element hat gerade die Bedingungen des Wahlschalters auf der rechten Seite des Kombinators erfüllt.

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

Dann müssen ähnliche Prüfungen tief im DOM-Baum durchgeführt werden, daher sollte für untergeordnete Elemente rekursiv 'find' aufgerufen werden.

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

Die Kombinatoren '+' und '~' legen die Bedingungen fest, ob sich zwei Elemente auf dasselbe Elternelement beziehen.

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

Eines der Elemente muss bereits durch einen Selektor auf der linken Seite ausgewählt sein. Wenn diese Bedingung erfüllt ist, überprüfen wir die "Geschwister" für den Selektor auf der rechten Seite ("Geschwister" sind die Kinder des aktuellen Knoten-Elternteils).

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

Der Unterschied zwischen der Behandlung von '+' und '~' ist folgender: Bei '+' müssen Elemente unmittelbare Nachbarn sein, bei '~' kann es beliebig viele andere "Geschwister" zwischen den Elementen geben. Daher wird die Schleife nur einmal für '+', d.h. für das nächste Element in der Anordnung der untergeordneten Elemente, ausgeführt. Die Funktion 'match' wird innerhalb der Schleife erneut aufgerufen (Details siehe später).

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

Nach allen Prüfungen wechseln wir zur nächsten Hierarchieebene des DOM-Elementbaums und rufen 'find' für die Kindknoten auf.

Es geht um die Methode 'find'. Betrachten wir nun die Funktion 'match'. Dies ist der letzte Punkt in der Beschreibung der Selektor-Implementierung.

Die Funktion überprüft im aktuellen Objekt die gesamte Kette der Komponenten eines einfachen Selektors, der durch einen Eingabeparameter geleitet wird. Wenn mindestens eine Komponente in der Schleife nicht mit den Element-Eigenschaften übereinstimmt, schlägt die Prüfung fehl.

    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
          }

Der Selektor vom Typ 0 ist der Tag-Name oder eine Pseudoklasse. Jedes Tag ist für einen Selektor mit einem Sternchen geeignet. Andernfalls sollte der Selektor 'string' mit dem Tag-Namen verglichen werden:

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

Die aktuell implementierten Pseudoklassen setzen Grenzen für die Anzahl der aktuellen Elemente im Array der Kindelemente eines Elternteils, daher analysieren wir die Indizes:

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

Der Selektor '.' schränkt das Attribut "class" ein:

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

Der Selektor '#' schränkt das Attribut "id" ein:

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

Der Selektor '[' ermöglicht die Angabe einer beliebigen Menge von erforderlichen Attributen. Zusätzlich zum strengen Vergleich der Werte ist es auch möglich, das Auftreten einer Teilzeichenkette (Suffix '*'), Anfang ('^') und Ende ('$') zu überprüfen.

        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;

    }

Beachten Sie, dass das Attribut "class" auch hier unterstützt und verarbeitet wird, und wie im Falle des Selektors '.' Wird der Vergleich nicht für eine strikte Übereinstimmung durchgeführt, sondern für die Anwesenheit der gewünschten Klasse unter den wahrscheinlich vielen anderen. In HTML wird häufig ein Mechanismus verwendet, wenn einem Element mehrere Klassen gleichzeitig zugewiesen werden. In diesem Fall werden Klassen in der Attributklasse 'class' angegeben, die durch ein Leerzeichen getrennt ist.

Fassen wir die Zwischenergebnisse zusammen. Wir haben in der Klasse DomElement die Methode querySelect implementiert, die eine Zeichenkette mit dem vollständigen CSS-Selektor als Parameter akzeptiert und das Objekt DomIterator zurückgibt, d.h. ein Array von gefundenen passenden Elementen. Innerhalb von querySelect ist die CSS-Selektorzeichenkette in eine Folge von einfachen Selektoren und Kombinatorzeichen zwischen ihnen unterteilt. Für jeden einfachen Selektor wird die Methode 'find' mit dem angegebenen Kombinator aufgerufen. Diese Methode aktualisiert die Liste der Ergebnisse, während sie sich rekursiv nach untergeordneten Elementen ruft. Der Vergleich von einfachen Selektorkomponenten mit den Eigenschaften eines bestimmten Elements wird nach der Methode 'match' durchgeführt.

Mit der Methode querySelect können wir beispielsweise Zeilen aus einer Tabelle mit einem CSS-Selektor auswählen und dann querySelect für jede Zeile mit einem anderen CSS-Selektor aufrufen, um bestimmte Zellen zu isolieren. Da Operationen mit Tabellen sehr häufig erforderlich sind, legen wir die Methode tableSelect in der Klasse DomElement an, die den oben beschriebenen Ansatz implementiert. Der Code wird in vereinfachter Form zur Verfügung gestellt.

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

Der Zeilenauswahlschalter wird im Parameter rowSelector angegeben, während Zellauswahlschalter im Array ColumSelectors angegeben werden.

Sobald alle Elemente ausgewählt sind, müssen wir einige Informationen von ihnen übernehmen, wie z.B. Text oder Attributwert. Verwenden wir die dataSelectors, um die Position der benötigten Informationen innerhalb eines Elements zu bestimmen, während für jede Tabellenspalte eine individuelle Datenextraktionsmethode verwendet werden kann.

Wenn dataSelectors[i] eine leere Zeile ist, lesen wir den Textinhalt des Tags (zwischen dem Öffnungs- und Schließteil, z.B. "100%" vom Tag "<p>100%</p>"). Wenn dataSelectors[i] eine Zeile ist, betrachten wir dies als Attributnamen und verwenden diesen Wert.

Lassen Sie uns die vollständige Umsetzung im Detail betrachten:

      DomIterator *r = querySelect(rowSelector);

Hier erhalten wir die resultierende Liste der Elemente nach Zeilenauswahl.

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

Hier erstellen wir eine leere Map, zu der Tabellendaten hinzugefügt werden, und bereiten uns auf eine Schleife durch Zeilenobjekte vor. Hier ist die Schleife:

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

So erhalten wir die nächste Zeile, (e), erstellen eine Container-Map für sie (Zeile), zu der Zellen hinzugefügt werden, und führen eine Schleife durch Spalten durch:

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

Wir wählen in jedem Zeilenobjekt die Liste der Zellenobjekte (d) mit dem entsprechenden Selektor aus. Wählen wir Daten aus jeder gefundenen Zelle aus und speichern sie in der Map '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);
          }

Ganzzahlige Schlüssel werden hier zur Vereinfachung des Codes verwendet, während der vollständige Quellcode die Verwendung von Elementbezeichnern für die Schlüssel unterstützt.

Wenn keine passende Zelle gefunden wird, markieren wir diese als leer.

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

Hinzufügen des Feldes 'row' zur Tabelle 'data'.

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

So erhalten wir als Ausgabe eine Map der Maps, d.h. eine Tabelle mit Zeilennummern entlang der ersten Dimension und Spaltennummern entlang der zweiten. Bei Bedarf kann die Funktion tableSelect an andere Datencontainer angepasst werden.

Für die Anwendung aller oben genannten Klassen wurde ein Expert Advisor, der nicht handelt, geschaffen.

Der Expert Advisor in Form des Dienstprogramms WebDataExtractor

Der Expert Advisor dient zur Konvertierung von Daten aus Webseiten in eine tabellarische Struktur mit der Möglichkeit, das Ergebnis in einer CSV-Datei zu speichern.

Der Expert Advisor erhielt folgende Eingabeparameter: einen Link zu den Quelldaten (eine lokale Datei oder eine Webseite, die über WebRequest heruntergeladen werden kann), Zeilen- und Spaltenauswahl und den CSV-Dateinamen. Die wichtigsten Eingangsparameter sind nachfolgend dargestellt:

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

In der URL tragen wir die Adresse der Webseite (beginnend mit http:// oder https://) oder den lokalen HTML-Dateinamen ein.

In SaveName wird der Name der CSV-Datei mit den Ergebnissen im Normalmodus angegeben. SaveName kann aber auch für andere Zwecke verwendet werden: zum Speichern der heruntergeladenen Seite für das spätere Debuggen von Selektoren. In diesem Modus sollte der nächste Parameter leer gelassen werden: RowSelector, in dem in der Regel der CSS-Zeilenauswahlschalter angegeben ist.

Da es mehrere Spaltenauswahlselektoren gibt, werden diese in einer separaten CSV-Datei mit den Einstellungen gesetzt, deren Name im Parameter ColumnSettingsFile angegeben ist. Das Dateiformat ist wie folgt.

Die erste Zeile ist die Kopfzeile, jede weitere Zeile beschreibt ein eigenes Feld (eine Datenspalte in der Tabellenzeile).

Die Datei sollte drei Spalten haben: Name, CSS-Selektor, Data Locator:

Die Parameter TestQuery und TestSubQuery ermöglichen das Testen von Selektoren für eine Zeile und eine Spalte, während das Ergebnis zum Protokoll ausgegeben, aber nicht im CSV gespeichert wird und nicht Einstellungsdateien für alle Spalten verwendet werden.

Hier ist die Hauptbetriebsfunktion des Expert Advisor in Kurzform.

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

So haben wir eine HTML-Seite aus einer Datei gelesen oder aus dem Internet heruntergeladen. Um das Dokument in die Hierarchie der DOM-Objekte zu konvertieren, erstellen wir nun das Objekt HtmlParser und starten das Parsen:

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

Wenn Testselektoren angegebenen wurde, werden sie durch den Aufruf von querySelect abgearbeitet

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

Im normalen Betriebsmodus wird die Spalteneinstellungsdatei gelesen und die Funktion tableSelect aufgerufen:

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

Wenn eine CSV-Datei zum Speichern der Ergebnisse angegeben ist, wird die Map 'data' diese Aufgabe ausführen.

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

Kommen wir zur praktischen Anwendung des Expert Advisors.


Praktische Verwendung

Händler haben oft mit einigen Standard-HTML-Dateien zu tun, wie z.B. Testberichten und Handelsberichten, die von MetaTrader generiert werden. Manchmal erhalten wir solche Dateien von anderen Händlern oder laden sie aus dem Internet herunter und möchten die Daten zur weiteren Analyse in einem Chart visualisieren. Zu diesem Zweck sollten Daten aus HTML in eine tabellarische Sicht (im einfachen Fall in das CSV-Format) konvertiert werden.

CSS-Selektor in unserem Dienstprogramm kann diesen Prozess automatisieren.

Werfen wir einen Blick in die HTML-Dateien. Nachfolgend sehen Sie das Aussehen und den Teil des HTML-Codes des MetaTrader 5 Handelsberichts (die Datei ReportHistory.html ist unten angehängt).

Darstellung des Handels-Reports und Teil des HTML-Codes

Darstellung des Handels-Reports und Teil des HTML-Codes

Und nun ist hier das Aussehen und der Teil des HTML-Codes des MetaTrader 5 Testberichts (die Datei Tester.html ist unten angehängt).

Aussehen eines Testberichts und Teile des HTML-Codes

Aussehen eines Testberichts und Teile des HTML-Codes

Gemäß dem Erscheinungsbild in der obigen Abbildung besteht der Handelsbericht aus 2 Tabellen: Aufträge und Transaktionen (Deals). Aus dem internen Layout ist jedoch ersichtlich, dass es sich um eine einzige Tabelle handelt. Alle sichtbaren Überschriften und die Trennlinie werden durch die Stile von Tabellenzellen gebildet. Wir müssen lernen, zwischen Aufträgen und Deals zu unterscheiden und jede der Untertabellen in einer separaten CSV-Datei zu speichern.

Der Unterschied zwischen dem ersten Teil und dem zweiten Teil liegt in der Anzahl der Spalten: 11 Spalten für die Aufträge und 13 Spalten für die Deals. Leider erlaubt der CSS-Standard keine Festlegung von Bedingungen für die Auswahl von Elternelementen (in unserem Fall die Tabellenzeilen, 'tr' Tag) basierend auf der Anzahl oder dem Inhalt von Kindern (in unserem Fall Tabellenzellen, 'td' Tag). Daher können in einigen Fällen die benötigten Elemente nicht mit Standardmitteln ausgewählt werden. Aber wir entwickeln unsere eigene Implementierung von Selektoren und können so einen speziellen, nicht standardisierten Selektor für die Anzahl der Child-Elemente hinzufügen. Dies wird eine neue Pseudoklasse sein. Setzen wir es als ":has-n-children(n)", analog zu ":nth-child(n)".

Der folgende Selektor kann zur Auswahl von Zeilen mit Aufträgen verwendet werden:

tr:has-n-children(11)

Dies ist jedoch nicht die vollständige Lösung des Problems, da dieser Selektor neben den Datenzeilen auch den Tabellenkopf auswählt. Entfernen wir ihn. Achten wir auf die Einfärbung der Datenzeilen - das Attribut bgcolor wird für sie gesetzt, und der Farbwert wechselt für gerade und ungerade Zeilen (#FFFFFFFF und #F7F7F7). Eine Farbe, d.h. das Attribut bgcolor wird auch für den Header verwendet, aber sein Wert ist gleich #E5F0FC. Somit haben die Datenzeilen helle Farben mit bgcolor beginnend mit "#F". Fügen wir diese Bedingung dem Selektor hinzu:

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

Der Selektor bestimmt korrekt alle Zeilen mit Aufträgen.

Parameter jeder Bestellung können aus den Zeilenzellen gelesen werden. Dazu schreiben wir die Konfigurationsdatei 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),

Alle Felder in dieser Datei werden einfach durch die Sequenznummer identifiziert. In anderen Fällen benötigen wir möglicherweise intelligentere Selektoren mit Attributen und Klassen.

Um eine Tabelle der Deals zu erhalten, ersetzen wir einfach die Anzahl der untergeordneten Elemente durch 13 in der Zeilenauswahl:

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

Die Konfigurationsdatei ReportHistoryDeals.cfg.csv ist unten angehängt.

Nun, indem wir den WebDataExtractor mit den folgenden Eingabeparametern starten (die Datei webdataex-report1.set ist angehängt):

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

erhalten wir die resultierende Datei ReportOrders.csv, die dem Quell-HTML-Bericht entspricht:

CSV-Datei, die sich aus der Anwendung von CSS-Selektoren auf einen Handelsbericht ergibt

CSV-Datei, die sich aus der Anwendung von CSS-Selektoren auf einen Handelsbericht ergibt

Um die Tabelle der Deals zu erhalten, verwenden wir die angehängten Einstellungen aus webdataex-report2.set.

Die von uns erstellten Selektoren sind auch für Testberichte geeignet. Die angehängten Dateien webdataex-tester1.set und webdataex-tester2.set ermöglichen es Ihnen, einen exemplarischen HTML-Bericht Tester.html in CSV-Dateien zu konvertieren.

Wichtig! Das Layout vieler Webseiten sowie der erzeugten HTML-Dateien in MetaTrader kann sich von Zeit zu Zeit ändern. Dadurch entfallen die Auswahlmöglichkeiten, auch wenn die externe Darstellung nahezu gleich ist. In diesem Fall sollten Sie den HTML-Code neu analysieren und die CSS-Selektoren entsprechend anpassen.

Kommen wir nun zur Konvertierung der Testberichte des MetaTrader 4; dies ermöglicht die Demonstration einiger interessanter Techniken bei der Auswahl von CSS-Selektoren. Für die Überprüfung verwenden wir den beigefügten StrategyTester-ecn-1.htm.

Diese Dateien haben zwei Tabellen: eine mit den Testergebnissen und eine mit den Handelsoperationen. Um die zweite Tabelle auszuwählen, verwenden wir den Selektor "table ~ table". Lassen wir die erste Zeile in der Operationstabelle weg, da sie einen Header darstellt. Dies kann mit dem Selektor "tr + tr" erfolgen.

Nachdem wir sie kombiniert haben, erhalten wir einen Selektor zur Auswahl der Arbeitsreihen:

table ~ table tr + tr

Das bedeutet eigentlich folgendes: Wählen Sie eine Tabelle nach der Tabelle aus (d.h. die zweite, innerhalb der Tabelle jede Zeile mit einer vorherigen Zeile, d.h. alle außer der ersten).

Einstellungen zum Extrahieren von Transaktionsparametern aus Zellen sind in der Datei test-report-mt4.cfg.csv verfügbar. Das Datumsfeld wird vom Klassenselektor abgearbeitet:

DateTime,td.msdate,

d.h. es wird nach td-Tags mit dem Attribut class="msdate" gesucht.

Die vollständige Einstellungsdatei für das Dienstprogramm ist webdataex-tester-mt4.set.

Weitere Beispiele für die Verwendung und Einrichtung von CSS-Selektoren finden Sie auf der Seite WebDataExtractor discussion.

Das Dienstprogramm kann aber noch viel mehr:

Wenn Sie Hilfe bei der Einrichtung von CSS-Selektoren für eine bestimmte Webseite benötigen, können Sie WebDataExtractor (für MetaTrader 4, für MetaTrader 5) erwerben und Empfehlungen im Rahmen des Produktsupports erhalten. Die Verfügbarkeit von Quellcode ermöglicht es Ihnen jedoch, die gesamte Funktionalität zu nutzen und bei Bedarf zu erweitern. Dies ist absolut kostenlos.


Schlussfolgerungen

Wir haben die Technologie der CSS-Selektoren berücksichtigt, die einer der wichtigsten Standards bei der Interpretation von Web-Dokumenten ist. Die Implementierung der gebräuchlichsten CSS-Selektoren in MQL ermöglicht die flexible Einrichtung und Konvertierung jeder HTML-Seite, einschließlich Standard-MetaTrader-Dokumente, in strukturierte Daten ohne Verwendung von Fremdsoftware.

Wir haben nicht an andere Technologien gedacht, die auch vielseitige Werkzeuge für die Verarbeitung von Web-Dokumenten bieten können. Solche Tools können nützlich sein, da MetaTrader nicht nur HTML, sondern auch das XML-Format verwendet. Händler können sich besonders für XPath und XSLT interessieren. Diese Formate können als weitere Schritte bei der Entwicklung der Idee zur Automatisierung von Handelssystemen auf Basis von Webstandards dienen. Die Unterstützung von CSS-Selektoren in MQL ist nur der erste Schritt hin zu diesem Ziel.