使用CSS选择器从HTML页面提取结构化数据

9 五月 2019, 08:59
Stanislav Korotky
0
578

MetaTrader开发环境使应用程序能够与外部数据集成,特别是与使用WebRequest功能从Internet获取的数据集成,HTML是Web上最通用和最常用的数据格式。如果公共服务没有为请求提供开放式API,或者其协议在MQL中难以实现,则可以解析所需的HTML页面,特别是,交易者经常使用各种经济日历。尽管现在任务并不那么重要,因为平台具有内置日历,一些交易者可能需要来自特定站点的特定新闻。此外,我们有时需要从从第三方收到的交易HTML报告中分析交易。

MQL5生态系统为该问题提供了各种解决方案,但这些解决方案通常是特定的,并有其局限性。另一方面,有一种“特定”和通用的方法来搜索和解析HTML中的数据,这种方法与CSS选择器的使用有关。在本文中,我们将探讨此方法的MQL5实现,以及它们的实际使用示例。

要分析HTML,我们需要创建一个解析器,它可以将内部页面文本转换为称为文档对象模型(Document Object Model)或 DOM 的某些对象的层次结构。从这个层次结构中,我们将能够找到具有指定参数的对象。这种方法基于对文档结构的服务信息的使用,而文档结构在外部页面视图中不可用。

例如,我们可以在文档中选择特定表的行,从中读取所需的列,并获取一个具有值的数组,这些值可以轻松保存到csv文件中,显示在图表上或用于EA交易的计算。


HTML/CSS 和 DOM 技术概览

HTML是一种几乎所有人都熟悉的流行格式,因此,我不会详细描述这种超文本标记语言的语法。

相关技术信息的主要来源是IETF(互联网工程工作组)及其规范,即所谓的RFC(征求意见)。有很多HTML的规格说明 (这里是 一个例子). 标准也可在相关组织W3C的网站上获得(万维网联合会HTML5.2)。

这些组织已经开发了CSS(级联样式表, Cascading Style Sheets)技术,并对其进行了管理。然而,我们对这项技术感兴趣的原因并不是因为它描述了网页上的信息表示样式,而是因为其中包含了CSS 选择器,也就是说,它是一种特殊的查询语言,能够搜索HTML页面内的元素。

在创建新版本的同时,HTML和CSS都在不断发展。例如,当前的相关版本是 HTML5.2 和 CSS4,然而,更新和扩展总是伴随着旧版本特性的继承。网络是如此庞大、异类化,而且常常是惰性的,因此新的版本与旧的版本共存。因此,在编写暗示使用Web技术的算法时,您应该小心地使用规范:一方面,您应该考虑到可能的传统偏差,另一方面,您应该添加一些简化,这将有助于避免多个变体的问题。

在这个项目中,我们将考虑简化的HTML语法。

HTML文档由字符“<”和“>”内的标记组成,标记名和可选属性在标记内指定。可选属性是 name=“value” 的字符串对,而符号“=”有时可以省略。这里是一个标记的例子:

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

— 这是一个名为“a”的标记(被Web浏览器解释为超链接),有两个参数:“href”表示指定超链接的网站地址,“target”表示网站打开选项(在这种情况下,它等于“_blank”,即该网站应在新的浏览器选项卡中打开)。

第一个标签是开始标签。后面是网页上实际可见的文本:“HTML 和 CSS”,以及匹配的结束标记,其名称与开始标记相同,并且在尖括号“<”之后还有一个斜线“/”(所有字符一起构成标记“<”)。换句话说,开始和结束标记成对使用,可能包括其他标记,但只包括整个标记,而不重叠。以下是正确嵌套的示例:

<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规则,这条斜线被认为是多余的。所有这些特定的情况都可以在正常的网页中得到满足,所以解析器必须能够处理它们。

Web浏览器解释的标记和属性名称是标准的,但HTML可以包含自定义元素。浏览器会跳过这些元素,除非开发人员使用专门的脚本API将它们“连接”到DOM。我们应该记住,每个标签都可能包含有用的信息。

解析器可以被视为有限状态机,它逐字前进,并根据上下文更改其状态。从上面的标记结构描述可以清楚地看出,最初解析器在任何标记之外(让我们称此状态为“空白”)。然后,在遇到开口角括号“<”后,我们进入一个开口标记(“InsideTagOpen”状态),该状态持续到关闭角括号“>”。字符“</”的组合表明我们处于结束标记(“InsideTagClose”状态),等等。在解析器实现部分将考虑其他状态,

