使用CSS选择器从HTML页面提取结构化数据·进阶篇
🔍

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

(2/3)· 当平台内置日历不够用,如何用CSS选择器从异类HTML里抠出你要的经济新闻与交易报告

偏理论 第 2/3 篇
很多交易者遇到第三方站点没有开放API就卡在数据采集上,手动复制既慢又漏。其实HTML里藏着一套现成的查询语言——CSS选择器,能让MQL5像数据库检索一样定位表格行与列。本篇接着基础篇,把这套方法的内部机制讲透。

「从 HTML 片段抠出 DOM 节点」

这段逻辑干的事很直接:在 MT5 的 MQL5 解析器里,从一个 HTML 字符串中截出标签名、顺手规整大小写与尾部空格,再挂到当前游标下成为新的 DomElement。注意 name 被强制转小写并 TrimRight,意味着后续匹配标签不区分大小写,但尾部空白不会带进节点名。 遇到非自闭合标签时,代码会先查 empties 这张散列表——若标签名在里面(比如 br、img 这类),就软自闭合(softSelfClose=true),游标不往下走。这能解释为什么有些标签没写斜杠也被视为闭合,解析树深度不会乱。 闭合标签分支更有意思:从 </ 后找到 >,取出 tag 名转小写,然后不断往父节点回退,直到当前节点名与 tag 一致。若退到根还找不到(cursor==NULL),就回滚到 rewind 并切回 blank 状态,不会因为网页结构烂而直接崩。开 MT5 把这段塞进你自己的 HTML 解析类,拿一段缺闭合标签的报价页测一下,回退逻辑大概率能兜住。

MQL5 / C++
class="type">class="kw">string name = StringSubstr(html, offset, p - offset);
StringToLower(name);
StringTrimRight(name);
DomElement *e = new DomElement(cursor, name);
if(pspace != -class="num">1)
{
  class="type">class="kw">string txt;
  if(pright - pspace > class="num">1)
  {
    txt = StringSubstr(html, pspace + class="num">1, pright - (pspace + class="num">1));
    e.parseAttributes(txt);
  }
}
class="type">bool softSelfClose = class="kw">false;
if(!selfclose)
{
  if(empties.isKeyExisting(name))
  {
    selfclose = true;
    softSelfClose = true;
  }
}
pright++;
if(!selfclose)
{
  cursor = e;
}
else
{
  if(!softSelfClose) pright++;
}
offset = pright;
if((name == "script") && !selfclose)
{
  state = insideScript;
}
else
{
  state = blank;
}
class="kw">return(true);

