news 2026/10/8 11:10:51

考试宝试题爬虫实战:Selenium环境搭建、登录态复用与XPath定位全攻略

作者头像

张小明

前端开发工程师

1.2k 24
文章封面图
考试宝试题爬虫实战:Selenium环境搭建、登录态复用与XPath定位全攻略

简介:基于Python与Selenium实现的考试宝试题爬虫源码包,面向计算机专业毕业设计、课程作业与爬虫实战学习者,提供完整可运行的爬虫项目及使用说明。压缩包共6个文件,包含两个Python源文件(主逻辑与配置文件)、Chromedriver执行驱动、Git忽略文件、Markdown说明文档及一张项目结构示意图,整体8.2MB,轻量易部署。目前已有273人学习下载,项目源自评审98分的高分设计,源码经本地编译调试确保可运行。通过Selenium实现网页自动化访问与试题数据抓取,配套说明对驱动配置、代码组成和运行流程有清晰介绍,可帮助读者快速跑通Selenium爬虫开发流程,并迁移至其他采集场景。

1. 考试宝试题爬虫这件事:为什么选 Selenium 而不是 requests

想批量把考试宝某个题库的题目抓下来离线练习的人,多半会先试 requests + BeautifulSoup,结果发现页面源码里根本没有题目文本——题干和选项都是前端 JS 动态渲染出来的,直接请求 HTML 只能拿到一堆空壳 div。这时候 selenium 就成了最省事的方案:它用真实浏览器跑页面,等 JS 执行完再抓 DOM,比逆向接口加解密省太多时间。这篇笔记就是一套基于 Python + selenium 的考试宝试题爬虫完整落地路径,从环境搭建、登录态复用、元素定位到翻页落盘,全部给出可复制的代码和参数,并把最容易翻车的几个点单独拎出来讲透。适合想抓题自用的学生或做题库分析的人,照着改改选择器就能用在同类考试站点上。

2. 环境搭建与登录态复用:Selenium 版本、驱动安装与 cookies 持久化

先解决一个最现实的问题:Selenium 4 之后 API 变化不小,网上大量教程还在用find_element_by_xpath这种旧写法,直接复制运行会报AttributeError: 'WebDriver' object has no attribute 'find_element_by_xpath'。新项目建议直接用 Selenium 4.x 配合 ChromeDriver,同时用 webdriver-manager 自动拉取驱动,省掉手动对版本号这一整类麻烦。

2.1 依赖安装与驱动版本:最常见的翻车点

Python 环境要求很低,3.8 往上都行。安装命令两条搞定:

pip install selenium webdriver-manager

如果你的机器上 Python 是通过官网安装包装的,建议顺手确认一下 pip 指向的是哪个解释器,避免装到了别的环境里。安装完成后先写一段最简启动代码验证驱动能不能起来:

from selenium import webdriver from selenium.webdriver.chrome.service import Service from webdriver_manager.chrome import ChromeDriverManager options = webdriver.ChromeOptions() options.add_argument('--headless=new') # 新版无头模式,Chrome 109+ 可用 options.add_argument('--disable-gpu') # 无头模式下关闭 GPU 加速,减少渲染报错 options.add_argument('--no-sandbox') # Linux 服务器上必须加,否则以 root 运行会直接崩 options.add_argument('--window-size=1920,1080') # 固定窗口尺寸,避免响应式布局把元素挤到屏幕外 driver = webdriver.Chrome( service=Service(ChromeDriverManager().install()), options=options ) driver.get('https://www.baidu.com') print(driver.title) driver.quit()

这段代码里参数不是随便加的,每个都有实际意义。--headless=new是 Chrome 109 之后的新无头模式,行为更接近有头浏览器,跑很多带懒加载的页面比旧版 headless 稳定得多。--window-size值得专门说一下:考试宝这类站点对不同宽度屏幕会切换布局,宽度不够时题目选项可能渲染成纵向排列,或者某些元素被折叠进弹层里,固定 1920 宽能规避一大半定位问题。

