首页 / 资讯中心 / 文章详情

驾考题库数据采集与结构化实战:从接口分析到SQLite存储

驾考题库数据采集与结构化实战:从接口分析到SQLite存储 ★ FEATURED ARTICLE
驾考科目一和科目四的题库几乎是每个学车的人都绕不开的东西。我当初备考的时候手机里装了不下三个刷题应用用下来发现一个共同问题题目解析的质量参差不齐有些题目的答案对不上有些解析写得含糊其辞还有些题目在不同应用里表述都不一样。那时候我就动了心思想把这些题库数据整理下来自己做交叉比对看看哪些题目是真正的高频考点哪些解析是靠谱的。后来跟几个做数据的朋友聊起来发现大家都有类似的需求——有人是为了做题库分析有人是想训练一个驾考问答的小模型还有人纯粹是想离线刷题不依赖网络。这篇内容就是把我自己整理题库数据的完整思路和踩过的坑梳理出来从目标分析、数据结构拆解、采集策略设计到实际落地时的技术选型和避坑经验尽量讲透。适合有一定编程基础、想自己动手整理结构化题库数据的朋友参考也适合对数据采集流程感兴趣但还没实际动过手的读者。1. 先想清楚驾考题库数据到底长什么样很多人一上来就想着写代码去抓数据结果抓回来一堆乱七八糟的文本清洗起来比重新抓还费劲。我在动手之前花了大半天时间把科目一和科目四的题目结构摸了一遍这个前置工作直接决定了后面采集脚本的设计思路。1.1 科目一与科目四的题型差异科目一和科目四虽然都是理论考试但题型结构差别不小。科目一以单选题和判断题为主题目偏向交通法规、标志标线、扣分罚款这类记忆性内容科目四则多了多选题而且场景题占比更高很多题目会配一张路况示意图或者动画描述。这个差异直接影响数据模型的设计——如果只按题干选项答案三个字段来存科目四的多选题和场景题就会丢信息。我实际拆解下来一道题至少需要这些字段才能完整描述字段名说明是否必需question_id题目唯一标识必需subject科目一/科目四必需question_type单选/判断/多选必需stem题干文本必需options选项列表含序号和内容单选多选必需answer正确答案必需explanation解析文本建议保留image_url配图地址场景题必需chapter所属章节分类建议保留这个表看着简单但每一项都有讲究。比如options字段如果存成纯文本后面做选项分析的时候还得再解析一遍不如直接存成结构化的列表。再比如chapter很多题库应用会把题目按交通标志违法行为处理安全行车等章节归类这个分类信息对于做高频考点分析非常有用采集的时候能带上就尽量带上。1.2 题目数据的隐藏结构分类树与难度标签除了单道题目的字段题库本身还有一层组织结构。大多数题库应用会把题目挂在一棵分类树上科目一下面分若干章每章下面再分小节。这个树形结构如果不采集后面想做哪个章节的题目最容易错这类分析就无从下手。我的做法是在采集题目之前先把分类树完整拉一遍给每个分类节点分配一个内部 ID然后在采集题目时把分类 ID 作为外键关联上。这样存下来的数据就是一张题目表加一张分类表用 SQL 关联查询非常方便。难度标签也是类似的处理有些题库会给题目标注简单/中等/困难这个字段虽然不一定所有应用都有但如果有就一定要保留做错题分析的时候很有价值。提示分类树和题目列表往往是两个独立的接口先拉分类树再按分类逐个拉题目比一次性拉全量题目更稳也更容易做增量更新。1.3 为什么不能直接复制粘贴了事有人可能会说题目数量又不多手动复制粘贴不就行了。我一开始也这么想过但实际试了之后发现完全不现实。科目一题库在一千道以上科目四也有几百道手动复制不仅耗时而且极易出错——选项顺序搞混、答案对错行、解析漏掉半句这些错误在后期核对时非常折磨人。更关键的是手动复制没法保留分类结构和题目 ID后面想做任何结构化分析都得从头再来。所以我的结论很明确只要题目数量超过一百道就值得写脚本处理。脚本的好处不只是快更重要的是可重复、可校验、可增量更新。题库会随着法规调整而变化写好的脚本改个参数就能重新跑一遍比手动维护省心太多。2. 采集策略设计从接口分析到请求节奏控制搞清楚数据结构之后下一步就是设计采集策略。这一步是整个流程里技术含量最高的部分也是最容易出问题的地方。我前后调整了三四版方案才跑通下面把关键决策点拆开讲。2.1 接口分析的基本思路题库类应用的数据来源通常有两种一种是页面直接渲染数据嵌在 HTML 里另一种是前端通过异步请求拉取 JSON 数据。前者用常规的 HTML 解析就能拿到后者需要找到实际的请求地址和参数。我遇到的情况是后者居多所以重点说接口分析。分析接口的工具就是浏览器开发者工具的网络面板。操作步骤不复杂打开目标页面按 F12 调出开发者工具切到网络面板然后刷新页面或者点击下一题观察新出现的请求。重点看两类请求一类是返回 JSON 的 XHR 请求另一类是返回图片的静态资源请求。JSON 请求里通常就藏着题目数据图片请求则对应场景题的配图。找到候选请求之后右键复制为 cURL 命令拿到命令行里跑一下确认返回的数据结构和页面显示一致。这一步很关键因为有些请求带了签名参数或者时间戳直接复制出来的命令可能过几秒就失效了需要观察参数的变化规律。2.2 请求参数里的门道接口参数通常分三类分页参数、分类参数和鉴权参数。分页参数一般是page和pageSize或者offset和limit这个好处理循环递增就行。分类参数可能是chapterId或者categoryCode需要从分类树接口里拿到。鉴权参数是最麻烦的可能是 token、签名或者某种加密串。我实际处理的时候鉴权参数是一个放在请求头里的 token登录后由服务端下发。这个 token 有有效期跑批量采集的时候中途会过期导致后面的请求全部失败。解决办法是在脚本里加一个过期检测当返回状态码是未授权或者返回体里包含特定错误信息时暂停采集并提示重新获取 token。这个机制看起来简单但没有它的话跑一半失败重来非常浪费时间。注意token 这类鉴权信息属于个人账户凭证采集时务必控制请求频率避免对服务造成不必要的压力也避免触发风控导致账户异常。2.3 请求节奏与并发控制请求节奏是很多人容易忽略的点。我第一版脚本为了追求速度开了十个并发线程猛拉结果跑了不到两百道题就被限流了返回的全是空数据。后来把并发降到两个每个请求之间加零点五到一秒的随机间隔就稳定多了。这里有个经验值可以参考单线程顺序请求间隔控制在零点五秒以上基本不会触发限流如果确实需要提速并发不要超过三个并且每个线程独立控制间隔。另外夜间或者非高峰时段的请求成功率通常更高如果数据量不大可以挑这些时段跑。import time import random def fetch_with_retry(url, headers, max_retries3): for attempt in range(max_retries): try: response requests.get(url, headersheaders, timeout10) if response.status_code 200: return response.json() elif response.status_code 429: # 被限流退避等待 wait (attempt 1) * 5 random.uniform(0, 2) time.sleep(wait) else: time.sleep(1) except Exception as e: time.sleep(2) return None上面这段重试逻辑是我实际用下来的简化版核心就是遇到限流时指数退避遇到其他错误时短暂等待后重试。重试次数不要设太多三次足够再多说明接口本身有问题继续重试也没意义。2.4 增量采集与断点续传题库采集不是一次性的活法规更新后题目会变所以脚本要支持增量采集。我的做法是在本地维护一个已采集题目 ID 的集合每次采集前先加载这个集合遇到已存在的 ID 就跳过。这样即使中途中断重新跑的时候也能从断点继续不用从头再来。断点续传的实现依赖两个东西一个是已采集 ID 的持久化存储简单点用 JSON 文件就行另一个是采集进度的记录比如当前采到第几页、第几个分类。这两个信息每次采集完一批就落盘一次避免程序崩溃时丢失进度。3. 数据解析与清洗把半结构化文本变成规整数据数据抓回来只是第一步真正费时间的是解析和清洗。原始数据往往是半结构化的题干里混着各种符号选项格式不统一解析文本里还可能有 HTML 标签。这一节讲我怎么把这些脏数据整理成能直接用的结构化数据。3.1 题干与选项的分离原始数据里题干和选项有时候是混在一个字段里的比如题干字段里包含了 A. 选项一 B. 选项二这样的内容。分离的思路是用正则匹配选项标记。常见的选项标记有几种格式A.、A、、A:、A还有的直接用数字1.、2.。我写了一个正则把这些情况都覆盖到import re def split_stem_options(raw_text): # 匹配 A. A、 A: A 等多种选项标记 pattern r(?[A-Da-d][\.、:\)]\s*) parts re.split(pattern, raw_text) stem parts[0].strip() options [p.strip() for p in parts[1:] if p.strip()] return stem, options这个正则的关键在于用零宽断言做分割这样选项标记本身会保留在分割结果里方便后续提取选项序号。实际跑的时候发现有些题目选项里本身包含A.这样的内容会误分割所以后来又加了一层校验分割出来的选项数量必须在二到四个之间超出范围就回退到不分割的处理逻辑。3.2 解析文本的清洗解析文本里最常见的问题是 HTML 标签和多余空白。有些题库的解析是从网页直接抓的里面带着p、br、span这类标签还有nbsp;这样的实体字符。清洗的时候先用 HTML 解析库把标签去掉再把连续空白压缩成单个空格。from bs4 import BeautifulSoup def clean_explanation(html_text): if not html_text: return soup BeautifulSoup(html_text, html.parser) text soup.get_text(separator ) # 压缩连续空白 text re.sub(r\s, , text).strip() return text清洗完之后还要做一轮人工抽检。我随机抽了五十道题把清洗后的解析和原始页面对比发现有几道题的解析里混进了广告文案还有几道题的解析被截断了。这类问题脚本很难自动识别只能靠抽检发现后针对性处理。抽检比例建议不低于百分之五题目总量大的话可以适当降低但至少抽三十道。3.3 答案格式的统一答案字段的格式五花八门有的是单个字母A有的是A,B有的是AB还有的是正确答案A。统一的做法是先提取出所有字母再排序去重最后用逗号连接。判断题的答案有时候是正确/错误有时候是对/错还有的是√/×需要映射成统一的布尔值或者A/B。原始格式统一后AAA,BA,BABA,B正确答案AA正确 / 对 / √A判断题错误 / 错 / ×B判断题这个映射表是我实际遇到的所有格式汇总基本能覆盖常见情况。如果遇到表里没有的格式先记下来别急着写通用规则等积累几个样本再统一处理避免过度设计。3.4 配图资源的处理科目四的场景题经常配图图片地址一般是完整的 URL 或者相对路径。完整的 URL 直接下载就行相对路径需要拼上域名前缀。下载图片的时候要注意两点一是文件名用题目 ID 命名方便和题目数据关联二是控制下载速度图片资源通常比 JSON 接口更容易触发限流。我实际处理的时候图片下载单独开了一个队列和题目数据采集分开跑。题目数据先全部采完存好图片再慢慢下这样即使图片下载中途出问题也不影响题目数据的完整性。图片存本地之后在题目数据里把image_url字段改成对应的本地路径后面用起来就不用再依赖网络了。4. 存储方案选型JSON、CSV 还是数据库数据清洗完接下来要考虑存哪里。这个选择看似简单但选错了后面查询和分析会很别扭。我把几种常见方案的实际体验说一下供你按自己的需求取舍。4.1 几种存储方案的对比方案优点缺点适用场景JSON 文件结构灵活读写简单数据量大时查询慢数据量小、一次性分析CSV通用性强Excel 可直接打开嵌套结构表达困难简单表格型数据SQLite单文件、支持 SQL 查询并发写入能力弱本地分析、中小数据量MySQL/PostgreSQL查询能力强、支持并发需要部署维护多人协作、大数据量我自己的选择是 SQLite。原因很实际题库数据量不大科目一加科目四也就两千道题左右SQLite 完全够用单文件存储拷来拷去方便支持 SQL做分类统计和交叉查询很顺手不需要额外部署服务脚本里直接调库就行。4.2 表结构设计的一个关键决策建表的时候有个决策点值得说选项是单独建一张表还是直接存在题目表的一个字段里。单独建表的好处是查询灵活比如想找所有包含某个选项内容的题目很方便缺点是关联查询多一层写起来麻烦。存在一个字段里的好处是简单读一道题一次查询就够缺点是做选项级分析时要先解析字段。我最终选了存在一个字段里用 JSON 字符串存选项列表。理由是选项级分析的需求其实不多大部分分析是题目级的存一个字段够用。如果后面确实需要选项级查询SQLite 也支持 JSON 函数可以临时解析不至于推倒重来。CREATE TABLE questions ( question_id TEXT PRIMARY KEY, subject TEXT NOT NULL, question_type TEXT NOT NULL, stem TEXT NOT NULL, options TEXT, answer TEXT NOT NULL, explanation TEXT, image_path TEXT, chapter_id TEXT, difficulty TEXT, created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP ); CREATE TABLE chapters ( chapter_id TEXT PRIMARY KEY, subject TEXT NOT NULL, parent_id TEXT, name TEXT NOT NULL );上面这个表结构是我实际用的简化版字段够用又不冗余。options存 JSON 字符串chapter_id关联分类表image_path存本地路径。建完表之后加个索引在subject和chapter_id上查询速度明显提升。4.3 数据校验采集完必须做的一步数据入库之后一定要做一轮完整性校验。我踩过的坑是采集脚本跑完了没报错以为万事大吉结果分析的时候发现少了整整一个章节的题目原因是那个章节的分类 ID 在分类树里是空的脚本跳过了。这种问题不校验根本发现不了。校验的维度我总结了几个题目总数是否和预期量级相符、每个分类下的题目数是否为零、答案字段是否有空值、选项数量是否合理单选至少两个、多选至少三个、配图路径是否存在对应文件。写个简单的校验脚本跑一遍输出异常清单比人工翻数据高效得多。def validate_data(conn): issues [] # 检查空答案 cur conn.execute(SELECT question_id FROM questions WHERE answer IS NULL OR answer ) for row in cur: issues.append(f空答案: {row[0]}) # 检查分类下题目数为零 cur conn.execute( SELECT c.chapter_id, c.name FROM chapters c LEFT JOIN questions q ON c.chapter_id q.chapter_id WHERE q.question_id IS NULL ) for row in cur: issues.append(f空分类: {row[0]} - {row[1]}) return issues这段校验逻辑不复杂但非常实用。每次采集完跑一遍有问题及时补采比事后发现数据缺失再回头排查省事太多。5. 实操中踩过的坑与应对经验前面讲的都是相对顺利的路径实际动手的时候坑远比想象的多。这一节把我印象最深的几个问题拿出来说都是那种文档里不会写、只有实际跑过才知道的经验。5.1 分页参数的陷阱页码从零还是从一开始这个坑我踩了两次。第一次是分页参数从零开始我按从一开始循环结果第一页数据重复采了两遍最后一页永远采不到。第二次换了个接口又从一开始我按从零开始循环结果第一页直接漏了。判断方法很简单先手动请求第一页看返回的数据和页面显示是否一致再请求第二页对比两页数据有没有重叠。确认起始值之后再写循环能省掉大量返工。更隐蔽的一种情况是分页参数不是页码而是偏移量比如offset0limit20表示前二十条下一页是offset20limit20。这种参数如果按页码逻辑去递增采到的数据会错位。识别方法是看参数名page开头的基本是页码offset开头的基本是偏移量但也不能全信还是得实际请求验证。5.2 数据去重题目 ID 不一定可靠按理说题目 ID 应该是唯一的但实际数据里不一定。我遇到过两种情况一种是同一道题在不同分类下出现ID 相同但分类不同这种其实是合理的去重时应该保留分类信息另一种是不同题目复用了同一个 ID这种就是数据源的问题去重时如果只按 ID 去重会误删题目。应对办法是组合去重用题目 ID 加题干文本的哈希值作为联合主键。ID 相同但题干不同的视为不同题目ID 和题干都相同的才判定为重复。这个策略跑下来基本没出过错。5.3 编码问题中文乱码的几种来源中文乱码是采集中文数据绕不开的问题。我遇到的乱码来源有三个一是响应头里没声明编码requests 库默认按 ISO-8859-1 解码中文全变问号二是响应头声明了编码但和实际编码不符三是数据本身是 Unicode 转义序列比如\u4ea4\u901a这种。第一种情况的解决办法是手动指定编码response.encoding utf-8或者用response.apparent_encoding自动检测。第二种情况比较麻烦需要实际看原始字节判断编码常见的是 GBK 和 UTF-8 混用。第三种情况用json.loads解析时通常会自动处理如果是纯文本里的转义序列需要手动decode(unicode_escape)再重新编码。提示采集中文数据时建议在脚本里统一做一次编码规范化把所有文本转成 UTF-8 再入库避免后续处理时反复遇到编码问题。5.4 接口结构变化脚本跑不通了怎么办题库应用的接口不是一成不变的我隔了一个月重新跑脚本发现返回的数据结构变了原来在data.list里的题目数组挪到了data.items字段名也从questionContent改成了content。这种变化没有任何预告只能靠脚本报错发现。应对策略是给解析逻辑加一层容错字段取值时用多候选键名比如item.get(content) or item.get(questionContent)这样小范围改名不至于让脚本直接崩。同时把原始响应存一份到本地出问题的时候可以拿原始数据对照快速定位是哪个字段变了。5.5 图片下载的坑防盗链与格式伪装图片下载遇到的问题比 JSON 接口多。最常见的是防盗链直接请求图片地址返回 403解决办法是在请求头里带上 Referer值设成题库页面的域名。还有一种情况是图片地址返回的内容其实是 HTML 错误页只是扩展名是.jpg下载下来打开一看是空白。判断方法是检查响应的 Content-Type如果不是image/开头就说明有问题。另外图片格式也不一定和扩展名一致有的.png实际是 WebP 格式。这个对后续使用影响不大但如果要做图片处理最好用图像库实际探测一下格式别信扩展名。6. 采集到的数据能拿来做什么数据整理好之后能做的事情比想象的多。我最初只是为了交叉比对解析质量后来发现这些结构化数据在很多场景下都有用。这一节说几个我实际做过或者见过别人做的用法给有类似需求的朋友一些参考。6.1 高频考点与易错题分析有了带分类和答案的结构化数据做考点分析就很直接了。统计每个章节的题目数量能看出哪些章节是重点如果有错题记录把错题和题目数据关联能算出每个章节的错题率错题率高的章节就是需要重点复习的。我做过一个简单的分析发现某个章节的题目数量只占总量的一成但错题率接近三成这个章节显然值得多花时间。做这类分析的时候分类粒度很关键。太粗了看不出差异太细了样本量不够。我的经验是按二级分类做统计比较合适一级分类太笼统三级分类每类只有几道题统计意义不大。6.2 题库质量交叉比对如果从多个来源采集了题库数据可以做交叉比对。同一道题在不同来源里的答案是否一致、解析是否互补这些信息对于判断题库质量很有价值。比对的关键是题目匹配题干文本完全一致的好办表述略有差异的需要做文本相似度计算。简单的做法是用编辑距离或者 Jaccard 相似度阈值设在零点八左右高于阈值的判定为同一道题。比对结果里最有价值的是答案不一致的题目这些题目往往就是容易混淆的考点单独拎出来重点看对备考帮助很大。我比对下来发现答案不一致的题目大概占百分之三到五比例不高但每一道都值得深究。6.3 离线刷题工具的数据底座把数据存成本地 SQLite 之后做一个离线刷题的小工具就很方便了。不需要联网随机抽题、按章节练习、错题重做这些功能都能实现。我用 Python 的 Tkinter 写过一个简易版本界面很粗糙但功能完整核心逻辑就是查数据库、随机取题、记录答题结果。如果不想写界面用命令行交互也能刷题体验差一点但胜在简单。做离线工具的时候有个细节要注意题目顺序要打乱不然每次刷题顺序一样容易形成位置记忆而不是真正记住知识点。打乱的实现很简单SQL 查询加ORDER BY RANDOM()就行SQLite 原生支持。6.4 数据更新与长期维护题库数据不是采一次就完事了法规调整后题目会增删改。长期维护的关键是建立一套更新机制定期跑一次增量采集对比新旧数据的差异新增的题目入库删除的题目标记失效修改的题目更新内容。差异对比用题目 ID 加题干哈希做键能准确识别出增删改三类变化。我实际维护的时候是每个月跑一次增量跑完生成一份变更报告列出新增多少题、删除多少题、修改多少题。这个报告本身也有价值能看出题库的更新节奏和法规调整的关联。7. 一些实操层面的建议写到这里技术层面的东西基本讲完了。最后分享几条偏经验性的建议都是我在实际动手过程中体会比较深的希望对准备动手的朋友有点帮助。第一条是关于工具选择的。Python 做这类数据采集和清洗是最顺手的requests 处理请求、BeautifulSoup 处理 HTML、sqlite3 处理存储标准库加几个常用第三方库就能覆盖全流程不需要引入重型框架。如果你的数据量特别大可以考虑加个 pandas 做批量处理但题库这个量级其实用不上。第二条是关于代码组织的。采集脚本建议拆成几个独立的模块请求模块负责发请求和重试解析模块负责把原始数据转成结构化数据存储模块负责入库校验模块负责完整性检查。拆开之后每个模块都好测试、好修改接口结构变了只需要改解析模块不用动其他部分。我第一版把所有逻辑写在一个文件里后来改起来非常痛苦拆开之后清爽很多。第三条是关于数据备份的。采集过程中产生的原始响应数据建议单独存一份不要只存清洗后的结果。清洗逻辑可能会改改了之后需要重新处理原始数据如果原始数据没存就得重新采集一遍。原始响应存成按日期分目录的 JSON 文件就行占不了多少空间但关键时刻能救命。第四条是关于合规边界的。采集数据用于个人学习分析是一回事大规模采集后公开传播或者商用是另一回事这个边界要心里有数。控制请求频率、不绕过正常的访问限制、不采集个人隐私信息这些都是基本的自我约束。技术本身是中性的怎么用取决于使用的人。最后说一个小的技术细节。SQLite 在写入大量数据时默认每条 INSERT 都会触发一次磁盘同步速度很慢。解决办法是批量写入时开一个事务所有 INSERT 放在一个事务里提交速度能提升一个数量级。具体做法是写入前执行BEGIN写完执行COMMIT中间不要自动提交。这个优化对于两千道题的量级可能感知不明显但如果数据量上万差别就很大了。
阅读完成 · 觉得有帮助?
咨询建站