bigywater.tech
爬虫工程 · 52

正则提取为什么在 HTML 上会坏

“用正则从 HTML 里取数据又快又简单”是爬虫新手的常见做法。这篇用 12 种写法略有不同的 HTML,看正则到底在哪些地方会出错,正则的速度优势到底有多大,以及回溯爆炸是怎么回事。

3 / 5 / 12
12 种写法里,最直接的正则、更宽松的正则、lxml 解析各自答对的数量
4.1 秒
嵌套量词的正则在 L=26 的恶劣输入上的耗时(L=28 超过 10 秒)
4.6 倍
正则 findall 比 lxml 解析加 xpath 快的倍数(1.39 对 6.43 ms)

写法差异

实测 · 本机执行输出
  正则一(最直接的写法):<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&amp;b=2', 'One &amp; Two')];正则二 → [('/p/1?a=1&amp;b=2', 'One &amp; 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 的结构。

分步演示

正则与 HTML
本步的对象

    本文用到的工具与边界

    • 本机 macOS 27 arm64 / Python 3.14.8 / requests 2.34.2 / aiohttp 3.14.3 / lxml 6.1.1(系统 Python);所有请求只发给本机自己起的测试服务器(127.0.0.1,端口 9960 以上,不访问任何真实网站);服务器可以按实验需要制造延迟、断连、压缩、重定向等行为,并记录收到的原始请求;计时用 time.perf_counter,数字每次运行会有小幅抖动。12 种写法是手写的 HTML 片段;正则一、正则二是脚本里列出的两个写法;lxml 6.1.1 的 lxml.html.fromstring 与 XPath(按 class 词匹配);答对的标准是取到的 (href, 文字) 恰好等于 [(/p/1, One)],实体要解码、标签要去掉(实体那一项期望 /p/1?a=1&b=2 和 One & Two);回溯测试在子进程里跑,每个 L 单独计时,超过 10 秒强制终止;速度每项重复 50 次取平均
    • 没有验证:Python 之外的正则引擎(有的有回溯保护);re 模块的第三方替代 regex 是否更安全;更多真实页面上的正则命中率;所谓“HTML 是上下文无关语言所以正则不能解析”的理论说法(只做了实证);在 HTML 片段已知非常固定的场景(自己生成的页面)里正则是否足够
    京ICP备2026067502号-1京公网安备11010502063487号