在做数据抓取时,有些网站表面上页面正常显示,但用R读取源码后,原本的数字或汉字会变成私用区字符、空白或方框。这类问题的核心并不在HTTP响应,而在浏览器渲染层。网站通过引入一个自定义字体文件,把原本应该显示为数字3的字形挂到一个不常见的Unicode码位上,源码里保存的是这个码位,页面渲染时才由字体文件把它画成用户能看懂的内容。因此,单纯用rvest提取文本并不能得到真实数据,必须把字体文件中的映射关系还原出来。

一、字体反爬的本质:Unicode码位与字形显示被人为错位
Unicode为每个字符分配码位,但最终显示成什么样子,由字体文件中的字形决定。常规字体里,码位0033对应数字3;而在反爬字体里,开发者可能把数字3的字形放到私用区码位E123上,再把页面上的价格写成该码位。浏览器加载自定义字体后,看到E123就渲染数字3,用户不会察觉;但爬虫拿到的是E123,直接输出就会变成无法识别的字符。
这种方案通常配合CSS的@font-face使用。网站会在样式表中声明一个字体族,并把价格、评分、手机号等关键字段的样式指向该字体族。页面中的<span>或<div>看起来只是普通文本节点,实际上其文本已经被替换成自定义码位。有些站点还会定期更换字体文件,让码位与字形的对应关系不断变化,从而阻止静态映射表长期复用。
需要区分的是,字体反爬并不等同于编码错误。编码错误通常表现为UTF-8、GBK解析不一致,乱码形态比较稳定;字体反爬则表现为字符能解析但语义不对,且换用浏览器查看时内容正常。判断方法也比较直接:查看页面源码或接口返回中的关键字段,如果包含大量私用区字符,且对应元素使用了特殊字体族,基本就可以判定为字体反爬。
二、用R语言定位加密文本并下载自定义字体文件
处理这类问题的第一步是完整获取页面,并找到加密文本所在的节点。可以使用rvest解析HTML,用httr2处理请求。若页面有必要的请求头,应先在请求中设置User-Agent、Referer等头部,避免被拦截。拿到HTML后,先定位包含目标数据的节点,例如价格、评分或销量字段,并观察其文本是否出现不可读字符。
library(rvest)
library(httr2)
library(stringr)
page_url = "https://ipipp.com/data"
resp = req_perform(request(page_url))
html_doc = read_html(resp)
css_text = html_text(html_element(html_doc, "style"))
font_match = str_match(css_text, "url\\('(https?://[^']+\\.woff2?)'\\)")
font_url = font_match[1, 2]
font_raw = resp_body_raw(req_perform(request(font_url)))
writeBin(font_raw, "site_font.woff2")
上面的代码先请求页面,再从内联样式中提取字体地址。实际站点可能把@font-face放在外部CSS文件中,这时需要先获取CSS响应,再用同样的正则匹配字体链接。若字体地址是相对路径,需要根据页面URL拼接完整地址。若网站使用woff2格式,R本身没有特别成熟的解析方案,但可以先把文件保存到本地,再交给专门的字体解析工具处理。
下载字体后,建议同时保存当前页面的原始HTML、目标节点文本和字体文件的哈希值。因为字体反爬常常按会话或按页面生成不同映射,只有把三者绑定起来,后续还原结果才能对应到同一次抓取任务。若页面返回的字体文件频繁变化,还需要在每次请求详情页时重新下载字体,而不能假设一个字体文件可以全站通用。
三、解析字体映射表:从cmap到真实字符
字体文件中最重要的表之一是cmap表,它记录Unicode码位到字形名称的对应关系。拿到cmap后,我们可以知道源码中的某个私用区码位对应字体里的哪一个字形。不过,cmap本身只能告诉我们码位对应哪个字形名称,并不能直接告诉我们这个字形是数字几。要得到真实字符,还需要建立字形名称到真实字符的第二层映射。
在R生态中,直接解析woff2和cmap的包并不算丰富,比较稳妥的方式是通过reticulate调用Python的fontTools。fontTools可以读取ttf、otf、woff等格式;如果处理woff2,通常还需要安装brotli相关依赖。下面的代码演示如何导出码位到字形名称的映射,并保存为JSON文件,方便R继续处理。
library(reticulate)
py_code = '
from fontTools.ttLib import TTFont
import json
def export_cmap(path, out):
font = TTFont(path)
cmap = font.getBestCmap()
data = {}
for code, name in cmap.items():
data[format(code, "04X")] = name
with open(out, "w", encoding="utf-8") as f:
json.dump(data, f, ensure_ascii=False)
'
py_run_string(py_code)
export_cmap = py$export_cmap
export_cmap("site_font.woff2", "cmap.json")
得到cmap.json后,R可以读取这个映射表。假设我们已经知道某些字形名称对应的真实数字,就可以构造一个映射向量。实际项目中,这个映射可以通过人工标注获得:用浏览器打开页面,对照显示出来的数字,再查看源码中的私用区字符;也可以用字体查看工具打开字体文件,逐个确认字形。若数据量较大,也可以把字形渲染成图片,再交给OCR识别。
library(jsonlite)
cmap = fromJSON("cmap.json")
glyph_truth = c(
"uniE123" = "3",
"uniE1F2" = "8",
"uniE2A7" = "5"
)
decode_font_text = function(x, cmap_table, truth_table) {
chars = utf8ToInt(x)
hex_code = sprintf("%04X", chars)
glyph_name = unname(cmap_table[hex_code])
real_char = unname(truth_table[glyph_name])
missing = is.na(real_char)
if (any(missing)) {
real_char[missing] = intToUtf8(chars[missing], multiple = TRUE)
}
paste(real_char, collapse = "")
}
raw_text = html_text(html_element(html_doc, ".price"))
clean_text = decode_font_text(raw_text, cmap, glyph_truth)
这段代码的关键是把原始文本拆成Unicode码位,再用cmap找到字形名称,最后用人工标注或OCR得到的真实字符替换。对于没有命中映射的字符,函数会保留原字符,避免把普通中文、标点或数字也误替换。若目标字段只包含数字,也可以更严格地检查映射覆盖率,一旦覆盖率明显偏低,就说明当前字体文件已经变化,需要重新生成映射。
四、映射关系不稳定时的工程化处理方案
很多网站的字体反爬并不是固定不变的。有的每次刷新都会生成新的私用区码位,有的会交换字形顺序,有的甚至会微调字形轮廓。面对这种情况,只靠一次人工标注很难长期稳定运行,需要把映射生成过程自动化。
- 已知样本对照法:如果页面上存在部分明文数据,例如总数、分页、评论数等,可以把明文与加密文本对照,反推出部分映射关系。这种方法适合映射较少、字段规律明显的场景。
- 字形轮廓比对法:将当前字体中的字形轮廓与一份基准字体中的已知字形进行比较,通过轮廓点数、边界框、路径哈希等特征判断当前字形对应哪个真实字符。这种方法对码位变化不敏感,但需要处理字体微调带来的差异。
- 渲染加识别法:把自定义字体按指定文本渲染成图片,再使用OCR识别。这种方式实现成本较高,但对复杂汉字或图标化数字比较有效,适合无法直接获得基准字形的情况。
从工程角度看,最稳定的做法是把字体下载、cmap解析、字形识别和文本还原拆成独立步骤。每次抓取页面时,先计算字体文件的哈希;如果哈希与上次相同,就复用已有映射;如果哈希变化,就触发一次映射重建。这样可以避免每个页面都重复解析字体,也能在映射失效时及时发现问题。
| 方案 | 适用场景 | 优点 | 局限 |
|---|---|---|---|
| 人工标注 | 字段少且映射稳定 | 实现简单,准确率高 | 字体变化后需要重新标注 |
| 样本对照 | 页面有明文字段 | 可自动推导部分映射 | 依赖样本数量和质量 |
| 轮廓比对 | 数字或简单汉字反爬 | 对码位变化有较好适应性 | 需要基准字体和特征工程 |
| 渲染识别 | 字形复杂或缺少基准 | 更接近人眼所见 | 速度和识别准确率需要优化 |
五、常见坑点与合规建议
第一个坑是把字体反爬误判为编码问题。看到乱码就反复转换UTF-8和GBK,通常不会解决问题。更好的做法是先检查目标节点的CSS字体族,再看源码字符是否落在私用区。第二个坑是只解析字体名称而不解析具体字体文件。有些网站会声明多个字体族,或者在不同页面使用不同字体,必须根据当前页面实际引用的字体文件建立映射。
第三个坑是忽略woff2的解压和解析依赖。woff2本质上经过压缩,部分工具不能直接读取,需要安装相应支持。第四个坑是把映射表写死在代码里。一旦网站更新字体,爬虫会批量产出错误数据,甚至把错误数字写入数据库。因此,抓取流程中应加入映射覆盖率检查、抽样人工校验和异常告警。
最后,字体反爬属于网站的技术保护措施,解析时应遵守目标站点的服务条款和robots协议,控制请求频率,避免对服务造成压力。对于涉及个人隐私、付费内容或明确禁止抓取的数据,不应绕过保护机制。技术方案的边界应当建立在合法合规、尊重数据所有权和不对服务造成负担的前提之上。