在状态之间切换时,我们可以从文档中的当前位置选择结构化信息,因为我们知道状态的含义。例如,如果当前位置在开始标记内,则可以选择标记名称作为最后一个“<”和后续空格或“>”之间的一行(取决于标记是否包含属性)。解析器将提取数据并创建某个 DomElement 类的对象。除了名称、属性和内容之外,还将根据标记嵌套结构保留对象的层次结构。换句话说,每个对象都有一个父对象(描述整个文档的根元素除外)和一个可选的子对象数组。

解析器将输出完整的对象树,其中一个对象将对应于源文档中的一个标记。

CSS选择器根据对象在层次结构中的参数和位置描述对象条件选择的标准符号。选择器的完整列表非常广泛,我们将为其中一些提供支持,这些支持包括在CSS1、CSS2和CSS3标准中。

以下是主要选择器组件的列表:

  • * - 任何对象(通用选择器);
  • .value — 具有“class”属性和“value”的对象;示例:<div class=“example”><div>;对应的选择器:.example;
  • #id — 具有“id”属性和“value”的对象;对于标记<div id=“unique”><div> 它就是选择器 unique;
  • tag — 具有“tag”名称的对象;若要查找上面的所有“div” 或者<div>text</div>,使用选择器:div;
它们可以伴随着所谓的伪类,在右边加上:

  • :first-child — 对象是父类中的第一个子类;
  • :last-child — 对象是父类中最后一个子类 i9nside;
  • :nth-child(n) — 对象在其父节点的子节点列表中具有指定的位置号;
  • :nth-last-child(n) — 对象在其父节点的子节点列表中具有指定的位置编号,且编号相反;

单个选择器可以由与属性相关的条件进行补充:
  • [attr] — 对象具有“attr”属性(该属性是否有任何值并不重要);
  • [attr=value] — 对象具有“attr”属性和“value”;
  • [attr*=text] — 对象具有“attr”属性,其值包含子字符串“text”;
  • [attr^=start] — 对象具有“attr”属性,值以“start”字符串开头;
  • [attr$=end] — 对象具有“attr”属性,值以“end”子字符串结尾;

如果需要,可以指定具有不同属性的几对括号。

简单选择器(Simple selector)是名称选择器或通用选择器,可以选择后跟任何顺序的类、标识符、零个或多个属性或伪类。当选择器的所有组件与元素属性匹配时,简单选择器选择一个元素。

