字体反爬与CSS偏移

网络爬虫专题 · 掌握字体反爬破解技术

专题:Python网络爬虫系统学习

关键词:Python, 网络爬虫, 字体反爬, fontTools, TTF, CSS偏移, SVG反爬, 动态字体, 反爬破解

一、字体反爬概述

字体反爬(Font Anti-crawling)是当前互联网行业中应用最广泛的反爬虫技术之一。其核心原理是网站使用自定义字体文件(TTF/WOFF)替换标准字体中的字符映射关系,使得用户在浏览器中看到的文字与实际HTML源代码中的文字编码不一致。爬虫直接获取到的文本内容显示为乱码,而浏览器通过加载自定义字体文件将其渲染为正常文字。

这种技术广泛应用于各大主流网站,包括58同城、猫眼电影、大众点评、天眼查、企查查等。这些网站使用字体反爬技术来保护其核心数据,如电话号码、价格信息、企业数据等。对于爬虫开发者而言,理解并破解字体反爬是进阶爬虫技术的必备技能。

核心特征:页面显示正常,但爬取到的HTML源码中包含乱码字符。同一个字符在不同请求中可能对应不同的乱码编码,增加了破解难度。

字体反爬的破解思路通常分为三步:第一,下载网站自定义的字体文件;第二,分析字体文件中字符编码与真实文字的映射关系;第三,编写代码自动将乱码内容替换为正常文字。根据字体文件是否动态变化,还可进一步分为静态字体反爬和动态字体反爬两种类型。

二、字体文件基础

要破解字体反爬,首先要理解字体文件的基本结构。常见的字体格式包括TTF(TrueType Font)和WOFF(Web Open Font Format)。WOFF本质上是TTF的压缩版本,包含相同的字体数据但体积更小,更适合网络传输。

字体文件的核心是字形(Glyph)与编码(Encoding)之间的映射关系。在标准字体中,Unicode编码与字形之间存在固定的对应关系,例如U+0041对应字母"A"。在自定义字体中,网站会重新映射这种关系,使某个Unicode编码对应完全不同的字形,从而制造出"显示正常、爬取乱码"的效果。

一个TTF字体文件主要由以下表格组成:cmap表(字符到字形的映射)、glyf表(字形数据)、head表(字体头部信息)、hhea表(水平排版信息)等。其中cmap表定义了从Unicode编码到字形索引的映射,glyf表则存储了每个字形的实际轮廓数据。反爬破解主要关注的就是cmap表的映射关系。

关键知识点:自定义字体中的Unicode编码通常指向一个完全不同的字形。例如U+E023可能显示为"1",而U+E045可能显示为"8"。爬虫需要重建这个映射字典才能正确识别文字。

三、Python字体解析

fontTools是Python生态中最强大的字体解析库,由Google开发维护。使用fontTools可以读取字体文件的cmap表、glyf表等核心数据结构,从而重建字符映射关系。

首先安装fontTools库:

pip install fonttools

基础用法示例——读取字体文件并获取字形信息:

from fontTools.ttLib import TTFont # 加载字体文件 font = TTFont('custom_font.woff') # 获取cmap映射表 cmap = font.getBestCmap() print(cmap) # 输出示例:{61475: 'one', 61476: 'two', ...} # 获取字形顺序 glyph_order = font.getGlyphOrder() print(glyph_order[:10]) # 获取字形坐标信息 glyf = font['glyf'] for glyph_name in glyph_order[:5]: if glyph_name in glyf: glyph = glyf[glyph_name] if hasattr(glyph, 'coordinates'): print(f"{glyph_name}: {glyph.coordinates}")

通过cmap表,我们可以获得从Unicode编码(表现为乱码字符的编码)到字形名称(如"one"、"two")的映射。字形名称通常是有意义的英文单词,直接指明了该字形代表的真实字符。通过建立以下映射链,就能实现乱码还原:

对于字形名称不明确(如"uniE023"这种格式)的字体文件,我们需要进一步分析字形坐标。每个字形由一系列轮廓点(坐标)组成,通过比较不同字形的坐标特征(如笔画数量、轮廓形状),可以判断其对应的真实字符。

def analyze_glyph_coordinates(font, glyph_name): """分析字形坐标,辅助识别字符""" glyf = font['glyf'] glyph = glyf[glyph_name] if not hasattr(glyph, 'coordinates'): return None coords = list(glyph.coordinates) # 计算边界框 x_coords = [p[0] for p in coords] y_coords = [p[1] for p in coords] bbox = { 'x_min': min(x_coords), 'x_max': max(x_coords), 'y_min': min(y_coords), 'y_max': max(y_coords), 'width': max(x_coords) - min(x_coords), 'height': max(y_coords) - min(y_coords), 'num_points': len(coords) } return bbox

