跳到正文
DEV Community· ryan·· 4 小時前AI 評分45

我如何構建 ExamParser:把雜亂的 Word、PDF 和 Google Docs 轉成結構化題庫

How I Built a Document-to-Quiz Parser: Turning Messy Word, PDF, and Google Docs into Structured Question Banks

AI 導讀

作者構建 ExamParser,將 Word、PDF 和 Google Docs 解析為可匯入 Canvas 的測驗。流程先把不同格式正規化為共通區塊,再分割題目、分類題型和提取答案;未標記答案會交由人工審查。Canvas 輸出採用 QTI 1.2 ZIP,題庫遷移則可輸出 Excel/CSV。

正文

A teardown of the parsing pipeline behind ExamParser — and the lessons that cost me a few weekends.

The problem nobody puts on the roadmap

So I built ExamParser (https://examparser.com) to close that gap — drop in a Word, PDF, or Google Doc and get a Canvas-ready quiz out. No login, files never reused. This post is the engineering teardown: how the parser actually works, where it breaks, and what I'd tell myself at the start.

Why "just parse the file" is a trap

The trap is assuming a document is a list of questions. It isn't. A .docx is a ZIP archive of XML where "question" is just a paragraph that happens to start with "1." A PDF is either real text with coordinates or a picture of text. A Google Doc is the only one that's honest — it gives you structural elements — but you still have to interpret them.

There is no document.getQuestions(). You have to infer questions, types, and answers from layout, styling, and a handful of authoring conventions. That inference is the whole product.

The pipeline

Word / PDF / Google Docs
        │
        ▼
┌─────────────────┐
│ Format Extractor │  → blocks: {text, style, coords, is_list}
└─────────────────┘
        │
        ▼
┌─────────────────┐
│   Normalizer     │  → common block schema across formats
└─────────────────┘
        │
        ▼
┌─────────────────┐   ┌──────────────────┐
│   Segmenter      │ → │   Classifier      │  → type: mcq / tf / cloze / match
└─────────────────┘   └──────────────────┘
        │
        ▼
┌─────────────────┐
│ Answer Extractor │  → correct option(s), rationale
└─────────────────┘
        │
        ▼
┌─────────────────┐
│   Emitter        │  → QTI 1.2 zip (Canvas) | Excel/CSV (migration)
└─────────────────┘

The single most important design decision: one normalizer, many extractors. Each format is messy in its own way, but once they all emit the same block shape, everything downstream is format-agnostic. Don't write three segmenters.

Step 1 — Extracting text and structure

For DOCX, python-docx gets you started, but go one level deeper. You want the paragraph style and whether it's a list item, because that's your cheapest "is this a new question?" signal.

from docx import Document

def extract_blocks(path):
    doc = Document(path)
    blocks = []
    for p in doc.paragraphs:
        pPr = p._p.pPr
        is_list = bool(pPr is not None and pPr.numPr is not None)
        blocks.append({
            "text": p.text.strip(),
            "style": p.style.name,
            "is_list": is_list,
        })
    return blocks

For PDF, coordinates are the secret. With pdfplumber you get each character's x0/y0/x1/y1. Two pieces of text at the same y but different x are side-by-side — that's how you tell a two-column layout from a single column, and how you separate an option label (A)) from its text. Scanned PDFs have no text layer, so they fall through to OCR + a layout model; everything downstream is the same once you have blocks with coordinates.

Google Docs is the easy one: the Docs API returns Paragraph and TextRun elements with explicit nesting. If you can ask authors to use Google Docs, do it — the structure is already there.

Step 2 — Segmenting questions

Once you have blocks, split them into questions. The signal is almost always a numbered start, but authors number inconsistently: 1., 1), (1), 一、, Q1, Question 1. A tolerant regex covers most of it:

import re

QUESTION_RE = re.compile(
    r'^\s*(?:\(?(\d{1,3})[.)]|[一二三四五六七八九十]+[、.]|Q\s*\d+|Question\s+\d+)'
)