CSS 选择器 (或完整选择器) 是由一个或多个简单选择器组成的链,通过组合字符(“'(空格)、'>'、'+'、'~')连接:
  • container element — “element”对象以任意级别嵌套在“container”对象中;
  • parent > element — “element”对象有一个直接的父“parent”(嵌套级别等于1);
  • e1 + element — “element”对象与“e1”有一个共同的父对象,并且就在它后面;
  • e1 ~ element — “element”对象与“e1”有一个共同的父对象,并在它之后的任意距离;

到目前为止,我们一直在研究纯理论,让我们来看看上述想法是如何在实践中发挥作用的。

任何现代Web浏览器都允许查看当前打开页面的HTML,例如,在 Chrome 中,您可以从上下文菜单运行“查看页面源代码”命令或打开开发人员窗口(开发人员工具,Ctrl+Shift+I)。开发人员窗口有控制台选项卡,在该选项卡中,我们可以尝试使用CSS选择器查找元素。要应用选择器,只需从控制台调用 document.querySelectorAll 函数(它包含在所有浏览器的软件API中)。

例如,在开始论坛页面 https://www.mql5.com/en/forum中,我们可以运行以下命令(JavaScript 代码):

document.querySelectorAll("div.widgetHeader")

结果,我们将收到一个“div”元素(标记)列表,其中指定了“widgetHeader”类。我决定在查看源页面代码之后使用这个选择器,很明显,基于源页面代码,论坛主题是以这种方式设计的。

选择器可以展开如下:

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

要接收论坛主题讨论标题列表:它们作为超链接“a”提供,这是在第一阶段选择的每个“div”块中的第一个子元素,如下所示(取决于浏览器版本):

MQL5 网页和使用 CSS 选择器选择 HTML 元素的结果

MQL5 网页和使用 CSS 选择器选择 HTML 元素的结果

同样,您应该分析所需站点的HTML代码,找出感兴趣的元素,并选择适当的CSS选择器。“开发人员”窗口具有“元素”(或类似的)选项卡,您可以在其中选择文档中的任何标记(此标记将突出显示),并为该标记找到适当的CSS选择器。这样,您将逐渐练习使用选择器,并学习手动创建选择器链。此外,我们将探讨如何为特定网页选择适当的选择器。


设计

让我们在全局范围内查看我们可能需要的类。最初的HTML文本处理将由 HtmlParser 类执行,这个类将扫描文本中的标记字符“<”、“/”、“>”和其他一些字符,并将根据上述有限状态机规则创建 DomElement 类对象:将为每个空标记或一对打开和关闭标记创建一个对象。开始标记可能有属性,我们需要在当前 DomElement 对象中读取和保存这些属性,这将由 AttributeParser类执行,该类也将按照有限状态机的原理运行。

解析器将创建 DomElement 对象,同时考虑到与标记嵌套顺序相同的层次结构。例如,如果文本包含“div”标记,其中放置了多个段落(这意味着存在“p”标记),则这些段落将转换为描述“div”的对象的子对象。

初始根对象将包含整个文档。与浏览器(提供 document.querySelectorAll方法)类似,我们应该在 DomElement 中提供一个方法,用于请求与传递的 CSS 选择器对应的对象。选择器也应该预先分析,并从字符串表示形式转换为对象:一个选择器组件将存储在 SubSelector 器类中,整个简单选择器将存储在 SubSelectorArray 中。

一旦解析器操作的结果是有了准备好的 DOM 树,就可以从根 DomElement对象(或任何其他对象)请求与选择器参数匹配的所有子元素。所有选定的元素都将被放置在可迭代的 DomItator列表中。为了简单起见,让我们将列表实现为 DomElement的子级,其中使用子节点数组存储找到的元素。

具有特定站点或HTML文件处理规则和算法执行结果的设置可以方便地存储在一个类中,该类结合了映射属性(即根据适当属性的名称提供对值的访问)和数组属性(即通过索引访问元素)。让我们称这个类为 IndexMap。

让我们提供将索引映射彼此嵌套的功能:当从网页收集表格数据时,我们得到一个包含列列表的行列表。对于这两种数据类型,我们都可以保存源元素的名称。这可以是特别有用的,在一些情况下的元素是将在源文件中(这一点很可能经常在搜索索引的情况下),这是一盘简单的信息数据是缺失的。作为一个额外的好处,让我们“训练” IndexMap 以序列化为多行文本,包括 CSV 格式。此功能在将HTML页转换为表格数据时很有用。如果需要,可以在保留主要功能的同时用自己的 IndexMap 类替换。

下面的UML图显示了所描述的类。

在MQL中实现CSS选择器的类的UML图

在MQL中实现CSS选择器的类的UML图



实现

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 枚举包含根据文本中的当前位置描述以下分析器状态的元素:

  • blank — 标签之外;
  • insideTagOpen — 在开始标签内;
  • insideTagClose — 在结束标签内;
  • insideComment — 在注释中(HTML代码中的注释用标记<!--comment-->);只要解析器位于注释中,无论注释中包含哪些标记,都不会生成任何对象;
  • insideScript — 在脚本中,应该突出显示这种状态,因为JavaScript代码通常包含子字符串,这些子字符串可以解释为HTML标记,尽管它们不是DOM元素,而是脚本的一部分);

此外,让我们描述用于搜索标记的常量字符串:

    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,将光标设置为它,而文本中的当前位置(偏移量)设置为最开始的位置。然后在循环中调用 processText 帮助方法,直到成功读取整个文本。然后用这种方法执行有限状态机,状态机的默认状态为空。

    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,这意味着将在循环中再次调用该方法,但解析器状态现在将不同。如果当前位置在开始标记中,则执行以下代码。

      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; // 外部空间,不考虑
        }

        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 >标签,解析器切换到内部状态,否则切换到空白状态。

        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)
          {
            // 处理结束标记
            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);
      }

