1. 项目概述与核心挑战最近在帮朋友处理一个在线考试系统的复习资料时遇到了一个挺有意思的技术问题。这个系统为了“保护”题库对题目和选项的文本内容进行了字体加密。简单来说你在网页上看到的清晰文字在HTML源码里却是一堆乱码或者奇怪的字符直接复制粘贴下来全是“火星文”。这显然是为了防止用户简单地右键复制或者通过爬虫批量获取题目。我的目标很明确用Python写个工具能自动登录系统、下载这些被“加密”的题目页面然后破解这种字体加密最终把干净的题目和答案整理成结构化的数据比如Excel或者JSON方便离线复习和打印。这活儿听起来像是个典型的“爬虫逆向”组合拳但难点就在于那个“字体加密”。它不像常见的反爬手段如验证码、IP限制那样有明确的对抗性更像是一种“障眼法”。系统使用了一种自定义的字体文件通常是.woff或.ttf格式这个字体文件里重新定义了字符的映射关系。比如在标准Unicode里数字“1”对应的编码是\u0031但系统可能在自己的字体文件里把某个生僻字比如\ue001的图形画成了“1”的样子。于是网页源码里存放的是\ue001浏览器加载了这个自定义字体后就把\ue001渲染成了我们看到的“1”。如果我们没有这个字体文件或者看不懂它的映射规则拿到源码就是一堆无法识别的字符。所以这个项目的核心就变成了三步第一模拟用户行为搞定登录和会话保持把包含加密文字的考试页面或题库列表页给“扒”下来第二找到并下载那个关键的字体文件解析出字符编码到实际字形或者说到我们认识的文字的映射关系第三应用这个映射关系把网页源码中的乱码“翻译”回正常文本再进行数据清洗和保存。整个过程充满了细节从HTTP请求头的设置到字体文件的解析每一步都可能藏着坑。2. 核心思路与技术选型面对这样一个问题我的整体思路是“抓取-解析-解密-存储”四步走。技术选型上Python无疑是首选生态丰富库多干这种活正合适。2.1 网页抓取与模拟登录首先得能拿到数据。很多在线考试系统需要登录才能访问题库。这里的关键在于模拟真实的浏览器会话。请求库选择requests库是基础但有些网站可能有简单的JavaScript校验或动态加载Selenium配合ChromeDriver是更强大的选择它能执行JS更像真人操作。我优先尝试requests因为它轻量、速度快。如果发现页面内容是通过JS动态生成的比如题目是滚动加载的再考虑上Selenium。会话保持使用requests.Session()对象非常重要。它能够自动处理Cookies在一次会话中保持登录状态避免每次请求都要重新登录。请求头伪装必须设置合理的User-Agent、Referer等请求头让自己看起来像个正常的浏览器。有时候还需要带上登录后的AuthorizationToken可能在Cookie或Header里。登录破解登录表单可能有隐藏字段、动态Token如csrf_token或简单的图片验证码。需要分析登录请求的Form Data或Payload用requests模拟提交。对于验证码如果复杂度不高可以考虑使用OCR库如ddddocr或pytesseract尝试识别如果复杂可能需要考虑人工介入一次获取长期有效的会话。2.2 字体加密分析与解密这是本项目的技术核心。定位字体文件在浏览器的开发者工具F12中查看包含乱码文字的HTML元素。通常其CSS的font-family属性会指向一个自定义字体。在开发者工具的“网络”(Network)选项卡中筛选“字体”(Font)类型刷新页面就能找到这个.woff或.ttf文件的请求链接。下载字体文件用Python的requests库将这个字体文件下载到本地。解析字体映射这是最关键的一步。我们需要知道字体文件中每个glyph字形对应的Unicode码点在网页源码中出现的那个乱码和它实际代表的字符是什么。工具选择fontTools库是处理字体文件的瑞士军刀。我们可以用它来解析字体文件提取字形名称(glyph names)和对应的Unicode映射(cmap表)。映射关系建立然而自定义字体常常不按常理出牌。它的cmap表可能把字形映射到私用区Private Use Area, PUA的Unicode码点如E001,E002这些码点本身没有标准含义。我们需要找到字形和真实字符的对应关系。建立映射表的方法手动对照法基础下载字体后用字体查看工具如FontForge打开或者写一个简单的Python脚本用PILPillow库把每个字形渲染成图片人工对照网页上显示的文字建立一个编码到真实字符的字典。这适用于字符集不大的情况比如只加密了0-9和A-F。自动化映射法进阶如果字符集较大比如加密了所有数字、字母、常用汉字可以尝试“基准对照”思路。先获取一个已知的、包含所有加密字符的样本页面比如题目ID、或者某些固定不变的标题文字。同时用标准字体如系统默认字体渲染出这些真实字符。然后分别用自定义字体和标准字体渲染同一组“坐标”或“特征”通过比较字形轮廓的相似度计算向量距离或使用图像哈希来建立映射。fontTools可以获取字形的轮廓坐标这为自动化比对提供了可能。文本替换解密拿到{加密编码: 真实字符}的映射字典后对抓取到的HTML源码进行全文扫描和替换。这里要注意加密编码在源码中可能是#xe001;十进制HTML实体、\ue001Unicode转义序列等不同形式需要统一处理。2.3 数据解析与存储解密后的页面是结构化的HTML。我们需要用BeautifulSoup或lxml这样的HTML解析库根据具体的DOM结构定位到题目题干、选项、答案等元素把它们提取出来。数据清洗提取的文本可能包含多余的空格、换行符或HTML标签需要清洗干净。结构化将每道题处理成一个字典或对象包含id、question、options列表、answer、analysis解析等字段。存储根据需求选择存储方式。pandasopenpyxl可以方便地存成Excel一目了然。json模块可以存成JSON文件便于程序后续读取。如果需要持久化和复杂查询可以用SQLAlchemy存入数据库如SQLite。注意整个项目的合法性边界非常清晰。这个工具仅用于个人学习、复习以及对网络反爬技术的研究与学习。绝对禁止用于破解、盗取有明确版权保护的商业题库或任何干扰考试系统正常运营、破坏公平性的行为。技术是一把双刃剑请务必用在正道上。3. 实战步骤拆解与代码实现下面我将以一个模拟的、简化了的在线考试系统为例展示核心步骤的代码实现。假设我们通过分析发现其加密字体只针对了数字0-9。3.1 环境准备与依赖安装首先创建一个新的Python虚拟环境是个好习惯。然后安装必要的库。# 创建虚拟环境 (可选) python -m venv venv source venv/bin/activate # Linux/Mac # venv\Scripts\activate # Windows # 安装核心库 pip install requests beautifulsoup4 fonttools pillow pandas openpyxl # 如果需要处理动态页面安装selenium # pip install selenium # 并下载对应浏览器版本的WebDriver如ChromeDriver3.2 模拟登录与页面抓取假设目标登录页面是一个简单的POST表单。import requests from bs4 import BeautifulSoup import re class ExamSpider: def __init__(self, login_url, exam_url): self.session requests.Session() # 设置一个常见的浏览器User-Agent self.headers { User-Agent: Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/91.0.4472.124 Safari/537.36 } self.session.headers.update(self.headers) self.login_url login_url self.exam_url exam_url # 用于存储字体映射 self.font_map {} def login(self, username, password): 模拟登录这里需要根据实际网站调整 # 首先可能需要GET一下登录页获取隐藏的token login_page self.session.get(self.login_url) soup BeautifulSoup(login_page.text, html.parser) # 假设有一个csrf token的input标签 csrf_token soup.find(input, {name: csrf_token})[value] if soup.find(input, {name: csrf_token}) else # 构造登录数据 login_data { username: username, password: password, csrf_token: csrf_token, # 可能还有其他固定字段 } # 发送登录请求 resp self.session.post(self.login_url, datalogin_data) # 检查登录是否成功可以根据返回内容或状态码判断 if 登录成功 in resp.text or resp.status_code 200: print(登录成功) return True else: print(登录失败) return False def fetch_exam_page(self): 抓取考试/题库页面 resp self.session.get(self.exam_url) resp.encoding resp.apparent_encoding # 自动判断编码 return resp.text3.3 定位并下载字体文件我们需要从抓取到的页面HTML中找到字体文件的链接。def extract_font_url(self, html_content): 从HTML中提取字体文件URL soup BeautifulSoup(html_content, html.parser) # 方式1查找style标签内的font-face font_face_css for style in soup.find_all(style): if font-face in style.text: font_face_css style.text break # 方式2查找link标签引入的CSS然后可能需要再去下载CSS解析 # 这里简化处理假设字体URL直接在style标签的font-face里 pattern rurl\((.*?\.woff2?)\) # 匹配woff或woff2 match re.search(pattern, font_face_css) if match: font_url match.group(1) # 处理相对路径 if font_url.startswith(//): font_url https: font_url elif font_url.startswith(/): # 假设基准URL是考试页面的域名这里需要根据实际情况拼接 from urllib.parse import urljoin font_url urljoin(self.exam_url, font_url) return font_url return None def download_font(self, font_url, save_pathcustom_font.woff): 下载字体文件到本地 resp self.session.get(font_url) with open(save_path, wb) as f: f.write(resp.content) print(f字体文件已下载到: {save_path}) return save_path3.4 解析字体文件并建立映射这是最核心的部分。我们假设已知加密的字符是数字0-9。from fontTools.ttLib import TTFont from PIL import Image, ImageDraw, ImageFont import io def parse_font_and_build_map(self, font_path): 解析字体文件建立加密字符到真实字符的映射 font TTFont(font_path) # 获取cmap表这是Unicode到字形名的映射 cmap font.getBestCmap() # 返回格式如 {code: glyph_name} # 例如{60321: uniEBE1}这里的60321是十进制对应十六进制EBE1 # 方法一手动/半自动映射已知加密范围 # 假设我们通过观察发现网页源码中出现的加密字符形如 #xe001; 到 #xe00a; (对应数字1-9和0) # 我们需要找到这些编码在字体文件中对应的字形名然后人工或通过特征匹配确定其真实字符。 # 这里演示一个假设的映射关系实际需要分析得出 # 网页中的 #xe001; 对应 Unicode 码点 UE001其十进制为 57345。 # 我们需要检查 cmap 中是否有 57345 或附近的值。 print(字体CMAP表部分:, list(cmap.items())[:10]) # 查看前10个映射 # 方法二通过字形轮廓相似度匹配更通用 # 1. 准备标准字体如Arial渲染出的0-9图片作为基准 # 2. 用自定义字体渲染其每个字形生成图片 # 3. 比较图片相似度找出最匹配的基准数字 # 由于字体设计不同直接比较轮廓坐标更精确。fontTools可以获取字形轮廓 glyph_order font.getGlyphOrder() # 获取所有字形名如 [.notdef, uniE001, uniE002, ...] # 通常前几个是特殊字形我们跳过 .notdef, .null 等 relevant_glyphs [g for g in glyph_order[2:12]] # 假设前10个是我们需要的数字字形 # 创建一个映射字典需要人工介入或通过已知样本推导 # 这里我们假设已经通过分析得出如下映射实战中这是需要破解的关键一步 # 字体中的字形名 uniE001 对应网页中的 #xe001; 且实际显示为 1 # 注意网页中的 #xe001; 是十六进制对应十进制 57345。而字体cmap中可能用 57345 映射到 uniE001。 # 我们建立 {加密字符的unicode码点: 真实字符} 的映射。 # 为了替换方便我们建立 {网页中出现的字符串形式: 真实字符} 的映射。 # 例如网页源码中是 #xe001;我们想把它换成 1。 # 假设我们分析后得到如下映射此映射需根据实际情况调整 # 这个映射需要你通过手动查看字体、对比网页来确定。 # 例如用浏览器检查元素看到一个显示为“5”的字符其HTML是 #xe005;。 # 然后在字体文件中找到编码 57349 (0xE005) 对应的字形确认它画的是5。 manual_map { #xe001;: 1, #xe002;: 2, #xe003;: 3, #xe004;: 4, #xe005;: 5, #xe006;: 6, #xe007;: 7, #xe008;: 8, #xe009;: 9, #xe00a;: 0, } self.font_map manual_map print(字体映射表已建立:, self.font_map) return manual_map # 辅助函数将字体中的某个字形渲染成图片用于人工查看 def save_glyph_as_image(self, font_path, glyph_name, output_path): 将指定字形渲染为图片方便人工核对 font TTFont(font_path) # 将字体转换为PIL可用的格式 # fontTools不直接支持渲染我们可以用变通方法安装pygame或cairo但较复杂。 # 一个简单的方法是使用reportlab或直接系统安装该字体后用PIL调用。 # 这里推荐一个更直接的方法使用在线字体查看器或本地字体管理软件如FontForge打开下载的.woff文件查看。 print(f请使用字体查看软件打开 {font_path} 查看字形 {glyph_name})实操心得建立映射关系是整个项目最耗时、最需要耐心的部分。对于简单加密仅数字、字母可以手动完成。对于复杂加密可以尝试编写脚本通过比较自定义字体和系统字体渲染同一段“基准文本”如“0123456789ABCDEF”的图片相似度来自动生成映射。fontTools的glyph对象提供了轮廓坐标(coordinates)可以计算轮廓的“特征向量”如重心、二阶矩进行相似度比较但这部分代码较为复杂。3.5 应用映射解密并提取数据有了映射表就可以清洗抓取到的页面了。def decrypt_html(self, html_content): 根据映射表解密HTML中的加密字符 decrypted_html html_content for encrypted_char, real_char in self.font_map.items(): # 注意加密字符在HTML中可能是实体形式(#xe001;)也可能是Unicode字符(\ue001) # 需要处理多种形式 # 替换HTML实体 decrypted_html decrypted_html.replace(encrypted_char, real_char) # 也可以尝试替换Unicode转义序列如果源码中是直接写的 # 例如将 \ue001 替换为 1 # 先获取十六进制部分 hex_code encrypted_char.replace(#x, ).replace(;, ) unicode_char chr(int(hex_code, 16)) # 将xe001转为Unicode字符 decrypted_html decrypted_html.replace(unicode_char, real_char) return decrypted_html def parse_questions(self, decrypted_html): 从解密后的HTML中解析题目、选项和答案 soup BeautifulSoup(decrypted_html, html.parser) questions [] # 这里需要根据目标网站的实际HTML结构来写选择器 # 假设每道题都在一个class为question-item的div里 question_items soup.find_all(div, class_question-item) for item in question_items: question_data {} # 提取题干 (假设在p classstem里) stem_elem item.find(p, class_stem) question_data[stem] stem_elem.get_text(stripTrue) if stem_elem else # 提取选项 (假设在ul classoptions的多个li里) options_elem item.find(ul, class_options) options [] if options_elem: for li in options_elem.find_all(li): option_text li.get_text(stripTrue) # 可能选项前有A. B. C. D.可以清理或保留 options.append(option_text) question_data[options] options # 提取答案 (假设在span classanswer里) answer_elem item.find(span, class_answer) question_data[answer] answer_elem.get_text(stripTrue) if answer_elem else # 提取解析 (假设在div classanalysis里) analysis_elem item.find(div, class_analysis) question_data[analysis] analysis_elem.get_text(stripTrue) if analysis_elem else if question_data[stem]: # 确保有题干才加入列表 questions.append(question_data) return questions3.6 数据存储与主流程整合最后将解析好的数据保存起来并串联整个流程。import pandas as pd import json def save_to_excel(self, questions, filenamequestions.xlsx): 保存题目到Excel # 将列表转换为DataFrame df pd.DataFrame(questions) # 将选项列表转换为字符串方便查看 df[options] df[options].apply(lambda x: \n.join(x) if isinstance(x, list) else x) df.to_excel(filename, indexFalse, engineopenpyxl) print(f题目已保存到 {filename}) def save_to_json(self, questions, filenamequestions.json): 保存题目到JSON with open(filename, w, encodingutf-8) as f: json.dump(questions, f, ensure_asciiFalse, indent2) print(f题目已保存到 {filename}) def run(self, username, password): 主运行流程 # 1. 登录 if not self.login(username, password): print(登录失败退出) return # 2. 抓取页面 html self.fetch_exam_page() # 3. 提取并下载字体 font_url self.extract_font_url(html) if not font_url: print(未找到字体文件链接) return font_path self.download_font(font_url) # 4. 解析字体建立映射 self.parse_font_and_build_map(font_path) # 5. 解密页面 decrypted_html self.decrypt_html(html) # 6. 解析题目 questions self.parse_questions(decrypted_html) print(f共解析到 {len(questions)} 道题) # 7. 保存 self.save_to_excel(questions) self.save_to_json(questions) # 使用示例 if __name__ __main__: # 替换成真实的URL和账号密码 spider ExamSpider( login_urlhttps://example.com/login, exam_urlhttps://example.com/exam/123 ) spider.run(your_username, your_password)4. 常见问题与排查技巧实录在实际操作中你几乎一定会遇到下面这些问题。这里记录了我的踩坑经验和解决方案。4.1 字体文件找不到或链接是动态的问题在style标签或CSS文件里找不到字体链接或者链接是JavaScript动态生成的。排查在浏览器开发者工具的“网络”选项卡中勾选“保留日志”(Preserve log)然后刷新页面。筛选“字体”(Font)类型查看实际加载的字体文件请求。这个请求的URL就是最准确的。如果字体URL包含一串随机字符如/fonts/iconfont.woff?vabcdef12345这通常是缓存控制参数直接复制整个URL使用即可。有些网站可能会将字体文件进行Base64编码直接内嵌在CSS中src: url(data:font/woff2;base64,...)。如果是这种情况你需要从CSS中提取出Base64字符串然后用Python解码并保存为文件。import base64 # 假设 base64_str 是提取出来的字符串不含 data:font/woff2;base64, 前缀 font_data base64.b64decode(base64_str) with open(font.woff2, wb) as f: f.write(font_data)4.2 字体映射关系复杂或无法人工识别问题加密的字符集很大包含成百上千个汉字或者字形扭曲严重人工对照几乎不可能。解决方案寻找基准文本这是最关键的一步。在网页上寻找一段已知的、未加密的明文同时它也以加密形式存在于源码中。例如页面标题、导航栏菜单、页码“第1页”等。找到这样的对照样本就能建立一部分映射。自动化轮廓比对使用fontTools提取自定义字体和系统标准字体如Arial Unicode MS中对应字符的字形轮廓glyph对象的coordinates。计算轮廓的几何特征如矩、Hu矩、或简单的点集距离需先进行归一化对齐。为自定义字体的每个未知字形在标准字体的已知字形中寻找最相似的一个。scipy或numpy可以辅助计算。使用OCR辅助野路子如果字体只是简单变形而非完全自定义符号可以将自定义字体安装到系统然后用Python的PIL库将加密的文本使用该字体渲染成图片再用OCR库如pytesseract去识别图片中的文字。这个方法成功率取决于字体扭曲程度和OCR引擎的能力。4.3 登录失败或会话过期问题requests模拟登录后后续请求依然被重定向到登录页。排查检查Cookies登录成功后打印self.session.cookies看看是否有关键的会话Cookie如JSESSIONID,PHPSESSID,token等。确保这个Cookie被带到了后续请求中。分析登录请求用浏览器开发者工具仔细查看登录过程的每一个请求。除了主要的POST请求前面可能还有一个GET请求用来获取初始Token后面可能还有一个重定向或额外的验证请求。用requests完整地模拟这个链条。注意请求头有些网站会检查Origin、Referer、X-Requested-With等头信息。确保你的请求头与浏览器一致。考虑动态参数登录表单可能包含随时间或会话变化的动态参数这些参数可能藏在页面的JavaScript变量或隐藏的input标签里。你需要先用BeautifulSoup或正则表达式把它们提取出来再放入POST数据中。4.4 解密后仍有部分乱码或替换不全问题应用映射表后大部分文字正常了但还有零星乱码。排查检查映射表完整性确认所有出现的加密字符都在你的映射表里。可能加密的字符集比你想象的大比如还包括了标点符号。检查字符表示形式加密字符在HTML中可能有多种表示形式。除了#xe001;这种十进制或十六进制实体还可能是\ue001这种Unicode转义序列在JSON格式的响应中常见甚至是经过URL编码的。你需要针对不同的形式编写替换规则。查看解密后的HTML将decrypted_html保存到文件并用浏览器打开看看乱码部分在浏览器中是否显示正常。如果正常说明你的映射是对的但后续的BeautifulSoup解析可能因为HTML结构不规则而提取错误。如果仍然乱码说明映射或替换环节有问题。4.5 网站结构变化导致解析失败问题代码昨天还能用今天就不行了BeautifulSoup找不到任何题目元素。解决方案更新选择器网站前端更新是常态。你需要重新检查新的HTML结构更新find或find_all中使用的标签名、class、id等。编写更健壮的选择器不要依赖过于具体或易变的class名。尝试使用标签的层级关系、属性包含(contains)等更稳定的方式来定位。例如用soup.select(‘div.question-container p:first-child’)可能比soup.find(‘p’, class_‘stem-xyz123’)更稳定。加入异常处理和日志在解析每个题目时用try...except包裹即使某一道题解析失败也不会影响整个程序同时记录下错误信息方便调试。字体加密反爬是前端保护数据的一种常见思路破解它的过程就像一场有趣的解密游戏。核心在于耐心分析网络请求、理解字体文件结构、并巧妙地建立编码与实义的映射关系。整个过程极大地锻炼了HTTP协议、数据解析和逆向思维的能力。最后再次强调技术学习与研究的目的在于提升自身请务必遵守法律法规和网站的使用条款尊重知识产权不要将此类技术用于不当用途。