DOMDocument::loadHTML() 遇 C0 控制字符(\x00–\x08、\x0B、\x0C、\x0E–\x1F)会直接崩溃,因 libxml 零容忍且不修复;必须在调用前用正则清洗,strip_tags() 和 htmlspecialchars() 无效。

DOMDocument::loadHTML() 遇到 \x00–\x1F 会直接崩溃,不是警告
PHP 的 DOMDocument::loadHTML() 在底层调用 libxml,而 libxml 对 C0 控制字符(\x00–\x08、\x0B、\x0C、\x0E–\x1F)零容忍:不跳过、不忽略、不修复,直接抛 DOMDocument::loadHTML(): Unexpected end tag 或更底层的 XML_ERR_INVALID_CHAR。尤其 \x00 在 PHP 字符串中会截断后续所有内容,导致 HTML 被“砍掉一半”后解析失败。
常见触发场景包括:从 MySQL TEXT 字段读取未清理的日志、用户粘贴 Word 文档生成的 HTML、爬虫抓取含乱码响应头的页面。
- 必须在调用
loadHTML()前清洗,不能依赖libxml_use_internal_errors(true)挽救——它只捕获错误,不阻止解析中断 -
strip_tags()和htmlspecialchars()完全无效,因为它们运行在字符串层面,而控制字符会让 DOM 构建根本无法开始 - 推荐清洗正则:
preg_replace('/[\x00-\x08\x0B\x0C\x0E-\x1F]/u', '', $html)(保留\x09、\x0A、\x0D)
lxml.etree.parse() 报 UnicodeDecodeError 很可能不是编码问题,而是控制字符干扰 UTF-8 边界
Python 的 lxml.etree.parse() 或 fromstring() 报 UnicodeDecodeError: 'utf-8' codec can't decode byte 0xXX,常被误判为编码声明错误。实际更可能是源 HTML 中混入了 \x00–\x1F 等非法字节,破坏了 UTF-8 多字节序列的完整性(例如把 \xe2\x8c\x9a 中间插入 \x01,导致解码器无法识别完整码点)。
这种错误在用 requests.get().text 获取响应后直接喂给 lxml 时高发,因为 .text 自动解码可能掩盖原始字节污染。
立即学习“前端免费学习笔记(深入)”;
- 安全做法:用
response.content(bytes)先做清洗,再 decode;或直接对 bytes 运行re.sub(b'[\x00-\x08\x0B\x0C\x0E-\x1F]', b'', raw_bytes) - 不要用
errors='ignore'或'replace'强行 decode——这会让 lxml 后续解析出错更隐蔽 - 若源头是数据库,清洗前加一层
mb_convert_encoding($html, 'HTML-ENTITIES', 'UTF-8')(PHP)或html.unescape()(Python)可一并处理部分非法实体残留
&xyz; 这类非法实体浏览器和 DOMParser 都不报错,但会原样留在 textContent 里
HTML 解析器(包括浏览器、DOMParser().parseFromString()、html5-php、BeautifulSoup)对 &xyz;、yz;、GG; 等非法实体一律静默处理:不替换、不报错、不警告,原样保留在 DOM 节点的 textContent 中。这意味着你用 el.textContent 提取纯文本时,会拿到带裸 & 的字符串,而非预期的干净内容。
这和控制字符不同——它不会让解析失败,但会导致数据提取逻辑失效(比如你假设 textContent 是“已解码文本”,结果发现里面还有未解码的 < 或非法 &xyz;)。
- 检测非法实体只能靠正则主动扫描:
/&(?!(amp|lt|gt|quot|apos|#\d+|#x[0-9a-fA-F]+);)/g -
html.unescape()在 Python 中遇到非法实体会抛ValueError,可用 try/except 捕获;he.decode()(he 库)更鲁棒,对&xyz;返回原串不报错 - 注意嵌套:< 实际解出来是
,不是 <code><;但会解成 <code><,再解一次才得——只解一层是标准行为
data-* 是唯一能跨解析器链路存活的自定义属性,其他名字全看下游环节脸色
写 <div my-id="123"></div>,浏览器 DOM 里 getAttribute('my-id') 能取到,但 dataset.myId 是 undefined;CSS div[my-id] 不匹配;HTML Purifier 默认删;Vue 3 SSR 可能丢;jsdom 保留但不进 el.myId 属性。而 <div data-my-id="123"></div> 在所有环节都稳如老狗——这不是因为 data-* “更安全”,而是 W3C 规范、浏览器实现、主流净化库、SSR 框架、服务端解析器(lxml、html5lib)全部显式支持它作为合法私有通道。
想绕过网关或净化层?别碰 x-、ng-、v- 这类前缀,它们没标准背书,每个下游环节都可能单独打补丁删掉。
- HTML Purifier 必须显式配置
$config->set('HTML.AllowedAttributes', 'data-*');才放行,否则默认白名单里只有标准属性 - DOMPurify 默认允许
data-,但禁用my-id;要放开需手动加ADD_ATTR: ['my-id']并指定类型(如'CDATA') - 服务端用
lxml.html.fromstring(html).xpath('//div')[0].attrib能看到my-id,但若该 HTML 后续被前端 DOMPurify 再处理,就又没了
真正容易被忽略的是:非法字符是否造成问题,不取决于你写的代码有多严谨,而取决于紧贴你输出下游的那个解析环节——它可能是一个 Nginx if 规则、一个 HTML Purifier 配置、一个 Vue 模板编译器,或者只是浏览器地址栏里的一次粘贴。没有统一过滤,只有层层设防。



















