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

2.2 字体加密分析与解密

这是本项目的技术核心。

  1. 定位字体文件 :在浏览器的开发者工具(F12)中,查看包含乱码文字的HTML元素。通常,其 CSS font-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。我们需要用 BeautifulSoup lxml 这样的HTML解析库,根据具体的DOM结构,定位到题目题干、选项、答案等元素,把它们提取出来。

  • 数据清洗 :提取的文本可能包含多余的空格、换行符或HTML标签,需要清洗干净。
  • 结构化 :将每道题处理成一个字典或对象,包含 id question options (列表)、 answer analysis (解析)等字段。
  • 存储 :根据需求选择存储方式。 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”)的图片相似度来自动生成映射。 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中可能是实体形式(),也可能是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矩、或简单的点集距离(需先进行归一化对齐)。为自定义字体的每个未知字形,在标准字体的已知字形中寻找最相似的一个。 scipy numpy 可以辅助计算。
    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. 注意请求头 :有些网站会检查 Origin Referer X-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结构,更新 find find_all 中使用的标签名、class、id等。
    2. 编写更健壮的选择器 :不要依赖过于具体或易变的class名。尝试使用标签的层级关系、属性包含( contains )等更稳定的方式来定位。例如,用 soup.select(‘div.question-container > p:first-child’) 可能比 soup.find(‘p’, class_=‘stem-xyz123’) 更稳定。
    3. 加入异常处理和日志 :在解析每个题目时,用 try...except 包裹,即使某一道题解析失败,也不会影响整个程序,同时记录下错误信息,方便调试。

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

Logo

这里是“一人公司”的成长家园。我们提供从产品曝光、技术变现到法律财税的全栈内容,并连接云服务、办公空间等稀缺资源,助你专注创造,无忧运营。

更多推荐