四、动态字体反爬

静态字体反爬的破解相对简单,只需要分析一次字体映射关系即可永久使用。然而,大多数商业网站采用更高级的动态字体反爬技术,每次页面请求都会生成新的字体文件,字符映射关系随之改变。

动态字体反爬的实现方式主要有两种:一是服务器端在每次请求时随机生成字体文件,确保每次的映射关系都不同;二是预先生成多个字体文件,每次请求随机返回其中一个。无论哪种方式,爬虫都无法通过预先分析的映射字典来破解。

破解方案:动态字体反爬的核心解决方案是"实时下载、实时分析、实时替换"。在每次请求页面时,同步下载该页面引用的字体文件,并立即建立映射关系,在同一个请求周期内完成乱码还原。

以下是动态字体反爬的通用破解方案代码框架:

import requests from fontTools.ttLib import TTFont from io import BytesIO class DynamicFontCracker: """动态字体反爬破解器""" def __init__(self): self.font_url_template = "https://example.com/font/{}.woff" def download_font(self, font_url): """下载最新的字体文件""" resp = requests.get(font_url, headers=headers) return BytesIO(resp.content) def build_mapping(self, font_data): """建立当前字体文件的映射关系""" font = TTFont(font_data) cmap = font.getBestCmap() glyf = font['glyf'] # 标准映射字典:字形名称 -> 真实字符 std_map = { 'one': '1', 'two': '2', 'three': '3', 'four': '4', 'five': '5', 'six': '6', 'seven': '7', 'eight': '8', 'nine': '9', 'zero': '0', 'period': '.' } # Unicode编码 -> 真实字符 mapping = {} for unicode_code, glyph_name in cmap.items(): if glyph_name in std_map: mapping[chr(unicode_code)] = std_map[glyph_name] return mapping def decode(self, html_text, font_url): """自动解码页面中的乱码文字""" font_data = self.download_font(font_url) mapping = self.build_mapping(font_data) result = [] for char in html_text: result.append(mapping.get(char, char)) return ''.join(result)

在实际工程中,还需要考虑字体缓存的优化策略。对于短时间内的多次请求,字体文件可能不会频繁变化,可以设置合理的缓存过期时间(如5-10分钟),避免重复下载和分析字体文件,提高爬取效率。

五、CSS偏移反爬

CSS偏移反爬是另一种常见的前端反爬技术。其核心原理是在页面中同时包含正常文字和"干扰文字",通过CSS样式将干扰文字进行偏移或隐藏,使得用户在浏览器中只看到正常文字,而爬虫直接获取HTML时则拿到混合了干扰文字的内容。

典型实现:HTML中包含两个文字序列——正常序列和打乱序列。正常序列中的某些字符被随机替换为干扰字符,然后通过CSS的position/transform属性将干扰字符移出可视区域,或者用负text-indent将干扰字符隐藏。爬虫直接提取文本时,这些干扰字符仍然存在,导致提取结果错误。

常见的CSS偏移实现方式包括:

CSS偏移的破解方法主要分为两类:一是通过解析CSSOM(CSS Object Model)来识别哪些class对应可见元素;二是通过分析页面中字符的排列规律,结合视觉渲染结果进行还原。

from selenium import webdriver from selenium.webdriver.common.by import By def crack_css_offset(url): """通过Selenium破解CSS偏移反爬""" driver = webdriver.Chrome() driver.get(url) # 获取所有字符元素及其样式 elements = driver.find_elements(By.CSS_SELECTOR, '[class*="char"]') visible_text = [] for elem in elements: # 检查元素是否可见 if elem.is_displayed(): # 获取元素的实际位置和大小 rect = elem.rect if rect['x'] >= 0 and rect['y'] >= 0: visible_text.append(elem.text) driver.quit() return ''.join(visible_text)

需要注意的是,CSS偏移反爬往往与字体反爬结合使用,形成多层防护。在实际爬取过程中,需要同时处理字体映射和CSS偏移两个层面的反爬机制。

六、SVG反爬

SVG反爬是另一种基于前端技术的反爬手段。网站将核心文字数据渲染为SVG(可缩放矢量图形)中的路径或text元素,通过坐标定位将文字展示在指定位置。爬虫直接提取HTML时无法获取到正确的文字内容,因为文字信息被编码在SVG的坐标和路径数据中。

