返回

PHP DOMDocument 获取 body 内所有元素 (非 body 本身)

php

PHP DOMDocument 如何获取 body 内所有元素 (排除 body 本身)

用 PHP 的 DOMDocument 处理 HTML 片段时,你可能碰到过一个小问题:明明原始 HTML 字符串里只有 <p><ol> 这些内容标签,解析后一遍历,却发现前面多了个 <html><body>。这俩不速之客是哪来的?又该怎么只拿到我们真正关心的那些元素呢?

比如下面这段代码:

<?php
// 假设 $product['description'] 来自数据库,内容如下:
// <p>这是一个段落</p><ol><li>这是一个列表项</li></ol>
$htmlSnippet = '<p>这是一个段落</p><ol><li>这是一个列表项</li></ol>';

$doc = new DOMDocument();
// 使用 @ 抑制 loadHTML 对不规范 HTML 发出的警告
@$doc->loadHTML($htmlSnippet, LIBXML_HTML_NOIMPLIED | LIBXML_HTML_NODEFDTD); 

// 直接获取所有元素
$allEls = $doc->getElementsByTagName('*');

echo "直接获取 document 下的所有元素:\n";
foreach ($allEls as $node) {
    echo '- ' . $node->nodeName . "\n";
}

// 看看会输出什么:
// 直接获取 document 下的所有元素:
// - html  <-- 多出来的
// - body  <-- 多出来的
// - p
// - ol
// - li 

看到输出里的 htmlbody 了吗?它们并不是我们原始字符串 $htmlSnippet 里的。这确实有点烦人,特别是只想处理原始内容的时候。尝试用 $doc->getElementsByTagName('body *') 或者先获取 body 再用 $body->getElementsByTagName('*') 的思路,似乎也不太对路。

别急,我们先看看为啥会这样,然后给出几种靠谱的解决方案。

为啥多了 html 和 body 标签?

这其实是 DOMDocument::loadHTML() 的“贴心”之举。这个方法设计用来解析 HTML 文档,哪怕你给它的只是一小段 HTML 代码 (fragment)。为了保证解析后的 DOM 树结构是完整且符合 HTML 规范的,loadHTML() 会自动帮你补全缺失的骨架标签,比如 <html>, <head>, <body> 以及必要的文档类型声明 <!DOCTYPE>

你提供的 <p>...</p><ol>...</ol> 在它看来,就是一个缺少了基本框架的 HTML 内容,所以它自动把这些内容放进了 <body> 标签里,并给整个文档套上了 <html> 标签。

即便你后来加了 LIBXML_HTML_NOIMPLIED | LIBXML_HTML_NODEFDTD 参数(就像上面更新的代码示例),这些参数主要是阻止 loadHTML 添加 <html>, <head>, <body><!DOCTYPE> 骨架。但如果你的输入片段本身不构成一个完整的、有单一根元素的结构,DOMDocument 在内部表示时可能仍然需要某种形式的根。不过,用了这两个参数后,直接在 $doc 上调用 getElementsByTagName('*') 的行为会更接近你想要的,它可能不会再显式输出 htmlbody,但这取决于具体的 PHP 版本和 libxml2 库版本。

更可靠的做法不是依赖于 loadHTML 参数完全改变 DOM 结构,而是接受它可能生成 <body>(即使有时不可见),然后精确地只选取 <body> 内部的元素。

解决办法来了

知道了原因,解决起来就思路清晰了。目标是:迭代处理原始 HTML 片段中的所有标签,忽略掉 DOMDocument 可能自动添加的 html<body>

方法一:先抓 body,再找孩子 (DOM 操作)

最直观的想法:既然内容都在 <body> 里(不管是自动加的还是原本就有的),那我先定位到 <body> 元素,然后只查找它内部的所有后代元素就行了。

原理:

  1. 使用 getElementsByTagName('body') 获取文档中所有的 <body> 元素。正常情况下,一个 HTML 文档只有一个 <body>,所以这个方法返回的 DOMNodeList 通常只包含一个元素。
  2. 通过 item(0)DOMNodeList 中取出第一个(也是唯一一个)<body> 元素(DOMElement 对象)。
  3. 在该 <body> 元素上调用 getElementsByTagName('*'),这样就能获取到 <body> 内部的所有后代元素,不再包含 <html><body> 自身。

