Python实战:破解在线考试系统字体加密反爬,实现题库自动解密与整理
2026/7/16 5:09:29 网站建设 项目流程

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-AgentReferer等请求头,让自己看起来像个正常的浏览器。有时候还需要带上登录后的AuthorizationToken(可能在Cookie或Header里)。
  • 登录破解:登录表单可能有隐藏字段、动态Token(如csrf_token)或简单的图片验证码。需要分析登录请求的Form DataPayload,用requests模拟提交。对于验证码,如果复杂度不高,可以考虑使用OCR库(如ddddocrpytesseract)尝试识别;如果复杂,可能需要考虑人工介入一次获取长期有效的会话。

2.2 字体加密分析与解密

这是本项目的技术核心。

  1. 定位字体文件:在浏览器的开发者工具(F12)中,查看包含乱码文字的HTML元素。通常,其CSSfont-family属性会指向一个自定义字体。在开发者工具的“网络”(Network)选项卡中,筛选“字体”(Font)类型,刷新页面就能找到这个.woff.ttf文件的请求链接。
  2. 下载字体文件:用Python的requests库将这个字体文件下载到本地。
  3. 解析字体映射:这是最关键的一步。我们需要知道字体文件中,每个glyph(字形)对应的Unicode码点(在网页源码中出现的那个乱码)和它实际代表的字符是什么。
    • 工具选择fontTools库是处理字体文件的瑞士军刀。我们可以用它来解析字体文件,提取字形名称(glyph names)和对应的Unicode映射(cmap表)。
    • 映射关系建立:然而,自定义字体常常不按常理出牌。它的cmap表可能把字形映射到私用区(Private Use Area, PUA)的Unicode码点(如E001,E002),这些码点本身没有标准含义。我们需要找到字形和真实字符的对应关系。
    • 建立映射表的方法
      • 手动对照法(基础):下载字体后,用字体查看工具(如FontForge)打开,或者写一个简单的Python脚本用PIL(Pillow库)把每个字形渲染成图片,人工对照网页上显示的文字,建立一个编码到真实字符的字典。这适用于字符集不大的情况(比如只加密了0-9和A-F)。
      • 自动化映射法(进阶):如果字符集较大(比如加密了所有数字、字母、常用汉字),可以尝试“基准对照”思路。先获取一个已知的、包含所有加密字符的样本页面(比如题目ID、或者某些固定不变的标题文字)。同时,用标准字体(如系统默认字体)渲染出这些真实字符。然后,分别用自定义字体和标准字体渲染同一组“坐标”或“特征”,通过比较字形轮廓的相似度(计算向量距离或使用图像哈希)来建立映射。fontTools可以获取字形的轮廓坐标,这为自动化比对提供了可能。
  4. 文本替换解密:拿到{加密编码: 真实字符}的映射字典后,对抓取到的HTML源码进行全文扫描和替换。这里要注意,加密编码在源码中可能是(十进制HTML实体)、\ue001(Unicode转义序列)等不同形式,需要统一处理。

2.3 数据解析与存储

解密后的页面是结构化的HTML。我们需要用BeautifulSouplxml这样的HTML解析库,根据具体的DOM结构,定位到题目题干、选项、答案等元素,把它们提取出来。

  • 数据清洗:提取的文本可能包含多余的空格、换行符或HTML标签,需要清洗干净。
  • 结构化:将每道题处理成一个字典或对象,包含idquestionoptions(列表)、answeranalysis(解析)等字段。
  • 存储:根据需求选择存储方式。pandas+openpyxl可以方便地存成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,如ChromeDriver

3.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, data=login_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.text

3.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 = r"url\('(.*?\.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_path='custom_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_path

3.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 # 方法一:手动/半自动映射(已知加密范围) # 假设我们通过观察,发现网页源码中出现的加密字符形如  到  (对应数字1-9和0) # 我们需要找到这些编码在字体文件中对应的字形名,然后人工或通过特征匹配确定其真实字符。 # 这里演示一个假设的映射关系(实际需要分析得出): # 网页中的  对应 Unicode 码点 U+E001,其十进制为 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' 对应网页中的  且实际显示为 '1' # 注意:网页中的  是十六进制,对应十进制 57345。而字体cmap中可能用 57345 映射到 'uniE001'。 # 我们建立 {加密字符的unicode码点: 真实字符} 的映射。 # 为了替换方便,我们建立 {网页中出现的字符串形式: 真实字符} 的映射。 # 例如,网页源码中是 ``,我们想把它换成 `1`。 # 假设我们分析后得到如下映射(此映射需根据实际情况调整): # 这个映射需要你通过手动查看字体、对比网页来确定。 # 例如:用浏览器检查元素,看到一个显示为“5”的字符,其HTML是 ``。 # 然后在字体文件中找到编码 57349 (0xE005) 对应的字形,确认它画的是5。 manual_map = { '': '1', '': '2', '': '3', '': '4', '': '5', '': '6', '': '7', '': '8', '': '9', '': '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”)的图片相似度来自动生成映射。fontToolsglyph对象提供了轮廓坐标(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 class='stem'>里) stem_elem = item.find('p', class_='stem') question_data['stem'] = stem_elem.get_text(strip=True) if stem_elem else '' # 提取选项 (假设在<ul class='options'>的多个<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(strip=True) # 可能选项前有A. B. C. D.,可以清理或保留 options.append(option_text) question_data['options'] = options # 提取答案 (假设在<span class='answer'>里) answer_elem = item.find('span', class_='answer') question_data['answer'] = answer_elem.get_text(strip=True) if answer_elem else '' # 提取解析 (假设在<div class='analysis'>里) analysis_elem = item.find('div', class_='analysis') question_data['analysis'] = analysis_elem.get_text(strip=True) if analysis_elem else '' if question_data['stem']: # 确保有题干才加入列表 questions.append(question_data) return questions