版本错位是新手遇到最多的坑:Chrome 浏览器自动升级到了 114,但你系统里装的 chromedriver 还是 110,启动时直接报session not created: This version of ChromeDriver only supports Chrome version 110。webdriver-manager 的原理就是启动前先检测本机 Chrome 版本,然后去下载对应驱动,基本一劳永逸。它的install()默认走的是 Chrome 官方渠道,如果你在公司内网、连不上外网,就得手动下载驱动放到项目目录,再用Service(executable_path='./chromedriver')指定路径。

2.2 登录态复用:让爬虫不用每次扫码

考试宝的很多题库需要登录才能看完整题目,而登录方式是扫码或者手机验证码,爬虫没法自动化完成。所以正确策略是:第一次手动登录,把浏览器 cookies 存下来,之后每次启动爬虫直接加载 cookies,跳过登录环节。

import json from pathlib import Path COOKIE_FILE = 'cookies.json' def save_cookies(driver, path=COOKIE_FILE): """把当前浏览器所有 cookies 写入 JSON 文件""" cookies = driver.get_cookies() with open(path, 'w', encoding='utf-8') as f: json.dump(cookies, f, ensure_ascii=False, indent=2) print(f'已保存 {len(cookies)} 条 cookies 到 {path}') def load_cookies(driver, path=COOKIE_FILE): """从 JSON 文件读取 cookies 并挂到当前浏览器""" if not Path(path).exists(): return False with open(path, 'r', encoding='utf-8') as f: cookies = json.load(f) for cookie in cookies: driver.add_cookie(cookie) return True def login_once(driver): """第一次登录:打开登录页,等用户扫码完成,再保存 cookies""" driver.get('https://www.example.com/login') # 换成考试宝实际登录地址 input('扫完码、确认登录成功后按回车继续...') # 登录成功后页面会跳转,此时 cookies 已经写入浏览器 save_cookies(driver)

这里有个关键顺序问题:add_cookie之前必须先driver.get()访问一次目标域名。因为浏览器只接受"归属当前域名"的 cookie,你还没打开过这个站点就直接塞 cookie,Selenium 会抛InvalidCookieDomainException。所以加载 cookies 的正确姿势是:

driver.get('https://www.example.com') # 先建立域 load_cookies(driver) # 再挂 cookie driver.refresh() # 刷新让登录态生效

另外提醒一点:cookies 是有时效的,考试宝的登录态一般能撑几天到几周。如果发现抓着抓着突然全部返回"请登录"页面,那就是 cookie 过期了,重新跑一次登录流程即可。还有一坑是 cookies 文件里可能包含expiry、sameSite这类字段,Selenium 的add_cookie对字段有校验,个别字段格式不对会报错。稳妥的做法是保存前先做一次清洗,只保留name、value、domain、path这四个必填字段:

clean = [] for c in driver.get_cookies(): clean.append({ 'name': c['name'], 'value': c['value'], 'domain': c.get('domain'), 'path': c.get('path', '/'), })

这个细节是我自己踩过的:有一次保存了完整 cookie 结构,第二天加载时 Selenium 报invalid argument: cannot parse cookie,排查半天发现是expiry字段类型问题。清洗之后再没出过事。

3. 元素定位与等待策略:考试宝页面上怎么找准题目

跑通环境之后,真正的技术活开始了。爬虫能不能稳定抓到题目,取决于两件事:等得够不够准、选得够不够稳。很多新人上来就time.sleep(3),页面慢一点就抓空,快一点又白等,这种写法注定不长久。这一章把等待策略和 XPath 定位讲透,代码可以直接抄。

3.1 用 WebDriverWait 而不是 sleep:超时与轮询参数怎么设

页面里的题目是 JS 异步渲染的,driver.get()返回时 DOM 可能还是空的。Selenium 官方推荐的方案是显式等待:明确告诉浏览器"你等到某个元素出现再继续执行"。

from selenium.webdriver.common.by import By from selenium.webdriver.support.ui import WebDriverWait from selenium.webdriver.support import expected_conditions as EC wait = WebDriverWait(driver, timeout=10) # 等题目容器出现,最多等 10 秒 question_items = wait.until( EC.presence_of_element_located((By.CSS_SELECTOR, 'div.question-item')) ) # 等翻页按钮可点击,再去点击 next_btn = wait.until( EC.element_to_be_clickable((By.XPATH, '//button[contains(text(), "下一页")]')) )