else
if(state == insideTagClose)
{
  offset += StringLen(TAG_CLOSE_START);
  p = StringFind(html, ">", offset);
  if(p == -class="num">1)
  {
    class="kw">return(class="kw">false);
  }
  class="type">class="kw">string tag = StringSubstr(html, offset, p - offset);
  StringToLower(tag);
  DomElement *rewind = cursor;
  class="kw">while(StringCompare(cursor.getName(), tag) != class="num">0)
  {
    class="type">class="kw">string previous = cursor.getName();
    cursor = cursor.getParent();
    if(cursor == NULL)
    {
      class=class="str">"cmt">// 处理结束标记
      cursor = rewind;
      state = blank;

◍ 解析器状态机与 DOM 节点类的衔接

上面这段是 HTML 解析器后半段状态机:遇到注释块时,先按 COMMENT_START 长度推进 offset,再用 StringFind 从 offset 搜 COMMENT_STOP;若返回 -1 说明注释未闭合,直接 return(false) 中断解析。 脚本块 insideScript 同理,用 SCRIPT_STOP 定位结束标签,成功后 offset = p + StringLen(SCRIPT_STOP),并把 cursor 回退到父节点;一旦 cursor 为 NULL 也返回 false,避免越界访问。 DomElement 类则承接解析结果:私有成员存 name、content、attributes 和 parent 指针,保护成员 children[] 挂子节点。构造函数 DomElement(DomElement *p, const string &n, const string text="") 会调用 p.addChild 把自己挂上去,同时写 name 与可选 text。 在 MT5 里把这段贴进 EA 的 include 解析模块,断点跟一次 offset 变化,能确认注释或脚本截断时返回 false 的触发条件——外汇与贵金属行情数据经网页注入时,这种解析失败可能让后续 K 线字段为空,属高风险环节,需自行校验源串完整性。

MQL5 / C++
offset = p + class="num">1;
class="kw">return(true);
}
}
cursor = cursor.getParent();
if(cursor == NULL) class="kw">return(class="kw">false);

state = blank;
offset = p + class="num">1;

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

offset = p + StringLen(COMMENT_STOP);
state = blank;

class="kw">return(true);
}
else
if(state == insideScript)
{
p = StringFind(html, SCRIPT_STOP, offset);
if(p == -class="num">1)
{
class="kw">return(class="kw">false);
}

offset = p + StringLen(SCRIPT_STOP);
state = blank;

cursor = cursor.getParent();
if(cursor == NULL) class="kw">return(class="kw">false);

class="kw">return(true);
}
class="kw">return(class="kw">false);
}
class DomElement
{
class="kw">private:
class="type">class="kw">string name;
class="type">class="kw">string content;
IndexMap attributes;
DomElement *parent;
class="kw">protected:
DomElement *children[];
class="kw">public:
DomElement(): parent(NULL) {}
DomElement(class="kw">const class="type">class="kw">string n): parent(NULL)
{
name = n;
}
DomElement(DomElement *p, class="kw">const class="type">class="kw">string &n, class="kw">const class="type">class="kw">string text = "")
{
p.addChild(&this);
parent = p;
name = n;
if(text != "") content = text;
}
class="type">void addChild(DomElement *child)
class="type">int getChildrenCount() class="kw">const;
DomElement *getChild(class="kw">const class="type">int i) class="kw">const;
class="type">void addChildren(DomElement *p)
class="type">int getChildIndex(DomElement *e) class="kw">const;
class="type">void parseAttributes(class="kw">const class="type">class="kw">string &data)
{
AttributesParser p;

伪类修饰符的索引映射与解析

在 MT5 自定义选择器引擎里,SubSelectorArray 用一组静态 TypeContainer 把 CSS 风格伪类映射成枚举值。first、last、nth、nthLast 四个容器分别绑定 PseudoClassModifier 的 firstChild、lastChild、nthChild、nthLastChild,初始化时通过 mod.add 把字符串键(如 ":first-child")和容器指针关联起来。 add 方法是核心入口:先用 ArraySize 取当前数组长度 n,ArrayResize 把 selectors 扩 1 位。随后遍历 mod 的所有键,用 StringFind 在传入的 v 里搜伪类串;命中后若后面还跟括号参数(首字符 '(' 尾字符 ')'),就截取中间内容赋给 param,否则置空。 最后把匹配到的修饰符枚举写回 m,并用 StringSubstr 把 v 截断到伪类出现之前。这样一条 "div:nth-child(2)" 会被拆成 type=标签、value="div"、modifier=nthChild、param="2",后续匹配节点时直接查枚举即可,省去重复字符串比对。 开 MT5 把这段类结构塞进 EA 的 include,断点跑一次 add("li:last-child"),看 selectors[0].modifier 是否落为 lastChild,就能验证映射没接反。

MQL5 / C++
enum AttrBit
{
  name,
  value
};
class SubSelector
{
  enum PseudoClassModifier
  {
    none,
    firstChild,
    lastChild,
    nthChild,
    nthLastChild
  };
  
  class="kw">public:
    class="type">class="kw">ushort type;
    class="type">class="kw">string value;
    PseudoClassModifier modifier;
    class="type">class="kw">string param;
};
class SubSelectorArray
{
  class="kw">private:
    SubSelector *selectors[];
    IndexMap mod;
    
    class="kw">static TypeContainer<PseudoClassModifier> first;
    class="kw">static TypeContainer<PseudoClassModifier> last;
    class="kw">static TypeContainer<PseudoClassModifier> nth;
    class="kw">static TypeContainer<PseudoClassModifier> nthLast;
    
    class="type">void init()
    {
      mod.add(":first-child", &first);
      mod.add(":last-child", &last);
      mod.add(":nth-child", &nth);
      mod.add(":nth-last-child", &nthLast);
    }
};

TypeContainer<PseudoClassModifier> SubSelectorArray::first(PseudoClassModifier::firstChild);
TypeContainer<PseudoClassModifier> SubSelectorArray::last(PseudoClassModifier::lastChild);
TypeContainer<PseudoClassModifier> SubSelectorArray::nth(PseudoClassModifier::nthChild);
TypeContainer<PseudoClassModifier> SubSelectorArray::nthLast(PseudoClassModifier::nthLastChild);
    class="type">void add(class="kw">const class="type">class="kw">ushort t, class="type">class="kw">string v)
    {
      class="type">int n = ArraySize(selectors);
      ArrayResize(selectors, n + class="num">1);
      
      PseudoClassModifier m = PseudoClassModifier::none;
      class="type">class="kw">string param;
      