def split_questions(blocks):
    questions, current = [], []
    for b in blocks:
        if QUESTION_RE.match(b["text"]) and current:
            questions.append(current)
            current = []
        current.append(b)
    if current:
        questions.append(current)
    return questions

The and current guard is what stops question 2 from splitting mid-question when an option happens to start with a number.

Step 3 — Classifying the type

You don't need ML for this. Signals:

  • MCQ: option markers A), a., (B), bullet list of 2–5 short items.
  • True/False: exactly two options that are variants of true/false/对/错.
  • Cloze / fill-in: the stem contains ____ or ( ) or ___.
  • Matching: two parallel columns.
  • Short answer: long option-shaped text or no options at all.

Order matters — check cloze and T/F before generic MCQ, because a T/F question also has "options."

Step 4 — Extracting the answer

Authors mark the right answer in a dozen ways: an asterisk (*a.), bold, highlight, or a trailing Ans: B. Normalize them into one rule: the option carrying the annotation is correct; if more than one is annotated, it's a "select all that apply."

def extract_answer(options):
    marked = [o for o in options if o["annotated"]]
    if not marked:
        return None  # flag for human review
    return [o["label"] for o in marked]  # len>1 → multi-select

Returning None instead of guessing is the discipline that keeps the tool trustworthy. A flagged question beats a wrong one.

Step 5 — Emitting what LMSes actually accept

Canvas will not take your beautiful JSON. It takes QTI 1.2 as a .zip. That's the only import path that works reliably:

<questestinterop xmlns="http://www.imsglobal.org/xsd/ims_qtiasiv1p2">
  <assessment ident="quiz_001" title="Chapter 9">
    <section ident="sec_001">
      <item ident="q1" title="Q1">
        <presentation>
          <material><mattext texttype="text/plain">Chris' favorite color is:</mattext></material>
          <response_lid ident="r1" rcardinality="Single">
            <render_choice>
              <response_label ident="a"><material><mattext>Black</mattext></material></response_label>
              <response_label ident="b"><material><mattext>Blue</mattext></material></response_label>
            </render_choice>
          </response_lid>
        </presentation>
        <resprocessing>
          <respcondition continue="No">
            <conditionvar><varequal respident="r1">a</varequal></conditionvar>
            <setvar action="Set" varname="SCORE">100</setvar>
          </respcondition>
        </resprocessing>
      </item>
    </section>
  </assessment>
</questestinterop>

For migrations (old bank → new system), a structured Excel/CSV with stem | option_a | option_b | ... | answer columns is what downstream scripts actually want. That column-aligned output is also what made batch jobs of hundreds of papers finish in days instead of weeks.

Edge cases that ate my weekends

  • Two-column publisher layouts. Without coordinates you merge column A's question 5 with column B's question 6. Coordinates fix it; reading order doesn't.
  • Math formulas. Preserve LaTeX/MathML or the equation becomes an image alt-text blob. Authors notice immediately.
  • Images and tables inside a stem. They break naive paragraph iteration — you need to attach media to the question, not drop it.
  • Mixed 1. / 一、 numbering in the same document (common in bilingual or localized exams). The regex above handles both; a single-format matcher silently drops half the test.
  • Sub-questions (1a, 1b). They need to nest under the parent or the grader scores them independently.

Design decisions, and why they stuck

A few calls came straight from watching real users:

  • Flag, don't guess. Every uncertain question surfaces for review instead of shipping wrong. Trust compounds.

Wrapping up

If you build something like this, the lever that pays off hardest is the shared normalizer between format-specific extractors — it's what lets one segmenter serve Word, PDF, and Docs. Spend your cleverness there, not on three parallel pipelines.

And if you'd rather not rebuild the whole thing, that's the exact problem ExamParser solves today: paste a Word, PDF, or Google Doc at https://examparser.com, get a Canvas quiz back, no account, files gone after. The Chinese edition lives at https://examparser.com/cn/.

Happy parsing.

來源:DEV Community · dev.to