presence_of_element_located表示元素出现在 DOM 树里就算满足,哪怕它还藏在屏幕外;element_to_be_clickable则要求元素可见且可交互,适合按钮、链接这类要点击的对象。另一个常用的是visibility_of_element_located,用于需要读取文本内容的元素——如果只是判断"存在"但页面还没渲染完,text可能拿到空字符串。

关于超时参数,我的习惯是:页面初次加载给 10 秒,点击后二次渲染给 5 秒,碰到需要额外校验的场景(比如提交后出现结果)给到 30 秒。超时会抛TimeoutException,所以要配合 try/except 做重试,而不是让它直接中断整个爬虫:

from selenium.common.exceptions import TimeoutException try: wait.until(EC.presence_of_element_located((By.CSS_SELECTOR, 'div.question-item'))) except TimeoutException: print('题目元素加载超时,尝试滚动页面触发懒加载') driver.execute_script('window.scrollTo(0, document.body.scrollHeight)') time.sleep(2)

考试宝这类题库站通常用懒加载:页面底部一滚,才往 DOM 里塞下一批题目。所以光等还不够,得先触发滚动。这个组合拳我在多个站点上都验证过是有效的。

3.2 XPath 与 text 函数:提取题干和选项的标准写法

定位到题目容器之后,要在容器内部再找题干、选项和答案。下面的 HTML 结构是典型考试站点的题目骨架,具体 class 名你 F12 看一眼就能对上:

<div class="question-item"> <div class="stem">Python 中用于定义函数的关键字是?( )</div> <div class="option">A. class</div> <div class="option">B. def</div> <div class="option">C. function</div> <div class="option">D. lambda</div> <div class="answer">B</div> </div>

用 XPath 提取时,核心写法是这样的:

def parse_question(item): # 题干:定位 stem 类名的 div,取文本 stem_el = item.find_element(By.XPATH, './/div[contains(@class, "stem")]') stem = stem_el.text.strip() # 选项:可能有多个,用 find_elements 返回列表 option_els = item.find_element(By.XPATH, './/div[contains(@class, "option")]') options = [o.text.strip() for o in option_els] # 答案:有的站点直接渲染,有的要点击“查看答案”后才出现 try: answer_el = item.find_element(By.XPATH, './/div[contains(@class, "answer")]') answer = answer_el.text.strip() except Exception: answer = item.get_attribute('data-answer') # 兜底:读自定义属性 return {'stem': stem, 'options': options, 'answer': answer}

这里有两个必须避开的坑。第一,find_element不能直接选text()节点——很多人从网上抄到类似.//div/text()的写法,Selenium 会直接抛The result of the xpath expression is: [object Text]。正确做法是先定位到元素,再通过.text属性拿文本。第二,contains(text(), 'xxx')这个函数只匹配元素的直接文本子节点,不递归匹配子元素。如果题干里某个关键词包在<span>里,你得写成.//div[contains(., '题型')]用.代替text(),表示匹配整个元素的所有文本内容。这俩坑我见人踩过无数回。

定位元素还有一种常见场景是按文本内容找节点,比如找"下一页"按钮:

next_btn = driver.find_element( By.XPATH, '//button[contains(text(), "下一页") or contains(.//span, "下一页")]' )

