PHP DOMDocument 获取 body 内所有元素 (非 body 本身)
2025-03-25 22:44:35
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
看到输出里的 html
和 body
了吗?它们并不是我们原始字符串 $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('*')
的行为会更接近你想要的,它可能不会再显式输出 html
和 body
,但这取决于具体的 PHP 版本和 libxml2 库版本。
更可靠的做法不是依赖于 loadHTML
参数完全改变 DOM 结构,而是接受它可能生成 <body>
(即使有时不可见),然后精确地只选取 <body>
内部的元素。
解决办法来了
知道了原因,解决起来就思路清晰了。目标是:迭代处理原始 HTML 片段中的所有标签,忽略掉 DOMDocument
可能自动添加的 html
和 <body>
。
方法一:先抓 body,再找孩子 (DOM 操作)
最直观的想法:既然内容都在 <body>
里(不管是自动加的还是原本就有的),那我先定位到 <body>
元素,然后只查找它内部的所有后代元素就行了。
原理:
- 使用
getElementsByTagName('body')
获取文档中所有的<body>
元素。正常情况下,一个 HTML 文档只有一个<body>
,所以这个方法返回的DOMNodeList
通常只包含一个元素。 - 通过
item(0)
从DOMNodeList
中取出第一个(也是唯一一个)<body>
元素(DOMElement
对象)。 - 在该
<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 表达式来查询节点。
原理:
- 创建一个
DOMXPath
对象,关联到我们的$doc
文档对象。 - 使用 XPath 表达式
//body/*
来查询。//
表示在整个文档中搜索,不受层级限制。body
指定查找<body>
元素。/
表示层级关系,选取子元素。*
是通配符,表示匹配任何名称的元素。- 整个表达式
//body/*
的意思是:找到文档中所有<body>
元素下的所有直接子元素。如果要包含所有后代元素(孙子、曾孙子等),应该用//body//*
。不过,对于你的原始问题,//body/*
可能更符合直觉,只拿第一层;但如果想模拟getElementsByTagName('*')
的行为(获取所有后代),//body//*
更合适。这里我们用//body//*
来获取所有后代。
$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 字符串。
原理:
- 通过方法一或方法二找到
<body>
元素 ($bodyElement
)。 - 遍历
$bodyElement
的 直接子节点 (childNodes
属性,注意不是children
,childNodes
包含文本节点和注释节点)。 - 对每个子节点,调用
$doc->saveHTML($childNode)
将其转换回 HTML 字符串。 - 拼接所有子节点的 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 字符串,方法三 (获取内容字符串) 更合适。