代码示例:

<?php
$htmlSnippet = '<p>这是一个段落</p><ol><li>这是一个列表项</li></ol>';

$doc = new DOMDocument();
// 就算不用 LIBXML 参数,此方法也有效
@$doc->loadHTML('<?xml encoding="UTF-8">' . $htmlSnippet); // 添加编码信息更稳妥

$bodyNodeList = $doc->getElementsByTagName('body');

// 确保 body 元素存在
if ($bodyNodeList->length > 0) {
    $bodyElement = $bodyNodeList->item(0); // 获取第一个 body 元素
    
    // 获取 body 内的所有后代元素
    $elsInsideBody = $bodyElement->getElementsByTagName('*');
    
    echo "方法一:获取 body 内所有元素:\n";
    foreach ($elsInsideBody as $node) {
        echo '- ' . $node->nodeName . "\n";
    }
} else {
    echo "奇怪,没有找到 body 元素。\n";
}

// 预期输出:
// 方法一:获取 body 内所有元素:
// - p
// - ol
// - li

说明:

  • $doc->getElementsByTagName('body') 返回的是一个 DOMNodeList 对象,即使只有一个 <body> 元素,它也包含在这个列表里。
  • ->item(0) 用来访问列表中的第一个元素。在操作前最好检查一下 $bodyNodeList->length > 0,确保 <body> 确实被找到了,避免在空列表上调用 item(0) 出错。
  • $bodyElement->getElementsByTagName('*') 只搜索 $bodyElement 的子孙节点,完美避开 <html><body> 本身。
  • loadHTML 前加上 <?xml encoding="UTF-8"> 可以帮助 DOMDocument 正确处理 UTF-8 编码的字符,避免乱码问题。

安全建议:

  • 处理用户输入或不可信来源的 HTML 时,务必进行清理和过滤(例如使用 HTML Purifier 库),防止 XSS 攻击。DOMDocument 本身不提供 XSS 防护。

方法二:XPath 指哪打哪,精准定位

XPath 是一种在 XML/HTML 文档中查找信息的语言,功能强大且灵活。DOMDocument 可以配合 DOMXPath 类使用 XPath 表达式来查询节点。

原理:

  1. 创建一个 DOMXPath 对象,关联到我们的 $doc 文档对象。
  2. 使用 XPath 表达式 //body/* 来查询。
    • // 表示在整个文档中搜索,不受层级限制。
    • body 指定查找 <body> 元素。
    • / 表示层级关系,选取子元素。
    • * 是通配符,表示匹配任何名称的元素。
    • 整个表达式 //body/* 的意思是:找到文档中所有 <body> 元素下的所有直接子元素。如果要包含所有后代元素(孙子、曾孙子等),应该用 //body//*。不过,对于你的原始问题,//body/* 可能更符合直觉,只拿第一层;但如果想模拟 getElementsByTagName('*') 的行为(获取所有后代),//body//* 更合适。这里我们用 //body//* 来获取所有后代。
  3. $xpath->query() 方法执行查询,返回一个包含所有匹配节点的 DOMNodeList

代码示例:

<?php
$htmlSnippet = '<p>这是一个段落</p><ol><li>这是一个列表项 <span>深层内容</span></li></ol>';

$doc = new DOMDocument();
@$doc->loadHTML('<?xml encoding="UTF-8">' . $htmlSnippet); 

$xpath = new DOMXPath($doc);

// 使用 XPath 查询 body 内的所有后代元素
$elsInsideBody = $xpath->query('//body//*'); 
// 如果只想获取 body 的直接子元素,用 '//body/*'

echo "方法二:使用 XPath 获取 body 内所有后代元素:\n";
if ($elsInsideBody) { // query 可能返回 false 或 DOMNodeList
    foreach ($elsInsideBody as $node) {
        echo '- ' . $node->nodeName . "\n";
    }
}

// 预期输出 (使用 //body//*):
// 方法二:使用 XPath 获取 body 内所有后代元素:
// - p
// - ol
// - li
// - span