      for(class="type">int j = class="num">0; j < mod.getSize(); j++)
      {
        class="type">int p = StringFind(v, mod.getKey(j));
        if(p > -class="num">1)
        {
          if(p + StringLen(mod.getKey(j)) < StringLen(v))
          {
            param = StringSubstr(v, p + StringLen(mod.getKey(j)));
            if(StringGetCharacter(param, class="num">0) == &class="macro">#x27;(&class="macro">#x27; && StringGetCharacter(param, StringLen(param) - class="num">1) == &class="macro">#x27;)&class="macro">#x27;)
            {
              param = StringSubstr(param, class="num">1, StringLen(param) - class="num">2);
            }
            else
            {
              param = "";
            }
          }
          
          m = mod[j].get<PseudoClassModifier>();
          v = StringSubstr(v, class="num">0, p);
        }
      }
    }

「正文」

&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;<span class="keyword">break</span>; &nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;} &nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;} &nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp; &nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;<span class="keyword">if</span>(<span class="functions">StringLen</span>(param) == <span class="number">0</span>) &nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;{ &nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;selectors[n] = <span class="keyword">new</span> SubSelector(t, v, m); &nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;} &nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;<span class="keyword">else</span> &nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;{ &nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;selectors[n] = <span class="keyword">new</span> SubSelector(t, v, m, param); &nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;} &nbsp;&nbsp;&nbsp;&nbsp;} &nbsp;&nbsp;<span class="keyword">private</span>: &nbsp;&nbsp;&nbsp;&nbsp;<span class="keyword">void</span> createFromString(<span class="keyword">const</span> <span class="keyword">string</span> &amp;selector) &nbsp;&nbsp;&nbsp;&nbsp;{ &nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;<span class="keyword">ushort</span> p = <span class="number">0</span>; <span class="comment">// 之前的类型</span> &nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;<span class="keyword">int</span> ppos = <span class="number">0</span>; &nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;<span class="keyword">int</span> i, n = <span class="functions">StringLen</span>(selector); &nbsp;&nbsp;&nbsp;&nb

◍ 选择器遍历里的组合符消歧

这段解析逻辑干的事,是把类似 CSS 选择器的查询串拆成「组合符 + 简单选择器」的片段,再逐步往结果数组里塞节点。初始时把 b 设为 '/' 当作根运算符,selector 默认 '*' 表示任意节点,index 记录当前结果数组游标,从 0 起步。 遇到 isCombinator(c) 为真时,若前一个字符 p 不是组合符,就截取 cursor 到 i 之间的子串作为当前 selector;若 p 已是组合符,则取 a = MathMax(c, p) 来压缩连续空白组合符,避免 ' ' 和 '>' 叠写产生歧义。 在非组合符分支里,一旦发现 p 是组合符就触发一次 find 调用:先记下 index = result.getChildrenCount(),用 SubSelectorArray 包装 selector 去匹配,再把 b 更新为 a,随后 result.removeFirst(index) 清掉本次操作前的过期结果。循环结束后若 cursor < i,还会补一次收尾 find 并同样裁剪。 组合符集合固定为 ' '、'+'、'>'、'~' 四种,对应后代、相邻、子级、兄弟关系。开 MT5 把这段塞进 EA 的 Dom 解析模块,改一下 combinators[] 的顺序,就能验证 MathMax 压缩逻辑是否如预期吞掉多余空白。

MQL5 / C++
class="type">class="kw">ushort b = &class="macro">#x27;/&class="macro">#x27;; class=class="str">"cmt">// 当前运算符,“根”符号从开始
class="type">class="kw">string selector = "*"; class=class="str">"cmt">// 当前简单选择器默认为“any”
class="type">int index = class="num">0;  class=class="str">"cmt">// 在结果对象数组中的位置
for(i = class="num">0; i < n; i++)
{
  class="type">class="kw">ushort c = StringGetCharacter(q, i);
  if(isCombinator(c))
  {
    a = c;
    if(!isCombinator(p))
    {
      selector = StringSubstr(q, cursor, i - cursor);
    }
    else
    {
      class=class="str">"cmt">// 压缩其他组合符周围的空白
      a = MathMax(c, p);
    }
    cursor = i + class="num">1;
  }
  else
  {
    if(isCombinator(p)) class=class="str">"cmt">// 操作
    {
      index = result.getChildrenCount();
      
      SubSelectorArray selectors(selector);
      find(b, &selectors, result);
      b = a;
      
      class=class="str">"cmt">// 现在我们可以删除“index”之前位置中的过期结果。
      result.removeFirst(index);
    }
  }
  p = c;
}

if(cursor < i) class=class="str">"cmt">// action
{
  selector = StringSubstr(q, cursor, i - cursor);
  
  index = result.getChildrenCount();
  
  SubSelectorArray selectors(selector);
  find(b, &selectors, result);
  result.removeFirst(index);
}

class="kw">return result;
}
a = MathMax(c, p);
class="type">class="kw">ushort combinators[] =
{
  &class="macro">#x27; &class="macro">#x27;, &class="macro">#x27;+&class="macro">#x27;, &class="macro">#x27;>&class="macro">#x27;, &class="macro">#x27;~&class="macro">#x27;
};
index = result.getChildrenCount();
SubSelectorArray selectors(selector);
find(b, &selectors, result);
result.removeFirst(index);
  class="type">bool find(class="kw">const class="type">class="kw">ushort op, class="kw">const SubSelectorArray *selectors, DomIterator *output)
  {
    class="type">bool found = class="kw">false;
    class="type">int i, n;
    if(op == &class="macro">#x27; &class="macro">#x27; || op == &class="macro">#x27;>&class="macro">#x27; || op == &class="macro">#x27;/&class="macro">#x27;)
    {
      n = ArraySize(children);
      for(i = class="num">0; i < n; i++)
      {
        if(children[i].match(selectors))
        {
          if(op == &class="macro">#x27;/&class="macro">#x27;)
          {

兄弟选择器在 DOM 树里的落地逻辑

上面这段是选择器引擎里处理相邻与通用兄弟关系的核心分支。当操作符为 '+' 或 '~' 时,先确认当前节点有动态父节点,再在父节点的子队列里定位自身索引 q,找不到就直接跳过。 对 '+' 只取紧邻的下一个(q+2 封顶),对 '~' 则向后扫到父节点末尾;每遇到能 match 剩余选择器的兄弟节点就塞进 output,并置 found=true。回测这类遍历时,子节点数超过 50 的层级会让 '~' 的耗时近似线性膨胀,MT5 里跑大文档解析要留意。 实际写EA面板解析,别默认父节点一定存在——CheckPointer(parent)==POINTER_DYNAMIC 这道闸少了就会在孤立节点上崩。把这段直接抄进你的 DomElement::find 重载,挂个 EURUSD 的 M5 测试脚本就能看到选中集合的差异。

MQL5 / C++
      found = true;
      output.addChild(GetPointer(children[i]));
      }
      else
      if(op == &class="macro">#x27; &class="macro">#x27;)
      {
      DomElement *p = &this;
      class="kw">while(p != NULL)
      {
      if(output.getChildIndex(p) != -class="num">1)
      {
      found = true;
      output.addChild(GetPointer(children[i]));
      class="kw">break;
      }
      p = p.parent;
      }
      }
      else class=class="str">"cmt">// op == &class="macro">#x27;>&class="macro">#x27;
      {
      if(output.getChildIndex(&this) != -class="num">1)
      {
      found = true;
      output.addChild(GetPointer(children[i]));
      }
      }
      }
      children[i].find(op, selectors, output);
      }
      }
      }
      else
      if(op == &class="macro">#x27;+&class="macro">#x27; || op == &class="macro">#x27;~&class="macro">#x27;)
      {
      if(CheckPointer(parent) == POINTER_DYNAMIC)
      {
      if(output.getChildIndex(&this) != -class="num">1)
      {
      class="type">int q = parent.getChildIndex(&this);
      if(q != -class="num">1)
      {
      n = (op == &class="macro">#x27;+&class="macro">#x27;) ? (q + class="num">2) : parent.getChildrenCount();
      if(n > parent.getChildrenCount()) n = parent.getChildrenCount();
      for(i = q + class="num">1; i < n; i++)
      {
      DomElement *m = parent.getChild(i);
      if(m.match(selectors))
      {
      found = true;
      output.addChild(m);
      }
      }
让小布替你验证选择器
这些DOM遍历与选择器诊断,小布盯盘的AIGC已内置,打开对应品种页即可看到结构预览,把重复劳动交给小布,你专注决策。

常见问题

正则对嵌套标签和属性顺序极敏感,遇到不规范HTML就失效;DOM把文档转成树,CSS选择器按结构查,容错高得多。
后代、子代、属性存在与等值选择最常用,能锁定带特定class的表格或报告区块,其余伪类多数可省略。
会。倾向用多层降级:先按id,再按class,最后按标签路径,任一层缺失就回退,概率上更稳。
可以。小布内置了结构预览与选择器校验,不需要你本地搭解析环境,打开页就能看抽取结果。
自闭合标签和属性引号常省略,解析器须兼容无引号属性,否则传统页面会断树。