3.6 数据存储与主流程整合

最后,将解析好的数据保存起来,并串联整个流程。

import pandas as pd import json def save_to_excel(self, questions, filename='questions.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, index=False, engine='openpyxl') print(f"题目已保存到 {filename}") def save_to_json(self, questions, filename='questions.json'): """保存题目到JSON""" with open(filename, 'w', encoding='utf-8') as f: json.dump(questions, f, ensure_ascii=False, indent=2) 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_url='https://example.com/login', exam_url='https://example.com/exam/123' ) spider.run('your_username', 'your_password')

4. 常见问题与排查技巧实录

在实际操作中,你几乎一定会遇到下面这些问题。这里记录了我的踩坑经验和解决方案。

4.1 字体文件找不到或链接是动态的

  • 问题:在<style>标签或CSS文件里找不到字体链接,或者链接是JavaScript动态生成的。
  • 排查
    1. 在浏览器开发者工具的“网络”选项卡中,勾选“保留日志”(Preserve log),然后刷新页面。筛选“字体”(Font)类型,查看实际加载的字体文件请求。这个请求的URL就是最准确的。
    2. 如果字体URL包含一串随机字符(如/fonts/iconfont.woff?v=abcdef12345),这通常是缓存控制参数,直接复制整个URL使用即可。
    3. 有些网站可能会将字体文件进行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. 寻找基准文本:这是最关键的一步。在网页上寻找一段已知的、未加密的明文,同时它也以加密形式存在于源码中。例如,页面标题、导航栏菜单、页码“第1页”等。找到这样的对照样本,就能建立一部分映射。
    2. 自动化轮廓比对:使用fontTools提取自定义字体和系统标准字体(如Arial Unicode MS)中对应字符的字形轮廓(glyph对象的coordinates)。计算轮廓的几何特征,如矩、Hu矩、或简单的点集距离(需先进行归一化对齐)。为自定义字体的每个未知字形,在标准字体的已知字形中寻找最相似的一个。scipynumpy可以辅助计算。
    3. 使用OCR辅助(野路子):如果字体只是简单变形而非完全自定义符号,可以将自定义字体安装到系统,然后用Python的PIL库将加密的文本(使用该字体)渲染成图片,再用OCR库(如pytesseract)去识别图片中的文字。这个方法成功率取决于字体扭曲程度和OCR引擎的能力。

4.3 登录失败或会话过期

  • 问题requests模拟登录后,后续请求依然被重定向到登录页。
  • 排查
    1. 检查Cookies:登录成功后,打印self.session.cookies,看看是否有关键的会话Cookie(如JSESSIONID,PHPSESSID,token等)。确保这个Cookie被带到了后续请求中。
    2. 分析登录请求:用浏览器开发者工具仔细查看登录过程的每一个请求。除了主要的POST请求,前面可能还有一个GET请求用来获取初始Token,后面可能还有一个重定向或额外的验证请求。用requests完整地模拟这个链条。
    3. 注意请求头:有些网站会检查OriginRefererX-Requested-With等头信息。确保你的请求头与浏览器一致。
    4. 考虑动态参数:登录表单可能包含随时间或会话变化的动态参数,这些参数可能藏在页面的JavaScript变量或隐藏的<input>标签里。你需要先用BeautifulSoup或正则表达式把它们提取出来,再放入POST数据中。

4.4 解密后仍有部分乱码或替换不全

  • 问题:应用映射表后,大部分文字正常了,但还有零星乱码。
  • 排查
    1. 检查映射表完整性:确认所有出现的加密字符都在你的映射表里。可能加密的字符集比你想象的大(比如还包括了标点符号)。
    2. 检查字符表示形式:加密字符在HTML中可能有多种表示形式。除了&#xe001;这种十进制或十六进制实体,还可能是\ue001这种Unicode转义序列(在JSON格式的响应中常见),甚至是经过URL编码的。你需要针对不同的形式编写替换规则。
    3. 查看解密后的HTML:将decrypted_html保存到文件并用浏览器打开,看看乱码部分在浏览器中是否显示正常。如果正常,说明你的映射是对的,但后续的BeautifulSoup解析可能因为HTML结构不规则而提取错误。如果仍然乱码,说明映射或替换环节有问题。

4.5 网站结构变化导致解析失败

  • 问题:代码昨天还能用,今天就不行了,BeautifulSoup找不到任何题目元素。
  • 解决方案
    1. 更新选择器:网站前端更新是常态。你需要重新检查新的HTML结构,更新findfind_all中使用的标签名、class、id等。
    2. 编写更健壮的选择器:不要依赖过于具体或易变的class名。尝试使用标签的层级关系、属性包含(contains)等更稳定的方式来定位。例如,用soup.select(‘div.question-container > p:first-child’)可能比soup.find(‘p’, class_=‘stem-xyz123’)更稳定。
    3. 加入异常处理和日志:在解析每个题目时,用try...except包裹,即使某一道题解析失败,也不会影响整个程序,同时记录下错误信息,方便调试。

字体加密反爬是前端保护数据的一种常见思路,破解它的过程就像一场有趣的解密游戏。核心在于耐心分析网络请求、理解字体文件结构、并巧妙地建立编码与实义的映射关系。整个过程极大地锻炼了HTTP协议、数据解析和逆向思维的能力。最后再次强调,技术学习与研究的目的在于提升自身,请务必遵守法律法规和网站的使用条款,尊重知识产权,不要将此类技术用于不当用途。

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询