contains(text(), ...) or contains(.//span, ...)这种写法覆盖了"文本直接写在按钮里"和"文本包在 span 里"两种情况,实战里非常管用。如果按钮文本带首尾空格,text()匹配不上,normalize-space()可以解决:

'//button[normalize-space() = "下一页"]'

关于选择器选型我的建议是:能用 XPath 用 XPath。CSS 选择器适合按 class 精确定位,但考试类站点前端改版频繁,class 名经常带随机后缀或者改名,contains(@class, "stem")这种模糊匹配的容错度比完整 class 高得多。

4. 组装一个能跑的考试宝试题爬虫:翻页、解析与落盘

前面几章把零件备齐了,这一章把它们组装成一台完整机器。我会给出一份可以直接保存运行的脚本,包含登录、抓取、解析、落盘四个模块,并解释每段代码在流程里的作用。标题里提到的"使用说明"在这里落到实处:脚本跑完,你会得到一份结构化的 JSON 题库文件。

4.1 主流程代码:最小可用的单套题库爬虫

# exam_crawler.py # 使用说明: # 首次使用:python exam_crawler.py --login 手动登录并保存 cookies # 日常抓取:python exam_crawler.py --crawl --start 1 --end 10 # 参数说明: # --start 起始页码,--end 结束页码,页码从 1 开始 import argparse import json import time from pathlib import Path from selenium import webdriver from selenium.webdriver.common.by import By from selenium.webdriver.support.ui import WebDriverWait from selenium.webdriver.support import expected_conditions as EC from selenium.webdriver.chrome.service import Service from webdriver_manager.chrome import ChromeDriverManager # ============ 配置区 ============ BASE_URL = 'https://www.example.com/question/list?page=' # 换成题库实际列表页地址 COOKIE_FILE = 'cookies.json' OUTPUT_FILE = 'questions.json' # 题干和选项的选择器,按你 F12 看到的实际 class 修改 SEL_QUESTION = 'div.question-item' SEL_STEM = './/div[contains(@class, "stem")]' SEL_OPTION = './/div[contains(@class, "option")]' SEL_ANSWER = './/div[contains(@class, "answer")]' # ================================ def get_driver(headless=True): options = webdriver.ChromeOptions() if headless: options.add_argument('--headless=new') options.add_argument('--disable-gpu') options.add_argument('--no-sandbox') options.add_argument('--window-size=1920,1080') options.add_argument('--lang=zh-CN') options.add_argument('--disable-blink-features=AutomationControlled') options.add_experimental_option('excludeSwitches', ['enable-automation']) return webdriver.Chrome( service=Service(ChromeDriverManager().install()), options=options ) def save_cookies(driver): clean = [] for c in driver.get_cookies(): clean.append({ 'name': c['name'], 'value': c['value'], 'domain': c.get('domain'), 'path': c.get('path', '/'), }) with open(COOKIE_FILE, 'w', encoding='utf-8') as f: json.dump(clean, f, ensure_ascii=False, indent=2) def load_cookies(driver): if not Path(COOKIE_FILE).exists(): return False with open(COOKIE_FILE, 'r', encoding='utf-8') as f: cookies = json.load(f) for cookie in cookies: driver.add_cookie(cookie) return True def is_logged_in(driver): """检测登录态:找一个登录后才会出现的元素做判断""" try: driver.find_element(By.CSS_SELECTOR, 'div.user-info') # 换成实际元素 return True except Exception: return False def parse_page(driver): questions = [] items = driver.find_elements(By.CSS_SELECTOR, SEL_QUESTION) for item in items: stem = item.find_element(By.XPATH, SEL_STEM).text.strip() options = [o.text.strip() for o in item.find_elements(By.XPATH, SEL_OPTION)] answer = '' try: answer = item.find_element(By.XPATH, SEL_ANSWER).text.strip() except Exception: answer = item.get_attribute('data-answer') or '' questions.append({ 'stem': stem, 'options': options, 'answer': answer, }) return questions def crawl_pages(driver, start, end): all_questions = [] for page in range(start, end + 1): driver.get(f'{BASE_URL}{page}') wait = WebDriverWait(driver, 10) try: wait.until(EC.presence_of_element_located( (By.CSS_SELECTOR, SEL_QUESTION))) except Exception: print(f'第 {page} 页加载超时,可能是最后一页或结构变了') break # 触发懒加载:滚动两次 driver.execute_script('window.scrollTo(0, document.body.scrollHeight)') time.sleep(1) driver.execute_script('window.scrollTo(0, 0)') page_questions = parse_page(driver) if not page_questions: print(f'第 {page} 页解析到 0 题,提前结束') break all_questions.extend(page_questions) print(f'第 {page} 页:{len(page_questions)} 题,累计 {len(all_questions)} 题') time.sleep(2) # 礼貌限速,别把对方服务器打得太狠 return all_questions def main(): parser = argparse.ArgumentParser() parser.add_argument('--login', action='store_true', help='手动登录并保存 cookies') parser.add_argument('--crawl', action='store_true', help='开始抓取') parser.add_argument('--start', type=int, default=1) parser.add_argument('--end', type=int, default=10) args = parser.parse_args() driver = get_driver(headless=False if args.login else True) if args.login: driver.get('https://www.example.com/login') input('登录成功后按回车,保存 cookies...') save_cookies(driver) driver.quit() return if args.crawl: driver.get(BASE_URL.split('page=')[0]) # 先访问根域 load_cookies(driver) driver.refresh() if not is_logged_in(driver): print('登录态失效,先执行 python exam_crawler.py --login') driver.quit() return questions = crawl_pages(driver, args.start, args.end) with open(OUTPUT_FILE, 'w', encoding='utf-8') as f: json.dump(questions, f, ensure_ascii=False, indent=2) print(f'完成,共 {len(questions)} 题,已写入 {OUTPUT_FILE}') driver.quit() if __name__ == '__main__': main()

这段代码的流程逻辑是:--login模式用有头浏览器打开登录页,等人扫完码后把 cookies 落盘;--crawl模式启动无头浏览器,先访问根域挂 cookies,再逐页抓取。翻页方式这里用的是最简单的一种——直接修改 URL 里的 page 参数。这是因为考试宝这类题库站的列表页通常支持 URL 传参翻页,比模拟点击"下一页"按钮稳定得多。

如果目标站点不支持 URL 翻页,就得走按钮点击,代码要改成这样:

while True: # 抓当前页... try: next_btn = wait.until(EC.element_to_be_clickable( (By.XPATH, '//button[contains(text(), "下一页")]'))) next_btn.click() time.sleep(1) except Exception: print('没有下一页按钮,抓取结束') break

按钮点击有两个隐患:一是点击后页面如果是 AJAX 局部刷新,Selenium 拿着旧元素句柄去操作会报StaleElementReferenceException,解决办法是每次循环重新查找按钮;二是按钮离屏时点击会失效,需要先driver.execute_script('arguments[0].scrollIntoView(true)', next_btn)把按钮滚进视口再点。

4.2 数据结构与落盘:JSON 与 CSV 怎么选、答案如何对齐

落盘格式我优先推荐 JSON。原因很简单:题库数据的自然形态是嵌套的——一道题包含题干、多个选项、答案、可能还有解析。用 CSV 存长这样:stem,option1,option2,option3,option4,answer,analysis,选项数量一变化就得改表头,同时题干里如果有逗号、换行还得转义,非常痛苦。JSON 则天然支持任意结构:

[ { "stem": "Python 中用于定义函数的关键字是?", "options": ["A. class", "B. def", "C. function", "D. lambda"], "answer": "B" } ]

写文件时两个参数必须记住:ensure_ascii=False保证中文原样输出而不是变成\uXXXX转义序列,indent=2让文件可读,方便人工检查。

如果你后续要用 Excel 或者 pandas 做数据分析,CSV 更顺手。转换逻辑很简单,选项列表用|分隔拼进单列:

import csv with open('questions.csv', 'w', encoding='utf-8-sig', newline='') as f: writer = csv.writer(f) writer.writerow(['题干', '选项', '答案']) for q in questions: writer.writerow([ q['stem'], ' | '.join(q['options']), q['answer'], ])

这里编码用utf-8-sig而不是utf-8,是因为 Excel 打开 UTF-8 文件默认不识别,中文会乱码,带 BOM 的utf-8-sig才能让 Excel 直接正常显示。

关于答案对齐,考试宝有些题目的答案不是直接渲染在 DOM 里的,而是藏在点击"查看答案"之后才展开的区域内。遇到这种情况,解析时得模拟点击:

# 点击题目里的“查看答案”按钮 try: btn = item.find_element(By.XPATH, './/button[contains(text(), "查看答案")]') btn.click() time.sleep(0.3) # 展开动画或异步渲染需要一点时间 answer = item.find_element(By.XPATH, './/div[contains(@class, "analysis")]').text.strip() except Exception: answer = ''

注意time.sleep(0.3)在这里是故意的:答案区域展开通常有 CSS 动画,等 0.3 秒成本极低,但能显著降低"元素存在但文本还没渲染完"的概率。等待不是不能用,而是要用在明确知道等待什么、等多久的场合。

另外提醒一句合规底线:这套爬虫只适用于抓取你自己有权限访问的题目,用于个人学习或内部题库整理。不要用它去高频请求把对方服务器打挂,不要绕过付费墙去抓收费内容,抓取频率控制在每页 2 秒以上是基本礼貌。

5. 考试宝爬虫高频翻车点排查:定位失效、滑块验证与长时间运行

这一章是把前面所有坑集中爆出来。每条都是"现象 → 原因 → 解决"的结构,都是我实际处理过或者见过别人踩的典型场景。写爬虫的人最怕的不是代码报错,而是代码不报错但结果全错——所以这一章值得多看两遍。

5.1 选择题干元素突然失效:页面改版与登录态页面差异

现象:爬虫昨天还能跑,今天启动后每一页都打印"解析到 0 题",或者直接报NoSuchElementException。

原因:两种情况最常见。一是站点前端改版,CSS 类名变了,比如question-item改成了question-card;二是登录和未登录渲染的页面结构不一样——未登录时显示的是诱导登录的遮罩层,题目 DOM 根本不存在。

解决:先不要改代码,打开浏览器手动访问一页,按 F12 看实际 DOM。确认是登录态问题还是结构变化。如果是结构变化,用多级选择器做兜底,我一般会维护一个候选列表:

QUESTION_SELECTORS = [ 'div.question-item', 'div.question-card', 'div.exam-question', 'div[class*="question"]', # 最后兜底:只要 class 里含 question 就抓 ] def find_question_items(driver): for selector in QUESTION_SELECTORS: items = driver.find_elements(By.CSS_SELECTOR, selector) if items: return items return []

这样即使主选择器失效,爬虫还能继续跑,同时打印一条警告提示你手动核验。比直接崩掉强太多。

5.2 无头模式触发滑块验证码:自动化特征太明显

现象:headless 模式一打开题库页面,还没开始抓,页面就弹出滑块验证,或者提示"访问环境异常,请完成验证"。

原因:无头模式的浏览器指纹特征非常明显,window.navigator.webdriver标志为 true,加上请求频率过快、IP 段异常,很容易被风控系统识别。滑块验证不是针对你的爬虫,而是针对所有异常访问的统一防护。

解决:分三层处理。第一层,降低触发概率:去掉 headless 改有头模式、把每页间隔从 2 秒提到 4 秒、同一时间只跑一个爬虫实例。第二层,处理触发后的验证:有头模式下滑块的拖动可以用ActionChains模拟,但成功率看网站风控强度,不保证每次都过。第三层,最实际的方案是——账号被风控之后就停手,等半小时一小时的冷却期再继续,别硬刚。另外有几个减少指纹暴露的常见参数:

options.add_argument('--disable-blink-features=AutomationControlled') options.add_experimental_option('excludeSwitches', ['enable-automation']) options.add_experimental_option('useAutomationExtension', False)

这三个参数能去掉一部分自动化标志,但实话讲,现在主流站点的风控早就不只看这个标志了。更有效的是控制节奏和总量,把自己伪装成"正常但有点急性子的用户",而不是"机器人在疯狂刷页面"。

5.3 登录态静默失效:cookies 过期但不报错

现象:抓了 300 页一直正常,突然某页返回的数据变成"请先登录"的提示页,但因为页面里没有题目元素,爬虫直接判定"解析到 0 题"并退出,你回头看日志才发现数据缺了一大截。

原因:登录 cookies 在抓取中途过期,或者服务端检测到频繁请求强制下线。Selenium 不会主动告诉你登录态没了,只有等你发现"页面内容不对"才知道。

解决:在每页抓完后的翻页间隙做一次登录态校验,把问题提前暴露出来:

def check_login(driver): """返回 True 表示还在登录态,False 表示已失效""" try: # 找一个只有登录后才能看到的元素,比如用户名、头像、退出按钮 driver.find_element(By.CSS_SELECTOR, 'a.logout-btn') return True except Exception: return False # 在 crawl_pages 的循环里: for page in range(start, end + 1): driver.get(f'{BASE_URL}{page}') if not check_login(driver): print(f'第 {page} 页检测到登录态失效,停止抓取') break # 后续正常解析...

检测到失效之后,不要自动重新登录,直接停。因为自动登录需要扫码,爬虫没法自己完成,停下来人工处理是唯一靠谱的路。损失可控,至少你知道断在哪一页,补抓的时候从那页继续就行。

5.4 抓了几千题之后浏览器越来越慢直至崩溃

现象:爬虫跑到 2000 题以后,每页响应时间从 3 秒涨到 10 秒,最后浏览器标签页直接变成"无响应",进程被杀。

原因:Selenium 长时间运行不重启,浏览器进程内积累了大量 DOM 节点和渲染资源。虽然每页解析完题目对象就释放了,但 Chrome 渲染进程的内存不会立刻还给系统。无头模式下这个问题更严重。

解决:定时重启 driver 是最简单有效的方案。每抓 50 页左右杀掉浏览器重新起一个,代价是重新加载 cookies,但换来的是稳定:

def crawl_with_restart(start, end, restart_every=50): all_questions = [] page = start while page <= end: driver = get_driver(headless=True) driver.get(BASE_URL.split('page=')[0]) load_cookies(driver) driver.refresh() batch_end = min(page + restart_every - 1, end) all_questions.extend(crawl_pages(driver, page, batch_end)) driver.quit() # 强制释放内存 page = batch_end + 1 return all_questions

重启的代价很小,因为每页的数据已经落盘过一轮,重启后只是接着上次的页码继续。内存相关的排查也有个实用技巧:抓取过程中用系统资源监视器盯着 Chrome 的进程占用,如果单进程内存超过 1.5GB 还不回落,基本就是该重启的信号。

6. 让爬虫长期稳定跑的三件事:失败重试、增量抓取与运行日志

爬虫写出来能跑一次不算本事,能周周跑、月月跑才算数。因为题库站会更新题目、改版页面、清理异常账号,一套裸爬虫在真实环境里的存活周期通常不超过两周。我自己的经验是把三件事做进代码里:失败重试、增量抓取、运行日志。

失败重试要覆盖两种异常。一种是网络层的TimeoutException,页面加载超时,重试大概率能过;一种是解析层的"页面有结构但抓不到数据",重试两次还有问题就直接跳过,不要卡死整个任务。

def get_page_with_retry(driver, url, retries=3): for attempt in range(retries): try: driver.get(url) WebDriverWait(driver, 10).until( EC.presence_of_element_located((By.CSS_SELECTOR, SEL_QUESTION))) return True except Exception: print(f'第 {attempt + 1} 次加载失败: {url}') time.sleep(3 * (attempt + 1)) # 退避等待,3 秒、6 秒、9 秒 return False

增量抓取解决的是"题目太多、一次抓不完"的问题。思路很简单:启动时把已抓题目的题干哈希值载入一个 set,每抓一道就核对一下,重复的直接丢弃。考试类站点的题干长度稳定,适合做去重键。这样下次运行从断掉的页码继续,而不是从头再来。

import hashlib def stem_key(stem): # 取题干前 50 个字符做哈希,兼顾去重效率和区分度 return hashlib.md5(stem[:50].encode('utf-8')).hexdigest() seen = set() if Path(OUTPUT_FILE).exists(): for q in json.loads(Path(OUTPUT_FILE).read_text(encoding='utf-8')): seen.add(stem_key(q['stem'])) # 抓取时: if stem_key(q['stem']) not in seen: seen.add(stem_key(q['stem'])) all_questions.append(q)

运行日志是最后一道保险。我习惯用 Python 标准库的logging写文件日志,每页记一行:页码、抓题数、耗时。出问题的时候直接tail日志文件看最后的页码,就知道断在哪,不用凭记忆猜。

import logging logging.basicConfig( filename='crawler.log', level=logging.INFO, format='%(asctime)s %(levelname)s %(message)s' ) # 每页结束后: logging.info(f'page={page} ok={len(page_questions)} total={len(all_questions)}')

我做这类爬虫项目,代码里永远给日志留位置。原因很简单:爬虫这东西,跑得顺的时候你不需要看它,跑得不顺的时候你只能靠日志查案。早期我自己犯过一个低级错误,把所有输出全打在控制台,结果半夜跑挂了,第二天只剩一个黑乎乎的终端窗口,连挂在哪一页都不知道,只能从头再扫一遍。后来养成了"日志先行"的习惯:先写日志模块,再写抓取逻辑。

如果你准备拿这份代码去改造成自己的题库爬虫,我最后唠叨一句经验:把第 4 章的选择器配置区当成全项目最核心的部分维护。每次页面改版,最先要改的永远是那几行选择器,而不是翻页逻辑、落盘逻辑。把选择器集中放在文件顶部,加上注释说明抓取的是什么站的什么页面,三个月后你回来维护时,会感激当初这个决定。希望帮到你。

本文还有配套的精品资源,点击获取

版权声明: 本文来自互联网用户投稿,该文观点仅代表作者本人,不代表本站立场。本站仅提供信息存储空间服务,不拥有所有权,不承担相关法律责任。如若内容造成侵权/违法违规/事实不符,请联系邮箱:809451989@qq.com进行投诉反馈,一经查实,立即删除!
网站建设 2026/10/8 11:10:15

开源雷达周刊:每周精选10个能跑通的自动化工具

1. 为什么我要做这个开源雷达周刊 先说清楚这个周刊到底是个什么东西。简单讲&#xff0c;它是我每周花几个小时&#xff0c;把过去七天里在开源社区里冒出来的、跟自动化沾边的工具筛一遍&#xff0c;挑出十个真正能跑起来、能解决具体问题的项目&#xff0c;然后整理成一份可…

作者头像 李华
网站建设 2026/10/8 11:08:19

text-to-cad实战指南:从自然语言到参数化CAD模型的落地流程

上个月朋友让我帮忙弄一个传感器支架&#xff0c;微信里就甩来一句话&#xff1a;「6061 铝&#xff0c;L 型&#xff0c;底边四个孔&#xff0c;立边一个 M8 螺纹孔&#xff0c;总高 60。」这句话放到十年前&#xff0c;够我开软件画半小时&#xff1b;放到现在&#xff0c;te…

作者头像 李华
网站建设 2026/10/8 11:07:19

生产级AI Agent的七个工程决策点与落地实践

1. 这不是概念炒作&#xff0c;是工程师每天要填的七个坑 “AI Agent”这个词最近半年在技术社区里炸得比春节鞭炮还响。但你点开十篇讲Agent的文章&#xff0c;八篇在画思维导图——“感知-规划-行动-记忆-工具调用-反思-自我修正”&#xff0c;配个带箭头的圆环图&#xff0c…

作者头像 李华
网站建设 2026/10/8 11:06:50

从AI Agent到多AI协作:构建可靠AI流水线的实战指南

今天后台的热搜词里&#xff0c;AI Agent、多AI协作、AI编程、AI漫剧几乎霸了屏&#xff0c;连“AI诵经”这种画风清奇的关键词都挤了进来。我在翻这些关键词时&#xff0c;一个很明显的感受是&#xff1a;大家已经不满足于“问AI一句话”&#xff0c;而是开始琢磨“怎么让一批…

作者头像 李华
网站建设 2026/10/8 11:06:50

Spyder中文支持终极方案:locale注入与UTF-8编码统一

简介&#xff1a;本资源是面向Python初学者与数据科学从业者的Spyder IDE中文本地化工具包&#xff0c;专为解决英文界面理解门槛高、手动安装语言包易报错、编码配置复杂等痛点设计。内含简体中文语言包&#xff08;.mo文件&#xff09;及配套一键安装脚本&#xff08;main.py…

作者头像 李华