使用CSS选择器从HTML页面提取结构化数据·进阶篇
(2/3)· 当平台内置日历不够用,如何用CSS选择器从异类HTML里抠出你要的经济新闻与交易报告
「从 HTML 片段抠出 DOM 节点」
这段逻辑干的事很直接:在 MT5 的 MQL5 解析器里,从一个 HTML 字符串中截出标签名、顺手规整大小写与尾部空格,再挂到当前游标下成为新的 DomElement。注意 name 被强制转小写并 TrimRight,意味着后续匹配标签不区分大小写,但尾部空白不会带进节点名。
遇到非自闭合标签时,代码会先查 empties 这张散列表——若标签名在里面(比如 br、img 这类),就软自闭合(softSelfClose=true),游标不往下走。这能解释为什么有些标签没写斜杠也被视为闭合,解析树深度不会乱。
闭合标签分支更有意思:从 </ 后找到 >,取出 tag 名转小写,然后不断往父节点回退,直到当前节点名与 tag 一致。若退到根还找不到(cursor==NULL),就回滚到 rewind 并切回 blank 状态,不会因为网页结构烂而直接崩。开 MT5 把这段塞进你自己的 HTML 解析类,拿一段缺闭合标签的报价页测一下,回退逻辑大概率能兜住。
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 线字段为空,属高风险环节,需自行校验源串完整性。
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,就能验证映射没接反。
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); } } }
「正文」
<span class="keyword">break</span>; } } <span class="keyword">if</span>(<span class="functions">StringLen</span>(param) == <span class="number">0</span>) { selectors[n] = <span class="keyword">new</span> SubSelector(t, v, m); } <span class="keyword">else</span> { selectors[n] = <span class="keyword">new</span> SubSelector(t, v, m, param); } } <span class="keyword">private</span>: <span class="keyword">void</span> createFromString(<span class="keyword">const</span> <span class="keyword">string</span> &selector) { <span class="keyword">ushort</span> p = <span class="number">0</span>; <span class="comment">// 之前的类型</span> <span class="keyword">int</span> ppos = <span class="number">0</span>; <span class="keyword">int</span> i, n = <span class="functions">StringLen</span>(selector); &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 压缩逻辑是否如预期吞掉多余空白。
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 测试脚本就能看到选中集合的差异。
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); } }