如果成功,分析器状态再次变为“空白”。

当解析器在注释中时,它显然在寻找注释的结尾。

      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”映射。

下面的附件中提供了完整的 AttributeParser 代码。该类并不大,并且使用有限状态机原理操作,类似于HTMLParser。但是它只有两种状态:

enum AttrBit
{
  name,
  value
};

由于属性列表是由name=“value”对组成的字符串,因此 AttributesParser 始终位于名称或值处。这个解析器可以使用 StringSplit 函数来实现,但是由于格式可能存在偏差(例如引号的存在或不存在、引号内的空格的使用等),所以选择了状态机方法。

对于 DomElement 类,其中的大部分工作应该由方法来执行,这些方法选择与给定的 CSS 选择器对应的子元素。在我们继续这个特性之前,有必要描述选择器类。

SubSelector 和 SubSelectorArray

SubSelector 类描述简单选择器的一个组件,例如,简单选择器 “td[align=left][width=325]” 有三个组件:

  • 标签名称 - td
  • 对齐属性条件 - [align=left]
  • 宽度属性条件 - [width=325]
简单选择器“td:first child”有两个组件:
  • 标签名称 - td
  • 使用伪类的子索引条件 - :first-child
简单的选择器“span.main[id^=calendartip]”同样包含三个组件:
  • 标签名称 — 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变量存储字符后面的子字符串,即实际搜索的元素。如果选择器字符串具有伪类,则其 id 将写入“modifier”字段。在选择器“:nth child”和“:nth last child”的描述中,搜索元素的索引在括号中指定。这将保存在“param”字段中(它只能是当前实现中的数字,但也允许特殊公式,因此该字段声明为字符串)。

SubSelectorArray类提供了一系列组件,因此让我们在其中声明“selectors”数组:

class SubSelectorArray
{
  private:
    SubSelector *selectors[];

SubSelectorArray是一个简单的选择器。完整的CSS选择器不需要类,因为它们是按顺序逐步处理的,即每个层次结构级别都有一个选择器。

让我们将支持的伪类选择器添加到“mod”映射中。这样可以立即从该字符串的伪类修饰符中获取适当的修饰符:

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

将传给它第一个字符(type)和下一个字符串,字符串被解析为搜索的对象名,也可以是伪类和参数。所有这些都将传递给子选择器构造函数,同时新的选择器组件将添加到“selectors”数组中。

从简单选择器构造函数间接使用add函数:

