“用正则从 HTML 里取数据又快又简单”是爬虫新手的常见做法。这篇用 12 种写法略有不同的 HTML,看正则到底在哪些地方会出错,正则的速度优势到底有多大,以及回溯爆炸是怎么回事。
正则一(最直接的写法):<a\s+class="item"\s+href="([^"]+)"\s*>(.*?)</a> 正则二(更宽松的写法):<a[^>]*href=["\']?([^"\'\s>]+)["\']?[^>]*>(.*?)</a> HTML 写法 正则一 正则二 lxml 解析 (要取的是 /p/1 和 One;其它结果标出) 标准写法 ✓ ✓ ✓ 属性顺序不同 ✗0条 ✓ ✓ 单引号和多余空白 ✗0条 ✗0条 ✓ 没有引号的属性 ✗0条 ✓ ✓ 大小写混用 ✗0条 ✓ ✓ 文字里有嵌套标签 ✗1条 ✗1条 ✓ 文字里有换行 ✓ ✓ ✓ 属性值里有 > 符号 ✗0条 ✗0条 ✓ HTML 实体 ✗1条 ✗1条 ✓ 注释里的同样内容 ✗2条 ✗2条 ✓ <script> 里的字符串 ✗2条 ✗2条 ✓ 属性值里有 <a 字样 ✓ ✗1条 ✓ 正确的写法数(共 12 种):正则一 3,正则二 5,lxml 12
属性顺序、引号种类、属性周围的空白、大小写,都是同一个页面里可能出现的写法差异;正则要把它们一一写进模式,写得越宽松越容易误匹配。lxml 解析之后用 XPath 取,12 种全对。
属性顺序不同:正则一 → [];正则二 → [('/p/1', 'One')];lxml → [('/p/1', 'One')]
没有引号的属性:正则一 → [];正则二 → [('/p/1', 'One')];lxml → [('/p/1', 'One')]
文字里有嵌套标签:正则一 → [('/p/1', '<b>On</b>e')];正则二 → [('/p/1', '<b>On</b>e')];lxml → [('/p/1', 'One')]
注释里的同样内容:正则一 → [('/p/9', 'Old'), ('/p/1', 'One')];正则二 → [('/p/9', 'Old'), ('/p/1', 'One')];lxml → [('/p/1', 'One')]
<script> 里的字符串:正则一 → [('/p/9', 'Fake'), ('/p/1', 'One')];正则二 → [('/p/9', 'Fake'), ('/p/1', 'One')];lxml → [('/p/1', 'One')]
属性值里有 > 符号:正则一 → [];正则二 → [];lxml → [('/p/1', 'One')]
HTML 实体:正则一 → [('/p/1?a=1&b=2', 'One & Two')];正则二 → [('/p/1?a=1&b=2', 'One & Two')];lxml → [('/p/1?a=1&b=2', 'One & Two')]注释和 <script> 字符串里的同样的 <a>,正则会当成真的链接;文字里的嵌套标签、HTML 实体,正则不会处理。解析器会丢掉注释和脚本里的内容、去掉标签、解码实体。
L = 12:匹配失败,耗时 0.3 ms L = 16:匹配失败,耗时 4.0 ms(比上一行 L 小 2 时慢 15.8 倍) L = 18:匹配失败,耗时 15.7 ms(比上一行 L 小 2 时慢 3.9 倍) L = 20:匹配失败,耗时 63.1 ms(比上一行 L 小 2 时慢 4.0 倍) L = 22:匹配失败,耗时 259.0 ms(比上一行 L 小 2 时慢 4.1 倍) L = 24:匹配失败,耗时 1019.6 ms(比上一行 L 小 2 时慢 3.9 倍) L = 26:匹配失败,耗时 4099.9 ms(比上一行 L 小 2 时慢 4.0 倍) L = 28:超过 10 秒仍没有结束(被我强制终止) 正则一/二那种不带嵌套量词的写法不会这样,但页面的内容不受你控制,嵌套量词一旦写进去就有这个风险
带嵌套量词的正则(^(\w+\s?)+$)遇到不匹配的恶劣输入时耗时指数增长:L 每加 2 约 ×4,L=26 就要 4.1 秒。网页内容不受你控制,这类正则是一个现实的风险。
正则一 findall:1.39 ms;lxml 解析 + xpath:6.43 ms;只解析不取值:4.13 ms 结论:速度不是正则的理由——它快得有限,而且在上面 12 种写法里错了好几种
正则确实更快(1.39 ms 对 6.43 ms),但绝对值是毫秒级;在受网络限制的爬虫里,这个差别可以忽略,换来的却是 12 种写法里错 9 种。适合正则的是格式固定的文本(如日志、已经提取出的字符串),不是 HTML 的结构。