ARTICLE DETAIL

资讯详情

深耕网站建设、视觉设计与SEO优化的一线实战洞察。

实战破解字体反爬:从原理到Python代码实现

实战破解字体反爬:从原理到Python代码实现 1. 项目概述当小说网站的文字变成“天书”最近在分析一个小说网站的数据时我遇到了一个挺有意思的反爬虫手段页面显示正常但复制下来的文字全是乱码或者用常规的解析库比如Python的requestsBeautifulSoup抓取到的HTML里中文部分全是“口口口”或者一些莫名其妙的符号。这其实就是典型的“字体反爬”。字体反爬的原理并不复杂。网站不再使用操作系统或浏览器预装的通用字体如宋体、微软雅黑来显示文字而是使用一套自己定制或动态生成的字体文件通常是.woff或.woff2格式。在这套自定义字体中每个字形glyph对应的Unicode码点code point被重新映射了。比如在标准字体里Unicode码点U4E2D对应汉字“中”。但在网站的定制字体里U4E2D这个码点可能被映射成了一个“国”字的形状。当浏览器加载了这套字体后就能正确显示“国”字但源代码里记录的依然是#x4e2d;“中”的HTML实体。如果你没有加载这套字体或者用程序直接读取HTML代码得到的就是“中”这个字和页面上显示的就对不上了。这个项目就是专门破解这种反爬机制的实战记录。它适合所有需要从采用此类技术的小说、文学、资讯类网站获取结构化数据的开发者、数据分析师或爬虫爱好者。无论你是前端出身想了解反爬原理还是后端想搞定数据采集这里面的思路和工具都能给你直接的参考。接下来我会从原理分析、动态字体捕获、字体文件解析、映射关系重建到最终数据还原一步步拆解整个过程并分享我踩过的坑和总结的技巧。2. 核心原理与前端实现深度拆解要破解它必须先彻底理解它是如何工作的。这不仅仅是一个后端问题更是一个涉及前端字体渲染、CSS和JavaScript的综合性技术点。2.1 字体反爬的技术本质字形与码点的“错位”标准字体文件中字形和Unicode码点的映射是遵循统一标准的这保证了“所见即所得”。字体反爬破坏的正是这种一致性。其技术核心可以概括为两步创建自定义字体网站设计者使用字体编辑工具如FontForge创建一个新的字体文件。在这个文件里他们打乱了常用汉字或数字、英文的映射关系。例如将实际形状为“一”的字形关联到码点U4E8C原本是“二”。他们可能会生成多个字体文件在不同时间或对不同内容动态切换以增加破解难度。在网页中动态应用通过CSS的font-face规则将自定义字体文件引入页面。然后通过CSS的font-family属性将特定的样式类class应用到需要反爬的文字内容上。这些文字在HTML中的原始编码仍是标准Unicode但浏览器渲染时会优先使用自定义字体进行绘制从而显示出“正确”但映射关系错乱的内容。2.2 前端如何实现“动态”与“混淆”单纯使用静态自定义字体很容易被找到规律一次性破解。因此实战中的网站往往会增加动态性和混淆。动态字体生成字体文件可能不是静态的而是由后端根据每次请求的会话Session、时间戳甚至文章ID动态生成的。这意味着不同时间、不同页面同一个码点对应的字形可能不同。你这次破解的映射关系下次请求可能就失效了。CSS类名混淆应用自定义字体的CSS类名class可能不是固定的.custom-font而是随机生成的字符串如.x12ab、.y34cd并且这些类名可能会定期变化。这增加了定位反爬元素的难度。JavaScript动态加载字体文件的URL或CSS规则可能由JavaScript在页面加载后动态插入到DOM中。如果你只用简单的HTTP请求获取初始HTML而没有执行JS就根本拿不到字体文件的关键信息。Base64内联字体为了减少请求和增加破解难度字体文件有时会以Base64编码的形式直接内嵌在CSS文件或style标签中而不是一个独立的.woff文件链接。理解这些前端实现手段是我们选择正确破解工具和方法的前提。例如面对动态字体我们需要在单次会话内完成“抓取-解析-映射”的闭环面对JS加载我们需要使用能执行JavaScript的爬虫工具如Selenium、Playwright或Puppeteer。3. 实战工具链选择与环境准备工欲善其事必先利其器。破解字体反爬需要一个从页面抓取、字体提取到字体分析的全套工具链。3.1 爬虫框架选择为什么是Playwright对于带有复杂JavaScript动态加载的现代网页传统的requests库力不从心。我们需要一个能渲染完整页面的“浏览器环境”。Selenium老牌工具功能强大但配置相对繁琐运行速度较慢。PuppeteerGoogle官方出品控制Chrome/Chromium能力极强但主要支持Node.js环境。Playwright微软出品后起之秀。它支持多种浏览器Chromium, Firefox, WebKitAPI设计现代优雅并且默认等待机制更智能能自动等待字体等资源加载完成这对我们抓取完整渲染的页面至关重要。同时它的Python绑定非常成熟。因此本项目我选择Playwright for Python作为爬虫核心。注意有些反爬策略会检测无头浏览器Headless Browser的特征。Playwright可以通过注入一些JS代码来模拟更真实的浏览器环境或者直接使用非无头模式虽然更慢。这是后续可能需要的进阶对抗手段。3.2 字体分析与处理工具抓到字体文件后我们需要解析它建立“错乱码点”到“真实字形”的映射关系。字体解析库fontTools这是Python中处理字体文件的瑞士军刀。它的TTLib模块可以读取.woff,.woff2,.ttf,.otf等格式让我们能访问字体的所有表Table特别是cmap字符映射表和glyf字形数据表。可视化与手动校对在线字体查看器虽然fontTools能编程化处理但在初步探索和验证时一个可视化的工具不可或缺。像FontDrop!或Woff2Info这样的网站可以直接上传字体文件清晰地列出所有码点及其对应的字形图片非常直观。OCR备用方案针对复杂字形极少数情况下网站可能使用非常规的、难以直接通过编码映射的图形符号。这时可以将字形图片截取下来使用OCR库如pytesseract需要安装Tesseract-OCR引擎进行识别。但这会大幅增加复杂度和耗时通常只在最后手段时使用。3.3 环境搭建步骤# 1. 安装Playwright及其浏览器驱动 pip install playwright playwright install chromium # 安装Chromium浏览器足够使用 # 2. 安装字体处理库 pip install fonttools # 3. 可选如果需要备用OCR方案 pip install pytesseract pillow # 同时需要从 https://github.com/tesseract-ocr/tesseract 下载并安装Tesseract-OCR程序4. 完整破解流程与核心环节实现下面我将以一个模拟的实战场景分步拆解整个破解过程。假设目标网站的小说正文使用了动态字体反爬。4.1 第一步捕获完整页面与字体文件目标是获取到应用了自定义字体后的完整HTML以及关键的字体文件。import asyncio from playwright.async_api import async_playwright import re async def fetch_page_and_fonts(url): async with async_playwright() as p: # 启动浏览器使用非无头模式便于调试 browser await p.chromium.launch(headlessFalse) context await browser.new_context() page await context.new_page() # 监听并拦截网络请求中的字体文件 font_resources [] def on_response(response): if response.request.resource_type font: font_url response.url font_resources.append(font_url) print(f捕获到字体文件: {font_url}) # 这里可以同时将字体内容保存下来 # with open(ffont_{len(font_resources)}.woff2, wb) as f: # f.write(response.body()) page.on(response, on_response) # 导航到目标页面等待网络空闲确保字体加载 await page.goto(url, wait_untilnetworkidle) # 额外等待一下确保动态应用字体的JS也执行完毕 await page.wait_for_timeout(2000) # 获取渲染后的页面HTML html_content await page.content() # 查找CSS中内联的Base64字体常见于style标签 base64_fonts [] base64_pattern re.compile(rurl\(data:application/font-woff2;base64,([^)])\)) style_tags await page.query_selector_all(style) for tag in style_tags: style_text await tag.inner_text() matches base64_pattern.findall(style_text) base64_fonts.extend(matches) await browser.close() return html_content, font_resources, base64_fonts # 使用示例 url https://目标小说网站/章节页 html, font_urls, base64_fonts asyncio.run(fetch_page_and_fonts(url)) print(f获取到HTML长度: {len(html)}) print(f捕获到字体链接: {font_urls}) print(f捕获到Base64内联字体数量: {len(base64_fonts)})关键点解析wait_untilnetworkidle这个参数至关重要它让Playwright等待页面网络活动基本停止确保包括字体在内的所有资源都加载完毕。监听response事件这是抓取动态加载字体文件URL的核心方法。资源类型resource_type font能精准过滤出字体请求。正则匹配Base64很多网站为了性能和安全会将字体转为Base64嵌入CSS。我们必须同时从style标签中提取这些信息。4.2 第二步解析字体文件建立映射关系假设我们从一个.woff2文件链接下载到了字体文件custom_font.woff2。from fontTools.ttLib import TTFont import io import base64 def parse_font_from_file(file_path): 从文件路径解析字体 font TTFont(file_path) return _extract_cmap(font) def parse_font_from_base64(base64_str): 从Base64字符串解析字体 font_data base64.b64decode(base64_str) font TTFont(io.BytesIO(font_data)) # 使用BytesIO包装二进制数据 return _extract_cmap(font) def _extract_cmap(font): 核心函数提取字体中的码点到字形名称的映射。 注意这里拿到的是错乱码点 - 字形名我们还需要知道字形名对应的实际形状。 cmap font.getBestCmap() # 获取最佳的字符映射表 # cmap 格式: {十进制码点: 字形名称, ...} print(f字体中包含 {len(cmap)} 个字符映射) # 获取字形名称到字形轮廓的映射用于后续可视化或OCR glyph_set font.getGlyphSet() mapping {} for code, glyph_name in cmap.items(): # 将十进制码点转为十六进制Unicode字符串便于和HTML实体对比 unicode_hex hex(code).upper().replace(0X, #x) ; mapping[unicode_hex] { glyph_name: glyph_name, # 可以在这里保存字形轮廓信息但通常我们更关心它对应的真实字符 } return font, mapping # 使用示例假设我们有一个Base64字体 base64_data base64_fonts[0] # 从第一步获取 font_obj, code_to_glyph_map parse_font_from_base64(base64_data) print(前5个映射关系示例) for i, (unicode_hex, info) in enumerate(list(code_to_glyph_map.items())[:5]): print(f HTML实体 {unicode_hex} - 字体中的字形名 {info[glyph_name]})现在我们有了code_to_glyph_map它告诉我们在HTML中像一这样的实体对应字体文件里名叫uniE001的字形。但这个字形画出来到底是什么字我们需要一个“真实字符参考表”。4.3 第三步构建真实字符的映射参考这是整个破解过程的“密钥”。我们需要知道字体文件中每个字形glyph_name对应的真实汉字是什么。有两种主流方法方法一从页面可见文本中提取推荐自动化程度高原理页面总有一些未被字体混淆的“明文”区域如标题、作者、发布时间或者我们可以通过对比字体应用前后的差异来推断。更直接的方法是利用浏览器环境获取渲染后的文本。async def extract_visible_text_and_codes(page, selector): 从指定选择器获取渲染后的文本以及该文本在HTML中对应的原始字符编码。 这需要一些巧妙的JS执行。 # 获取元素的innerHTML包含字符实体 inner_html await page.inner_html(selector) # 获取元素渲染后的文本内容 visible_text await page.inner_text(selector) # 一个简单的思路如果元素内全是文本节点可以通过遍历子节点来匹配 # 更通用的方法是通过JS获取每个文本节点的data原始编码和其父元素计算后的样式 # 实际上更稳健的做法是“对比法”。 return inner_html, visible_text # 对比法思路需手动或半自动 # 1. 准备一个已知的、包含大量常用汉字的字符串如“的一是在不了有和人这中大为上个国我以要他时来用们生到作地于出就分对成会可主发年动同工也能下过子说产种面而方后多定行学法所民得经十三之进着等部度家电力里如水化高自二理起小物现实加量都两体制机当使点从业本去把性好应开它合还因由其些然前外天政四日那社义事平形相全表间样与关各重新线内数正心反你明看原又么利比或但质气第向道命此变条只没结解问意建月公无系军很情者最立代想已通并提直题党程展五果料象员革位入常文总次品式活设及管特件长求老头基资边流路级少图山统接知较将组见计别她手角期根论运农指几九区强放决西被干做必战先回则任取据处队南给色光门即保治北造百规热领七海口东导器压志世金增争济阶油思术极交受联什认六共权收证改清己美再采转更单风切打白教速花带安场身车例真务具万每目至达走积示议声报斗完类八离华名确才科张信马节话米整空元况今集温传土许步群广石记需段研界拉林律叫且究观越织装影算低持音众书布复容儿须际商非验连断深难近矿千周委素技备半办青省列习响约支般史感劳便团往酸历市克何除消构府称太准精值号率族维划选标写存候毛亲快效斯院查江型眼王按格养易置派层片始却专状育厂京识适属圆包火住调满县局照参红细引听该铁价严龙飞” # 2. 在浏览器中将这个字符串用目标自定义字体渲染可以通过修改页面元素或注入CSS实现。 # 3. 获取渲染后的字符串的HTML实体编码这一步可能需要特殊JS脚本遍历节点的charCodeAt。 # 4. 对比原始字符串和获取到的编码建立“字形”到“真实字符”的映射。 # 这个过程可以编写一个半自动化的脚本来完成首次破解新网站时可能需要一些手动干预。方法二使用OCR识别字形图片备用精度要求高如果自动化对比困难可以走OCR路线。用fontTools将关键字形导出为图片然后用Tesseract识别。from PIL import Image, ImageDraw, ImageFont import pytesseract def glyph_to_image(font, glyph_name, font_size100): 将单个字形渲染为图片需要将字体临时保存为.ttf # 临时保存为TTF因为PIL的ImageFont需要.ttf或.otf temp_path temp_font.ttf font.save(temp_path) # 使用PIL绘制 try: pil_font ImageFont.truetype(temp_path, font_size) except IOError: # 如果TTF保存失败可能是可变字体尝试更复杂的方法 # 此处简化处理实际可能需要用reportlab等其他库 print(f警告无法直接加载字体尝试备选方案) return None # 创建一个空白图片 img Image.new(RGB, (font_size, font_size), colorwhite) d ImageDraw.Draw(img) # 绘制字形。注意glyph_name可能不是直接可绘制的字符。 # 我们需要知道这个字形在字体中对应的“字符”是什么。 # 这又回到了问题原点。因此OCR方法更适合在已有部分映射后用于校验或识别特殊符号。 # 一个迂回的方法是使用fontTools的font.getGlyphSet()[glyph_name].draw()方法 # 但这需要更底层的绘图库如matplotlib的path比较复杂。 # 更实用的做法利用在线字体查看器手动建立初始映射然后用程序固化。 return img # 实际操作中手动从FontDrop!等工具获取“字形名-真实字”的对照表保存为JSON文件更高效。建立映射字典无论用哪种方法最终我们得到一个核心的glyph_to_char字典。# 示例假设通过对比法我们发现了以下映射字形名 - 真实汉字 glyph_to_char { uniE001: 一, uniE002: 国, uniE003: 的, uniE004: 了, uniE005: 是, # ... 成百上千个映射 }4.4 第四步还原页面文本数据现在我们有了三样东西包含混淆字符实体如一的原始HTML。混淆实体到字形名的映射code_to_glyph_map。字形名到真实汉字的映射glyph_to_char。还原就是简单的两次查找替换。import re def restore_text(html_content, code_map, glyph_map): 还原HTML中的混淆文本。 :param html_content: 原始HTML字符串 :param code_map: 第一步得到的 {‘#xE001;’: {‘glyph_name’: ‘uniE001’}, ...} :param glyph_map: 第二步得到的 {‘uniE001’: ‘一’, ...} # 创建一个反向查找从字形名到HTML实体方便替换 # 但通常我们直接遍历code_map restored_html html_content for code_entity, glyph_info in code_map.items(): glyph_name glyph_info[glyph_name] real_char glyph_map.get(glyph_name) if real_char: # 使用正则替换所有出现的该实体 # 注意需要转义实体中的特殊字符如‘’ pattern re.escape(code_entity) restored_html re.sub(pattern, real_char, restored_html) else: print(f警告未找到字形名 {glyph_name} 对应的真实字符实体 {code_entity} 将被保留。) return restored_html # 使用示例 restored_html restore_text(html, code_to_glyph_map, glyph_to_char) # 现在可以从restored_html中用BeautifulSoup等工具提取干净的文本了 from bs4 import BeautifulSoup soup BeautifulSoup(restored_html, html.parser) # 假设正文在 div classcontent 里 content_div soup.find(div, class_content) if content_div: clean_text content_div.get_text(stripTrue, separator\n) print(clean_text[:500]) # 打印前500字符看看效果5. 动态字体与多文件映射的应对策略实战中一个网站可能使用多套字体文件或者字体映射关系动态变化。这需要我们升级脚本的健壮性。5.1 识别与匹配当前页面的字体字体文件URL或Base64内容可能每次不同但同一套映射关系的字体其字形轮廓数据可能是相同的。我们可以计算字体文件的哈希值如MD5或提取其核心特征如前N个字形轮廓的坐标摘要作为指纹。import hashlib def get_font_fingerprint(font_data_bytes): 计算字体文件的MD5指纹。注意动态字体可能微调导致MD5不同。 return hashlib.md5(font_data_bytes).hexdigest() def get_font_cmap_signature(font): 获取字体cmap表的特征签名可能比MD5更稳定。 cmap font.getBestCmap() # 取码点排序后的字符串作为简单签名适用于静态映射 signature ,.join(sorted([hex(k) for k in cmap.keys()])) return hashlib.sha256(signature.encode()).hexdigest() # 在抓取页面时为每个捕获的字体计算指纹 # 如果发现新指纹说明是新字体需要重新建立映射。 # 可以将指纹与已知的映射关系字典glyph_map缓存起来下次遇到相同指纹直接使用。5.2 建立本地字体映射缓存这是一个关键的优化和实战技巧。一旦成功破解某个字体文件就将其指纹和对应的glyph_to_char映射字典保存到本地文件如JSON或数据库中。import json import os FONT_CACHE_FILE font_mapping_cache.json def load_font_cache(): if os.path.exists(FONT_CACHE_FILE): with open(FONT_CACHE_FILE, r, encodingutf-8) as f: return json.load(f) return {} def save_font_cache(cache): with open(FONT_CACHE_FILE, w, encodingutf-8) as f: json.dump(cache, f, ensure_asciiFalse, indent2) def get_cached_mapping(font_signature): cache load_font_cache() return cache.get(font_signature) def cache_mapping(font_signature, glyph_map): cache load_font_cache() cache[font_signature] glyph_map save_font_cache(cache) # 在破解流程中整合缓存 async def intelligent_restore(page_html, font_data_bytes): font TTFont(io.BytesIO(font_data_bytes)) signature get_font_cmap_signature(font) cached_map get_cached_mapping(signature) if cached_map: print(f命中缓存字体映射签名: {signature[:16]}...) code_map _extract_cmap(font) # 注意code_map每次需要从当前字体重新提取 restored restore_text(page_html, code_map, cached_map) return restored else: print(f发现新字体签名: {signature[:16]}...需要建立新映射) # 调用半自动或手动方法建立新映射如对比法 new_glyph_map await manual_or_semi_auto_build_mapping(font) cache_mapping(signature, new_glyph_map) code_map _extract_cmap(font) restored restore_text(page_html, code_map, new_glyph_map) return restored6. 常见问题、排查技巧与进阶对抗即使掌握了核心流程在实际操作中依然会遇到各种“坑”。这里记录一些典型问题和我的解决方案。6.1 问题抓取到的HTML中根本没有异常字符实体现象页面显示正常但HTML里就是标准的UTF-8中文没有#x开头的实体。排查检查CSS样式可能网站不是通过替换字符实体而是通过CSS偏移如position: absolute; left: -9999px隐藏真实文本再用背景图或伪元素显示混淆后的内容。仔细检查目标元素的CSS查看是否有::before、::after伪元素其content属性是否包含异常字符。检查JavaScript混淆文本可能由JavaScript动态生成并插入。你需要分析页面加载后的JS网络请求或直接调试JS代码找到生成文本的逻辑。这时Playwright的page.evaluate()函数就非常有用可以在页面上下文中执行JS来获取动态生成的内容。检查Canvas/SVG更高级的反爬可能用Canvas或SVG来绘制文本这属于“图片反爬”范畴需要OCR或深度学习模型来识别已超出字体反爬范围。6.2 问题映射关系不稳定每次都不一样现象这次破解成功的映射过几分钟或换个章节就失效了。解决方案会话保持确保你的爬虫在整个抓取会话中使用相同的Cookies和Session。字体生成可能与会话ID绑定。实时破解放弃“一次破解永久使用”的想法。将字体捕获和映射建立作为每次抓取流程的一部分。利用缓存机制如上节所述只有遇到新字体时才触发破解流程。分析字体生成逻辑尝试找到字体文件URL的生成规律。它可能和文章ID、时间戳、一个加密参数有关。如果能逆向出这个逻辑就可以直接请求到字体文件而无需渲染整个页面。6.3 问题字形与真实字符不是一一对应现象一个字形可能对应多个真实字符多对一或者一个真实字符由多个字形组合显示合字。解决方案多对一这通常不影响还原因为我们的映射是字形-字符只要最终能替换成正确的字符即可。在映射字典里一个字形名对应一个字符是没问题的。合字 (Ligatures)例如“fi”可能被设计成一个独立的字形。在中文中较少见但在数字、英文反爬中可能出现。fontTools可以读取字体的GSUB字形替换表来了解合字规则。处理起来比较复杂可能需要将合字字形映射回多个字符如glyph_xyz-fi。幸运的是大多数简单字体反爬不会用到这么复杂的特性。6.4 进阶对抗反爬虫的检测与绕过浏览器指纹检测网站可能检测Playwright/Selenium的自动化特征。应对方法使用playwright.chromium.launch(headlessFalse)非无头模式。通过context.add_init_script()注入JS覆盖常见的自动化检测变量如navigator.webdriver。使用playwright的stealth插件或自己模拟更真实的人类操作间隔和鼠标移动。字体文件作为JS变量极端情况下字体文件可能不是直接加载而是作为一段巨大的JS数组或字符串变量由JS解码并动态创建font-face。这时你需要用page.evaluate()提取这个JS变量并在Python端还原出字体二进制数据。6.5 一份快速自查清单当你遇到字体反爬问题时可以按此清单逐步排查步骤检查项正常结果/应对措施1. 页面获取是否获取到完全渲染的HTML使用wait_untilnetworkidle并检查HTML中是否包含小说正文区域。2. 字体捕获是否监听到font类型的网络请求在Playwright的response事件监听器中打印资源类型和URL。3. 字体内容捕获的字体文件能否用fontTools正常打开检查文件头确保是有效的.woff/.woff2格式。Base64数据需正确解码。4. 映射提取font.getBestCmap()是否返回了非空的字典字典应包含几十到几千个键值对对应被混淆的字符。5. 参考文本是否有办法获得一段已知明文及其混淆后的编码寻找页面固定不变的非混淆区域如导航栏版权信息或通过JS注入已知文本来对比。6. 替换还原替换后文本是否仍有乱码或“口口”检查映射字典是否完整覆盖了页面中的所有混淆实体。未覆盖的字符需要补充映射。7. 动态变化再次运行脚本映射是否还有效实现字体指纹缓存机制应对动态字体。检查会话Cookies是否保持一致。字体反爬是一场“编码游戏”。它的核心是对抗自动化程序对文本数据的直接读取。作为应对者我们的策略是“模拟浏览器解析字体重建映射”。整个过程就像在破解一份简单的密码表一旦掌握了当前页面的“密码本”所有内容就一目了然。最耗时的部分往往是首次建立映射关系一旦实现并辅以缓存后续的抓取就能全自动化进行。记住耐心和细致的观察是解决这类问题的关键多利用浏览器开发者工具Network面板、Elements面板、Console进行分析往往比盲目写代码更有效率。
返回列表