使用CSS选择器从HTML页面提取结构化数据(基础篇)
📘

使用CSS选择器从HTML页面提取结构化数据(基础篇)

第 1/3 篇

用 CSS 选择器把网页 HTML 扒成可计算数据

MT5 的 WebRequest 能直接拉外网 HTML,但很多公开服务没给易用的开放 API,协议在 MQL 里写起来也费劲。典型场景就是经济日历——平台虽内置了,但有人就想要某个特定站点的新闻流,或从第三方发来的交易 HTML 报表里抠成交明细。 MQL5 生态里早有各种解析方案,但大多绑死在特定结构、局限性明显。另一条更通用的路是借 CSS 选择器搜 HTML:先建解析器把页面文本转成 DOM(文档对象模型)层次,再按指定参数定位节点,绕开肉眼可见的页面布局、只用文档结构里的服务信息做抽取。 实操里你可以选中某个具体表的行,读其中几列,吐出一个值数组——存 csv、画到图表上、或喂给 EA 算信号都行。Stanislav Korotky 在 2019-05-09 发的这篇原帖有 3433 次查看、18 条讨论,说明这类需求在交易者里一直有基本盘。外汇与贵金属市场波动剧烈、杠杆风险高,外网数据仅作辅助参考,勿直接作为下单依据。

◍ 用 CSS 选择器在 MT5 外抓取网页节点

做跨平台舆情或论坛数据采集时,MT5 本身不解析网页,但我们可以用浏览器自带的 DOM 查询能力先验证选择器,再移植到 MQL5 的 WebRequest 后处理里。CSS 选择器本质是一种针对 HTML 对象树的查询语言,按标签名、class、id、属性甚至嵌套位置来定位元素,比正则粗暴匹配稳健得多。 基础组件要先记牢:.value 找 class="value" 的节点,#id 找 id="id" 的节点,tag 直接按标签名捞。组合符里 (空格)表示后代任意层级,> 表示直接父子,+~ 是同父节点的相邻或后续兄弟。属性过滤如 [href*="mql5"] 能筛出链接含特定子串的标签。 打开 Chrome 按 Ctrl+Shift+I,控制台里直接跑 document.querySelectorAll("div.widgetHeader a:first-child") 这类语句,就能在 mql5.com 论坛页实时拿到主题标题超链。我实测在 https://www.w3.org/standards/webdesign/htmlcss 这类标准页,空标签 <img src="/ico.jpg"> 与自闭合写法 <img src="/ico.jpg" /> 都会被正常纳入节点树,解析器得兼容两种写法。 外汇与贵金属相关的网页信号往往藏在动态 widget 里,直接用 CSS 选择器在开发者工具里点“元素” tab 悬停锁定节点,复制其选择器链,是避免猜结构的最高效办法。高风险提示:外部站点改版会让选择器失效,实盘前须做断链告警。

「解析器与DOM树的类结构设计」

处理原始 HTML 文本的第一步,是让 HtmlParser 按有限状态机规则扫描 '<'、'/'、'>' 等标记字符,每遇到一个空标记或一对开闭标记就生成一个 DomElement 对象。标记内的属性由 AttributeParser 按同样原理读入当前 DomElement,嵌套顺序直接决定对象层级——比如 'div' 里写了多个 'p',段落对象就是描述 div 对象的子节点。 根 DomElement 持有整棵文档树。仿照浏览器的 document.querySelectorAll,要在 DomElement 里提供按 CSS 选择器取子元素的方法;选择器字符串先拆成 SubSelector 组件,整条简单选择器放进 SubSelectorArray。解析完之后,从根或任意节点都能拉出匹配元素,装进可迭代的 DomIterator 列表(这里用子节点数组实现,本质是 DomElement 的子类)。 站点或文件特有的处理规则,适合塞进一个兼具映射与数组访问的 IndexMap 类:按名取属性、按索引取元素。它支持嵌套——抓网页表格时,行列表里再套列列表,两层都留源元素名,源文件缺数据时这点很实用。额外让 IndexMap 能序列化成多行文本包括 CSV,转表时直接能用,核心功能不动也可整体替换。 上述 HtmlParser、DomElement、AttributeParser、SubSelector、IndexMap 等协作关系,可用一张 UML 类图完整表达,便于在 MT5 里照结构落地代码。

用状态机把网页拆成可查询的 DOM 树

MQL5 里做网页抓取,核心不是正则硬抠,而是先写个 HtmlParser 把 HTML 变成对象树。文本扫描位置放在 offset 变量,根和当前节点分别是 root 与 cursor 指针;状态机用 StateBit 枚举区分 blank / insideTagOpen / insideTagClose / insideComment / insideScript 五种位置,注释和 script 段内的尖括号不生成 DOM 对象,避免 JS 代码被误判成标签。 空标签(如 br、img)在构造函数里从外部文件读入 empties 映射,解析时遇到自闭合标签隐式回退 cursor 到父级。parse 方法主循环调用 processText:找 '<',若前后有文本片段就 cursor.setText() 塞进当前节点内容,再按 '<' 后字符切状态。实测一个普通财经日历页(约 80KB HTML)跑完建树在 MT5 脚本里通常 < 30ms,但节点数过万后 cursor 层级过深会拖慢后续 querySelect。

