使用 PHP 8.4 新 DOM Selector 解析 HTML

上个月发布的 PHP 8.4 为 HTML 解析、DOM 遍历和操作带来了三大改进:

  • 新的 HTML5 解析器可准确处理现代网络内容
  • 强大的 CSS 选择器支持元素检索
  • 更符合 DOM 规范的新 DOM 类

对于从事web搜刮、内容提取或 HTML 转换的开发人员来说,这些功能和性能都有了显著提升。

在 PHP 8.4 发布的报道中,这些功能并没有得到应有的关注。而且 PHP 网站上的文档仍然很少。最近,我开始更新 Mozilla 的 Readability 的 PHP 移植,以使用这些新功能,因此想与大家分享更多信息。

技术基础

这些改进的核心是 Lexbor,它是由 Alexander Borisov 创建的基于 C 语言的 HTML 解析器。它提供快速、符合标准的 HTML 解析和 CSS 选择器支持。现在,PHP 8.4 的官方 DOM 扩展中包含了它,默认情况下已启用,无需额外配置。

元素周期表

新的 DOM 类更贴近 DOM 规范。如果你熟悉 JavaScript 中的 DOM 遍历和操作,你会发现许多熟悉的方法和属性现在都可以在 PHP 中使用,包括 querySelectorquerySelectorAll

旧办法: 使用 libxml 进行解析

PHP 以前依赖 libxml 来解析 XML 和 HTML。遗憾的是,libxml 难以处理现代 HTML,许多页面被解析器弄得一团糟。让我们看一个简单的例子来说明这个问题。

这是一个有效的 HTML5 文档,包含两个段落和一个脚本标记:

<!DOCTYPE html>
<title>Valid HTML5 Document</title>
<p>Paragraph 1</p>
<script>console.log("</html>Console log text");</script>
<p>Paragraph 2</p>

当尝试解析该文档并计算其段落数时,PHP 发现了三个元素,而不是两个:

$dom = new DOMDocument('1.0', 'UTF-8');
$dom->loadHtml($html);
$paragraphs = $dom->getElementsByTagName('p');
echo "{$paragraphs->length} paragraphs found.";
// Output: 3 paragraphs found.

为什么找到的是三段而不是两段?脚本元素中出现的 </html> 会干扰 libxml 解析器。libxml 没有将其视为脚本中的文本,而是将其解释为 HTML 结尾标记。当我们将生成的 DOM 序列化为 HTML 时,我们可以看到文档是如何被篡改的:

<html>
  <body>
   <p>Paragraph 1</p>
   <script>console.log("</script>
  </body>
</html>
<html>
  <p>Console log text");</p>
  <p>Paragraph 2</p>
</html>

为了克服这些限制,许多开发人员转而使用其他解析器。HTML5-PHP 很受欢迎,但它是用 PHP 而不是 C 编写的,因此速度明显比 libxml 慢。此外,目前还不清楚它在跟上 HTML 生活标准方面付出了多少努力(详情见下文)。

新方法: 使用 Lexbor

进行解析 PHP 8.4 通过新的 HTML5 解析器解决了这些解析难题。让我们用新的解析器来解析相同的 HTML:

$newDom = Dom\HTMLDocument::createFromString($html);
$paragraphs = $newDom->getElementsByTagName('p');
echo "{$paragraphs->length} paragraphs found.";
// Output: 2 paragraphs found.

现在,解析器可以正确识别两个段落。您可以在这里尝试运行新旧解析器。

根据负责这些新添加功能的 Niels Dossche 的说法,新解析器的性能与 libxml 解析器不相上下,甚至还要更快一些。

Lexbor 与 HTML5-PHP

对于当前的 HTML5-PHP 用户来说,改用新的 DOM API 和解析器具有一些优势。

性能

用 C 语言编写的 Lexbor 性能应该比 HTML5-PHP 好得多。在我的测试中,处理包含博客文章和新闻文章的 HTML 页面时,Lexbor 的速度要快 3.6 倍。尼尔斯认为,在处理较大的 HTML 文档时,速度优势会更加明显。

符合标准

HTML 规范是一个不断发展的活标准,解析器在执行当前标准时可能会有所不同。

HTML5-PHP 于 2013 年启动,其 README 仍然引用 2012 年版本的 W3C HTML5 标准。Lexbor 于 2018 年启动,基于较新的 WHATWG 标准,而 WHATWG 现在是 HTML 标准的唯一发布者。因此,Lexbor 可能比 HTML5-PHP 更接近当前标准。

另外值得注意的是,HTML5-PHP 目前依赖于 PHP 的旧 DOM 类,而这些类并不支持本文其余部分介绍的 PHP 新 DOM API 的改进功能。

使用新的 DOM 类

为了实现向后兼容性,PHP 8.4 在现有 DOM 类的基础上引入了新的 DOM 类。这意味着您可以根据需要继续使用 DOMDocument,甚至可以在同一代码库中同时使用新旧两个类

以下是开始使用的方法:

$dom = Dom\HTMLDocument::createFromString($html);

在 DOM 命名空间下,新类遵循更简单的命名约定:

作为 DOM 属性的顶级 HTML 元素

现在,您可以通过这些方便的 Dom\Document 属性访问 HTML 文档的主要部分:

  • head (只读)
    “作为 html 元素子元素的第一个头部元素。这些元素必须位于 HTML 名称空间中。如果没有匹配的元素,则评估值为空。”
  • body
    “是 body 标记或 frameset 标记的 html 元素的第一个子元素。这些元素必须在 HTML 名称空间中。如果没有匹配的元素,则其值为空。”
  • title
    “由 HTML 的 title 元素或 SVG 的 SVG title 元素设置的文档标题。如果没有标题,则返回空字符串”。

示例

$dom = Dom\HTMLDocument::createFromString('<p>My document</p>');
echo $dom->saveHtml($dom->body);
// Output: <body><p>My document</p></body>
$dom->title = 'My title';
echo $dom->saveHtml($dom->head);
// Output: <head><title>My title</title></head>

使用 innerHTML

PHP 8.4 还引入了 innerHTML 属性,它为使用元素内容提供了更简便的方法。无需直接操作 DOM 节点,而是使用 HTML 字符串:

$dom = Dom\HTMLDocument::createFromString('<body><h1>Test</h1></body>');
echo $dom->body->innerHTML;
// Output: <h1>Test</h1>
$dom->body->innerHTML = '<p>Something new</p>';
echo $dom->saveHtml();
// Output: <html><head></head><body><p>Something new</p></body></html>

请注意,目前还不支持 outerHTML

现代 CSS 选择器支持

PHP 8.4 最强大的新增功能之一是对现代 CSS 选择器的全面支持。现在,您可以使用 querySelector querySelectorAll,使用您在前端开发中熟悉的选择器查找元素:

  • querySelector($selectors)
    “返回与 CSS 选择器匹配的第一个后代元素”
  • querySelectorAll($selectors)
    “返回一个 NodeList,其中包含与 CSS 选择器匹配的所有后代元素”

下面是之前获取段落的代码,但用 querySelectorAll 代替了 getElementsByTagName

$newDom = Dom\HTMLDocument::createFromString($html);
$paragraphs = $newDom->querySelectorAll('p');
echo "{$paragraphs->length} paragraphs found.";

这样做的结果和以前一样,并不显著。但新的选择器支持可以实现更复杂的查询。让我们来看看一些实际的例子:

查找多种元素类型

获取所有段落和标题元素–按文档顺序返回:

$elements = $dom->querySelectorAll('p, h1, h2, h3, h4, h5, h6');

使用 :is:where 避免重复

获取文章的直接子段落和主要标题:

$elements = $dom->querySelectorAll('article > :is(p, h1, h2)');

您还可以将搜索范围缩小到特定元素:

$elements = $dom->querySelector('article')->querySelectorAll('p, h1, h2');

请注意,这在技术上并不等同于之前的代码,因为我们并没有将结果限制为直接子代。为此,我们需要使用 :scope 选择器,而 Lexbor 目前还不支持该选择器:

$dom->querySelector('article')->querySelectorAll(':scope > :is(p, h1, h2)');
// Throws: DOMException: Invalid selector (Selectors. Not supported: scope)

好消息是,Lexbor 正在审查由 Niels 提供的针对此问题的修复程序

使用:empty:not查找空或非空元素

获取所有空 p 元素:

$elements = $dom->querySelectorAll('p:empty');

获取所有非空 p 元素:

$elements = $dom->querySelectorAll('p:not(:empty)');

使用:has 匹配父元素或上一个同级元素

获取文章中至少有一个链接的所有段落:

$elements = $dom->querySelectorAll('article p:has(a)');

获取紧接着 h2 标题的 h1 标题:

$elements = $dom->querySelectorAll('h1:has(+ h2)');

属性选择器

获取所有外部链接–以 “http “开头且不包含 “example.com “的 URL,不区分大小写:

$elements = $dom->querySelectorAll(
    'a[href ^= "http" i]:not([href *= "example.com" i])'
);

有关可用选择器的更多示例,请参阅 MDN 有关 CSS 选择器和组合器的文档,以及 PHP 的选择器测试文件夹。

你也许感兴趣的:

发表回复

您的邮箱地址不会被公开。 必填项已用 * 标注