  private:
    void createFromString(const string &selector)
    {
      ushort p = 0; // 之前的类型
      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选择器被划分为简单的选择器,然后转换为子选择器数组对象。我们为每个简单选择器搜索的匹配元素列表。与下一个简单选择器匹配的其他元素将相对找到的元素进行搜索。这将一直持续,直到满足最后一个简单选择器,或者直到找到的元素列表变为空。

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

这里的返回值是不熟悉的 DomIterator类,它是 DomElement的子类。除了DomElement之外,它还提供了辅助功能(特别是,它允许“滚动”子元素),所以我们现在不详细分析 DomIterator。还有一个复杂的部分,

选择器字符串是逐字符分析的。为此,使用了几个局部变量。当前字符存储在变量c ('character 的缩写)中。前一个字符存储在变量 p ('pervious' 的缩写)中。如果字符是组合符字符(“”,'+','>','~')之一,则它保存在变量中(a),但在确定下一个简单选择器之前不使用。

组合器位于简单选择器之间,而组合器定义的操作只能在读取右侧的整个选择器之后执行。因此,最后一个读组合器(a)首先通过“waiting”状态:直到出现下一个组合器或到达字符串结束时,才使用变量a ,而这两种情况都意味着选择器已经完全形成。仅在此时,“old”组合器(b)才被应用,并被一个新的组合器(a)替换。代码本身比其描述更清楚:

      int cursor = 0; // 选择器字符串的起始位置
      int i, n = StringLen(q);
      ushort p = 0;   // 上一个字符
      ushort a = 0;   //下一个/挂起的操作符
      ushort b = '/'; // 当前运算符,“根”符号从开始
      string selector = "*"; // 当前简单选择器默认为“any”
      int index = 0;  // 在结果对象数组中的位置

      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
          {
            // 压缩其他组合符周围的空白
            a = MathMax(c, p);
          }
          cursor = i + 1;
        }
        else
        {
          if(isCombinator(p)) // 操作
          {
            index = result.getChildrenCount();
            
            SubSelectorArray selectors(selector);
            find(b, &selectors, result);
            b = a;
            
            // 现在我们可以删除“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”变量始终指向第一个字符,从该字符开始具有简单选择器的字符串(即,在紧接前一个组合器后面的字符处或在字符串开始处)。当找到另一个组合器时,将子字符串从“cursor”复制到当前字符(i)中的“selector”变量中。

有时有几个连续的组合符:当其他组合符字符包围空格时,通常会发生这种情况,而空格本身也是一个组合符。例如,条目“td>span”和“td>span”是等效的,但在第二种情况下插入空格以提高可读性。此类情况按顺序处理:

a = MathMax(c, p);

如果两个字符都是组合符,则比较当前字符和以前的字符。然后,根据空间中代码最少的事实,选择一个“old”组合器。组合器数组显然定义如下:

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

检查字符是否包含在此数组中是由一个简单的 isCombinator 辅助函数执行的,

如果一行中有两个组合器,而不是一个空格,那么选择器是错误的,行为在规范中没有定义。但是,我们的代码不会失去性能,并建议一致的行为。

如果当前字符不是组合符,而前一个字符是组合符,则执行将落在标记有“action”注释的分支中。现在,通过调用以下命令来记住当前选定的 DomElements 数组的大小:

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”注释标记)。在这种情况下,最后一个挂起的组合器应该与行的其余部分(从“光标”位置)一起处理。

现在让我们探讨一下“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,在搜索的最开始(combinator 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) // 标签名称和伪类
        {
          if(u[i].value == "*")
          {
            // 任意标签
          }

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);
          // 逐个选择属性:element[attr1=value][attr2=value]
          // 映射一次只能包含一个有效对
          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选择器从表中选择行,然后我们可以使用另一个CSS选择器为每行调用 querySelect 来隔离特定的单元格。由于经常需要对表进行操作,因此让我们在 DomElement 类中创建 tableSelect方法,该方法将实现上述方法。其代码以简化形式提供。

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

行选择器在 rowSelector 参数中指定,而单元格选择器在 columSelectors 数组中指定。

一旦选择了所有元素,我们就需要从中获取一些信息,例如文本或属性值。让我们使用数据选择器来确定元素中所需信息的位置,同时可以为每个表列使用单独的数据提取方法。

如果 dataSelectors[i] 是空行,则读取标记的文本内容(在开始部分和结束部分之间,例如从标记“<p>100%<p>”取得 “100%”)如果 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 // 没有找到栏位
          {
            row.set(IntegerToString(i));
          }
          delete d;
        }

将字段“row”添加到“data”表中。

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

这样,在输出端,我们得到一个映射图,即一个表,其中第一个维度上有行数,第二个维度上有列数。如有必要,可以将TableSelect函数调整为其他数据容器。

创建了一个非交易EA,以应用上述所有类别。

WebDataExtractor 工具EA

这个EA交易用于将网页中的数据转换为表格结构,并将结果保存到 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 set文件中,该文件的名称在 ColumnSettingsFile 参数中指定。文件格式如下,

第一行是标题,后面的每一行描述一个单独的字段(表行中的数据列)。

文件应该有三列:名称、CSS选择器、数据定位器:

  • name — 输出 CSV 文件中的第i列;
  • CSS selector — 选择元素时,将使用输出 CSV 文件第i列的数据。此选择器应用于每个DOM元素中,以前是使用 RowSelector 从网页中选择的。要直接选择行元素,请在此处指定“.”;
  • data "locator" — 确定将从中使用哪个元素部件数据;可以指定属性名称,也可以将其保留为空以获取标记的文本内容。

TestQuery 和 TestSubQuery 参数允许测试行和列的选择器,同时将结果输出到日志,但不保存到CSV,也不使用所有列的设置文件。

以下简要介绍 EA 交易的主要操作功能。

int process()
{
  string xml;
  
  if(StringFind(URL, "http://") == 0 || StringFind(URL, "https://") == 0)
  {
    xml = ReadWebPageWR(URL);
  }
  else
  {
    Print("读取 html 文件 ", 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("读取文件错误 '", URL, "': ", GetLastError());
      return -1;
    }
    StringInit(xml, (int)FileSize(h));
    while(!FileIsEnding(h))
    {
      xml += FileReadString(h) + "\n";
    }
    // xml = FileReadString(h, (int)FileSize(h)); - 二进制文件中有4095个字节的限制!
    FileClose(h);
  }
  ...

这样,我们已经从一个文件中读取了一个HTML页面,或者从互联网上下载了一个HTML页面。现在,为了将文档转换为DOM对象的层次结构,让我们创建 HtmlParser对象并开始分析:

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

如果指定了测试选择器,请通过querySelect调用处理它们:

  if(TestQuery != "")
  {
    Print("测试 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("把数据保存为 CSV 到 ", SaveName);
    int h = FileOpen(SaveName, FILE_WRITE|FILE_CSV|FILE_ANSI, '\t', CP_UTF8);
    if(h == INVALID_HANDLE)
    {
      Print("写入错误 ", data.getSize() ," rows to file '", SaveName, "': ", GetLastError());
    }
    else
    {
      FileWriteString(h, StringImplodeExt(headers, ",") + "\n");
      
      FileWriteString(h, data.asCSVString());
      FileClose(h);
      Print((string)data.getSize() + " 行已写入");
    }
  }
  else
  {
    Print("\n" + data.asCSVString());
  }
  
  delete data;
  
  return(0);
}

让我们继续讨论EA交易的实际应用。


实际应用

交易者通常处理一些标准的HTML文件,例如测试报告和由MetaTrader生成的交易报告。我们有时会从其他交易者那里收到这样的文件,或者从互联网上下载这些文件,并希望在图表上可视化数据,以便进一步分析。为此,HTML中的数据应转换为表格视图(简单情况下转换为CSV格式)。

我们实用程序中的CSS选择器可以自动执行这个过程。

让我们看看HTML文件的内部。以下是MetaTrader 5交易报告的外观和HTML代码的一部分(ReportHistory.html文件附在下面)。

交易报告外观及部分HTML代码

交易报告外观及部分HTML代码

下面是 MetaTrader 5测试报告的外观和HTML代码的一部分(tester.html文件附在下面)

测试报告外观和部分HTML代码

测试报告外观和部分HTML代码

根据上图所示,交易报告有两个表:订单和交易。但是,从内部布局可以看出,这是一个单独的表。所有可见的标题和分隔线都由表格单元格的样式组成。我们需要学会区分订单和交易,并将每个子表保存到单独的 CSV 文件中。

第一部分和第二部分的区别在于列的数量:11列用于订单,13列用于交易。不幸的是,CSS标准不允许根据子元素的数量或内容(在我们的例子中是表单元格“td”标记)设置选择父元素(在我们的例子中是表行“tr”标记)的条件。因此,在某些情况下,不能使用标准方法选择所需元素。但是我们正在开发自己的选择器实现,因此我们可以为子元素的数量添加一个特殊的非标准选择器,这将是一个新的伪类。让我们将其设置为“:has-n-children(n)”,与“:nth-child(n)”进行类比。

以下选择器可用于选择订单行:

tr:has-n-children(11)

但是,这并不是问题的全部解决方案,因为这个选择器除了选择数据行之外,还选择了表头,让我们把它移走。注意数据行的着色-为其设置bgcolor属性,偶数行和奇数行(#FFFFFF 和 #F7F7F7)的颜色值交替出现。A color, 也就是说,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),

这个文件中的所有字段都由序列号简单标识。在其他情况下,您可能需要具有属性和类的更智能的选择器。

要获取交易表,只需在行选择器中将子元素的数量替换为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

我们将收到对应于源HTML报告的结果 ReportOrders.csv文件:

将CSS选择器应用于交易报告的CSV文件

将CSS选择器应用于交易报告的CSV文件

要获取交易表,请使用webdataex-report2.set中的附加设置。

我们创建的选择器也适用于测试器报告。附加的webdataex-tester1.set和webdataex-tester2.set允许您将示例 HTML report Tester.html 转换为 CSV 文件。

要点!在MetaTrader中,许多网页的布局以及生成的HTML文件的布局可以随时更改。因此,一些选择器将不再适用,即使外部表示几乎相同。在这种情况下,您应该重新分析HTML代码并相应地修改CSS选择器。

现在让我们查看 MetaTrader 4测试报告的转换;这允许演示一些选择CSS选择器的有趣技术。对于检查,我们将使用附加的 StrategyTester-ecn-1.htm。

这些文件有两个表:一个包含测试结果,另一个包含交易操作。要选择第二个表,我们将使用选择器“table~ table”。省略 operations 表中的第一行,因为它包含标题。这可以使用选择器“tr+tr”来完成。

合并后,我们得到一个用于选择工作行的选择器:

table ~ table tr + tr

这实际上意味着如下:在表后选择一个表(即第二个表,在表内选择具有前一行的每一行,即除第一行以外的所有行)。

从单元格中提取交易参数的设置在test-report-mt4.cfg.csv文件中提供。日期字段由类选择器处理:

DateTime,td.msdate,

即,它使用class=“msdate”属性搜索td标记。

实用程序的完整设置文件是webdataex-tester-mt4.set。

WebDataExtractor讨论中提供了其他CSS选择器使用和设置示例。

实用程序可以做得更多:
  • 自动替换字符串(例如,将国家名称更改为货币符号或将新闻优先级的口头描述替换为数字)
  • 输出 DOM 树以记录并在没有浏览器的情况下找到合适的选择器;
  • 通过计时器或全局变量的请求下载和转换网页;

如果您在为特定网页设置CSS选择器时需要帮助,可以购买 WebDataExtractor(Metatrader 4 版Metatrader 5 版)并接收作为产品支持一部分的建议。但是,源代码的可用性允许您使用整个功能,并在必要时扩展它,这是绝对免费的。


结论

我们已经探讨了CSS选择器的技术,它是Web文档解释的主要标准之一。在MQL中最常用的CSS选择器的实现允许在不使用第三方软件的情况下灵活地设置和转换任何HTML页面,包括标准的 MetaTrader 文档到结构化数据。

我们还没有考虑其他一些技术,它们也可以为处理Web文档提供通用工具。这样的工具可能很有用,因为 MetaTrader 不仅使用HTML,还使用XML格式。交易者可能对 XPath 和 XSLT 特别感兴趣。这些格式可以作为开发基于Web标准的自动化交易系统的想法的进一步步骤。在MQL中支持CSS选择器仅仅是实现这一目标的第一步。

本文译自 MetaQuotes Software Corp. 撰写的俄文原文
原文地址: https://www.mql5.com/ru/articles/5706

附加的文件 |
html2css.zip (35.94 KB)
从网络中获取债券收益率数据 从网络中获取债券收益率数据

自动收集利率数据以提高EA交易的效率。

轻松快捷开发 MetaTrader 程序的函数库(第二部分)。 历史订单和成交的集合 轻松快捷开发 MetaTrader 程序的函数库(第二部分)。 历史订单和成交的集合

在第一部分中,我们已着手创建一个大型跨平台函数库,简化 MetaTrader 5 和 MetaTrader 4 平台程序的开发。 我们创建了 COrder 抽象对象,它是一个基础对象,用于存储历史订单和成交的数据,以及市价订单和仓位。 现在,我们将开发在集合中存储帐户历史数据的所有必要对象。

作为技术分析工具的 MTF 指标 作为技术分析工具的 MTF 指标

大多数交易者都同意,当前的市场状态分析从评估更高的图表时间框架开始。该分析向下执行,以缩短执行交易的时间范围。这种分析方法似乎是成功交易的专业方法的强制性部分。在本文中,我们将讨论多时间段指标及其创建方法,并提供MQL5代码示例。除了对优缺点进行综合评价外,我们还将提出一种采用MTF模式的新指标方法。

用于轻松快速开发 MetaTrader 程序的函数库(第三部分)。 市价订单和仓位的集合,搜索和排序 用于轻松快速开发 MetaTrader 程序的函数库(第三部分)。 市价订单和仓位的集合,搜索和排序

在第一部分中,我们曾创建了一个大型跨平台函数库,简化 MetaTrader 5 和 MetaTrader 4 平台程序的开发。 再者,我们实现了历史订单和成交的集合。 我们的下一步是创建一个类,用来针对订单、成交和仓位的集合进行选择和排序。 我们将实现名为引擎(Engine)的基准函数库对象,并向函数库中添加市价订单和仓位的集合。