php实战正则表达式(二):提取html元素

这篇文章通过提取html元素介绍了正则表达式中模式修饰符贪婪匹配非贪婪匹配Unicode模式环视等知识点。
在阅读这篇文章前最好把同系列文章php实战正则表达式(一):验证手机号先仔细阅读一遍。

基本提取

有这样一个表格

用户名 职业
Kobe Bryant 篮球运动员
Jay Chou 歌手、词曲创作人、制作人、演员、导演
Lionel Messi 足球运动员

它的源码如下:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
<table>
<thead>
<tr><th>用户名</th><th>职业</th></tr>
</thead>
<tbody>
<tr>
<td>Kobe Bryant</td><td>篮球运动员</td>
</tr>
<tr>
<td>Jay Chou</td><td>歌手、词曲创作人、制作人、演员、导演</td>
</tr>
<tr>
<td>Lionel Messi</td><td>足球运动员</td>
</tr>
</tbody>
</table>

现在要提取<tbody>第一个<tr>元素。最简单的正则表达式应该是这样:

<tbody>\s+<tr>.*<\/tr>

其中

  • \sphp实战正则表达式(一):验证手机号介绍过的字符组简记法中的一个,代表回车符、空格、制表符等空白字符
  • 量词+表示它所修饰的字符或字符组出现次数大于等于1
  • 点号字符.在正则表达式中是一个特殊的元字符,它可以匹配“任意字符”
  • 闭标签</tr>中的斜线/在php的正则表达式中是模式分隔符,所以需要转义来表示斜线字符。

但实际上这样一个表达式是无法从上面的<tbody>中提取第一个<tr>元素的

这里主要的问题是在默认情况下点号字符.无法匹配换行符\n。有两个方法可以解决这个问题:

  • 使用模式修饰符s,正则表达式为/<tbody>\s+<tr>.*<\/tr>/s(?s)<tbody>\s+<tr>.*<\/tr>。模式修饰符s的作用就是让点号字符.可以匹配换行符。
  • [\s\S][\w\W][\d\D]代替点号字符.来匹配所有字符,正则表达式为<tbody>\s+<tr>[\s\S]*<\/tr>

关于模式修饰符(Pattern Modifiers),这里需要详细介绍一下(点击这里查看php支持的所有模式修饰符)。模式修饰符可以改变正则表达式的一些默认规则,常用的模式修饰符有isUu等,我们在后面会用到它们中的一些,这里不展开介绍每个模式修饰符的作用,后面用到了再具体介绍。这里主要对比一下/.../{modifier}...(?{modifier})...两种表示方法的区别。

模式修饰符 /.../{modifier} ...(?{modifier})...
示例 /<tr>.*<\/tr>/s <tr>(?s).*<\/tr>
名称(php手册) 模式修饰符 模式内修饰符
名称(《正则指引》) 预定义常量 模式修饰符
作用范围 整个正则表达式 不在分组(子表达式)中时,对它后面的全部正则表达式起作用;如果在分组(子表达式)中,则对它分组中的剩余部分起作用。在没有分组,且放在整个正则表达式最前面的时候相当于/.../{modifier}
支持程度 支持所有模式修饰符 支持部分模式修饰符
其他编程语言 可能不支持 一般都支持

从上面的gif中可以看到提取的结果中有三个tr,而不是只有一个。这是因为正则表达式中量词默认是贪婪匹配,在这里,.*会匹配一切字符,直到最后没有字符再向前回溯,回溯到<tbody>中的最后一个</tr>时与正则表达式中的<\/tr>相匹配,从而完成整个匹配过程,最后的结果也就是包含了三个<tr>

可以使用模式修饰符U指定整个正则表达式为非贪婪模式,也可以使用非贪婪匹配量词指定某一个量词为非贪婪模式:

  • 指定整个正则表达式为非贪婪模式:
    • /<tbody>\s+<tr>.*<\/tr>/Us
    • (?Us)<tbody>\s+<tr>.*<\/tr>
  • 非贪婪量词:
    /<tbody>\s+<tr>.*?<\/tr>/s

完整的贪婪量词(匹配优先量词)与非贪婪量词(忽略优先量词)见下表:

贪婪量词 非贪婪量词 限定次数
* *? 可能出现,可能不出现,出现次数没有上限
+ +? 至少出现1次,没有上限
? ?? 出现0次或1次
{m,n} {m,n}? 出现次数大于等于m,小于等于n
{m,} {m,}? 至少出现m次,没有上限
{0,n} {0,n}? 出现0次-n次

提取包含指定内容的行

