
做本地生活数据采集的兄弟,大概率都被大众点评的字体加密折磨过。明明抓到了HTML,解析出来的却是乱码;换个时间再跑,同样的代码又失效了。这背后不是简单的反爬拦截,而是动态字体映射机制在作祟。
最近我们团队基于开源项目重构了一套采集方案,不仅搞定了最新的字体加密,还实现了全站点数据的稳定获取。今天这篇不讲理论,只分享工程落地中的核心拆解思路和避坑经验,全是线上跑通的实战干货。
一、 前期准备:认清字体加密的本质
动手前必须先搞懂对手怎么出牌的。大众点评的字体加密不是固定映射,而是每次请求都动态生成新的字体文件和映射关系,这也是传统OCR或固定字典方案失效的根本原因。
1. 什么是动态字体加密?
服务端返回的HTML中,关键数字(如评分、价格)被替换成自定义Unicode字符,同时下发一个woff字体文件。这个字体文件里定义了自定义字符到真实数字的映射,且每次访问都会重新生成,映射关系实时变化。
2. 为什么传统方案扛不住?
固定字典方案遇到新字体就废;纯OCR识别速度慢、成本高,还会把小数点、单位识别错;直接请求字体文件解析,又容易被风控拦截拿不到有效字体。
3. 技术选型原则
优先用“字体解析+基准比对”的方案,而非暴力OCR。通过解析woff字体文件的轮廓数据,和预存的数字基准轮廓做相似度匹配,既能保证准确率,又能避开OCR的性能瓶颈。核心是建立稳定的基准库,而非依赖单次识别 。
二、 分步实操:三步搞定动态字体解密
下面以采集店铺评分和人均消费为例,拆解从字体获取到数据还原的完整流程。
1. 字体文件获取与缓存
第一步要稳定拿到当前会话对应的woff字体文件,这是后续解密的基础。
import requests
from fontTools.ttLib import TTFont
def fetch_font(session, html):
# 从html中提取字体文件url
font_url = re.search(r'//s3plus.meituan.net/.*?\\.woff', html).group()
resp = session.get(f"https:{font_url}", timeout=5)
font = TTFont(BytesIO(resp.content))
return font
关键点:必须复用同一个session请求字体文件,否则拿到的字体和HTML中的字符不匹配;字体文件要做本地缓存,相同url的字体无需重复下载;提取url时用正则精准匹配,避免抓到其他无关字体资源。
2. 基准轮廓库构建
这是整个方案的核心,提前准备好0-9和小数点的标准轮廓数据,作为后续比对的参照物。
# 预存数字基准轮廓(简化示例)
BASE_GLYPHS = {
"0": [(100,200), (300,200), ...],
".": [(150,100), (180,100)]
}
def get_glyph_coords(font, code):
glyph = font['glyf'][font.getGlyphName(code)]
return glyph.coordinates if hasattr(glyph, 'coordinates') else []
关键点:基准库要用和目标站点相同字体的标准数字生成,避免轮廓差异导致误判;只保留关键坐标点,过滤掉冗余控制点提升比对速度;基准库要定期更新,防止站点更换基础字体后失效。
3. 轮廓相似度匹配与还原
拿到当前字体的自定义字符轮廓后,和基准库逐一比对,找到最相似的数字完成还原。
from difflib import SequenceMatcher
def match_number(coords):
best_match, max_sim = None, 0
for num, base in BASE_GLYPHS.items():
sim = SequenceMatcher(None, coords, base).ratio()
if sim > max_sim:
max_sim, best_match = sim, num
return best_match if max_sim > 0.85 else "?"
关键点:设置合理的相似度阈值,低于阈值的标记为未知,避免错误还原;对同一字符多次比对取最高相似度,减少偶然误差;还原后的数据要做格式校验,比如评分只能是0-5之间的小数,异常值自动告警。
三、 问题排查:上线后最容易踩的坑
字体解密看似简单,实际落地时有很多隐藏陷阱,以下问题在生产环境高频出现。
1. 字体文件和HTML字符不匹配
用A会话的字体解析B会话的HTML,还原结果全是乱码。
解法:严格绑定session和字体文件,每个采集任务使用独立的session;字体文件缓存key加上session标识,避免跨会话复用;请求字体前先验证HTML中的自定义字符是否存在于当前字体中。
2. 轮廓比对误判导致数据错误
数字6和8、小数点和逗号轮廓相似,容易被混淆。
解法:对易混淆字符增加额外特征判断,比如6的上半部分开口、8的中间闭合;结合上下文校验,比如价格字段不会出现小数点在开头的情况;引入多基准库交叉验证,降低单一基准的误判率。
3. 字体请求被风控拦截
频繁请求字体文件触发限流,拿不到有效字体。
解法:控制字体请求频率,和页面请求保持合理间隔;复用已缓存的字体文件,减少重复请求;使用住宅代理请求字体,避免数据中心IP被标记;字体请求失败时自动降级到备用字体源。
4. 全站点采集时字段缺失
不同页面的字体加密规则不一致,部分字段没有覆盖到。
解法:建立全站点字段映射表,明确每个页面的加密字段和对应字体位置;对未加密字段直接解析,不做多余处理;新增页面时先手动分析字体规则,再纳入自动化流程。
四、 架构总览:字体解密与采集协同流程
为了更直观展示各环节协作关系,下面是实际部署的决策流程图:
#mermaid-svg-bpEd36a4hKk3Ti1w{font-family:\”trebuchet ms\”,verdana,arial,sans-serif;font-size:16px;fill:#333;}@keyframes edge-animation-frame{from{stroke-dashoffset:0;}}@keyframes dash{to{stroke-dashoffset:0;}}#mermaid-svg-bpEd36a4hKk3Ti1w .edge-animation-slow{stroke-dasharray:9,5!important;stroke-dashoffset:900;animation:dash 50s linear infinite;stroke-linecap:round;}#mermaid-svg-bpEd36a4hKk3Ti1w .edge-animation-fast{stroke-dasharray:9,5!important;stroke-dashoffset:900;animation:dash 20s linear infinite;stroke-linecap:round;}#mermaid-svg-bpEd36a4hKk3Ti1w .error-icon{fill:#552222;}#mermaid-svg-bpEd36a4hKk3Ti1w .error-text{fill:#552222;stroke:#552222;}#mermaid-svg-bpEd36a4hKk3Ti1w .edge-thickness-normal{stroke-width:1px;}#mermaid-svg-bpEd36a4hKk3Ti1w .edge-thickness-thick{stroke-width:3.5px;}#mermaid-svg-bpEd36a4hKk3Ti1w .edge-pattern-solid{stroke-dasharray:0;}#mermaid-svg-bpEd36a4hKk3Ti1w .edge-thickness-invisible{stroke-width:0;fill:none;}#mermaid-svg-bpEd36a4hKk3Ti1w .edge-pattern-dashed{stroke-dasharray:3;}#mermaid-svg-bpEd36a4hKk3Ti1w .edge-pattern-dotted{stroke-dasharray:2;}#mermaid-svg-bpEd36a4hKk3Ti1w .marker{fill:#333333;stroke:#333333;}#mermaid-svg-bpEd36a4hKk3Ti1w .marker.cross{stroke:#333333;}#mermaid-svg-bpEd36a4hKk3Ti1w svg{font-family:\”trebuchet ms\”,verdana,arial,sans-serif;font-size:16px;}#mermaid-svg-bpEd36a4hKk3Ti1w p{margin:0;}#mermaid-svg-bpEd36a4hKk3Ti1w .label{font-family:\”trebuchet ms\”,verdana,arial,sans-serif;color:#333;}#mermaid-svg-bpEd36a4hKk3Ti1w .cluster-label text{fill:#333;}#mermaid-svg-bpEd36a4hKk3Ti1w .cluster-label span{color:#333;}#mermaid-svg-bpEd36a4hKk3Ti1w .cluster-label span p{background-color:transparent;}#mermaid-svg-bpEd36a4hKk3Ti1w .label text,#mermaid-svg-bpEd36a4hKk3Ti1w span{fill:#333;color:#333;}#mermaid-svg-bpEd36a4hKk3Ti1w .node rect,#mermaid-svg-bpEd36a4hKk3Ti1w .node circle,#mermaid-svg-bpEd36a4hKk3Ti1w .node ellipse,#mermaid-svg-bpEd36a4hKk3Ti1w .node polygon,#mermaid-svg-bpEd36a4hKk3Ti1w .node path{fill:#ECECFF;stroke:#9370DB;stroke-width:1px;}#mermaid-svg-bpEd36a4hKk3Ti1w .rough-node .label text,#mermaid-svg-bpEd36a4hKk3Ti1w .node .label text,#mermaid-svg-bpEd36a4hKk3Ti1w .image-shape .label,#mermaid-svg-bpEd36a4hKk3Ti1w .icon-shape .label{text-anchor:middle;}#mermaid-svg-bpEd36a4hKk3Ti1w .node .katex path{fill:#000;stroke:#000;stroke-width:1px;}#mermaid-svg-bpEd36a4hKk3Ti1w .rough-node .label,#mermaid-svg-bpEd36a4hKk3Ti1w .node .label,#mermaid-svg-bpEd36a4hKk3Ti1w .image-shape .label,#mermaid-svg-bpEd36a4hKk3Ti1w .icon-shape .label{text-align:center;}#mermaid-svg-bpEd36a4hKk3Ti1w .node.clickable{cursor:pointer;}#mermaid-svg-bpEd36a4hKk3Ti1w .root .anchor path{fill:#333333!important;stroke-width:0;stroke:#333333;}#mermaid-svg-bpEd36a4hKk3Ti1w .arrowheadPath{fill:#333333;}#mermaid-svg-bpEd36a4hKk3Ti1w .edgePath .path{stroke:#333333;stroke-width:2.0px;}#mermaid-svg-bpEd36a4hKk3Ti1w .flowchart-link{stroke:#333333;fill:none;}#mermaid-svg-bpEd36a4hKk3Ti1w .edgeLabel{background-color:rgba(232,232,232, 0.8);text-align:center;}#mermaid-svg-bpEd36a4hKk3Ti1w .edgeLabel p{background-color:rgba(232,232,232, 0.8);}#mermaid-svg-bpEd36a4hKk3Ti1w .edgeLabel rect{opacity:0.5;background-color:rgba(232,232,232, 0.8);fill:rgba(232,232,232, 0.8);}#mermaid-svg-bpEd36a4hKk3Ti1w .labelBkg{background-color:rgba(232, 232, 232, 0.5);}#mermaid-svg-bpEd36a4hKk3Ti1w .cluster rect{fill:#ffffde;stroke:#aaaa33;stroke-width:1px;}#mermaid-svg-bpEd36a4hKk3Ti1w .cluster text{fill:#333;}#mermaid-svg-bpEd36a4hKk3Ti1w .cluster span{color:#333;}#mermaid-svg-bpEd36a4hKk3Ti1w div.mermaidTooltip{position:absolute;text-align:center;max-width:200px;padding:2px;font-family:\”trebuchet ms\”,verdana,arial,sans-serif;font-size:12px;background:hsl(80, 100%, 96.2745098039%);border:1px solid #aaaa33;border-radius:2px;pointer-events:none;z-index:100;}#mermaid-svg-bpEd36a4hKk3Ti1w .flowchartTitleText{text-anchor:middle;font-size:18px;fill:#333;}#mermaid-svg-bpEd36a4hKk3Ti1w rect.text{fill:none;stroke-width:0;}#mermaid-svg-bpEd36a4hKk3Ti1w .icon-shape,#mermaid-svg-bpEd36a4hKk3Ti1w .image-shape{background-color:rgba(232,232,232, 0.8);text-align:center;}#mermaid-svg-bpEd36a4hKk3Ti1w .icon-shape p,#mermaid-svg-bpEd36a4hKk3Ti1w .image-shape p{background-color:rgba(232,232,232, 0.8);padding:2px;}#mermaid-svg-bpEd36a4hKk3Ti1w .icon-shape .label rect,#mermaid-svg-bpEd36a4hKk3Ti1w .image-shape .label rect{opacity:0.5;background-color:rgba(232,232,232, 0.8);fill:rgba(232,232,232, 0.8);}#mermaid-svg-bpEd36a4hKk3Ti1w .label-icon{display:inline-block;height:1em;overflow:visible;vertical-align:-0.125em;}#mermaid-svg-bpEd36a4hKk3Ti1w .node .label-icon path{fill:currentColor;stroke:revert;stroke-width:revert;}#mermaid-svg-bpEd36a4hKk3Ti1w :root{–mermaid-font-family:\”trebuchet ms\”,verdana,arial,sans-serif;}
否
是
是
否
否
是
是
否
发起页面请求
获取HTML成功?
重试/切换代理
提取字体URL
字体已缓存?
加载缓存字体
请求字体文件
请求成功?
降级/告警
解析字体+缓存
提取自定义字符
轮廓比对还原
还原结果有效?
数据入库
标记异常+人工复核
记录日志
核心思想是缓存优先、精准比对、异常兜底。不做无意义的重复请求;每次还原都有校验机制;异常情况有明确的记录和复核流程,避免脏数据入库。
五、 实战总结与合规提醒
这套方案在我们团队运行超4个月,覆盖大众点评全站点核心字段,字体解密准确率达99.2%,采集效率比纯OCR方案提升8倍。
几点务实经验分享:
- 基准库比算法更重要 。花70%精力打磨基准库,比优化比对算法收益更高。定期用真实数据校准基准,脱离实际的算法再精妙也没用。
- 缓存策略决定性能 。字体文件缓存要兼顾时效性和命中率,设置合理的过期时间,既避免用过期的字体,又减少不必要的请求。
- 严守合规底线 。仅采集公开可见的数据,不绕过登录墙、不采集用户隐私信息;控制采集频率,不影响目标站点正常服务;遵守robots.txt协议,尊重平台数据权益。
- 保持方案可维护性 。字体加密规则会迭代,代码要预留扩展接口;记录每次规则变化的应对方案,形成知识库,避免重复踩坑。
最后想说,搞定字体加密的关键不是“破解”,而是“适配”。理解它的动态机制,用工程化的方式稳定适配变化,才能实现长期可靠的采集。
工程师手记 :从手动分析字体到自动化解密,变的是技术手段,不变的是对数据质量的追求和对规则的敬畏。如果你也在被字体加密困扰,欢迎评论区交流具体场景,看到都会给出针对性建议。