说明:

  • DOMXPath 是处理 XML/HTML 文档查询的利器。
  • XPath 表达式 //body//* 非常直接地表达了“body 标签下的所有后代元素”这个意图。
  • $xpath->query() 返回的结果可以直接用于 foreach 循环。

进阶使用技巧:

  • 如果只想获取 <body> 下的特定标签,比如所有段落 <p>,XPath 可以写成 //body//p
  • 如果只想获取 <body> 的直接子元素,用 / 而不是 //,即 //body/*
  • XPath 还可以执行更复杂的查询,比如根据属性、文本内容等筛选元素。例如 //body//li[contains(text(), '列表')] 可以找到 <body> 下包含文本“列表”的 <li> 元素。

方法三:曲线救国,只取内容字符串

如果你的最终目的不是要遍历每个 DOMElement 对象进行复杂操作,而仅仅是想得到 <body> 标签内部的 HTML 字符串 ,那么可以结合方法一或方法二,找到 <body> 元素后,再把它内部的所有子节点序列化回 HTML 字符串。

原理:

  1. 通过方法一或方法二找到 <body> 元素 ($bodyElement)。
  2. 遍历 $bodyElement直接子节点 (childNodes 属性,注意不是 childrenchildNodes 包含文本节点和注释节点)。
  3. 对每个子节点,调用 $doc->saveHTML($childNode) 将其转换回 HTML 字符串。
  4. 拼接所有子节点的 HTML 字符串。

代码示例:

<?php
$htmlSnippet = '<p>这是一个段落</p><ol><li>这是一个列表项</li></ol>还有一些文本节点';

$doc = new DOMDocument();
// 重要:必须加载为 XML 并提供编码,否则 saveHTML 可能出问题
@$doc->loadHTML('<?xml encoding="UTF-8">' . $htmlSnippet); 

$bodyNodeList = $doc->getElementsByTagName('body');
$innerHtml = '';

if ($bodyNodeList->length > 0) {
    $bodyElement = $bodyNodeList->item(0);
    
    // 遍历 body 的所有直接子节点
    foreach ($bodyElement->childNodes as $childNode) {
        // 将每个子节点转换回 HTML 字符串并拼接到结果中
        // 注意:saveHTML($node) 会包含节点本身及其所有内容
        $innerHtml .= $doc->saveHTML($childNode);
    }
}

echo "方法三:获取 body 内部的 HTML 字符串:\n";
echo $innerHtml;

// 预期输出:
// 方法三:获取 body 内部的 HTML 字符串:
// <p>这是一个段落</p><ol><li>这是一个列表项</li></ol>还有一些文本节点

说明:

  • 这个方法是用来提取 <body> 内部的 原始 HTML 内容 字符串的,而不是元素对象列表。
  • $bodyElement->childNodes 返回的是一个 DOMNodeList,包含了元素节点、文本节点、注释节点等。这很重要,如果你的原始片段中有纯文本(不在任何标签内),childNodes 会包含它,而 getElementsByTagName('*') 或 XPath * 则不会。
  • $doc->saveHTML($node) 会输出指定节点及其内容的 HTML 字符串。注意,如果 $node 是一个元素节点,输出会包含该元素的标签本身。
  • 这种方法常用于需要将解析和处理后的内容(比如清理过 XSS 的)重新保存为 HTML 片段的场景。

安全建议:

  • 如前所述,对不可信 HTML 进行 loadHTML 前必须净化。
  • saveHTML() 输出的内容理论上应该是安全的(如果输入已净化),但要留意它是否按预期工作,特别是在处理特殊字符或编码时。loadHTML 时指定 UTF-8 编码有助于减少问题。

选择哪种方法取决于你的具体需求:

  • 如果你需要以对象形式操作 <body> 内的每个元素(比如修改属性、内容),方法一 (DOM 操作)方法二 (XPath) 是正选。XPath 通常更简洁、更强大。
  • 如果你只是想把 <body> 里的内容原封不动(或处理后)地再拿出来作为 HTML 字符串,方法三 (获取内容字符串) 更合适。