假设我们想把表格中有关于运动员的记录都提取出来,我们可能会使用/<tr>.*运动员.*<\/tr>/s这样的正则表达式。

这个表达式在Unicode编码环境下可以匹配出结果,但是在GBK环境下就未必了。我们可以通过模式修饰符u来指定Unicode模式:

/<tr>.*运动员.*<\/tr>/us

在Unicode模式下,我们甚至可以使用码值来代替汉字:

/<tr>.*\x{8fd0}\x{52a8}\x{5458}.*<\/tr>/us

php正则中使用\x{hex}的形式来表示Unicode字符的码值,使用码值的好处是可以结合字符组来表示一段范围,如[\x{4e00}-\x{9fff}]表示匹配所有汉字字符。

上面的表达式可以匹配出结果,但是却不正确。我们可以看到,它匹配了整个字符串的第一个<tr>到最后一个</tr>
直觉上,我们是想正则表达式先去匹配“运动员”,然后向左寻找最近的一个<tr>,向右寻找最近的一个</tr>。但事实上,正则表达式是从左往右匹配的,即从<tr>开始寻找,整个正则表达式的匹配情况见下表。

表达式 匹配值
/
<tr> <tr>
.* <th>用户名</th><th>职业</th></tr>
</thead>
<tbody>
  <tr>
    <td>Kobe Bryant</td><td>篮球
运动员 运动员
.* </td>
  </tr>
  <tr>
    <td>Jay Chou</td><td>歌手、词曲创作人、制作人、演员、导演</td>
  </tr>
  <tr>
    <td>Lionel Messi</td><td>足球运动员</td>
<\/tr> </tr>
/us

这里两个.*匹配到的字符都比预期要多。第二个.*匹配字符比预期多的原因是正则表达式默认是贪婪匹配模式,它会匹配剩余字符串中的每个字符,直到字符串的末尾,然后再向前回溯到最后一个</tr>,可以通过指定非贪婪匹配模式来解决这个问题。但是第一个.*匹配字符比预期多是正常现象,因为正则表达式是从左向右匹配的,表达式中的<tr>匹配字符串中第一个<tr>,后面的.*则匹配剩余的所有字符,直到字符串的末尾,然后再向前回溯到“运动员”。

我们先看看使用非贪婪匹配时的结果:

可以看到,第二个.*匹配的字符已经是我们想要的了。那么,对于第一个.*匹配字符比预期多这个问题怎么解决呢?

如果仅使用到目前为止我的文章中介绍的知识,也是有方法可以解决的。我们可以先从左到右匹配出所有的行(<tr>...</tr>),方法是使用php中的preg_match_all函数结合非贪婪匹配模式;然后再遍历每一行,过滤出其中包含“运动员”的行即可。

当然,我们也可以通过纯粹的正则表达式来解决这个问题。如果有一定正则表达式使用经验的朋友可能很容易联想到排除型字符组,我们介绍过字符组[...],它表示在同一位置可能出现的字符。而排除型字符组则表示在同一位置不能出现的字符,它的形式是[^...],通过紧跟在开方括号[后面的^来表示排除型字符组。例如,[^\d]表示匹配的字符是除了数字以外的任意字符。
如果有排除型子表达式,类似于(^<tr>)*,我们只需要指定第一个.*<tr>排除就行了。但是很遗憾,正则表达式中没有排除型子表达式或者说排除型分组。这种情况下,我们只能使用环视

/<tr>(.(?!<tr>))*运动员.*<\/tr>/Us

环视(look-around)不匹配任何字符,用来“停在原地,四处张望”。上面的表达式使用了否定顺序环视,它的形式是(?!...)。具体对于(.(?!<tr>))*来分析,每当.匹配了一个字符后,就向右看看,如果当前匹配字符的右边没有出现<tr>就匹配成功。

完整的环视有:

名字 记法 含义
肯定顺序环视 (?=...) 向右看看,右边出现了环视中的内容才匹配
否定顺序环视 (?!...) 向右看看,右边不出现环视中的内容才匹配
肯定逆序环视 (?<=...) 向左看看,左边出现了环视中的内容才匹配
否定逆序环视 (?<!...) 向左看看,左边不出现环视中的内容才匹配

由于上面的正则表达式有一个分组(子表达式),所以匹配的结果除了下标0,还有下标1,这里下标1的结果其实没有什么用,我们可以用之前介绍过的非捕获分组

/<tr>(?:.(?!<tr>))*运动员.*<\/tr>/Us

我们的真正目的是提取所有包含“运动员”的行,而上面只提取了第一个,所以需要将preg_match函数换成preg_match_all