Cheerio 只改 color,style 中的 SVG data URI 却被改坏
Cheerio 1.2.0 按分号拆内联样式,字符串或 url() 里的分号、冒号可能被误当声明边界。先核完整属性,再用 CSS 解析器修改指定声明。
用 Cheerio 给元素加一条 color,保存后原本的背景图片反而不显示了。如果 style 中有 SVG data URI,或带分号、冒号的引号字符串,先比较修改前后的完整 style,而不是只检查新增的 color。
Cheerio 1.2.0 的 .css() 用分号切开声明,再用第一个冒号划分属性和值。引号或 url() 内部的这些字符没有被当作完整 CSS 值保留。写入任意属性时,旧样式也会被重新序列化,原本没有修改的部分因而可能一起损坏。
一个不涉及图片编码的输入也能说明这条边界:合法声明 content: 'a;b:c' 中,a 后的分号属于字符串;简单切分却会把后面的 b 当作新属性。SVG data URI 中的分号与命名空间地址里的冒号也可能落入同一路径。只有分号、后半段没有冒号时,现有拼接逻辑可能表现不同,不能把一种 base64 地址正常当成所有 data URI 都安全。
先分清 HTML 属性与 CSS 的两层语法
把带引号的 CSS 拼进 HTML 字符串,还可能先破坏 HTML 属性边界。例如外层用了单引号,SVG 文本中也有未转义的单引号,交给 CSS 处理前属性就已经截断。
排查时应先用 .attr('style') 读取完整原值,确认 HTML 解析没有丢失内容。构造受控输入时,可以先加载空元素,再通过 attr 设置完整字符串,避免两层引号相互干扰。这里讨论的是完整属性进入 .css() 后的拆分,不是靠修 CSS 恢复已经损坏的 HTML。
需要保留复杂值时,用 CSS 解析器修改声明
已有 PostCSS 的处理流程可以直接解析原始属性,修改指定声明,再用 attr 写回,避开 Cheerio 的 .css() 重解析。下面使用 Cheerio 1.2.0 与 PostCSS 8.5.31 的公开接口,只演示把 color 改为 red;已有项目应纳入自己的依赖管理,不改 node_modules 内部文件:
import { load } from 'cheerio';
import postcss from 'postcss';
const $ = load('<div></div>');
const element = $('div');
element.attr('style', "content: 'a;b:c'; color: blue");
const styles = postcss.parse(element.attr('style') ?? '');
if (styles.nodes.some(node => !['decl', 'comment'].includes(node.type))) {
throw new Error('预期为内联 CSS 声明列表');
}
styles.walkDecls(decl => {
if (decl.prop.toLowerCase() === 'color') decl.remove();
});
styles.append({ prop: 'color', value: 'red' });
element.attr('style', styles.toString());
这里删除已有 color 后追加新值,会替换该属性原有的重复声明及 important 规则;只有这正是应用预期时才这样处理。若要保留优先级或只改特定声明,应明确选择对应节点。其他声明的字符串内容则交给 CSS 解析器保存,不自己按分号切。
解析失败时保留原属性并报告错误,不要先写回一半结果。对不可信 HTML、CSS 的安全过滤仍是独立步骤,能解析并不代表可以直接公开输出,也不会替你清理危险 URL。
最后比较的不只是新增 color。引号中的分号和冒号、data URI 对应的原始内容、其他重复属性,以及浏览器中的实际图片显示都应保留预期。若原属性已经被旧流程改坏,应从可信原件恢复后再处理;在损坏文本上重新解析,无法推回原来的图片字节或字符串含义。


