| name | standard-quiz-builder |
| description | Generates standardized local quiz banks (xlsx/apkg) for the fenbi-helper project, including extracting questions from PDF quiz books and converting them to the project's upload format. Invoke when user asks to create, build, or convert question banks, extract from PDF, design quiz templates, or standardize raw question data into the project's upload format. |
Standard Quiz Builder · 标准题库生成器
为错题助手项目(fenbi-helper)生成可直接上传到本地题库的标准题库文件。覆盖三种来源:从零生成 xlsx/apkg、从 PDF 题库(含扫描版)提取并转换、双文件题目册+答案册合并。所有输出严格对齐 quizLoader.js 的解析逻辑。
一、字段规范(与 quizLoader.js 严格对齐)
1. xlsx 表头(首行必需)
| 字段名 | 必填 | 说明 |
|---|
| 题干 | 是 | 题目正文;也支持列名「题目」 |
| 选项A ~ 选项D | 是 | 至少 4 列;多选可扩展到「选项E」「选项F」 |
| 答案 | 是 | 单选填 A/B/C/D;多选填字母组合如 ABC,顺序无关,系统自动排序去重 |
| 题型 | 选填 | 「单选」「多选」「多项选择」;留空默认按单选处理 |
| 解析 | 选填 | 题目解析文本 |
| 知识点 | 选填 | 考点标签 |
| 图片URL | 选填 | 题干配图链接,支持三种格式:1) http/https 绝对URL;2) 相对路径如 images/p01_q04_1.jpg(需配合题库目录下的 images 子目录 + 服务端 /quiz-img/:source/* 路由);3) 多图用 | 分隔,如 images/a.jpg|images/b.jpg |
| 解析图片URL | 选填 | 解析区配图链接(解析示意图),格式同"图片URL";在 quiz-result.ejs 的解析文本下方渲染 |
| 题号 | 选填 | 仅记录用,不影响加载 |
关键约束:
- 列名严格区分中文,必须完全匹配(如
选项A 不能写成 A选项)
- 多余列会被忽略,但不会报错
- 一个
.xlsx 文件 = 一个题套;文件名作为题套名
- 文件名建议含数字段,如
专项练习01.xlsx、第03套.xlsx,系统按末尾数字排序
2. apkg 字段(Anki 导出,7 字段顺序固定)
apkg 是 Anki 导出包,本质是 ZIP,内含 collection.anki2 SQLite 数据库。notes 表的 flds 列用 \x1f(0x1F)分隔 7 个字段:
| 序 | 字段 | 说明 |
|---|
| 1 | 题型 | 「单选」/「多选」/「多项选择」 |
| 2 | 题号 | 题号文本(选填) |
| 3 | 题干 | 题目正文 |
| 4 | 选项 | 用 <br> 分隔:A. 选项一<br>B. 选项二<br>C. 选项三<br>D. 选项四 |
| 5 | 答案 | 单选填单字母;多选填字母组合 |
| 6 | 解析 | 解析文本 |
| 7 | 知识点 | 考点标签 |
二、多选题处理规则(重要)
系统对多选题的识别与答案处理遵循以下规则,生成题库时必须配合:
- 题型识别正则:
/多(选|项)/ → 匹配「多选」「多项选择」「多项选择题」等变体
- 答案规范化:
- 单选题:取首个
[A-F] 字母,其余忽略
- 多选题:提取所有
[A-F] 字母,去重后按字母序排列
- 前端交互: 多选题不自动跳转,需手动点击「下一题」
- 判分逻辑: 后端
normMulti() 同样去重排序后比较
生成多选题时:
题型 字段必须包含「多选」或「多项」字样
答案 字段填写字母组合(如 ACD、BDE),顺序无关
- 至少提供 4 个选项,可扩展到 6 个(选项A~选项F)
三、生成 xlsx 题库(Node.js 脚本模板)
const xlsx = require('xlsx');
const path = require('path');
const questions = [
{
题型: '单选',
题干: '下列哪项属于宏观经济调控的目标?',
选项A: '经济增长',
选项B: '物价稳定',
选项C: '充分就业',
选项D: '国际收支平衡',
答案: 'ABCD',
解析: '宏观经济四大目标:经济增长、物价稳定、充分就业、国际收支平衡。',
知识点: '宏观经济',
题号: '1'
},
{
题型: '多选',
题干: '下列属于货币政策工具的有?',
选项A: '法定存款准备金率',
选项B: '再贴现率',
选项C: '公开市场业务',
选项D: '税收',
选项E: '政府支出',
答案: 'ABC',
解析: '货币政策工具包括法定存款准备金率、再贴现率、公开市场业务;税收与政府支出属于财政政策工具。',
知识点: '货币政策',
题号: '2'
}
];
const header = ['题号', '题型', '题干', '选项A', '选项B', '选项C', '选项D', '选项E', '选项F', , , , ];
aoa = [header];
questions.( {
aoa.([
q.题号 || ,
q.题型 || ,
q.题干 || ,
q.选项A || ,
q.选项B || ,
q.选项C || ,
q.选项D || ,
q.选项E || ,
q.选项F || ,
q.答案 || ,
q.解析 || ,
q.知识点 || ,
q.图片 ||
]);
});
ws = xlsx..(aoa);
wb = xlsx..();
xlsx..(wb, ws, );
outPath = path.(__dirname, );
xlsx.(wb, outPath);
.( + outPath + + questions. + );
生成多套题: 循环上述脚本,文件名按 专项练习01.xlsx、专项练习02.xlsx 递增,放入同一文件夹后整体上传。
四、生成 apkg 题库(高级,可选)
apkg 生成需借助 Anki 客户端或第三方库(如 Python 的 genanki)。若需程序化生成:
import genanki
model = genanki.Model(
1607392319,
'粉笔题库模型',
fields=[
{'name': '题型'},
{'name': '题号'},
{'name': '题干'},
{'name': '选项'},
{'name': '答案'},
{'name': '解析'},
{'name': '知识点'},
],
templates=[{
'name': 'Card 1',
'qfmt': '{{题干}}<br>{{选项}}',
'afmt': '{{FrontSide}}<hr>答案:{{答案}}<br>解析:{{解析}}',
}])
deck = genanki.Deck(2059402934, '粉笔专项题库')
questions = [
{
'题型': '单选',
'题号': '1',
'题干': '下列哪项是货币政策的工具?',
'选项': 'A. 法定存款准备金率<br>B. 税收<br>C. 政府支出<br>D. 国债发行',
'答案': 'A',
'解析': '货币政策工具由央行执行;税收、政府支出、国债属于财政政策。',
'知识点': '货币政策',
},
]
for q in questions:
note = genanki.Note(
model=model,
fields=[q['题型'], q[], q[], q[], q[], q[], q[]])
deck.add_note(note)
genanki.Package(deck).write_to_file()
()
关键点: 7 字段顺序必须为 [题型, 题号, 题干, 选项, 答案, 解析, 知识点],且 选项 用 <br> 分隔。
五、文件夹组织规范
上传时整体选择一个文件夹,文件夹内放多个 xlsx 或 apkg 文件:
我的专项题库/ ← 文件夹名 = 分类名(原样保留)
├── 专项练习01.xlsx ← 题套1
├── 专项练习02.xlsx ← 题套2
├── 专项练习03.xlsx ← 题套3
└── 专项练习04.xlsx ← 题套4
规范:
- 同格式不混放: 一个文件夹内只放 xlsx 或只放 apkg,避免混淆
- 文件名含数字: 系统按末尾数字段排序,无数字则按文件创建顺序
- 文件夹名即分类名: 上传弹窗的「分类名」字段留空时使用文件夹名;填写则覆盖
- 避免特殊字符: 文件夹/文件名避免
/\:*?"<>| 等 Windows 非法字符
六、从 PDF 题库提取并转换(重要)
适用场景
许多教辅题库原始形态是 PDF:
- 单文件 PDF:题目、选项、答案、解析在同一份文档中(可能答案解析附在每题后,也可能集中在文末)
- 双文件 PDF:一本题目册 PDF + 一本答案册 PDF(最常见于纸质教辅扫描件)
- 扫描版 PDF:纯图片,无文本层,需 OCR
系统限制: quizLoader.js 只能加载 xlsx / apkg,不能直接读 PDF。必须先把 PDF 内容提取并转成本规范定义的 xlsx 格式,再上传。
转换流程总览
PDF 题库 ──文本提取──> 结构化文本 ──正则/规则解析──> 题目对象数组 ──写入──> 标准 xlsx
↑ ↑
文本层 PDF:pdfplumber/PyMuPDF 合并题目+答案(双文件场景)
扫描版 PDF:OCR(paddleocr/Tesseract)
第一步:PDF 文本提取
1.1 文本层 PDF(可复制文字的 PDF)
推荐 Python pdfplumber(按行保留布局)或 PyMuPDF(速度更快):
import pdfplumber
def extract_text(pdf_path, out_txt_path):
"""提取 PDF 全文,保留分页与行结构"""
lines = []
with pdfplumber.open(pdf_path) as pdf:
for page_no, page in enumerate(pdf.pages, 1):
lines.append(f'=== 第 {page_no} 页 ===')
text = page.extract_text() or ''
for line in text.split('\n'):
lines.append(line.rstrip())
with open(out_txt_path, 'w', encoding='utf-8') as f:
f.write('\n'.join(lines))
print(f'已提取 {pdf_path} -> {out_txt_path},共 {len(lines)} 行')
extract_text('题目册.pdf', '题目册.txt')
extract_text('答案册.pdf', '答案册.txt')
关键点:
extract_text() 保留阅读顺序,适合单栏排版
- 双栏排版用
page.extract_text(layout=True) 或按坐标分栏提取
- 表格类内容用
page.extract_tables() 单独处理
1.2 扫描版 PDF(图片型,无文本层)
需先 OCR,推荐 PaddleOCR(中文识别率高):
from paddleocr import PaddleOCR
from pdf2image import convert_from_path
import os
def ocr_pdf(pdf_path, out_txt_path):
ocr = PaddleOCR(use_angle_cls=True, lang='ch', show_log=False)
images = convert_from_path(pdf_path, dpi=300)
lines = []
for i, img in enumerate(images, 1):
img_path = f'_tmp_page_{i}.png'
img.save(img_path, 'PNG')
result = ocr.ocr(img_path, cls=True)
lines.append(f'=== 第 {i} 页 ===')
if result and result[0]:
for line in result[0]:
text = line[1][0]
lines.append(text)
os.remove(img_path)
with open(out_txt_path, 'w', encoding='utf-8') as f:
f.write('\n'.join(lines))
print(f'OCR 完成: {pdf_path} -> {out_txt_path}')
ocr_pdf('扫描题目册.pdf', '题目册.txt')
OCR 注意事项:
- DPI 建议 300,低于 200 识别率明显下降
- OCR 后必须人工抽检,常见错误:
A 识别为 4、B 识别为 8、〇 与 O 混淆
- 选项字母前的点号可能丢失,后续正则需宽松匹配
第二步:题目结构化解析
从提取的文本中按规则切分出每道题的字段。不同题库排版差异大,需根据实际样张调整正则,以下为通用模板:
import re
def parse_quiz_text(txt_path):
"""从题目册文本解析出题目列表"""
with open(txt_path, 'r', encoding='utf-8') as f:
text = f.read()
text = re.sub(r'=== 第 \d+ 页 ===', '', text)
text = re.sub(r'.*?\d+/\d+.*', '', text)
questions = []
q_pattern = re.compile(
r'(?:^|\n)\s*(?P<no>\d{1,3})\s*[\.、)\)]\s*(?P<stem>.*?)(?=(?:\n\s*\d{1,3}\s*[\.、)\)])|\Z)',
re.DOTALL
)
for m in q_pattern.finditer(text):
block = m.group('stem')
q_no = m.group('no')
opt_pattern = re.compile(
r'([A-F])\s*[\.、)\)]\s*(.*?)(?=(?:\n\s*[A-F]\s*[\.、)\)])|\Z)',
re.DOTALL
)
options = {}
for om in opt_pattern.finditer(block):
letter = om.group(1)
opt_text = om.group(2).strip().replace('\n', ' ')
options[letter] = opt_text
first_opt_pos = block.find('A.')
if first_opt_pos < 0:
first_opt_pos = block.find('A、')
stem = block[:first_opt_pos].strip().replace('\n', ' ') first_opt_pos > block.strip().replace(, )
q_type =
(options) > stem stem:
q_type =
questions.append({
: q_no,
: q_type,
: stem,
: options.get(, ),
: options.get(, ),
: options.get(, ),
: options.get(, ),
: options.get(, ),
: options.get(, ),
})
questions
():
(txt_path, , encoding=) f:
text = f.read()
answers = {}
ans_pattern = re.(
,
re.DOTALL
)
m ans_pattern.finditer(text):
no = m.group()
ans = m.group().upper()
analysis = m.group().strip().replace(, )
answers[no] = {: ans, : analysis}
answers
解析规则需根据样张调整的关键点:
- 题号正则:
1. 1、 1) (1) (一) 等排版差异
- 选项标识:
A. A、 A) A) (A) 等
- 答案行:
1.A 1、A 1. A 解析:xxx 1. A【解析】xxx
- 多选题题型推断:选项数 > 4、题干含"多选/多项"、答案含多个字母
- 解析可能跨行,需合并到一行(写入 xlsx 时换行会被压缩)
第三步:题目与答案合并
def merge_quiz_and_answer(quiz_list, answer_map):
"""合并题目列表与答案映射,未匹配的题目告警"""
merged = []
unmatched = []
for q in quiz_list:
no = q['题号']
ans_info = answer_map.get(no)
if not ans_info:
unmatched.append(no)
q['答案'] = ''
q['解析'] = ''
q['知识点'] = ''
else:
q['答案'] = ans_info['答案']
q['解析'] = ans_info['解析']
q['知识点'] = ''
merged.append(q)
if unmatched:
print(f'警告:{len(unmatched)} 题未找到答案,题号: {unmatched}')
return merged
合并键选择:
- 题目册与答案册都含「题号」→ 按题号匹配(推荐,最稳)
- 两本题目顺序、数量完全一致 → 按索引匹配(兜底方案)
- 题号含套号(如
1-1、2-15)→ 用完整键匹配
第四步:写入标准 xlsx
from openpyxl import Workbook
HEADER = ['题号', '题型', '题干', '选项A', '选项B', '选项C', '选项D', '选项E', '选项F', '答案', '解析', '知识点', '图片URL']
def write_to_xlsx(questions, out_path):
wb = Workbook()
ws = wb.active
ws.append(HEADER)
for q in questions:
ws.append([
q.get('题号', ''),
q.get('题型', '单选'),
q.get('题干', ''),
q.get('选项A', ''),
q.get('选项B', ''),
q.get('选项C', ''),
q.get('选项D', ''),
q.get('选项E', ''),
q.get('选项F', ''),
q.get('答案', ''),
q.get('解析', ''),
q.get('知识点', ''),
q.get('图片URL', ''),
])
wb.save(out_path)
print(f'已写入 {out_path},共 题')
完整流程脚本(单文件版)
import pdfplumber
import re
from openpyxl import Workbook
def pdf_to_xlsx(pdf_path, out_xlsx_path, mode='single'):
"""
mode:
'single' - 单文件 PDF,题目与答案在同一文档
'double' - 双文件 PDF,需另外提供答案册路径
"""
lines = []
with pdfplumber.open(pdf_path) as pdf:
for page in pdf.pages:
lines.append(page.extract_text() or '')
text = '\n'.join(lines)
questions = parse_quiz_text_inline(text)
if mode == 'single':
answer_map = parse_answer_text_inline(text)
for q in questions:
ans_info = answer_map.get(q['题号'], {'答案': '', '解析': ''})
q['答案'] = ans_info['答案']
q['解析'] = ans_info['解析']
wb = Workbook()
ws = wb.active
ws.append(['题号', '题型', '题干', '选项A', '选项B', '选项C', '选项D', '选项E', '选项F', '答案', '解析', '知识点', ])
q questions:
ws.append([q.get(k, ) k [,,,,,,,,,,,,]])
wb.save(out_xlsx_path)
()
PDF 提取转换检查清单
七、生成流程清单
生成标准题库时按以下步骤逐项检查:
八、验证与调试
生成并上传后,可在以下位置验证:
- 题库列表: 访问
http://localhost:3000/quiz,确认分类与题套数量正确
- 单题验证: 进入任一题套,检查题干、选项、答案、解析渲染
- 多选验证: 多选题选择选项后不应自动跳转,需手动点击「下一题」
- 结果页验证: 提交后检查解析文本无大量空白、知识点标签显示
- 后端日志: 启动时控制台输出
[quizLoader] 加载完成,共 X 套题,Y 道题
常见问题排查:
- 题套不显示 → 检查文件名是否含目标扩展名、文件夹路径是否正确
- 多选题被当单选 → 检查
题型 字段是否包含「多选」或「多项」字样
- 答案错误 → 单选题答案只取首字母,多选题答案去重排序,确认填写规范
- 解析空白多 → 已在结果页自动
replace(/\s+/g, ' '),源数据可保留原始换行
- 图片不显示 → 检查
图片URL 字段:相对路径需以 images/ 开头且图片实际存在于题库目录的 images/ 子目录;多图需用 | 分隔;浏览器 Network 面板检查 /quiz-img/:source/* 请求是否 200
九、本地图片路径规范(重要)
当题目含图表、几何图、函数图等图片时,推荐使用本地相对路径而非远程 URL,避免链接过期。
目录结构
我的题库/ ← 文件夹名 = 分类名(source)
├── 专项练习01.xlsx ← 题套文件
├── 专项练习02.xlsx
├── ...
└── images/ ← 图片目录(固定命名)
├── p01_q04_1.jpg ← 命名建议:p{套号}_q{题号}_{序号}.jpg
├── p01_q04_2.jpg
└── p02_q05_1.jpg
xlsx 中图片URL字段填写
| 场景 | 填写值 | 说明 |
|---|
| 单图 | images/p01_q04_1.jpg | 相对路径,以 images/ 开头 |
| 多图 | images/p01_q04_1.jpg|images/p01_q04_2.jpg | 用 | 分隔 |
| 远程URL | https://example.com/img.jpg | http/https 开头,原样使用 |
| 空值 | (留空) | 无图片 |
服务端路由
服务端自动通过 /quiz-img/:source/* 路由提供图片访问:
- 请求
/quiz-img/我的题库/images/p01_q04_1.jpg
- 映射到
local-quiz-bank/我的题库/images/p01_q04_1.jpg
- 自动设置 Content-Type,支持 jpg/png/gif/webp
- 路径穿越防护:禁止
.. 和绝对路径
前端渲染
quiz-play.ejs 中的 buildImgSrc() 函数自动处理:
- 相对路径
images/xxx.jpg → /quiz-img/{source}/images/xxx.jpg
- 绝对 URL → 原样使用
- 多图
| 分隔 → 循环渲染多个 <img>
从 OCR markdown 提取图片
PaddleOCR 等 OCR 工具输出的 markdown 中,图片格式为:
<div style="text-align: center;"><img src="https://..." alt="Image" width="25%" /></div>
提取流程:
- 正则匹配
<div style="text-align: center;"><img src="(https?://[^"]+)" alt="Image" width="(\d+)%"
- 通过图片行号向上查找最近的题号行,确定归属题目
- 通过
【参考答案】/【实战解析】 等标记判断图片在题干区还是解析区
- 仅下载题干区图片到本地(解析区图片通常为解题示意图,可按需保留)
- 文件名规范:
p{套号2位}_q{题号2位}_{序号}.jpg,如 p01_q04_1.jpg