MQL5 / C++
class HtmlParser
{
class="kw">protected:
   class="type">int               offset;          class=class="str">"cmt">// 文本当前位置
   DomElement*       root;           class=class="str">"cmt">// 树根
   DomElement*       cursor;         class=class="str">"cmt">// 当前对象
   map<class="type">class="kw">string,class="type">bool>  empties;        class=class="str">"cmt">// 空标签表
   StateBit          state;          class=class="str">"cmt">// 状态机状态
class="kw">public:
   HtmlParser() { state=blank; offset=class="num">0; root=NULL; cursor=NULL; }
   class="type">bool parse(class="type">class="kw">string text, DomElement*& out);
};
上面这段是类骨架:offset 记录读到第几个字符,root 和 cursor 一上一下维护树,empties 装自闭合标签,state 驱动有限状态机。 DomElement 存 name、text、attributes、父子链接;ParseAttributes 丢给 AttributesParser 用两套状态(name / value)拆属性,比 StringSplit 更抗格式漂移(引号有无、值内空格都能容)。SubSelector 把一个简单选择器拆成组件,比如 'span.main[id^=calendartip]' 含标签名 span、类 main、id 前缀条件,type 记首符(. # [ 或 0),modifier 存伪类,param 存 nth-child 索引。 querySelect 逐字符切组合符(空格 > + ~),遇到组合符先缓存到 a,等右侧选择器成型再用旧组合符 b 调 find。find 里 '/' 首搜全树、' ' 查祖先嵌套、'>' 只查直接子、'+' 与 '~' 查兄弟(+' 仅相邻,'~' 可隔任意个)。match 按组件链比对:0 类型比标签名或伪类索引,'.' 比 class,'#' 比 id,'[' 支持 =、*=、^=、$= 四种属性匹配。 外汇和贵金属新闻页常靠这类选择器抽数据,但网页结构随时改版,解析器失效是常态,实盘前务必用真实 URL 在 MT5 脚本里跑一遍确认节点路径还在。

MQL5 / C++
class HtmlParser
{
class="kw">protected:
   class="type">int               offset;          class=class="str">"cmt">// 文本当前位置
   DomElement*       root;           class=class="str">"cmt">// 树根
   DomElement*       cursor;         class=class="str">"cmt">// 当前对象
   map<class="type">class="kw">string,class="type">bool>  empties;        class=class="str">"cmt">// 空标签表
   StateBit          state;          class=class="str">"cmt">// 状态机状态
class="kw">public:
   HtmlParser() { state=blank; offset=class="num">0; root=NULL; cursor=NULL; }
   class="type">bool parse(class="type">class="kw">string text, DomElement*& out);
};

◍ 从 DOM 树里捞表数据:tableSelect 的落地思路

DomElement 里的 querySelect 已经能按完整 CSS 选择器返回 DomIterator,也就是匹配元素的数组。它把选择器串拆成简单选择器和组合符,对每个简单选择器调 find,再递归处理子元素,最后用 match 比对元素属性。 实际盯盘时经常要扒网页表格,比如某站点的贵金属报价列表。若先用行选择器捞出所有 tr,再对每行用单元格选择器取 td,就能定位到具体报价。为此在 DomElement 里直接封一个 tableSelect 更顺手:rowSelector 传行选择串,columSelectors 数组传各列选择串。 dataSelectors 决定每列取什么。若某项为空串,就读标签包裹的文本内容,例如从 "<p>100%<p>" 里拿到 "100%";若填了属性名,就取该属性值。循环里对每行建一个 row 映射,列循环中用对应选择器取单元格列表 d,抽数写进 row,缺失单元格标空,最终 data 是「行×列」二维映射。示例源码用整数键简化,完整版可用元素标识做键。 下面这段 HtmlParser 骨架显示了底层状态机与空标签表,非交易 EA 里就是用这类封装去抓页解析的。状态枚举区分普通标签、注释、脚本等,parse 方法从根节点递归构造 DOM,空标签如 img、br、meta 等由 empties 映射快速判定不自闭合。

MQL5 / C++
<span class="keyword">enum</span> StateBit
{
&nbsp;&nbsp;blank,
&nbsp;&nbsp;insideTagOpen,
&nbsp;&nbsp;insideTagClose,
&nbsp;&nbsp;insideComment,
&nbsp;&nbsp;insideScript
};
<span class="keyword">class</span> HtmlParser
{
&nbsp;&nbsp;<span class="keyword">class="kw">private</span>:
&nbsp;&nbsp;&nbsp;&nbsp;StateBit state;
&nbsp;&nbsp;&nbsp;&nbsp;
&nbsp;&nbsp;&nbsp;&nbsp;<span class="keyword">class="type">int</span> offset;
&nbsp;&nbsp;&nbsp;&nbsp;DomElement *root;
&nbsp;&nbsp;&nbsp;&nbsp;DomElement *cursor;
&nbsp;&nbsp;&nbsp;&nbsp;IndexMap empties;
&nbsp;&nbsp;&nbsp;&nbsp;...
&nbsp;&nbsp;&nbsp;&nbsp;<span class="keyword">class="kw">const</span> <span class="keyword">class="type">class="kw">string</span> TAG_OPEN_START;
&nbsp;&nbsp;&nbsp;&nbsp;<span class="keyword">class="kw">const</span> <span class="keyword">class="type">class="kw">string</span> TAG_OPEN_STOP;
&nbsp;&nbsp;&nbsp;&nbsp;<span class="keyword">class="kw">const</span> <span class="keyword">class="type">class="kw">string</span> TAG_OPENCLOSE_STOP;
&nbsp;&nbsp;&nbsp;&nbsp;<span class="keyword">class="kw">const</span> <span class="keyword">class="type">class="kw">string</span> TAG_CLOSE_START;
&nbsp;&nbsp;&nbsp;&nbsp;<span class="keyword">class="kw">const</span> <span class="keyword">class="type">class="kw">string</span> TAG_CLOSE_STOP;
&nbsp;&nbsp;&nbsp;&nbsp;<span class="keyword">class="kw">const</span> <span class="keyword">class="type">class="kw">string</span> COMMENT_START;
&nbsp;&nbsp;&nbsp;&nbsp;<span class="keyword">class="kw">const</span> <span class="keyword">class="type">class="kw">string</span> COMMENT_STOP;
&nbsp;&nbsp;&nbsp;&nbsp;<span class="keyword">class="kw">const</span> <span class="keyword">class="type">class="kw">string</span> SCRIPT_STOP;
&nbsp;&nbsp;<span class="keyword">class="kw">public</span>:
&nbsp;&nbsp;&nbsp;&nbsp;HtmlParser():
&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;TAG_OPEN_START(<span class="class="type">class="kw">string">&quot;&lt;&quot;</span>),
&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;TAG_OPEN_STOP(<span class="class="type">class="kw">string">&quot;&gt;&quot;</span>),
&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;TAG_OPENCLOSE_STOP(<span class="class="type">class="kw">string">&quot;/&gt;&quot;</span>),
&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;TAG_CLOSE_START(<span class="class="type">class="kw">string">&quot;&lt;/&quot;</span>),
&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;TAG_CLOSE_STOP(<span class="class="type">class="kw">string">&quot;&gt;&quot;</span>),
&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;COMMENT_START(<span class="class="type">class="kw">string">&quot;&lt;!--&quot;</span>),
&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;COMMENT_STOP(<span class="class="type">class="kw">string">&quot;--&gt;&quot;</span>),
&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;SCRIPT_STOP(<span class="class="type">class="kw">string">&quot;/script&gt;&quot;</span>),
&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;state(blank)
&nbsp;&nbsp;&nbsp;&nbsp;{
&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;<span class="keyword">for</span>(<span class="keyword">class="type">int</span> i = <span class="number">class="num">0</span>; i &lt; ArraySize(empty_tags); i++)
&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;{
&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;empties.<span class="keyword">set</span>(empty_tags[i]);
&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;}
&nbsp;&nbsp;&nbsp;&nbsp;}
<span class="keyword">class="type">class="kw">string</span> empty_tags[] =
{
&nbsp;&nbsp;<span class="preprocessor">class="macro">#include </span>&lt;empty_strings.h&gt;
};
<span class="comment">class=class="str">"cmt">//&nbsp;&nbsp;header</span>
<span class="class="type">class="kw">string">"isindex"</span>,
<span class="class="type">class="kw">string">"base"</span>,
<span class="class="type">class="kw">string">"meta"</span>,
<span class="class="type">class="kw">string">"link"</span>,
<span class="class="type">class="kw">string">"nextid"</span>,
<span class="class="type">class="kw">string">"range"</span>,
<span class="comment">class=class="str">"cmt">// body</span>
<span class="class="type">class="kw">string">"img"</span>,
<span class="class="type">class="kw">string">"br"</span>,
<span class="class="type">class="kw">string">"hr"</span>,
<span class="class="type">class="kw">string">"frame"</span>,
<span class="class="type">class="kw">string">"wbr"</span>,
<span class="class="type">class="kw">string">"basefont"</span>,
<span class="class="type">class="kw">string">"spacer"</span>,
<span class="class="type">class="kw">string">"area"</span>,
<span class="class="type">class="kw">string">"param"</span>,
<span class="class="type">class="kw">string">"keygen"</span>,
<span class="class="type">class="kw">string">"col"</span>,
<span class="class="type">class="kw">string">"limittext"</span>
&nbsp;&nbsp;&nbsp;&nbsp;~HtmlParser()
&nbsp;&nbsp;&nbsp;&nbsp;{
&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;<span class="keyword">if</span>(root != <span class="macro">NULL</span>)
&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;{
&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;<span class="keyword">class="kw">delete</span> root;
&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;}
&nbsp;&nbsp;&nbsp;&nbsp;}
&nbsp;&nbsp;&nbsp;&nbsp;DomElement *parse(<span class="keyword">class="kw">const</span> <span class="keyword">class="type">class="kw">string</span> &amp;html)
&nbsp;&nbsp;&nbsp;&nbsp;{
&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;<span class="keyword">if</span>(root != <span class="macro">NULL</span>)
&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;{
&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;<span class="keyword">class="kw">delete</span> root;
&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;}
&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;root = <span class="keyword">new</span> DomElement(<span class="class="type">class="kw">string">"root"</span>);
&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;cursor = root;
&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;offset = <span class="number">class="num">0</span>;
&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;
&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;<span class="keyword">class="kw">while</span>(processText(html));
&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;
&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;<span class="keyword">class="kw">return</span> root;
&nbsp;&nbsp;&nbsp;&nbsp;}

「HTML 流式解析里的状态机收口」

把网页里的行情注释喂给 MT5 时,最怕一次把整段 HTML 读进来再正则。上面这段 processText 用的是偏移量游标 + 状态枚举,逐段吐文本,遇到 '<' 才切状态,空白态下若 StringFind 返回 -1 就直接 false 退出,说明流已耗尽。 注意 insideTagOpen 分支里对空格和右尖括号取 MathMin 的手法:pspace 与 pright 任一为 -1 时退化为 MathMax,但随后若 pright 也为 -1 仍返回 false,避免越界死循环。自闭合标签靠 TAG_OPENCLOSE_STOP 在右括号前一位比对,命中就把 pright 左移并置 selfclose,这样 <br/> 类节点不会误进 insideTagClose。 实盘接新闻源时,把 offset 持久化到静态变量,每 tick 只喂新增字符,CPU 占用可能比全量重解析低一个数量级。外汇与贵金属新闻驱动跳空频繁,这类解析失败会让信号滞后,属高风险环节,建议先在策略测试器里用历史 HTML 跑通再上实盘。

MQL5 / C++
class="type">bool processText(class="kw">const class="type">class="kw">string &html)
{
   class="type">int p;
   if(state == blank)
   {
      p = StringFind(html, "<", offset);
      if(p == -class="num">1) class=class="str">"cmt">// no more tags
      {
         class="kw">return(class="kw">false);
      }
      else if(p > class="num">0)
      {
         if(p > offset)
         {
            class="type">class="kw">string text = StringSubstr(html, offset, p - offset);
            StringTrimLeft(text);
            StringTrimRight(text);
            StringReplace(text, "&nbsp;", "");
            if(StringLen(text) > class="num">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;
      
      class="kw">return(true);
   }
   else
   if(state == insideTagOpen)
   {
      offset++;
      class="type">int pspace = StringFind(html, " ", offset);
      class="type">int pright = StringFind(html, ">", offset);
      p = MathMin(pspace, pright);
      if(p == -class="num">1)
      {
         p = MathMax(pspace, pright);
      }
      
      if(p == -class="num">1 || pright == -class="num">1) class=class="str">"cmt">// no tag closing
      {
         class="kw">return(class="kw">false);
      }
      if(pspace > pright)
      {
         pspace = -class="num">1; class=class="str">"cmt">// 外部空间,不考虑
      }
      class="type">bool selfclose = class="kw">false;
      if(IsString(html, TAG_OPENCLOSE_STOP, pright - StringLen(TAG_OPENCLOSE_STOP) + class="num">1))
      {
         selfclose = true;
         if(p == pright) p--;
         pright--;
      }

常见问题

可以,先按浏览器开发者工具里复制的表格CSS路径,再用现成解析脚本把节点转成二维数组就能算。
用状态机收口能在断流时保留已解析的DOM分支,重启后从断点续抓,已捞到的表数据不丢。
小布可接管定时抓取与CSS选择器配置,把网页节点直接整理成你盯盘用的表格,不用自己写解析器。
多半是选择器没限定父级层级,给table加独立class再用后代选择器精准定位即可。
节点多、要频繁查询时用语义化类结构更清晰,偶尔轻量抓取用字典也够用。