十年匠心定制 · 商业建站与技术教学双线并行 咨询热线:400-886-1026 service@lmnt.cn
ARTICLE DETAIL

资讯详情

深耕网站建设与运营推广的一线实战洞察。

Python爬虫进阶:Selenium破解前端动态渲染,抓取题库数据

Python爬虫进阶:Selenium破解前端动态渲染,抓取题库数据 简介本资源是一套基于Python与Selenium框架实现的考试宝试题爬虫实战项目专为计算机相关专业本科生设计适用于毕业设计、课程大作业及项目实战训练场景。项目完整可运行经导师指导并获98分高分评价内容难度适中、逻辑清晰覆盖网页自动化交互、动态页面解析与结构化数据提取等核心技能点。压缩包共6个文件含2个核心Python脚本主程序与配置模块、1个预编译chromedriver.exe驱动、1份详细README.md使用说明、1张运行效果截图及1个.gitignore配置文件整体大小8.2MB结构简洁利于快速上手。目前已有265人学习下载提供经过本地严格调试的可执行源码、清晰的操作指引与典型排错说明帮助学习者理解Selenium在真实教育平台爬取中的应用边界与工程实践要点。 去年被朋友拉去帮忙整理一批计算机二级的题库素材我盯上了考试宝这类在线题库页面。结果用 requests 直接请求返回的 HTML 源码里连题干的影子都没有数据全是动态渲染出来的。没办法最后选了 Python 爬虫 selenium 这个组合让浏览器“假装真人”把页面点开再逐题提取。这个项目完整跑通之后我把源代码和使用说明整理成了一个小工具也就是这篇博文的由来。如果你有点 Python 基础又正好想入坑爬虫或者 selenium 自动化测试这个例子比教科书上的 demo 要实在得多。1. 项目背景与方案选型1.1 为什么 requests 抓不到题目数据先交代一下我遇到的页面特征。打开考试宝的某个试卷页面浏览器里能看到题目、选项、答案解析但右键查看源代码发现这些内容根本不在初始 HTML 里。原因是这类题库站点普遍采用前端渲染页面框架先加载出来题目数据通过浏览器后续发起的 XHR/JSON 请求拿到再由 JavaScript 渲染到 DOM 上。requests 只能拿到服务器返回的第一版 HTML自然什么都抓不到。要处理这种场景通常有三条路。一是逆向分析 XHR 接口找出请求参数和加密逻辑然后用 requests 或 httpx 直接模拟接口调用二是用 selenium 这类浏览器自动化框架让真实浏览器去加载、渲染、拿到完整 DOM 后再提取文本三是上 playwright 或 puppeteer 这类无头浏览器库。三条路各有适用场景我选的是 selenium理由是它在 Python 生态里最成熟文档多遇到问题能抄的作业也多。1.2 方案对比requests、selenium、playwright我把三个方案放在一起对比过区别非常明显。requests 加接口逆向这条路性能最好但前提是你能把接口参数和加密逻辑啃下来selenium 上手快几乎不用关心前端框架具体怎么渲染缺点就是慢、吃内存playwright 自动化能力强自带等待机制但中文资料相对少调试遇到问题时要自己多折腾。方案优点缺点适用场景requests 接口逆向速度快、资源占用低需要破解参数加密维护成本高接口结构稳定、反爬不严重的站点selenium上手快、接近真人操作慢、耗内存容易被强风控识别动态渲染页面、中小体量数据量playwright自动化能力强、自带等待机制中文资料相对少调试稍麻烦复杂交互场景、跨浏览器测试我当时的量级是几百道题不会反复天天跑所以 selenium 慢一点没关系但省掉了逆向接口的时间。对于普通爬虫学习者和做一次性数据整理的人来说这个取舍很划算。如果你是要完成上百万量级的采集那还是老老实实啃接口协议别指望浏览器自动化能扛住那种体量。1.3 动手前的合规自查写爬虫之前有几件事一定要想清楚特别是涉及在线教育平台这种有账号体系的站点。我的原则是只抓取我自己账号有权限查看的题目只用于个人学习与题目整理不用于商业分发控制请求频率不把自己的访问做成对服务器的压力测试不碰任何需要额外付费才能解锁的 VIP 内容。这些不是空话我从项目一开始就把“抓取范围”写在了一个 CONFIG 配置项里只保留指定课程 ID避免脚本失控。爬虫技术本身是中性的但使用边界得自己把握。后续所有代码示例也都是在这个前提下进行的。我在项目里还顺手写了一个总开关默认对每次请求之间的延时做了强制下限防止手滑把频率调得太高。提示如果你正在做一个爬虫项目第一步不是写代码而是问自己“目标数据我有没有权限合法获取”“抓下来之后用于什么场景”。这两点想不清楚后面代码写得再漂亮都有隐患。另外建议去目标站点看一眼 robots.txt 有没有明确禁止抓取虽然这东西主要约束的是搜索引擎爬虫但至少能帮你理解平台的态度。2. 环境准备与使用说明2.1 Python 环境与依赖安装我的开发环境是 Python 3.10 Windows 11这个项目对 Python 版本要求不高3.8 以上就行。依赖其实就一个核心库外加两个辅助库pip install selenium pip install webdriver-manager pip install retryselenium 是核心webdriver-manager 用来自动管理浏览器驱动retry 用来做异常重试。可能会有人问装 selenium 是不是还要下载 chromedriver 并配到 PATH 里传统方式确实要手动下载还要保证版本和 Chrome 一致特别容易翻车。用了 webdriver-manager 之后它会自动检测本机 Chrome 版本并下载匹配的驱动省去了一大截事。2.2 浏览器驱动手动下载还是自动管理手动下载 chromedriver 的流程是打开 Chrome 浏览器在设置里看“关于 Chrome”拿到版本号然后去 chromedriver 镜像站下载对应版本解压后放到 Python 环境或项目根目录。这个流程本身不难但有个坑Chrome 经常自动升级升完版本号变了原本的 chromedriver 就失效脚本直接报 SessionNotCreatedException。我第一次跑项目就栽在这上面明明昨天还好好的第二天一开机就全废。所以我推荐直接用 webdriver-manager代码里这样写from selenium import webdriver from selenium.webdriver.chrome.service import Service from webdriver_manager.chrome import ChromeDriverManager service Service(ChromeDriverManager().install()) driver webdriver.Chrome(serviceservice)第一次运行会联网下载驱动之后就缓存到本地不用再管版本。如果你因为网络原因下载不了驱动再考虑手动方案把驱动文件放到项目根目录然后用Service(executable_path./chromedriver.exe)指定路径。2.3 项目目录结构与使用说明拿到源代码之后我建议先按下面的结构整理项目别把脚本都堆在一个文件里exam_crawler/ ├── config.py # 账号、URL、爬取范围等配置 ├── crawler.py # 主爬虫脚本 ├── cleaner.py # 数据清洗模块 ├── storage.py # 存储模块 ├── requirements.txt # 依赖清单 ├── README.md # 使用说明 ├── logs/ └── output/README 里我会写清楚三段内容第一步安装依赖第二步修改 config.py 里的账号和课程链接第三步运行 crawler.py。剩下的事情都由脚本自动处理。很多开源爬虫项目挂掉不是因为代码不行而是使用说明写得不够清楚。所以我写使用说明时会把“改哪里、跑哪条命令、生成什么文件”三件事单独列出来让一个从没接触过项目的人也能十分钟内跑通。3. 核心代码实现从登录到抓题的完整链路3.1 浏览器配置与登录态处理我先把浏览器启动参数贴在下面。这些参数不是随手写的每条都是从踩坑记录里提炼出来的。比如--disable-gpu是因为在部分 Windows 服务器上不关 GPU 会报错--no-sandbox则是因为 Linux 容器环境下 PID 命名空间受限--disable-blink-featuresAutomationControlled和excludeSwitches是为了把自动化特征的暴露面降到最低。这套配置组合我现在几乎每个 selenium 项目都会复用省掉了不少后续的破事。from selenium.webdriver.chrome.options import Options options Options() options.add_argument(--headlessnew) # 无头模式不弹浏览器窗口 options.add_argument(--disable-gpu) # 禁用GPU避免部分环境报错 options.add_argument(--no-sandbox) # 部分Linux服务器需要 options.add_argument(--disable-blink-featuresAutomationControlled) # 隐藏自动化标记 options.add_experimental_option(excludeSwitches, [enable-automation]) prefs {profile.managed_default_content_settings.images: 2} options.add_experimental_option(prefs, prefs) # 禁用图片加载提速再解释几个关键点。headless 模式能让浏览器在后台运行不弹窗适合放到服务器上跑但有些站点对 headless 的 JavaScript 执行结果有差异如果发现抓不到数据可以先关掉 headless 试一次确认逻辑没问题再改回无头模式。禁用图片加载是我个人习惯题库页面的图片几乎不影响题目文本提取却能明显降低带宽和内存占用。登录态是这种账号型站点的核心。在脚本里我做了两层处理第一层尝试直接复用登录 Cookie第二层才是自动输入账号密码。这个设计是有原因的密码登录可能触发验证码而验证码环节自动化处理成本高、稳定性差所以优先用 Cookie 登录。Cookie 保存成 pkl 格式后下次运行直接加载不需要再走一遍登录流程。import pickle def load_cookies(driver, cookie_pathcookies.pkl): try: with open(cookie_path, rb) as f: cookies pickle.load(f) driver.get(https://exam.example.com/login) for cookie in cookies: driver.add_cookie(cookie) return True except FileNotFoundError: return False def login_and_save_cookies(driver, username, password, cookie_pathcookies.pkl): driver.get(https://exam.example.com/login) WebDriverWait(driver, 20).until(EC.presence_of_element_located((By.NAME, username))) driver.find_element(By.NAME, username).send_keys(username) driver.find_element(By.NAME, password).send_keys(password) driver.find_element(By.CSS_SELECTOR, button[typesubmit]).click() time.sleep(3) with open(cookie_path, wb) as f: pickle.dump(driver.get_cookies(), f)注意add_cookie必须在目标域名下先打开一个页面才能生效这是很多新手会忽略的细节driver 初始打开的是 about:blank直接加 Cookie 会报 InvalidCookieDomainException。所以代码里先driver.get(https://exam.example.com/login)随便加载一个页面再添加 Cookie。3.2 等待机制别用 sleep用 WebDriverWaitselenium 里最容易翻车的就是页面元素还没加载完就去查找。有些人图省事统一time.sleep(8)结果页面慢的时候 8 秒不够快的时候又白白等 8 秒效率很低。正确做法是用 WebDriverWait 显式等待等那个“必须出现”的元素出现再继续执行。显式等待的本质是轮询 DOM每 0.5 秒查一次直到元素出现或者超时比固定 sleep 灵活得多。from selenium.webdriver.support.ui import WebDriverWait from selenium.webdriver.support import expected_conditions as EC from selenium.webdriver.common.by import By wait WebDriverWait(driver, timeout15, poll_frequency0.5) # 等待题目容器出现 question_container wait.until( EC.presence_of_element_located((By.CSS_SELECTOR, div.question-item)) )我在 crawler.py 里把等待和查找封装成了一个小函数失败就自动重试三次每次重试前退避 1 秒、2 秒、4 秒。这样网络波动时不用人盯着脚本自己就能扛过去。from retry import retry retry(tries3, delay1, backoff2) def find_with_wait(driver, by, selector, timeout15): return WebDriverWait(driver, timeout).until( EC.presence_of_element_located((by, selector)) )实际使用下来这个包装函数比每次手动写 WebDriverWait 要省心太多。唯一要注意的是retry 装饰器默认捕获所有异常但像 NoSuchElementException 这类“元素确实不存在”的情况重试三次也没有意义。所以我在函数里额外判断了一下只有超时异常才值得重试。3.3 题目提取与翻页主循环题库页面通常是一页若干题底部翻页或者点击“下一题”切换。我这次遇到的是列表页加分页器结构所以主循环思路是等待当前页题目容器出现提取所有题目文本然后点击下一页继续提取直到下一页按钮不可用为止。items 的定位用的是 CSS 选择器速度比 XPath 快语法也更清晰。import time import random from selenium.webdriver.common.by import By def crawl_page(driver): items driver.find_elements(By.CSS_SELECTOR, div.question-item) page_texts [] for item in items: # 题目正文、选项、答案都在item.text里 page_texts.append(item.text) return page_texts all_questions [] for page in range(1, max_pages 1): wait.until(EC.presence_of_element_located((By.CSS_SELECTOR, div.question-item))) all_questions.extend(crawl_page(driver)) next_btn driver.find_elements(By.CSS_SELECTOR, a.next) if not next_btn or disabled in next_btn[0].get_attribute(class): break next_btn[0].click() time.sleep(random.uniform(2, 5))这里我刻意加了一个随机等待范围在 2 到 5 秒之间。这样做的目的不是规避什么神秘检测而是模拟真实用户的阅读节奏降低对目标服务器造成瞬时请求尖峰的概率。真实的题库类页面翻页太快还容易出现“接口请求过于频繁”的提示得不偿失。你要是硬把 sleep 去掉跑到几十页大概率就会被限流到时候更浪费时间。3.4 异常处理与断点续爬爬虫跑到一半崩了是家常便饭。可能是网络抖动、元素结构变化、登录态失效都有可能。我采用了两级防护第一级是单次查找重试前面说到的 retry 装饰器第二级是全局的断点续爬把已爬取的页码记录到一个 progress.txt 文件里。这个文件很小但价值很大相当于给爬虫装了“记忆”。def save_progress(page): with open(progress.txt, w) as f: f.write(str(page)) def load_progress(): try: with open(progress.txt) as f: return int(f.read().strip()) except FileNotFoundError: return 1每次翻页成功就更新页码文件下次脚本启动时从记录页继续而不是从头开始。这个设计对题库类爬虫特别重要因为几百个页面跑到 80 页突然崩了重新来一遍的时间浪费是很心疼的。我还在解析层外套了 try/except捕获异常后把当前页面 URL 写入 error.log方便事后定位。如果你要跑更长时间的抓取建议再加一层心跳日志每十分钟输出一次当前页数和累计题数挂机时心里有数。4. 试题数据的清洗与结构化存储4.1 题目文本里的噪声来源从浏览器提取出来的item.text可没那么干净。典型问题有这几类一是空白字符HTML 里的缩进、换行、全角空格全混在文本里二是序号残留比如“1. 下列选项中……”里的“1.”转换题库后可能要重新编号就得删掉三是题干和选项位置不固定有的题选项在题干下方有的混在一行四是答案和解析的格式五花八门常见的有“答案A”“【答案】B”“正确答案: C”。这些噪声如果不处理存进 Excel 还可以忍但要做成可批量刷题的题库就完全不能用了。所以我在 cleaner.py 里做了一整套清洗流程。清洗的目标是把非结构化的页面文本转成“题干、选项A到D、答案、解析”这样一张规整的记录表。4.2 正则清洗题干、选项、答案的拆分以四选一选择题为例我用的核心正则是这样import re def parse_choice_question(raw: str) - dict | None: text re.sub(r\s, , raw).strip() # 去掉开头的序号如 12. text re.sub(r^\d[\.、]\s*, , text) pattern re.compile( r(?Pquestion.?) r(?PAA\.[^\sB]) r(?PBB\.[^\sC]) r(?PCC\.[^\sD]) r(?PDD\.[^\s]) r(?Panswer答案[:\s]*[A-D])?, re.S ) match pattern.search(text) if not match: return None return { question: match.group(question).strip(), A: match.group(A).strip(), B: match.group(B).strip(), C: match.group(C).strip(), D: match.group(D).strip(), answer: match.group(answer) or , }这个正则的思路是题干是非贪婪匹配到第一个“A.”就停下选项之间用“后面不跟下个选项字母”的前瞻来切分。简单场景够用但如果选项里本身带“B.”这类字样正则就会切错。为此我加了一个兜底逻辑如果解析失败就把原始文本原样保留绝不因为清洗失败丢数据。提示正则不是万能的。题库数据复杂到一定程度后我更建议先用规则拆拆不掉的丢进“待人工核对”列表批量清洗结束后统一处理。这个策略比追求一个完美正则要节省大量时间。而且你会发现真正复杂的题目永远是少数把精力花在 90% 的常规题上剩下的 10% 人工过一遍效率最高。4.3 存储到 CSV 和 SQLite清洗完的数据我同时存了 CSV 和 SQLite 两种格式。CSV 方便用 Excel 打开查看和人工校对SQLite 方便后续做题程序读取。CSV 写入用 csv 模块加 utf-8-sig 编码这样用 Excel 打开不会乱码这是个很关键的小细节。很多脚本直接用 UTF-8 编码写 CSVExcel 打开后中文全变问号就是这个原因。import csv def save_to_csv(records, filenameoutput/questions.csv): with open(filename, w, newline, encodingutf-8-sig) as f: writer csv.DictWriter(f, fieldnames[question, A, B, C, D, answer]) writer.writeheader() writer.writerows(records)SQLite 那边建了一张带唯一约束的表用题目文本的 SHA256 哈希作为去重键import sqlite3, hashlib conn sqlite3.connect(output/questions.db) conn.execute( CREATE TABLE IF NOT EXISTS questions ( id INTEGER PRIMARY KEY AUTOINCREMENT, q_hash TEXT UNIQUE, question TEXT, option_a TEXT, option_b TEXT, option_c TEXT, option_d TEXT, answer TEXT, source TEXT ) )4.4 题目去重与增量更新题库类数据最怕重复采集。同一套题可能在不同试卷里反复出现如果每次跑都全量 insert库里的重复数据会越来越多。我用 q_hash 解决这个问题insert 之前先算hashlib.sha256(question.encode()).hexdigest()然后执行INSERT OR IGNORE重复题目自动被忽略。这个办法比用题目文本直接比较要快很多尤其数据量上去以后差距很明显。增量更新的思路也一样只在内存里保留本次新增的题目数写入日志。这样我第二天再跑一遍脚本就能看到“本次新增 X 题跳过重复 Y 题”而不是傻乎乎地把所有题又写一遍。对于一次性抓几百页的采集任务来说这个机制能在多次调试中节省大量不必要的写入操作也方便核对是不是有遗漏的页面。5. 常见问题与排查技巧实录5.1 元素找不到先分清是等待不够还是选择器本文还有配套的精品资源点击获取
返回列表