SVG反爬的常见实现方式:

SVG反爬的破解思路是通过解析SVG的DOM结构,提取text元素的坐标和内容,然后根据坐标信息重新排序,还原出正确的文字序列。

from lxml import etree import re def parse_svg_anti_crawl(svg_html): """解析SVG反爬,提取真实文字""" tree = etree.HTML(svg_html) # 提取所有text元素 texts = [] for text in tree.xpath('//text'): content = text.text or '' x = text.get('x', '0') y = text.get('y', '0') # 有些SVG将文字拆分到多个tspan中 tspans = text.xpath('.//tspan') if tspans: span_texts = [] for tspan in tspans: span_texts.append(tspan.text or '') content = ''.join(span_texts) texts.append({ 'content': content, 'x': float(x.split()[0]) if x else 0, 'y': float(y.split()[0]) if y else 0 }) # 按坐标排序还原文字顺序 # 通常先按y轴(行),再按x轴(列)排序 texts.sort(key=lambda t: (t['y'], t['x'])) return ''.join(t['content'] for t in texts)

七、字体反爬实战

将前面介绍的知识综合起来,我们可以构建一个完整的字体反爬破解方案。以下是一个实战级破解代码,涵盖了从字体下载、映射分析到文字替换的全流程。

import re import requests from fontTools.ttLib import TTFont from io import BytesIO class FontAntiCrawlingCracker: """完整的字体反爬破解方案""" # 标准字形名称到真实字符的映射 GLYPH_NAME_MAP = { 'zero': '0', 'one': '1', 'two': '2', 'three': '3', 'four': '4', 'five': '5', 'six': '6', 'seven': '7', 'eight': '8', 'nine': '9', 'period': '.', 'dot': '.', 'comma': ',', 'colon': ':', 'semicolon': ';', 'dollar': '$', 'percent': '%', 'asterisk': '*', 'plus': '+', 'minus': '-', 'equal': '=', } def __init__(self, session=None): self.session = session or requests.Session() self.font_cache = {} def extract_font_urls(self, html): """从HTML中提取字体文件URL""" # 匹配 @font-face 中的 src url pattern = r"src:\s*url\(['\"]?(.*?\.(?:woff|ttf|woff2))['\"]?\)" return re.findall(pattern, html, re.IGNORECASE) def download_and_parse_font(self, font_url): """下载并解析字体文件""" if font_url in self.font_cache: return self.font_cache[font_url] resp = self.session.get(font_url) font_data = BytesIO(resp.content) font = TTFont(font_data) cmap = font.getBestCmap() mapping = {} for unicode_code, glyph_name in cmap.items(): # 尝试从字形名称直接映射 if glyph_name in self.GLYPH_NAME_MAP: mapping[chr(unicode_code)] = self.GLYPH_NAME_MAP[glyph_name] else: # 名称可能包含数字,如 glyph123 match = re.search(r'(\d+)', glyph_name) if match: mapping[chr(unicode_code)] = match.group(1) self.font_cache[font_url] = mapping font.close() return mapping def decode_text(self, text, mapping): """使用映射字典解码乱码文字""" result = [] for char in text: if char in mapping: result.append(mapping[char]) else: result.append(char) return ''.join(result) def crack(self, url): """主入口:对目标页面进行字体反爬破解""" resp = self.session.get(url) html = resp.text font_urls = self.extract_font_urls(html) for font_url in font_urls: # 处理相对URL if font_url.startswith('//'): font_url = 'https:' + font_url elif font_url.startswith('/'): from urllib.parse import urlparse parsed = urlparse(url) font_url = f"{parsed.scheme}://{parsed.netloc}{font_url}" mapping = self.download_and_parse_font(font_url) html = self.decode_text(html, mapping) return html

在实际部署时,还需要考虑以下几个优化点:第一,字体缓存策略——对于短时间内大量请求同一网站的场景,合理利用字体缓存可以显著提升效率;第二,异常处理——字体文件可能下载失败或格式异常,需要有完善的错误处理机制;第三,多字体支持——一个页面可能引用多个字体文件,需要逐一处理。

实战经验总结:字体反爬的难度主要体现在动态字体的实时映射和多种反爬技术的组合使用。建立完善的字体缓存机制、灵活的映射策略以及良好的错误处理,是构建生产级反爬系统的关键。同时,务必遵守网站的robots协议和相关法律法规,仅在合法范围内使用爬虫技术。